Files
cjk-autospace/README.md
T

678 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 聚珍(Juzhen)
> 現代簡/繁中文網頁排版套件。Han.css 之現代化重寫,沿其功能版圖,並針對
> 數種情況作調整:**複製乾淨**(中西間隙不入剪貼板)、**依 `lang` 全角/開明
> 自動切換**、**`justify` 下句末標點只佔一份間距**、**跨瀏覽器一致**
> (Chrome/Edge/Safari/WebKit/Firefox)。
倉庫名仍為 `cjk-autospace`;公開 API 為 `createJuzhen` / 全域 `Juzhen`;注入之
自訂元素前綴 `jz-`。完整設計見 [`ARCHITECTURE.md`](./ARCHITECTURE.md)。
> 本版(2.x)為從零重寫。v1 之 `createCjkAutospace` 相容層**已移除**(最後一批
> v1 消費端已遷移 v2 API);v1 原始碼保存於 `v1-autospace` 分支。
---
## 安裝與構建
```sh
npm install
npm run build # esbuild → dist/juzhen.{mjs,iife.js,css};tsc → dist/index.d.ts
npm test # jsdom DOM 正確性測試
node --test test/shared-rules.test.mjs # 共享規則 Schema/黃金向量/Web 漂移守門
npm run test:wkwebview # macOS 原生 WKWebView 版面/observer E2E(需 Xcode 與 GUI 會話)
```
產物(`dist/`,已入庫):
| 檔 | 用途 |
|---|---|
| `dist/juzhen.mjs` | ESM,供打包器/TypeScript |
| `dist/juzhen.iife.js` | IIFE,全域 `Juzhen`,原生 `<script>` 直跑 |
| `dist/juzhen.css` | 合併樣式(**必須引入**,視覺尺寸全在此) |
| `dist/index.d.ts` | TypeScript 型別宣告 |
| `rules/juzhen-rules.json` | 平台中立之共享排版規則(Web/XeLaTeX 共用) |
| `rules/golden-vectors.json` | 跨實作語義黃金向量 |
---
## 共享規則契約
`rules/` 是 Web/CSS 與並行 XeLaTeX 實作之唯一規則權威源;初版規則版本為 `1.0.0`。
規則以 Unicode code-point 區間、語言政策、標點寬度、間隙、禁斷與段末約束描述「應發生
什麼」,不規定 DOM/CSS、TeX node/glue/penalty 或其他 adapter 的實作機制。
套件提供四個唯讀子路徑:
```js
import rules from "cjk-autospace/rules" with { type: "json" };
import schema from "cjk-autospace/rules/schema" with { type: "json" };
import vectors from "cjk-autospace/rules/vectors" with { type: "json" };
import vectorsSchema from "cjk-autospace/rules/vectors-schema" with { type: "json" };
```
規則版本、Schema 版本、變更程序與 adapter 邊界詳見 [`rules/README.md`](./rules/README.md)。
任何規則變更必須同步更新 canonical JSON、Schema、黃金向量,並讓 Web 與 XeLaTeX adapter
同時通過相同語義向量;任一實作不得單邊分叉規則。
---
## 用法
呼叫端接入時須做兩個獨立選擇:
1. **元件如何參與排版**:依「行內元素三分類」選 pill(保護內部、處理交界)、
isolate(內部也排版、保持盒子完整)或 avoid(完全排除)。
2. **文字需要哪些功能**:依「功能分級」選 text(標題/UI 文字)或 paragraph(正文)。
完整操作範例與衝突規則見下方兩節;不要用其中一種分類代替另一種。
### 原生 `<script>`
```html
<link rel="stylesheet" href="dist/juzhen.css">
<script src="dist/juzhen.iife.js"></script>
<script>
const jz = Juzhen.createJuzhen({ scope: { include: ".prose" } });
jz.render(); // 對 document.body 套用(僅命中 .prose 子樹)
// jz.revert(); // 還原
</script>
```
### ESM/TypeScript
```ts
import { createJuzhen } from "cjk-autospace";
import "cjk-autospace/css";
const jz = createJuzhen({ lang: { default: "zh-Hant" } });
jz.render(document.querySelector("main"));
```
**JS 與 CSS 缺一不可**:JS 只注入 `jz-*` 結構,所有視覺尺寸(間隙寬度、擠壓量、
`justify` 原子化、字體堆疊)都在 `juzhen.css`。
### 動態頁面:增量恢復(局部變化免全樹重掃)
`render(root)`/`revert(root)` **皆可傳入局部 Element**——頁面局部變化時,只重處理受影響
之子樹即可,成本 O(該塊) 而非 O(全文)(實測編輯 400 段中之一段,局部恢復較全樹快數百倍)。
```ts
const jz = createJuzhen();
jz.render(document.body); // 初次全樹
// 局部變化後(手動):
jz.rerender(changedBlock); // = revert(changedBlock) + render(changedBlock)
jz.render(newlyAppendedBlock); // 新增之塊:直接 render(無須 revert)
// 自動(opt-in):觀察 DOM,內容/分類屬性變化時自動只重處理受影響之最近塊。
jz.observe(document.body); // 之後 DOM 變動即自動增量恢復
// jz.disconnect(); // 停止觀察
```
- **`rerender(root?)`**:`revert` 後 `render` 同一(子)樹(便利方法)。
- **`observe(root?)`/`disconnect()`**:以 `MutationObserver` 自動增量。juzhen 自身之注入
於處理期間暫停觀察、**絕不自觸發**;新增之塊直接 render(免全容器重掃)。需 `MutationObserver`
(瀏覽器/jsdom 有;無則 no-op)。
- **`invalidate(root?)`**:清該子樹之內部快取。**`revert` 已自動失效其子樹**,故僅在「改了既有
元素之分類相關屬性(`class`/`data-jz-*`/`lang` 等)**但不走 `revert`**」時才需顯式調用,以
免下次 render 沿用陳舊分類。內容(子節點/文本)變化不改分類、無需 `invalidate`(但須
`revert`+`render` 該塊方能重處理)。
> **元素變化之正確性**:實例內部對「元素分類(透明/pill/隔離/avoid)/功能級別/功能開關」
> 快取以加速跨 render 復用(快取隨實例持久、非每次 render 重建)。**改變既有元素之上列屬性後,
> 須經 `revert`/`rerender`/`invalidate`/`observe` 任一使該子樹快取失效**,否則沿用舊分類。
> 新增/移除元素與純內容編輯無此顧慮(新元素重新計算、移除者自動回收)。
> **增量之粒度為「塊」**:換塊內容(`textContent`/`innerHTML`)、增刪塊、改分類屬性——皆與
> 全量重繪一致。**不支持**對聚珍已切分之文本節點做外科式 `characterData` 直改(如持有 render
> **前**之 `p.firstChild` 引用、事後直改其 `.data`):render 後原文本節點已被 `jz-*` 切分/
> 替換,該引用已非原整段,直改單一碎片會令 `revert` 合併出之內容偏離作者意圖。此為後處理排版
> 器之固有限制(任何外科式文本 patch 之框架皆會與注入後之 DOM 失步)、非缺陷——聚珍對當前
> DOM 之處理始終自洽。欲改文字,以塊為單位替換內容即可。
> **`revert` 之範圍**:`revert` 乾淨移除聚珍注入之全部結構(`jz-*`/`data-jz-*`),DOM 回到**邏輯
> 等價**態;但**不還原** `spacing` 依契約規範化之贅餘作者空格(CJK↔西文吸收為 margin 間隙、
> CJK↔CJK 剝除;見上表「中西間隙」)。即 `render→revert` 對含 CJK↔西文作者空格之源,還原後語義
> 一致、無 `jz-*` 殘留,唯該空格已正規化(此差異穩定、不隨反覆 render/revert 累積)。若消費端需
> 保留原始作者空格以供編輯,應自存原文、勿以 `revert` 當「還原到未處理源碼」。
---
## 切片 1 已實作功能
| 功能 | 說明 |
|---|---|
| **中西間隙** `spacing` | CJK↔西文邊界以 copy-clean 之 `jz-hws` 與 `margin-right` 留隙;**不插入空白字元**,故剪貼板不多出空格。唯跨節點 IVS 產物以 `::after` 產生的 `U+200B` 斷行機會設 `user-select:none`;它不進入 DOM/`textContent`。「西文」涵蓋拉丁(含擴充 A/B,拼音聲調 `ā ǎ ē`)、希臘、西里爾、數字、貨幣(`€ ₩ ₿`)、數學運算符等;CJK 標點(`。,「」()……` 等)不誤判為西文、不生隙。含跨樣式行內邊界與 pill/隔離元素邊界(`code`/`kbd`/`.katex` 與 `mark`,兩端補隙口徑一致;pill 緊鄰標點時穿越 `jz-jinze` 仍補間隙;pill 跨行右隙、間隙落行末之自我應驗皆已修)。**pill 之邏輯鄰字為標點時(如 `漢字。<code>`)不誤加間隙**(標點非 pill 鄰字;`jz-char`/`jz-inner` 透明穿越,與 `collectRuns` 一致)。贅餘作者空格一律正規化:CJK↔西文吸收為受管間隙、CJK↔CJK(含跨 inline 標籤)剝除不留隙。 |
| **長英文詞** `longWord` | `[A-Za-z]{6,}` 包入 `<span lang="en">`,配合 CSS `hyphens:auto` 原生斷詞(DOM 無連字符 → 複製不污染)。 |
| **標點擠壓** `jiya` | 標點 charify 為 `jz-char`+`bd-*` class。三機制:①baseline 政策(全角式不擠/開明式擠句內點號+括號引號/半角式全擠);②行內連續擠壓(相鄰標點**雙側獨立**收——各標點若其有空白之側緊鄰另一標點即收半,故 `,「` 全角式與開明式皆 1em、`。」` 全角式 1.5em/開明式 1em;**唯句末點號接開類為句界**——`。「`/`?「` 保留句末點號右空白、僅收開類,全角式與開明式皆 1.5em;透明穿越 `strong`/`em` 等行內邊界);③行端擠壓(`lineedge` layout pass)。半形預設以字型 `halt` 渲染(按字形正確定位,居中字形如 `!?`/繁體 `。,` 不重疊;不依賴 `text-spacing-trim`)。**字型缺 `halt` 時可切 `margin` 後備模式**(見下節)。 |
| **禁則** `jinze` | 行首行末避頭尾:閉類/點號綁前一字(不置行首)、開類綁後一字(不置行末),只隔離邊界字故長引文中段仍可斷。亦為 `justify` 綁定載體。邊界字落於**透明**行內樣式元素(`strong`/`em` 等)內時**克隆切分**只綁邊界詞元(避免整個元素入 `nowrap` 致行末整體掉行、上行留巨大空隙),切出之克隆標 `data-jz-kind="splitoff"`、`revert` 時自動併回原元素;不可切元素(`a`/`abbr`/`ruby`/帶 `id`,以及 **pill/隔離/avoid**——見「行內元素三分類」)退化為**整元素綁定**——pill(如行內公式 `.katex`)即作為原子鄰字單位整體綁入 `jz-jinze`,絕不下潛肢解其內部。 |
| **垂懸字避免** `orphan` | 段末行 CJK 實義字 ≥ N(預設 2;標點/符號不計)。方案 C(斷行層、無測量):把段末「第 N 個實義字起至段末」包入 `jz-orphan`(`white-space:nowrap`),整體不可斷 → 末行恒含之 ⇒ 末行實義字 ≥ N,任意寬度/字型成立。單行段無害。邊界字落於行內元素內時同樣**克隆切分**(只綁末 N 字、避免整個長元素入 `nowrap` 溢出容器;機制同禁則)。**預設開**(`orphan:false` 關,`{chars:n}` 調)。段落級功能。 |
| **功能分級** `level` | 功能分文本級(中西間隙、標點擠壓——任意文本片段適用)與段落級(禁則、垂懸字、長詞——需行/段布局)。塊級元素預設全功能;`data-jz-level="text"` 子樹或選項 `level:{text:"選擇器"}`(如標題/按鈕)僅跑文本級。 |
| **justify 單份間距** | 每個 `jz-char` 為 `inline-block` 原子項,句末標點只貢獻一份對齊伸縮量。 |
相容選項 `hanging`/`biaodian`/`emphasis`/`ruby` 仍被接受,但當前均為
**no-op**:沒有對應 pass、DOM 產物或 CSS。後三者僅屬明確標記的未來設計;
`hanging` 之舊負 margin 實作已移除,僅保留 API 相容。
---
## 選項
```ts
createJuzhen({
lang: {
default: "zh-Hant", // 無 lang 祖先時之預設
style: "kaiming", // 全域政策一鍵覆寫(quanjiao|kaiming|banjiao)
policy: { "zh-Hant": "quanjiao", "zh-Hans": "kaiming" }, // 逐 lang(最高優先)
},
scope: {
root: document.body, // 處理根;render(root) 可逐次覆寫
include: ".prose", // 啟用選擇器(opt-in):僅處理命中子樹
avoid: "blockquote.raw", // 追加排除選擇器
},
spacing: true,
longWord: { minLength: 6, lang: "en" },
jiya: true, // 或物件:{ halfWidth: "halt" | "margin", bias }
jinze: true,
orphan: true, // 垂懸字避免(預設開);或 { chars: 2 };false 關
level: { text: "h1,h2,h3,button,.ui" }, // 此選擇器子樹僅跑文本級功能(見下)
pillSelector: "code, kbd, .katex", // 整體(pill)元素(見「行內元素三分類」)
isolateSelector: "mark", // 內外隔離元素(預設 mark;"" 停用)
justifyAtoms: true, // jz-char/jz-jinze 以 inline-block 作對齊原子;
// 分頁器(Paged.js)下設 false 改 inline(見下)
blocks: [ // 分塊功能選擇
{ selector: ".code-comment", features: [ "spacing" ] },
],
});
```
### 全部選項速查(含少見但強力者)
完整選項一覽。**「標籤名」型別只吃大寫標籤名陣列**(精確比對 `nodeName`),與
`pillSelector`/`isolateSelector`/`scope.*`(**CSS 選擇器**,可用 class/屬性)**不同機制**,
勿混。斜體「詳見」指向本文檔展開該項之小節。
**語言與擠壓政策**(優先序:**逐元素** `data-jz-style`/`stylePolicy` > `policy` > `style` > 逐語言內建)
| 選項 | 型別 | 預設 | 作用 |
|---|---|---|---|
| `lang.default` | string | `"zh-Hant"` | 無 `lang` 祖先時之預設語言標籤 |
| `lang.style` | `quanjiao|kaiming|banjiao` | 逐語言內建(繁→全角、簡→開明) | **全域政策一鍵覆寫** |
| `lang.policy` | `Record<lang, style>` | `{}` | 逐 lang 政策 |
| `lang.stylePolicy` | `{selector,style}[]` | `[]` | **逐元素樣式覆寫**(選擇器命中之子樹用指定樣式;如 `[{selector:"h1,button,.ui",style:"banjiao"}]`);配合 `data-jz-style` 屬性,優先於 lang |
**作用域**
| 選項 | 型別 | 預設 | 作用 |
|---|---|---|---|
| `scope.root` | Element|選擇器 | `document.body` | 處理根(`render(root)` 可逐次覆寫) |
| `scope.include` | 選擇器 | `null` | opt-in:僅處理命中此選擇器之子樹 |
| `scope.avoid` | 選擇器 | `null` | 追加排除子樹 |
**功能開關 / 對齊 / 分級 / 分塊**(詳見「已實作功能」表及各節)
| 選項 | 型別 | 預設 | 作用 |
|---|---|---|---|
| `spacing` | bool | `true` | 中西間隙 |
| `longWord` | bool|`{minLength,lang}` | `true`(`{6,"en"}`) | 長英文詞斷字 |
| `jiya` | bool|`{halfWidth,bias}` | `true` | 標點擠壓(*詳見「標點半形」*) |
| `jinze` | bool | `true` | 禁則避頭尾 |
| `orphan` | bool|`{chars}` | `true`(`{chars:2}`) | 垂懸字避免 |
| `justifyAtoms` | bool | `true` | inline-block 對齊原子(分頁器設 `false`) |
| `level.text` | 選擇器 | `null` | 命中子樹僅跑文本級(*詳見「功能分級」*) |
| `blocks` | `{selector,features}[]` | `[]` | 分塊功能選擇 |
| `charClass` | `{cjk,western,biaodian}` | — | 追加字符分類(*詳見「自定義字符分類」*) |
**行內三分類與排除**(*詳見「行內元素三分類」*)
| 選項 | 型別 | 預設 | 作用 |
|---|---|---|---|
| `pillSelector` | CSS 選擇器 | `"code, kbd"` | 整體元素(內部不可見、兩端補隙) |
| `isolateSelector` | CSS 選擇器 | `"mark"`(`""` 停用) | 隔離元素(內部照常、兩端補隙) |
| `styleInlines` | 標籤名[] | 27 個標準行內標籤 | **透明穿越白名單**(未列之行內標籤=邊界) |
| `blockTags` | 標籤名[] | 36 個塊標籤 | 塊級邊界集 |
| `skipTags` | 標籤名[] | `PRE,SAMP,TT,VAR,SCRIPT,STYLE,TEXTAREA,INPUT` | avoid 標籤集(**不含 CODE/KBD**,由 pill 涵蓋) |
| `skipAttribute` | string | `null` | 自訂「跳過」屬性名(命中即 avoid) |
| `isSkipped` | `(node)=>bool` | `null` | 自訂跳過謂詞(函數級排除鉤子) |
**HTML 屬性**(各屬性之繼承/衝突規則見對應小節)
| 屬性 | 值 | 作用 |
|---|---|---|
| `data-jz-level` | `text|paragraph` | 逐子樹功能分級 |
| `data-jz-style` | `quanjiao|kaiming|banjiao` | 逐子樹標點樣式覆寫(近祖先勝、同層優先於選擇器,整體優先於 lang) |
| `data-juzhen` | 功能名(空格分隔) | 白名單:僅列出之功能生效 |
| `data-juzhen-off` | 功能名 | 黑名單:停用列出之功能 |
| `data-jz-skip` | (存在即可) | 標記元素為 avoid(內外皆不處理) |
| `data-jz-halfwidth` | `halt|margin` | 逐子樹半形渲染模式 |
**相容 no-op 選項**:`hanging`/`biaodian`/`emphasis`/`ruby` 均可傳入,但當前不註冊
pass,也不產生 DOM/CSS 效果。注意頂層 `biaodian` 與 `charClass.biaodian`(已實作的
標點子類擴充表)**同名異義**。
### 逐元素標點樣式(`data-jz-style`/`stylePolicy`)
標點樣式(`quanjiao` 全角式/`kaiming` 開明式/`banjiao` 半角式)預設由 `lang` 決定,但可
**逐元素/逐範圍覆寫**——典型:**非段落/UI 元素(標題、按鈕)用半角式,段落用全角/開明**。
```ts
// 選擇器批量:h1/h2/button 及 .ui 子樹用半角式,其餘(段落)走 lang
createJuzhen({
lang: {
default: "zh-Hant", // 段落 → 全角式(繁)
stylePolicy: [ { selector: "h1, h2, button, .ui", style: "banjiao" } ],
},
});
```
```html
<!-- 或以 HTML 屬性逐元素/逐範圍標記(標於元素或任一祖先,該子樹生效) -->
<h1 data-jz-style="banjiao">標題:半角標點更緊湊</h1>
<p>段落沿用 lang 之全角/開明式。</p>
```
優先序:**逐元素覆寫**(`data-jz-style` 屬性/`stylePolicy` 選擇器,**近祖先勝、同層屬性優先於
選擇器**)> `lang.policy` > `lang.style` > 內建逐語言預設。屬性值非法(拼錯)則忽略、回退
lang。半角式渲染沿用既有 `halt`/`margin` 機制(見下節),本功能只做「按元素選樣式」、無新增
渲染邏輯。
### 行內元素三分類:透明/隔離/整體
**先決定哪些內容交給聚珍處理,再決定它與前後文字如何銜接。** 呼叫端須主動選擇
pill/isolate/avoid;不要把「保護內部內容」一律寫成跳過,也不要只依元素外觀猜分類。
普通 `strong`/`em`/`span` 等只改文字樣式的元素通常保持透明,無須額外標記。
| 你的需求 | 選擇 | 元素內部 | 與外部文字的交界 | 如何啟用 |
|---|---|---|---|---|
| 內部由程式碼/公式等元件自己管理,但仍要接入正文排版 | **pill(整體)** | 不進入、不改寫內部文字、不拆分元素 | 視為整體;依相鄰字符規則補間隙,禁則需要時連同整個元素綁定 | `pillSelector` |
| 內部是普通文字,仍要補間隙、處理標點,但盒子不能被拆開 | **isolate(隔離)** | 照常排版,內部自成文字脈絡 | 與外部隔開;依相鄰字符規則補間隙,禁則需要時整體綁定 | `isolateSelector` |
| 這一區完全不交給聚珍,連交界間隙也自行管理 | **avoid(排除)** | 不處理整個子樹 | 停止跨界排版,不替它建立 pill/isolate 式交界間隙 | `data-jz-skip` 或 `scope.avoid` |
| 只是粗體、斜體等樣式,文字仍屬同一句 | **透明(一般文字)** | 照常排版 | 前後文字連續判斷;安全的樣式元素可能被拆分後於回退時合併 | 預設 `styleInlines` 中的標籤 |
**pill 與 avoid 都保護內部,差別在外部:pill 仍參與正文交界排版;avoid 完全退出。**
**pill 與 isolate 都保持元素完整,差別在內部:pill 不處理;isolate 照常處理。**
這裡的「完整」指聚珍不拆分元素,不代表替任意元件提供 CSS `white-space:nowrap`。
交界也不是無條件各加一格:須符合相鄰字符規則,例如句號緊接 pill 不會因此多補間隙。
#### 主動指定你的元件
以下 class 名稱由呼叫端自行命名,透過選擇器接入;它們不是聚珍內建標記。
`data-jz-skip` 則是內建屬性,存在即排除,寫成 `data-jz-skip="false"` 仍會排除。
```html
<article class="juzhen" lang="zh-Hant">
<p>執行<code>npm test</code>後查看結果。</p>
<p>公式<span class="formula">x+y</span>由公式元件管理。</p>
<p>狀態<mark class="badge">版本API,已更新</mark>仍要處理內部中西間隙與標點。</p>
<p>外部元件<span data-jz-skip>原樣保留 API ,</span>的交界由呼叫端自行處理。</p>
<div class="raw-widget">這個子樹也完全排除。</div>
</article>
```
```js
const jz = Juzhen.createJuzhen({
scope: { include: ".juzhen", avoid: ".raw-widget" },
pillSelector: "code, kbd, .formula, .cite-ref",
isolateSelector: "mark, .badge",
});
jz.render();
```
- **pill 預設只有 `code, kbd`**。公式 `.katex`、引用標 `.cite-ref` 等須自行加入。
自訂 `pillSelector` 是取代預設選擇器;要保留 code/kbd,須像上例一併列出。
- **isolate 預設為 `mark`**。自訂 `isolateSelector` 同樣取代預設;傳 `""` 停用此選擇器。
只有背景色的 `span` 不會因外觀自動變成 isolate,應加 class 並列入選擇器。
- **avoid** 可用 `data-jz-skip` 或 `scope.avoid` 主動指定;另外 `skipTags`、SVG、可編輯內容、
`skipAttribute` 和 `isSkipped` 也可能使元素被排除。子元素不能以 pill/isolate 或
`data-jz-level="paragraph"` 重新打開已被排除的祖先子樹。
- **同一元素多重命中時:avoid > pill > isolate**。例如同時命中 `.formula` 與 `.badge`
的元素採 pill;再加 `data-jz-skip` 則採 avoid。請避免互相矛盾的選擇器。
- 從 `pillSelector` 移除 `code`/`kbd` 不等於排除它們:預設 `skipTags` 不含二者。
若要完全跳過,須另列入 `scope.avoid`/`skipTags`。
- 未列在 `styleInlines` 的未知行內標籤會形成隔離式邊界,不會自動當透明樣式。
`styleInlines` 接受**大寫標籤名陣列**,與 pill/isolate 的 CSS 選擇器不同。
內部結構由第三方管理的元件宜明確指定 pill 或 avoid,不要為了穿越它而加入透明名單。
分類不等於功能級別。isolate 內部是否執行禁則、避孤字等,仍由下節的 text/paragraph
及功能開關決定;pill/avoid 內部則根本不進入文字處理。
### 分頁器(Paged.js 等)相容:`justifyAtoms`
預設 `jz-char`/`jz-jinze` 為 `inline-block`,作 `justify` 之單一對齊原子(句末標點
單份間距)。但**分頁器**(如 Paged.js)之對齊引擎會把每個 inline-block 邊界當伸縮點、
與 CJK inter-ideograph 疊加,於窄欄混排時字距失控、整段散架。此時設
`justifyAtoms: false`——`jz-char`/`jz-jinze` 改 `display:inline`(半形 `halt` 與禁則
`nowrap` 綁定均保留)。常規流式版面無需設定。
> 注意:`margin` 後備半形依賴 `inline-block`(`width:0.5em`),故
> `justifyAtoms:false` 與 `margin` 模式不可兼得——分頁器場景請用含 `halt` 之字型。
### 分塊功能選擇(HTML 屬性,就近繼承)
先按下一節選擇 text/paragraph,再用功能白名單或黑名單微調。
它們不改變 pill/isolate/avoid 分類,也不會繞過級別限制。
```html
<p data-juzhen="spacing jiya">只選中西間隙與標點擠壓。</p>
<p data-juzhen-off="orphan longWord">保留其他功能,停用避孤字與長詞斷字。</p>
```
功能名區分大小寫,以空白分隔。一般呼叫端使用 `spacing`、`jiya`、`jinze`、`orphan`、
`longWord`;前述相容 no-op 名稱不會產生效果。
對每個功能,從元素自身向上找:同一元素先取 `data-juzhen` 白名單,再檢查
`data-juzhen-off` 黑名單,接著取第一條命中的 `blocks` 規則。白名單未列入的功能停用;
黑名單未列入的功能繼續查找;皆無決定時沿祖先向上,最後回退全域開關。
因此同一元素同時寫白名單與黑名單時,**白名單優先**;`data-juzhen=""` 是空白名單,
不是恢復預設。全域停用的功能不會被局部白名單重新啟用。
### 功能分級:文本級 vs 段落級(`level`/`data-jz-level`)
**text/paragraph 決定允許哪些排版功能,不是 HTML 標籤分類,也不設定 CSS display。**
呼叫端應按內容用途選擇,不能把「現在只有一行」當作 text 的唯一判準。
| 內容用途 | 選擇 | 允許的功能(仍受功能開關控制) | 不會替你做什麼 |
|---|---|---|---|
| 標題、按鈕、選單、短標籤等 UI 文字 | **text(文本級)** | `spacing` 中西間隙、`jiya` 標點擠壓 | 不做 `jinze` 禁則、`orphan` 避孤字、`longWord` 長詞斷字;不保證單行 |
| 正文、說明段落、完整句子的列表項等連續閱讀內容 | **paragraph(段落級)** | 上述文本級功能,加上禁則、避孤字、長詞斷字 | 不自動兩端對齊,不設定字型或容器寬度 |
**沒有任何級別訊號時預設 paragraph。** `h1`/`button` 不會因名稱自動變成 text;
`p` 也可以明確標成 text。即使一段正文在寬螢幕恰好只有一行,仍可選 paragraph;
即使標題在窄螢幕換行,也可保留 text。需不需段落約束由呼叫端決定。
#### HTML 標記與選擇器
```html
<article class="juzhen" lang="zh-Hant">
<h1 data-jz-level="text">聚珍API:使用說明</h1>
<p data-jz-level="paragraph">正文採段落級,允許禁則與段末避孤字。</p>
<div class="ui" data-jz-level="text">
<button>開啟API</button>
<p data-jz-level="paragraph">這段較長的說明重新採用段落級。</p>
</div>
</article>
```
也可在建立實例時用 `level: { text: "h1,h2,h3,button,.ui" }` 批量選定文本級子樹,
不必逐個寫屬性;已有 pill/isolate/avoid 設定時,將 `level` 加入**同一個**選項物件。
解析順序如下:
1. 從當前元素(文字則從父元素)開始往上找,包含自身。
2. 每一層先讀合法的 `data-jz-level="text|paragraph"`,再看是否命中 `level.text`。
3. 找到第一個訊號即停止;都沒有才用 paragraph。非法屬性值不構成訊號,繼續查找。
所以**距離優先、同層屬性優先**:近處選擇器可以勝過遠處祖先屬性;同一元素標
`paragraph` 則可以蓋過該元素命中的 `level.text`。在 text 子樹中寫
`data-juzhen="orphan"` 不能開啟避孤字,須先明確切回 paragraph,且該功能全域已啟用。
#### 與 pill/isolate/avoid 搭配
| 組合 | 實際意思 |
|---|---|
| paragraph 正文中的 pill 公式 | 正文有段落約束;公式內部由元件管理,公式整體仍參與正文交界排版 |
| text 標題中的 isolate 標籤 | 標籤內部仍補間隙、處理標點;不因 isolate 就開啟段落功能 |
| paragraph 容器中的 avoid 元件 | 元件子樹仍完全排除;paragraph 不會取消 avoid |
不要用 avoid 代替 text:若只是不想讓按鈕跑避孤字,應標 text,這樣仍保留間隙與標點處理。
也不要用 text 代替 pill:text 仍會改寫內部文字排版,不能用來保護公式或程式碼元件。
#### CSS 可查之 resolved 分類:`data-jz-block-level`(輸出屬性)
作者只寫 **`data-jz-level`**。`render` 後聚珍會對承載可處理非空白文字的塊級元素產生
**`data-jz-block-level`**,供呼叫端 CSS 使用;不要把輸出屬性當作設定值手寫。
```css
/* 依聚珍輸出分類選擇對齊;這是呼叫端的樣式決定。 */
.juzhen [data-jz-block-level="paragraph"] { text-align: justify; }
.juzhen .marginalia[data-jz-block-level="paragraph"] { text-align: start; }
```
純容器(例如只含子段落的 `div`/`ul`)不因包住正文就取得此輸出;域外文字、pill 內部與
avoid 子樹不計入。屬性描述**所屬塊的級別**,不是每個行內子元素的級別;同一段落內個別
`span` 標 text,不會把整個段落輸出改成 text。若需要段落 CSS 跟著切換,應標在該塊本身。
`revert` 會撤回本實例寫入的輸出,依所有權規則保留/恢復作者或其他實例的屬性。
輸入 `data-jz-level` 不由聚珍改寫;`data-jz-block-level` 不參與級別判定,也不觸發分類重算。
#### 標記改動後如何更新
在第一次 `render` **之前**準備好 class、排除與級別標記。已渲染後改動時,手動呼叫
`jz.rerender(changedBlock)`;若開啟 `jz.observe(root)`,受觀察範圍內的 `class`、
`data-jz-skip`、`data-jz-level`、`data-juzhen`/`data-juzhen-off` 變化會觸發增量更新。
自訂選擇器若依賴其他屬性(例如 `data-widget`),不要假設 observer 會追蹤該屬性,請手動
重排受影響區塊。更新時以塊為單位,詳見前面的「動態頁面」。
### 標點懸掛(`hanging`)— 已移除
原計劃之標點懸掛(行端標點凸出版心)經實測為**機制級死路、已移除**(負 margin 程式碼與
CSS 皆刪)。`hanging` 選項仍被接受(API 相容)但**無任何效果**——`hanging:true`/
`data-juzhen`/`blocks` 任一要求皆被靜默忽略,行端僅做半形擠壓。
**為何機制級不可行**(純 CSS 之寬度守恆矛盾):行端標點盒若保持非零寬,則「墨色出去多少、
版心內就空出多少」(行末實義字無法齊版心右緣);若收為零寬,則釋放一整字寬、斷行器把下一字
拉上本行與標點重疊(且 `justifyAtoms` 預設之 inline-block 下負 margin 被 justify 吸收)。
**墨色出去多少版心內就空多少;想不空就得讓斷行器重排這塊寬度,而 CSS hack 接觸不到斷行器**。
補償墊片、兩階段試錯都改變不了此恒等式。
唯一正解為原生 CSS `hanging-punctuation`,惟 **Chrome/Firefox 至今不支援**(僅 Safari),且
本庫之 `jz-char` 為 inline-block 原子、非文本,原生懸掛不作用——未來若做,須是繞開 `jz-char`
的「原生管線」新功能(`text-spacing-trim` + `hanging-punctuation`,按能力門控),與已移除之
程式碼無共享。詳見 `ARCHITECTURE.md §6.5.6`。
---
## 標點半形:`halt` 預設與 `margin` 後備
開明/半角式把標點收成「半形」,有兩種渲染機制:
| 機制 | 設定 | 原理 | 適用 |
|---|---|---|---|
| **`halt`**(預設) | `jiya: true` | 字型 OpenType `halt`,由字型按字形正確定位、不重疊 | 字型含 `halt`(Noto CJK/思源系列) |
| **`margin`**(後備) | `jiya: { halfWidth: "margin" }` | `width:0.5em`+字身依墨色偏側溢出定位(以 CSS 重現 halt) | 字型**缺** `halt`(多數系統字型、GBK 老字型如方正書宋 GBK、PingFang) |
> 為何需要後備:`halt` 缺席時開明式擠壓「完全無效」(標點仍佔全形)。實測
> **方正書宋 GBK 僅含 `vert`、無 `halt`** → halt 模式不擠;切 `margin` 後備後,
> (開明式 zh-Hans)同句行寬 `24.0em → 19.7em`、單一句內點號 `1.0em → 0.5em`,半形生效。
> **句末標點含全角句點 `.`**:句末點號為 `。.!?`——除表意句號 `。`(U+3002)外納入
> 全角句點 `.`(U+FF0E,實測 Noto CJK 有 `halt`)。政策行為與 `。!?` 同:全角式與開明式
> 句末**保持全角 1em**;唯 banjiao(或與鄰標點連續被收)時 `halt` 收為半形 advance 0.5em。
> **`halt` 即字型之「挤壓意願」**:字型不提供某標點之 `halt`(其字形占滿字框、不宜挤半,
> 如某些字型之全角 `?`、`〈〉`)時,聚珍**信任字型、保持全角不強挤**(優雅降級);
> `margin` 後備僅用於**整體無 `halt`** 之字型,逐字型以 `jiya.bias` 調教。
### 激活與粒度
模式由 **`data-jz-halfwidth` 屬性所在子樹**決定(CSS 後代選擇器),故粒度極靈活:
- **全域**:`jiya: { halfWidth: "margin" }` → `render()` 於處理 root 自動掛屬性。
- **逐區塊/逐語言**:在 HTML 任一祖先手寫 `data-jz-halfwidth="margin"`(或
`"halt"`),即令該子樹用對應機制——無需另建實例。常見如「全頁 `halt`,某用
無 `halt` 字型之區塊掛 `margin`」,或反之(`margin` 頁中嵌一塊 `halt` 島)。
```html
<!-- 全頁 halt(預設),此塊改用無 halt 字型 → 局部 margin 後備 -->
<section data-jz-halfwidth="margin" style="font-family: '方正书宋_GBK'">…</section>
```
### 機制與字形偏側表(`margin` 模式專用,可覆寫)
`margin` 模式把標點 `jz-char` 之 inline-block 寬收為 **0.5em(真半形 advance)**,字身
(`jz-inner`)以 `overflow` 溢出該盒,再依**墨色偏側**左移、使墨色落於 0.5em 盒內、
空白溢向外側——即以 CSS 盒模型重現 `halt` 之「收字身寬+按字形定位」。相鄰兩標點各
0.5em、合佔 1em,無鄰接疊加或單側重疊問題。
偏側須知每字之墨色中心。聚珍內建**參考表**(依方正書宋 GBK 實測之 glyph ink 位):
簡體點號 `,、。;:!?` 偏左;繁體點號居中;彎引號 `“”‘’`、角括號 `「」`、尖括號
`〈〉` 墨色強偏一側(開→`right`、閉→`left`);圓/方/書名等括號 `()【】《》…` 墨色
近中→`center`。於 charify 標為 `jz-ink-{left,center,right}`。因字型字身分布各異,可逐
`lang` 逐字覆寫:
```ts
createJuzhen({
jiya: {
halfWidth: "margin",
bias: {
"zh-Hans": { ",": "center", "(": "right" }, // 覆寫參考預設
"zh-Hant": { "。": "center" },
},
},
});
// 偏側值(決定字身於 0.5em 盒內之左移):
// left 墨偏左 → 不移(字身左對齊)
// center 墨居中 → 左移 0.25em
// right 墨偏右 → 左移 0.5em(右半入盒)
// full 無可壓縮空白 → 不收、維持全形
```
> `halt` 模式不讀偏側表(由字型負責定位)。偏側表僅在 `margin` 後備模式生效。
> `demo/fallback.html` 用方正書宋 GBK 對照 halt 失效 vs margin 生效。配字工作流見下節。
### 字型適配工作流(margin 模式配字)
換用新字型時,依下列步驟確認半形渲染:
1. **驗 `halt`**:`python3 tools/measure-bias.py <font.ttf>`(需 `pip install fonttools`)。
首行報該字型是否含 OpenType `halt`。
- **有 `halt`** → 直接用預設(`jiya: true`,halt 模式),無需配字,到此為止。
- **無 `halt`**(多數系統字型、GBK 老字型)→ 用 `margin` 後備,續下。
2. **取偏側建議**:同一工具同時量各標點 glyph 之墨色中心、印出建議偏側表
(`left|center|right|full`)與可貼入 `jiya.bias` 之 JSON。
3. **套用**:`createJuzhen({ jiya: { halfWidth: "margin", bias: <貼入 JSON> } })`;
或全用內建參考表(多數宋體系 GBK 字型與參考表相符,未必需覆寫)。
4. **目視微調**:以 `demo/fallback.html`(或自有頁面)對照原文,逐字檢查——
- 標點與相鄰字**重疊** → 該字墨色比量測更偏該側,偏側往「不移」方向調
(`right`→`center`→`left`)。
- 標點與內容**仍有空隙、不夠半形** → 往「多移」方向調(`left`→`center`→`right`)。
- 某標點根本無可壓空白(字面已滿全形)→ 設 `full`,不收。
5. **記錄**:把該字型之 `bias` 覆寫存入專案設定,作為該字型之適配結果。
> 偏側是「字身於 0.5em 盒內左移多少」的三檔量化(0/0.25/0.5em),量測值近門檻
> (如 0.6 介於 center/right)時,以目視為準。`halt` 字型走 OpenType 由字型精確
> 定位,無此配字需求——`margin` 配字僅為缺 `halt` 字型之務實近似。
---
## CSS 基礎設定(消費端須知)
JS 只注入 `jz-*` 結構,**所有視覺尺寸在 `juzhen.css`**——故 CSS 不可或缺。消費端
須備妥下列幾項:
1. **引入樣式(必須)**:`<link rel="stylesheet" href="dist/juzhen.css">`(或 ESM
`import "cjk-autospace/css"`)。缺此則間隙/擠壓/justify 原子化全部失效。
2. **設定 `lang`(強烈建議)**:在內容容器標 `lang="zh-Hans"`/`"zh-Hant"`/`"ja"`,
驅動全角/開明政策與字形 `locl`。無 `lang` 時回退 `options.lang.default`。
3. **作用域類(用 `scope.include` 時)**:若設 `scope.include: ".juzhen"`,須給內容
容器加對應 class(如 `class="juzhen"`);`juzhen.css` 之 normalize 亦收斂於此域,
不外溢。
4. **長英文詞斷字**:`longWord` 包出 `<span lang="en">` 後,須由消費端 CSS 提供
`hyphens: auto`(含 `-webkit-hyphens`)才會原生斷詞顯示連字符。
5. **`justify`(選用)**:欲兩端對齊則自行設 `text-align: justify`;句末標點單份間距
與行末去隙由聚珍處理,無需額外設定。
6. **字型由消費端負責(聚珍不管字型)**:聚珍**不**內建任何 `font-family`/字型堆疊
——逐語言字型完全由消費端 CSS 決定。聚珍只確保原子(`jz-*`)以 `font: inherit`
繼承消費端字型、絕不強加,並顯式保留 `locl`(見下「字型與 locl」一節)。半形擠壓
依字型 OpenType `halt`:含 `halt` 之字型(Noto CJK/思源)預設即可;**缺 `halt`
之字型**(如方正書宋 GBK)須改用 `jiya.halfWidth:"margin"` 後備,並按字型以
`jiya.bias` 逐字微調墨色偏側(見「字型適配工作流」)。
7. **自動處理、無需消費端介入**:`juzhen.css` 已在 `.juzhen` 範圍內以 `@supports`
關閉 Blink 原生 `text-spacing-trim`/`text-autospace`(避免與託管擠壓重複作用),
間隙不注入空白字元;僅跨節點 IVS gap 之 `U+200B ::after`
設 `user-select:none`,避免該斷行提示進入選取/複製。
8. **段落屬性照常設、原子透明**:聚珍把標點拆成 inline-block 原子,但已確保這些原子對
消費端段落樣式透明——可在段落正常設 `text-indent`(首行縮排)、`hanging-punctuation`、
`text-decoration`(下劃線)等;庫內已歸零會被原子重複套用之繼承屬性、並令原子承接段落
裝飾,故首行縮排正常、下劃線連續不斷,無需任何額外配置(機制見 ARCHITECTURE「原子透明
原則」)。
---
## 字型與 locl(消費端設定)
聚珍**不管字型**;逐語言字型由你以 CSS 設定。推薦範式——逐 `lang` 設 `font-family`,
掛在 `.juzhen` 或帶 `lang` 之容器(**勿**直接設 `jz-inner`/`jz-char`,它們靠繼承取字型):
```css
.juzhen:lang(zh-Hans), .juzhen [lang]:lang(zh-Hans) { font-family: "Noto Serif SC", "Source Han Serif SC", serif; }
.juzhen:lang(zh-Hant), .juzhen [lang]:lang(zh-Hant) { font-family: "Noto Serif TC", "Source Han Serif TC", serif; }
.juzhen:lang(ja), .juzhen [lang]:lang(ja) { font-family: "Noto Serif JP", serif; }
```
要點:
- **字型須含 OpenType `halt`** 才能半形擠壓;否則用 `jiya.halfWidth:"margin"` + 按字型
以 `jiya.bias` 逐字微調墨色偏側(見「字型適配工作流」)。
- **locl(地域字形)**:泛 CJK 字型(思源/Noto CJK)以 `locl` 依 `lang` 選地域字形
(如「直/海/骨」之簡繁日差異)。聚珍**不改寫 `lang`、不關閉 locl**,故只要你 ①
正確標 `lang`、② 用支援 locl 之字型,locl 即正常。body 文本之 locl 全由你的字型+
`lang` 決定;標點(`jz-inner`)之 `halt` 已顯式併入 `locl`,地域標點字形不致失。
- 若只想全語言同一字型,直接 `.juzhen { font-family: … }` 即可(無需逐 `lang`)。
---
## 自定義字符分類(`charClass`)
每個字符的分類(中文/西文、全寬標點各子類)抽象為**規則集**:內置默認規則集即聚珍
的分類表,消費端可經 `options.charClass` **擴充**,以覆寫 `spacing`/`jiya`/`jinze`
等規則對特定字符之判定。語義為**追加**——把當前未分類之字符、或新符號歸入對應類。
```js
createJuzhen({
charClass: {
cjk: "〇", // 追加為中文(CJK):與西文邊界補中西間隙、禁則/justify 視為單字
western: "★☆", // 追加為西文(半寬標點亦歸此):與 CJK 邊界補間隙
biaodian: { // 追加為全寬標點,按子類(jiya charify/擠壓、禁則避頭尾)
stop: "‼", // 追加為句末點號(。.!?已內置)
pause: "", // 句內(,、;:類)
open: "", close: "", // 開/閉括號引號
middle: "", liga: "", // 間隔號/省略破折
},
},
});
```
`.`(U+FF0E,全角句點)已是內置 `BD_STOP` 成員,無需再經
`charClass` 追加;其政策與 `。!?` 一致。`charClass.biaodian` 用於追加尚未內置的
符號(如上例 `‼`),不用來重複宣告內置字符。
要點:
- **向下兼容**:不傳 `charClass` 時,分類與行為與內置默認**完全一致**。
- **追加語義(v1)**:擴充內置類;**不**支援把內置字符「重分類/移除」(屬罕見需求,後續再議)。
勿把同一字符同時加入相衝之兩類。
- **結構性 ASCII**(字母數字 `A-Za-z0-9`、運算符 `+-*/` 等)為固定集、不開放覆寫——它們
服務於運算符/複合詞等內部規則,非「字符類型」。
- `margin` 後備模式下若新增開/閉括號,其墨色偏側未必命中內建參考表,可一併以
`jiya.bias` 指定(見「字型適配工作流」)。
---
## v1 向下相容 — 已移除
v1 相容層(`createCjkAutospace` 導出、IIFE 之 `globalThis.createCjkAutospace`
別名、`autospaceClass` 選項)**已隨最後一批 v1 消費端遷移完成而移除**。既有消費端
請改用 `createJuzhen(options).render(root)` v2 API;v1 原始碼保存於 `v1-autospace`
分支。
---
## 目視驗證
`demo/index.html` 載入 `dist/` 產物,簡繁並列、可切 `justify`、附 copy-clean 測試區。
`demo/e2e.html` 是可機讀的核心版面與 MutationObserver 驗收頁;macOS 上可以
`npm run test:wkwebview` 將同一頁面載入原生 `WKWebView`,自動執行全部斷言。
`demo/fallback.html` 以**方正書宋 GBK**(無 `halt`)對照 `halt` 模式失效 vs `margin`
後備生效。`demo/resize-check.html` 為**可拖曳容器**檢查頁:拖動方框寬度,驗證中西間隙在
各寬度下恆穩(含行末去隙之自我應驗、pill 跨行右隙、貨幣/拼音/西里爾外文補隙、全角省略號
與各式中文標點無誤隙),可切 `justify` 與「顯示間隙」描邊。版面相關結論(justify 單份間距、
擠壓視覺量、行端、`inline-block` 基線、間隙隨寬度之穩定性)須於 Chrome/Safari/Firefox
三引擎目視核對——jsdom 僅驗證 DOM 結構正確性。
---
## 授權
MIT。借鑑 Han.css/Heti 之思路與分類表(MIT),成品自含、不依賴其執行期程式碼。