Files
cjk-autospace/ARCHITECTURE.md
T
admin 2f04ea4085 docs: 效能/增量 API 文檔+I1/I6 收緊(作者空格規範化不還原)
README:新增「動態頁面:增量恢復」(rerender/invalidate/observe/disconnect 用法+元素變化正確性
告示);新增「revert 之範圍」注(revert 淨移除 jz-* 結構、回邏輯等價態,但不還原 spacing 依契約
規範化之贅餘作者空格;需保留原始源者勿以 revert 當「還原到未處理源碼」)。

ARCHITECTURE:§3.1 增量 API;§4.1 記憶化(category+祖先游走)與快取生命週期/失效;§6.1a S1
對齊門。**I1/I6 收緊**(消除與固有 strip 行為之措辭張力,方案 A):
- I1 明示 spacing 規範化贅餘作者空格為刻意之常態例外(語義不變、但確刪空格且不經 revert 還原),
  除此之外預設「只包裝、不改寫文字」。
- I6 明示 revert 淨移除全部 jz-* 結構、回「邏輯等價」態;「邏輯等價≠逐字節」之唯一差異即被規範化
  之作者空格(穩定、不隨反覆 render/revert 累積;revert 後無 [data-jz] 殘留)。

依據:4 份真人撰寫真實文檔驗收——非空白字元多重集守恆、無字元改/增/漏/亂序,唯贅餘作者空格依
契約規範化(doc-A 55、B 31、C 0、D 85 個);真瀏覽器整條管線(含 lineEdge/gapTrim)零報錯。
2026-07-12 02:21:07 +08:00

1012 lines
71 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 句末標點
> 單份間距、跨瀏覽器一致)作調整。
>
> 倉庫名 `cjk-autospace` 不變;公開 API 為 `createJuzhen` / 全域 `Juzhen`
> 自訂元素前綴 `jz-`。本文件先於任何程式碼,作為實作契約——後續每個功能點
> 皆按此落地,以避免不一致與重大返工。
---
## 1. 目標與範圍
1. **第一垂直切片 = v1cjk-autospace)全部功能**,在新引擎上重寫:中西間隙
(Pass A/B)、長英文詞斷詞包裝(Pass C)、開明式標點擠壓(Pass D)。
2. **架構須預留完整復刻 Han.css 的能力**:標點修正、擠壓、懸掛、禁則、著重號、
注音/ruby、樣式正常化、字體偵測、計數器等,皆能在不改動切片 1 既有程式碼
的前提下,以「新增一個 pass」的方式加入。
3. **跨瀏覽器一致**ChromeEdgeSafariWebKitFirefox):一致性關鍵的版面
行為,只用普遍支援的 CSS 原語;原生新特性僅作非必要增強。
非目標:不依賴 Han.css/Heti 等既有庫之執行期程式碼(MIT 條款下可借鑑其思路與
分類表,但成品自含)。
---
## 2. 設計不變量(所有 pass 必須遵守)
這些不變量是「避免返工」的核心。任何新功能點都不得違反。
- **I1 複製乾淨(copy-clean)**:注入之間隙與標記一律 `user-select: none`,不改變邏輯
`textContent` 的**語義**內容。**一項刻意之常態正規化例外**:`spacing` pass 剝除**贅餘之作者
空格**——CJK↔西文之作者空格吸收為受管 `margin` 間隙、CJK↔CJK(含跨 inline 標籤)之作者
空格剝除不留隙。此為 CJK 排版正解(該空格屬裝飾性、間隙改由 margin 承載,語義內容不變),
但**確實刪除空格字元、且此刪除不經 `revert` 還原**(見 I6)。除此常態正規化外,預設「只包裝、
不改寫文字」;其餘真正會改寫文字之 pass(如標點替換 `biaodian`)須為顯式 opt-in 且可 `revert`
還原。(實測 4 份真人撰寫之真實文檔:非空白字元多重集守恆、無任何字元改/增/漏/亂序,唯
贅餘作者空格依此契約規範化。)
- **I2 JSCSS 職責分離**JS 只注入**結構**(`jz-*` 元素、class、`data-jz-*`
屬性);**所有視覺尺寸(marginletter-spacing/寬度/懸掛位移)一律寫在
`juzhen.css`**。同一段 DOM 在 justify 與否、主題與否下都成立。此分離是擴充性
與跨瀏覽器調校的關鍵。
- **I3 依 `lang` 行為**:每個 pass 解析節點的有效 `lang`(最近祖先),行為與
CSS 皆以 `:lang()` 與 locale 政策驅動。全角式/開明式之別即由此決定。
- **I4 跨瀏覽器一致**:間隙、擠壓等一致性關鍵層只用 `margin``letter-spacing`
`inline-block``user-select` 等普遍原語。`text-spacing-trim`
`hanging-punctuation``text-emphasis` 等僅作漸進增強,絕不作為唯一機制。**且當
某原生原語在各引擎支援不一致、會與我們託管之版面衝突或重複作用時,須在
`.juzhen` 範圍內以 `@supports` 顯式禁用之**(如關閉 Blink 的 `text-spacing-trim`
`text-autospace`),使三引擎行為收斂於同一路徑(見 §9.1)。
- **I5 justify 單份間距**:間隙與擠壓的結構設計,須保證每個標點在
`text-align: justify` 下只貢獻**一份**對齊伸縮量(見 §8)。
- **I6 冪等與可還原**:每個 pass 對其產物打標記,重跑跳過;提供 `revert` **乾淨移除聚珍注入之
全部結構**`jz-*` 元素、class、`data-jz-*` 屬性;wrap→解包還原原內容、marker→移除、
splitoff→併回原元素),DOM 回到 pass 前狀態之**邏輯等價**態。「邏輯等價」而非逐字節相同之**唯一
差異**`spacing` 規範化之贅餘作者空格不還原(見 I1)——故 `render→revert` 對含 CJK↔西文作者
空格之源,還原後語義一致、結構全清,唯該空格已正規化(此差異穩定、不隨 `render``revert` 反覆
累積)。`revert` 後 DOM 內無任何 `[data-jz]` 殘留(實測驗收成立)。
- **I7 邊界感知**:跨樣式行內元素的鄰接(如 `<strong>是</strong>A`)須能識別並
正確處理——由核心 finder 統一提供,各 pass 不自行重複造輪。
- **I8 向下相容 v1(已退役)**:相容層曾保障 v1 消費端(build.sh 單檔串接、Nvim
peek、Claude 模板)過渡期不 break;消費端遷移 v2 API 完成後相容層已移除(見
§3.5),此不變量隨之退役、編號保留不復用。
- **I9 遍歷聚合**:邏輯上「一功能一 pass」,但執行上**相容之 pass 共享單次遍歷**
(見 §3.4),以減少全樹走訪次數;聚合不得犧牲 I6(冪等可還原)與逐功能開關。
- **I10 作用域與分塊**:作用範圍可由 `scope.include` 選擇器限定(opt-in);功能集
可逐塊覆寫(屬性或 `blocks` 映射),由 pass 在 `collect` 內以 per-node 閘解析
(見 §3.6)。CSS 視覺規則須與 JS 作用域同域收斂,不外溢。
---
## 3. 整體架構
### 3.1 對外 API
```
const jz = createJuzhen(options);
jz.render(root?); // 對 root(預設 document.body)套用啟用之 pass
jz.revert(root?); // 還原
// 增量恢復(動態頁面;root 皆可為局部 Element,成本 O(該塊) 而非 O(全文))
jz.rerender(root?); // revert(root) + render(root)(便利)
jz.invalidate(root?); // 清該子樹之內部快取(改屬性但不 revert 時用;revert 已自動失效)
jz.observe(root?); // 以 MutationObserver 自動增量恢復(opt-in;自身注入不自觸發)
jz.disconnect(); // 停止觀察
// 全域: window.Juzhen.createJuzhen(...)
```
**增量恢復(第三層)**`render``revert` 皆收局部 Element,配合**持久化 Finder 快取**(§4.1
末)與快取失效,頁面局部變化只重處理受影響子樹。`observe``MutationObserver`:內容/分類屬性
變化 → 自動 `revert`+`render` 最近塊、新增之塊直接 `render`(免全容器重掃);renderrevert 期間
**暫停觀察**,故 juzhen 自身之 jz-* 注入絕不自觸發。實測編輯 400 段中之一段,局部恢復較全樹快數
百倍。**元素變化正確性**見 §4.1 末「快取生命週期與失效」。
`options` 形如:
```
{
// 全域。style = 全域擠壓預設(quanjiaokaimingbanjiao);policy = 逐 lang
// 覆寫。優先序:policy[lang] > style > 內建預設(繁→全角、簡→開明)。
lang: { default: "zh-Hant", style: "kaiming", policy: { "zh-Hant": "quanjiao" } },
classPrefix: "jz",
// 作用域限定(§3.6
scope: {
root: document.body, // 預設處理根;render(root) 可逐次覆寫
include: null, // 啟用選擇器;設則僅處理命中子樹(opt-in)
// 如 ".juzhen, [data-juzhen]"
avoid: null, // 追加排除子樹之選擇器(預設 null,與內建 skipTags 名單並存;見 §4.1
},
// 全域逐功能開關(true/false 或細部物件),預設見各 pass
spacing: true, // 中西間隙
longWord: { minLength: 6, lang: "en" },
jiya: true, // 標點擠壓
jinze: true, // 禁則(含 justify 綁定)
hanging: false, // 標點懸掛(預設關,繁體橫排不宜)
biaodian: false, // 標點修正(opt-in,可能改寫文字)
emphasis: false,
ruby: false,
// 分塊功能選擇(§3.6):選擇器 → 該塊內生效之功能集(覆寫全域)
blocks: [
{ selector: ".prose", features: ["spacing", "jiya", "jinze", "emphasis"] },
{ selector: ".code-comment", features: ["spacing"] }, // 此塊只間隙、不擠壓
],
}
```
### 3.2 Pass 管線
每個功能點是一個 `Pass`
```
interface Pass {
name: string;
phase: "P0" | "P1" | "P2"; // 遍歷階段,供聚合(§3.4)
order: number; // phase 內 apply 先後(charify→擠壓→間隙→…)
enabled(options): boolean;
// 聚合模式:collect 純判定(同階段共享單次走訪),apply 批次變更
collect(node: Text, ctx: RenderContext): Target[];
apply(targets: Target[], ctx: RenderContext): void;
revert(ctx: RenderContext): void;
}
```
需獨立走訪/layout 之 pass(如 hanging)可改採 `render(ctx)` 整段式而不參與
collect 聚合;執行器兩種皆容納。
`createJuzhen` 內建一個**有序、可開關**的 pass 註冊表。`render``order` 執行
已啟用者。順序契約(避免互相破壞):
```
10 biaodian 標點修正(若開,最先;可能改寫文字/包裝)
20 charify 標點字元包裝(jz-char/jz-inner)——擠壓、禁則、著重號之共同前置
30 jiya 標點擠壓(依 class + :lang 之 CSS
40 jinze 禁則 + justify 綁定
50 spacing 中西間隙(jz-hws
60 longWord 長英文詞 lang 包裝
70 emphasis 著重號
80 ruby 注音/ruby
90 hanging 標點懸掛(需 layout,最後;掛 ResizeObserver
```
> 順序理由:charify 先把標點變成可定址的 `jz-char`,擠壓/禁則/著重號才有對象;
> 間隙在標點結構穩定後插入;懸掛依賴最終行盒,必須最後且 layout 後再算。
### 3.3 RenderContext
`render``revert` 收到統一上下文:`{ root, options, finder, locale }`。各 pass
共用同一 `finder` 實例與 locale 解析,確保 avoid 名單、邊界規則、語言判定一致。
### 3.4 遍歷聚合(減少 pass 遍歷次數,I9)
樸素做法是每個 pass 各跑一次 `TreeWalker`,9 個 pass 即 9 次全樹走訪。為此引入
**遍歷階段(phase**:相容之 pass 共享單次走訪。Pass 介面拆為
`collect(node, ctx) → targets[]`(純判定、不變更 DOM)與 `apply(targets, ctx)`
(批次變更),執行器:
```
for phase in phases:
walk(root) 一次:對每個文本節點,依序呼叫該 phase 內各 pass 的 collect()
依 order 對收集到的 targets 做 apply()(兩階段:先收集後變更,避免遍歷中改 DOM)
```
階段劃分(把 ~9 次走訪壓到 3 次):
| phase | 內含 pass | 走訪 | 備註 |
|---|---|---|---|
| P0 文字修正 | biaodianopt-in | 1 次 | 唯一可能改寫文字者,須最先 |
| P1 行內結構 | charifyjiya 標記/jinzespacinglongWordemphasis | 1 次 | 共享走訪,`apply` 依 order |
| P2 版面後處理 | hanging | 0 次走訪+layout | 需最終行盒,掛 ResizeObserver |
> 聚合僅是**執行優化**:邏輯上仍「一功能一 pass」、可獨立開關與 `revert`I6);
> 只是 P1 諸 pass 的 `collect` 在同一走訪內完成。若某 pass 不相容(需獨立走訪),
> 退化為自跑一次,不影響正確性。
### 3.5 v1 向下相容層 — 已移除(I8 退役)
相容層(`src/compat/v1.ts``createCjkAutospace` 導出、IIFE 之全域別名、
`autospaceClass` 選項)曾把 v1 API 映射到 `createJuzhen`,服務 build.sh 單檔串接/
Nvim peekClaude 模板等 v1 消費端之過渡期。**最後一批 v1 消費端遷移 v2 API 完成後
已整體移除**`autospaceClass` 經查從未被任何 pass 讀取,屬死選項,一併刪)。v1
原始碼保存於 `v1-autospace` 分支。
### 3.6 作用域與分塊功能選擇(I10)
**作用域限定**——控制「在頁面何處生效」,避免污染無關內容:
- `scope.root`:處理根(節點或選擇器),預設 `document.body``render(root)` 可逐次
覆寫;允許多實例/多根並存。
- `scope.include`**啟用選擇器**(opt-in 模式)。設定後,**僅處理命中該選擇器之
子樹**(如 `".juzhen, [data-juzhen]"`),其餘一概不動——此即「限定作用範圍」。
- `scope.avoid`:排除選擇器,與內建 avoid 合併。
- **CSS 同域**`juzhen.css` 之視覺規則一律收斂於同一啟用選擇器(如 `.juzhen` 下),
與 JS 作用域一致,杜絕樣式外溢(呼應 I2、§9.1 之禁用層亦限於此域)。
**分塊功能選擇**——不同塊啟用不同功能集:
- **宣告式(屬性,就近繼承)**
- `data-juzhen="spacing jiya"`——白名單:此塊內僅啟用列出之功能。
- `data-juzhen-off="jiya hanging"`——黑名單:此塊停用列出者,其餘繼承。
- `lang`——驅動全角/開明(正交於功能開關)。
- **程式式**`options.blocks: [{ selector, features }]`,選擇器 → 功能集。
- **解析(per-node 閘)**:對每個節點,向上找最近帶功能指令之祖先(屬性或命中
`blocks` 選擇器者)決定其**有效功能集**,無則用全域 `options`render 期間以
元素為鍵快取。每個 pass 之 `collect(node)` 先問 `featureEnabledFor(node, name)`
否則跳過——**故分塊閘只是單次走訪內的 per-node 判定,不增遍歷、不破壞聚合
(§3.4**。
- **與 revert 之關係**`revert` 依標記移除某功能產物,**不受當前閘狀態影響**,故
「關閉某功能 + revert」恆能乾淨還原(I6)。
---
## 4. 核心基座(`src/core/`
### 4.1 `finder.ts`——文本遍歷引擎(取代 Fibre.js)
職責(所有 pass 的共同地基):
- **遍歷**`TreeWalker(SHOW_TEXT)``root` 下文本節點。
- **avoid**avoid 之**七源**`isSkipped``skipTags` 名單/SVG`contentEditable`
`data-jz-skip``skipAttribute``scope.avoid`)任一命中即跳過其子樹。內建 `skipTags`
`pre/samp/tt/var/script/style/textarea/input`**不含 code/kbd**——二者由 `pillSelector`
以 pill 涵蓋,三分類互斥)。自身判定用 `avoidsSelf`、自身或祖先之組合用 `isAvoided`
- **scope.include**:若設啟用選擇器,僅走訪命中之子樹(opt-in,§3.6)。
- **featureEnabledFor(node, name)**:解析該節點之有效功能集(§3.6),供各 pass 之
`collect` per-node 閘用;結果以元素為鍵快取。
- **charify**:把符合條件之字元/字串包進指定 `jz-*` 元素(兩階段:先收集目標、
後變更,避免遍歷中改 DOM 失效)。
- **邊界感知 replace(I7)**:對「跨相鄰文本節點、可跨樣式行內元素」的邏輯字串
做正則 find,回呼決定如何包裝/插入標記。樣式行內元素(`strong/em/a/span/…`
透明穿越;block/pillavoid 中斷邏輯串。此即 v1 Pass A「剝離樣式收集邏輯文本
再反映射」之一般化,提升為可重用引擎。
設計要點:finder 不知道任何具體排版規則;它只提供「在何處、跨何種邊界、對哪些
字元」的遍歷與安全變更原語。規則由各 pass 以 unicode 表 + 回呼注入。
**行內元素三分類(下游問題六後確立)**:行內元素對排版有且僅有三種口徑。**邏輯文本流**之
遍歷原語(`collectRuns``adjacentLogicalChar``jiyaAdjacency` 之相鄰掃描、orphan 之實義字
收集)共用單一判定 `isTransparent`。**未在 `styleInlines` 之未知行內標籤採嚴格白名單、一律
當隔離式邊界**(斷 run/相鄰、內部自成脈絡),透明須顯式登記 `styleInlines`(另兩層見下)。
三口徑:
- **透明**`styleInlines` wrap 類 `jz-*`)——穿越、run 延續、禁則可克隆切分。
- **隔離**`isolateSelector`,預設 `mark`)——邊界斷 run/斷相鄰/不可切分、兩端
補隙(同 pill),**內部照常處理**(自成脈絡)。典型:帶背景色之 `<mark>`(切分
背景盒有可見接縫,故亦自 `SPLITTABLE` 白名單移除)。
- **整體**`pillSelector`)——原子單位:內部對一切 pass **不可見**`eachTextNode`
過濾 `insidePill`,不 charify/不插間隙/不切分/不下潛),禁則時作為原子鄰字
單位**整體綁入** `jz-jinze`。典型:`code``kbd`、行內公式 `.katex`
**單一分類 `finder.category(el)`(優先序)**:一元素恰屬六類之一,多選擇器同命中時依序短路
定案——`avoid pill isolate block transparent opaque``opaque`=未在 styleInlines
之未知行內標籤,嚴格白名單下當隔離式邊界)。故帶 `data-jz-skip` 之 pillisolate 判為 avoid
同命中 pill 與 isolate 判為 pillpill 覆蓋 isolate)。邏輯文本流之遍歷原語(`collectRuns`
`jiyaAdjacency``isTransparent`)皆 `switch(category)` 分派,`canSplit` `SPLITTABLE ∩ transparent`
——分類集中一處、優先序不再散落各原語各自短路(階段二後之清晰化)。
**效能(記憶化)**:三處逐元素/逐節點之重複計算以 `WeakMap` 記憶化——
- **S3 category**`category` 含至多三次 `el.matches()`pillisolateavoid),且被上列各遍歷原語跨
pass 重算 → `categoryCache` 砍除跨 pass 重複。
- **祖先游走(`insidePill``isAvoided`**:舊版逐文本節點沿祖先鏈調 `pillMatches``avoidsSelf`
`eachTextNode` 每文本節點一趟、`collectRuns` 再一趟),O(文本節點 × depth)、深 DOM 下二次 →
`ancestorOrSelfPill``…Avoided` 以「沿鏈迭代回填」記憶化(每元素恰算一次、兄弟共用祖先鏈快取),
收斂為 O(元素數)。`insidePill``el.matches()` 之最大來源;記憶化後掃描層 `el.matches()` 累計約降
五成、深 DOM render 時間顯著下降。
**快取生命週期與失效(元素變化正確性)**Finder 隨實例**持久化**(跨 renderrevert 復用上列快取,
非每次 render 重建),使頁面局部變化時未變子樹之分類/級別/功能免重算。元素鍵 `WeakMap`:移除之元素
自動回收、新增者重新計算。**屬性變化**classdata-jz-*lang 等改變既有元素之分類/級別/功能)會令其
快取變脏——且因級別/功能/祖先游走依賴祖先鏈,某祖先變則其**整棵子樹**之快取變脏,故 `invalidate(el)`
清 el 子樹全部元素鍵快取。`revert(el)` **自動** `invalidate` 其子樹(安全預設),故 `revert``rerender`
`observe` 路徑天然正確;`invalidate(el)` 另供「改屬性但不 revert」之顯式入口(見 §3.1 增量 API)。
判定**以元素為準、非標籤名**`isTransparent(el) category(el)==="transparent"`,前置排除 pill
isolateavoid
再查標籤集):KaTeX 全樹皆 `<span>`,舊版按標籤名判透明曾令 `canSplit``descend`
整棵下潛——禁則把公式內側末字連同完整殼層逐層克隆切出(`splitoff`),上標漂移錯位
(下游問題六);`pillSelector``skipAttribute``scope.avoid` 三層防護全被刺穿。
同根修復:**avoid 自身判定** `avoidsSelf(el)` 供遍歷原語逐元素下探自判;一次性「是否
avoid」用組合入口 `isAvoided`(=自身或祖先,階段二歸一,免「avoidsSelf ‖ isAvoided」
漏一半致自身命中 avoid 時仍被穿越)。`adjacentLogicalChar``descend` 改三態
(邊界字/無內容續找兄弟/**blocked** 終止——遇 pillisolateavoid 不可跳過續找
更遠之字,否則重蹈「越過 `jz-char` 取更前字 → 誤隙」之同型錯誤);`core/split`
`canSplit` 諮詢 finderpillisolateavoid 一律不可切、退化整綁)。
**「行內透明」判定之分層(階段二確立)**:「透明穿越」曾有兩套口徑散落——`collectRuns`
默認透明、`isTransparent``adjacentLogicalChar` 默認邊界——同一未登記行內標籤(如 `<button>`
在不同原語被判為兩種身份、切出相互矛盾之邏輯串。**階段二歸一**:邏輯文本流共用單一判定
`isTransparent`(列入樣式行內標籤、`jz-hws``jz-jinze` wrap 群組、**`jz-char``jz-inner`
標點原子**——後者必須透明,否則 `adjacentLogicalChar``descend` 遇標點原子既不進入亦不
終止而**跳過**它續找更前之字:`漢字。<code>` 綁定後 pill 左鄰字應為「。」卻誤取「字」補
`jz-hws`);未登記之未知行內標籤採嚴格白名單、一律當邊界。
**另兩層各有正當之不同邊界、刻意不併入 `isTransparent`**(承 lineedge#4 之教訓:正當差異不硬併):
- **結構克隆切分安全**`core/split``canSplit`)——更窄白名單(`STRONG/EM/…`,排除
`<a>``ABBR``RUBY`/帶 `id` 等——切分會重複語義/結構),故「可透明穿越」⊋「可克隆切分」。
- **物理版面**`lineedge``edgeRect`)——依 `getClientRects` 之物理相鄰判行首/行尾,攀爬集
`styleInlines` 派生(免遺漏 `ins``del``sub` 致行端誤判 T4),且**須跨 isolate**`<mark>`
找真實相鄰內容:isolate 斷的是**邏輯** run/相鄰,非**物理**位置(`文字<mark>「引」</mark>`
`「` 物理上接在「文字」後、非行首,若停在 mark 邊界會誤判行首而錯收左半)。
### 4.2 `unicode.ts`——字元分類與規則表
集中所有 Unicode 範圍與標點子類,供各 pass 共用,杜絕分類散落不一致:
- **CJK**:表意文字、假名、注音符號等範圍(沿用並擴充 v1 之 `PANGU_CJK`)。
- **西文**:拉丁字母、數字、常見符號(v1 之 `AN_CHARS` 等)。
- **標點(biaodian)六子類**(對應 `jz-char``bd-*` class):
- `bd-stop` 句末點號:`。.!?`(開明式與全角式保持全形,banjiao 擠半)
- `bd-pause` 句內點號:`,、;:`(開明式擠半形)
- `bd-open` 開始括號/引號:`「『(《〈【〖〔[{“‘`
- `bd-close` 結束括號/引號:`」』)》〉】〗〕]}”’`
- `bd-middle` 間隔號:`·・‧`
- `bd-liga` 省略/破折:`…—`
- **規則正則**:中西間隙邊界、擠壓相鄰組合、禁則對象等,皆以此表組裝。
**規則集抽象(`Ruleset``charClass`,可配置)**:字符分類不再各 pass 硬編碼,而統一為
一個**規則集**——`{ cjk, ansAfter, ansBefore, biaodian{6 子類} }`,各為 regex 字元類片段。
`DEFAULT_RULESET` 即內置默認(上述 CJK/西文/BD_* 值)。消費端經 `options.charClass`
`{ cjk?, western?, biaodian? }`**擴充**`resolveRuleset(override)` 以**追加**語義合併
為最終規則集,置於 `ResolvedOptions.ruleset`。各 pass 由此取分類:
- `spacing``makeRules(ruleset)``cjk``ansAfter``ansBefore` **per-instance 編譯**
全部間隙正則(createJuzhen 各一次,非每 block);`ALNUM``ALPHA`/運算符/括號等結構性
ASCII 集為固定常數、不隨規則集變。
- `jiya``classifyBiaodian(ch, ruleset.biaodian)` 決定 charify 與 bd-* 子類。
- `jinze``anyCjk = RegExp(ruleset.cjk)` 決定詞元邊界之 CJK 判定。
**設計取捨**:① **向下兼容**——不傳 `charClass``ruleset === DEFAULT_RULESET`,行為與
重構前完全一致(現有測試全綠)。② **追加語義(v1**——擴充內置類(把未分類字符如英文全角
句號 ``U+FF0E 歸入、或納新符號);不支援重分類/移除內置字符(範圍式集合難以挖洞、且需
求罕見,列為後續)。③ 結構性 ASCII 不開放(非「字符類型」)。
> 借鑑 Han 之 `regex/typeset.js`、`regex/unicode.js` 與 Heti 之分類字集,重寫為
> 現代 TS 常數表+可配置規則集。
### 4.3 `locale.ts`——語言與全角/開明政策
- `effectiveLang(node)`:最近祖先 `lang`(小寫),無則回退 `options.lang.default`
- `classify(lang)`:歸為 `zh-Hans` / `zh-Hant`(含 `zh-TW`/`zh-HK`/ `ja` / 其他;
BCP-47 前綴邊界比對(`zh` 命中 `zh-Hant`,不命中 `zhx`)。
- `resolveStyle(node)`:回傳節點之有效標點寬度政策——`quanjiao`(全角式)|`kaiming`
(開明式)|`banjiao`(半角式)。**優先序**:**逐元素覆寫**(`resolveStyleOverride`:自身及
祖先鏈掃 `data-jz-style` 屬性/`stylePolicy` 選擇器命中,近祖先勝、同層屬性優先於選擇器)
逐 lang `policy` 全域 `style` 內建逐語言預設(`zh-Hant`/`zh-HK` → 全角、`zh-Hans`
開明)。逐元素覆寫供「非段落/UI 元素用半角、段落用全角/開明」等需求(`data-jz-style` 屬性
值非法則忽略、回退 lang)。此純為「按元素選樣式」之路由,`banjiao` 半形渲染沿用 `halt`
`margin`(§6.3),無新增壓縮邏輯。
CSS 端對應以 `:lang()` 選擇器落實 lang 層政策(I2/I3);逐元素覆寫屬 JS 端 baseline 判定。
### 4.4 `dom.ts`——`jz-*` 元素工廠與還原
`createEl('jz-char', {class, attrs})``wrap(node, el)``splitAt``insertMarker`
冪等標記(`data-jz="<pass>"`)、`revertPass(name, root)`(移除該 pass 之產物、
解包 `jz-*`、合併回文本節點並 `normalize()`)。
`data-jz-kind` 區分產物還原方式:`marker`(純注入,移除)、`wrap`(包裝原內容,解包)、
**`splitoff`**(行內元素克隆切分之邊界詞元,見 `core/split.ts`/§6.4)。`revertPass`
**兩階段**處理:主迴圈逆序解包 marker/wrap,第二階段再把 splitoff 併回前兄弟(同標籤
原元素)——順序必要,否則 splitoff 之前兄弟可能仍是 wrap 殼(如 `jz-jinze`)而非原元素。
---
## 5. 渲染元素詞彙(`jz-*`
自訂元素皆含連字號(合法 custom element 名),預設 `display: inline`(未知元素
天然 inline),語義中性,可被 `revert` 移除:
| 元素 | 用途 |
|---|---|
| `jz-hws` | 中西間隙。`user-select:none`copy-cleanjustify 安全(§8 |
| `jz-char` | 包裝單一標點/漢字,承載 `bd-*` class 與 `data-jz` 屬性 |
| `jz-inner` | `jz-char` 內層字面容器,供 marginletter-spacing 擠壓而不動外盒 |
| `jz-cs` | 字間補償空白(連續/行端擠壓用,於行邊界折疊) |
| `jz-jinze` | 禁則群組:綁定不可斷單元(亦為 justify 綁定載體) |
| `jz-orphan` | 垂懸字避免群組:段末末 N 實義字 `nowrap`(§6.5.1 |
| `jz-em` | 著重號載體 |
| `jz-ruby``jz-rb``jz-rt` | ruby/注音 |
class 約定:`bd`(標點)、`bd-open/close/cop/stop/sep/liga``cjk``jiya`
`jiya-consecutive``hangable``jinze-{tou,wei,touwei}` 等。
---
## 6. 功能點逐項實作方案(對照 Han.css)
每項列出:Han 做法 → 我們做法 → 跨瀏覽器 → lang → 我們的調整 → 切片歸屬。
### 6.1 中西間隙(HWS,漢字↔西文)— 切片 1
- **Han**:插入 `<h-hws hidden>`,內含**真實空格** + `::before{content:' '}`
**未加 `user-select:none`** → 選取/複製會帶上空格(其已知缺陷)。
- **我們(margin 模型)**finder 邊界感知地把邊界**左側之一個字**(CJK 或西文)
包進 `jz-hws`,間隙由 CSS `margin-right`(0.25em)提供——**不注入任何空白字元**。
WHERE 之規則沿用 v1 pangu 規則表(已驗證);作者原寫之 CJK↔西文空格一律移除
(改由 margin 留隙)。
- **為何不用空白字元/inline-blockjustify 三難)**:純 CSS 下,CJK↔西文間隙在
`justify` 必有一個邊緣瑕疵——①真實空白→被當詞間空白**暴增**;②inline-block 固定
寬→罕見落**行首縮排**;③`margin-right`→**行末右緣內縮 0.25em**。選 ③(margin),
因其行首乾淨、不撐框、不暴增、`textContent` 完全不變(純 copy-clean,複製緊湊),
並以 §6.1a 之 layout pass 消除行末內縮。
- **跨瀏覽器**`margin`inline 普遍支援。
- **copy-cleanI1,較 Han 更純)**:間隙為純視覺 margin,`textContent` 不增不減
語義字元,剪貼板得緊湊原文。
- **切片**:涵蓋 v1 Pass A、Pass Bpill `code/kbd` 邊界亦走同一機制——左字或
pill 元素包入 `jz-hws`pill 由 avoid 與鄰接判定處理)。Pass B 之邊界選擇器為
**pill isolate**(§4.1 三分類:隔離元素兩端補隙口徑與 pill 一致,差異僅在
內部處理;巢於 pill 內者跳過)。
- **邊界正規化一致性(下游回饋 1a/1b 修)**:
- **1a pill 穿越 jz-jinze**jinzeorder 40)先於 spacingorder 50),會把尾隨
標點之 pill 納入 `jz-jinze``adjacentLogicalChar` 須**透明穿越 `jz-jinze`**
(與 `jz-hws`、樣式行內同列,見 `finder.isTransparent`),否則 pill 左鄰字
不可達、左緣間隙與作者空格剝除雙失。
- **1b CJK↔CJK 贅餘空格**:中文不用詞間空格,故 CJK 表意字之間的單一 ASCII 作者
空格(含跨樣式行內標籤,`collectRuns` 已透明合併)一律**剝除、不留隙**(以
`DEL` 哨符 1:1 替換空格保持對齊,`processBlock` 據此刪空格而不插 `jz-hws`)。
與 CJK↔Latin 之剝除一致——凡 inline 邊界之贅餘作者空格皆統一處理。全形空格
U+3000 不動。
- **西文字集之完整化(下游回饋 D;列舉法)**:CJK↔西文之邊界由 `unicode.ts`
`ANS_AFTER_CJK``ANS_BEFORE_CJK`(共用片段 `WEST_SHARED`)界定。原僅涵蓋拉丁基本
+拉丁-1+部分希臘,致**貨幣符號**(`€` U+20AC 等整塊 U+20A020BF)、**拼音聲調**
`ā ǎ ē` 等拉丁擴充 A/B U+0100024F)、**西里爾**、**數學運算符**U+220022FF
不被視為西文 → 鄰接 CJK 不補隙、手工空格亦不剝除。改採**逐區塊列舉**補全(見
`WEST_SHARED` 註記),並把希臘由原 U+0370–037D 補全至整塊 U+037003FF(原漏
`Αα-ω` 本身)。**取捨**:選列舉(whitelist)而非 `\p{…}` 類別法,因類別法之
集合減法須 `v` flagES2024),會破壞 2016Windows 10 時代相容性下限;列舉法所有
範圍皆 BMP、以 `\uXXXX` 表示於字元類內無需任何 flag。代價:新區塊需手補。
- **全角省略號不誤判(下游回饋 B)**:`DOTS_CJK` 原以 alternation `([\.]{2,}|…)`
匹配「省略號後接 CJK 補隙」,但 `|…` 誤把**全角省略號 U+2026** 納入;全角 `…/……`
屬 CJK 標點(已由 jiya charify 為 `bd-liga`),後接 CJK 不應有中西間隙。移除 `|…`
僅留 ASCII `...`
### 6.1a 行末去隙(gapTrimjustify 修正)— 切片 1
- **問題**margin-right 落在行末時,justify 下使右緣內縮 0.25em。
- **我們**layout pass `gapTrim` 量測各 `jz-hws`,對「其字落於行末(後一內容在
更低行)」者加 `jz-hws-trim`CSS `margin-right:0`),該行遂齊右、無內縮。掛
`ResizeObserver`+字型載入後重算。純版面、瀏覽器專用(jsdom 跳過)。與 `lineedge`
共用量測工具與 layout 安裝器。
- **自我應驗誤判(下游回饋 C/容器寬相關之「最後一個間隙消失」)**:間隙之 margin-right
會**參與斷行**。若依「後字是否在更低行」判行末,而後字落點又被 margin 推擠,便自相矛盾
——間隙 margin 把後字擠到次行 → 判行末 → trim 去 margin → 後字回同行,振盪停在「已
trim、後字同行、間隙消失」之錯誤終態(`ResizeObserver` 只觀 root 尺寸、不因內部 margin
變化重觸發,故卡死;resize 容器則該處重判、間隙「恢復」或轉移)。**污染雙向**:不僅當前
間隙自身 margin**下游間隙之 margin** 亦會令夾在中間之內容(如 `或<gap>$1299<gap>`
放不下其行、致後字假性換行。
- **修法**`trimGaps` 改為**起始全部 trim**margin 全 0),再**由左至右逐一定案**。量測
某間隙時其前皆已定案、其自身與其後全部仍 trim,故後字落點不受任何 margin 污染,純粹反映
「無本間隙 margin 時後字是否仍被擠到次行」:仍在更低行(或無後字)→ 真行末、維持 trim;
在同行 → 行內容得下、非真行末 → 還原間隙。逐一必要(斷行左→右因果);以末行 rect
`lastRect`,margin 實際落點)為準,兼顧跨行 pill(行尾 `code` 折行亦曾致右側間隙誤消)。
實 Chromium 多寬度(822em、raggedjustify)掃描確認:偽 trim 由「某些寬度存在」歸零;
真行末仍 trimjustify 齊右不變)。
- **效能(S1 對齊門)**:逐一定案為「左→右斷行因果 + 讀 `getClientRects` 觸發同步重排」之固有
代價——每間隙一次重排,gap 密集之頁面達 O(gaps) 次重排(實測 Chromium 上 ~19k gap 之頁面
render 達數秒、且 `gapTrim` 佔約 99%)。此代價**唯 justifyrightcenter 等「右緣 flush 或含
margin 置中」之對齊才需付**:尾隨 `margin-right` 之可見性依對齊而定——`left``start`(ltr) 之右緣
ragged、行末 margin 落於不可見側,去隙與否視覺無別。故 `trimGaps` 按最近容器之 computed
`text-align``direction` 過濾:**`left``start`(ltr) 之間隙整批跳過**(省其全部重排),其餘對齊
justifyrightcenterendRTL)保留完整逐一定案、行為**不變**。實測非兩端對齊之常見內容
`gapTrim` 由數千毫秒降至數十毫秒(getClientRects 歸零);justify 內容之成本不變(固有)。門以
per-parentElement memo`text-align``direction` 皆繼承)攤薄。
### 6.1b 標點稽核:CJK 標點不生誤隙(下游回饋)
- **不變量**:任何現代漢語標點介於 CJK 之間,皆**不**補中西間隙。實 Chromium 稽核全部
常用標點(。,、;:?!…… —— ·~ 簡繁引號 “”‘’「」『』、各式括號 ()【】《》〈〉
[]{}〔〕〖〗 等)× 簡繁兩政策共 104 例,jz-hws 皆 0、textContent 不變。
- **修正之偏例**:①全角省略號 `……`(§6.1 之 B`DOTS_CJK``|…` 誤匹配,已移除);
②收尾彎引號 `”`(原 pangu `QUOTE_AN` 規則令 `”` 後接拉丁/數字補隙,使其成為唯一會補隙
之收尾標點,與 `」』)》】〕` 皆不補不一致;clreq 下全角收尾標點字身已內含右側留白,後接
拉丁不需額外隙——已移除 `QUOTE_AN` 以求一致、消除誤隙)。
### 6.2 長英文詞斷詞包裝 — 切片 1
- **我們**:把 `[A-Za-z]{N,}` 長串包進 `<span lang="en">`,配合消費端
`hyphens:auto` 由瀏覽器原生斷詞渲染連字符(DOM 不含連字符 → 複製不污染)。
直接沿用 v1 Pass C。
- **跨瀏覽器**`hyphens` 各家支援良好(含 `-webkit-`)。
- **切片**v1 對等。
### 6.3 標點擠壓(Jiya)— 切片 1
- **Han**charify 標點為 `<h-char class="bd-jiya …"><h-inner>`,以負
`letter-spacing``margin` 擠壓;連續、行端、簡繁差異以 CSS + `:lang()` 落實
`bd-cop:lang(zh-Hant)` → 不擠=全角式)。
- **我們**:同構——charify → `jz-char.bd-*` + `jz-inner`;半形與否寫在
`juzhen.css`,由 JS 解析 `locale.style`(讀 lang)後加 `jz-half``jz-half-le`
class 觸發(I3;改用 class 而非 CSS `:lang()`,以避免 `:lang(zh-Hans)` 不匹配
`zh-CN` 等地區標籤之陷阱)。三機制並集落地:①**baseline 政策**(全角不擠/
開明擠句內點號+括號引號/半角全擠);②**行內連續擠壓**(`jiyaAdjacencyPass`
lang 無關,見下「連續標點挤壓規則」);③**行端擠壓**(`lineedge.ts` layout pass)。
- **跨瀏覽器(I4)— 修訂**:半形改以字型 OpenType **`halt`**`font-feature-
settings:"halt"`)渲染,**非**負 `margin`。原因:負 margin 假設可壓縮空白全在
單側,對**居中字形**`!?`、繁體 `locl` 下之 `。,`)會把字面拉入鄰字而**重疊**
`halt` 收的是字身寬度、由字型按字形正確定位(簡左/繁中/括號偏側皆對),故
不重疊。`font-feature-settings:"halt"` 各引擎一致(當初疑慮之不一致者為**自動**
的 `text-spacing-trim``chws`,非顯式 `halt`);缺 `halt` 之字型優雅降級為全形。
鄰接/行端之「收哪一字」之**決策**仍由 `blankSides` 邏輯定,渲染統一為 `halt`。
- **句末標點集含全角句點 ``(下游回饋)**`BD_STOP` 為 `。.!?`——除表意句號 ``
(U+3002)外,納入**全角句點 ``**U+FF0E)。實測 Noto CJK SCTC 之 `halt`GPOS
SinglePos 調 advance)皆覆蓋 ``、挤壓收 0.5em,與 `` 一致;`inkBias` 同步補 ``
(簡 left/繁 center),margin 後備亦可挤。政策行為與 `。!?` 同:quanjiaokaiming 句末
保持全角、banjiao 挤半。
- **halt 即字型之「挤壓意願」(設計原則)**:字型對某標點提供 `halt` =該字形適合挤半;
**不提供**=該字形不宜挤(占滿字框,如 Noto TC 之全角 `` 與 SC 不同、`〈〉` 無 halt)。
故聚珍**信任字型 halt 覆蓋**:有 halt 字型中個別無 halt 之字符,**保持全角、不強挤**(優雅
降級),不為其特設規則。`margin` 後備僅用於**整體無 halt** 之字型(GBK 老字型等),以
`inkBias` 逐字型調教近似 halt——非用來強挤「有 halt 字型刻意不挤」之字符。
- **連續標點挤壓規則(雙側獨立 + 透明穿越;下游回饋)**:相鄰兩標點,**對每一標點**獨立
判斷——若其「有空白之側」緊鄰另一標點,則挤掉該側空白(收半形 `jz-half`)。故 `,「`
pause 右空白 + open 左空白)兩側皆收 → 全角式與開明式皆合占 1em;`。」`stop 右空白;
」左為墨)只收 ``→ 全角式 1.5em/開明式 1em(」baseline 已半);`·—`(皆無指向對方之
空白)皆不收 → 兩式皆 2em(不宜挤之例外)。**句界例外**:句末點號(`。.!?`)後接開類
`。「``?「`)為句界,**保留句末點號右側空白、不收 L**,僅收開類左側 → 全角式與開明式皆
1.5em(對稱於 `。」`,半角式句末點號本已半形故仍 1em)。例外**僅限**句末點號+開類;句內點號
`,「` 與雙括號 `」「``》《`(同句相連/貼合,非句界)仍兩側皆收 1em。多標點鏈逐對處理。`jiyaAdjacencyPass` 以**從 root 之透明遍歷**(穿越 strong
em… 等樣式行內;block/pill/avoid(自身判定)斷相鄰且不下探,isolate 斷相鄰但內部
自成脈絡照常配對——§4.1 三分類;jz-char 為原子不下探)取代原「同 parent
直接兄弟」掃描,使**跨 inline 邊界**之連續標點亦正確配對(原漏致跨 strong/em 之標點對未挤、
達 2em)。
- **`margin` 後備模式(.feedback 增補)**:消費端字型未必含 `halt`(多數系統字型、
GBK 老字型,實測**方正書宋 GBK 僅含 `vert`、無 `halt`** → halt 模式不擠)。故提供
後備:`jiya: { halfWidth: "margin" }`。**機制**(演進後):把 `jz-char` 之 inline-block
寬收為 `width:0.5em`(真半形 advance——相鄰兩標點各 0.5em、合 1em,無負 margin 之
鄰接疊加/單側重疊問題),字身 `jz-inner` 以 `overflow` 溢出、再依墨色偏側左移
right 0.5emcenter 0.25emleft 0),使墨色落入 0.5em 盒、空白溢向外側——即以
CSS 盒模型重現 `halt`。偏側由 `core/unicode.ts` 之 `inkBias(ch, langClass, overrides)`
依**字身墨色中心**給出 `leftcenterrightfull`(簡體點號偏左、繁體居中;彎引號
`“”`/角括號 `「」`/尖括號 `〈〉` 墨強偏一側→開 right、閉 left;圓方書名括號
`()【】《》…` 近中→center),charify 時標為 `jz-ink-{left,center,right}`。消費端可
經 `jiya.bias` 逐 lang 逐字覆寫;配字工作流與量測工具 `tools/measure-bias.py` 見 README。
- **激活與粒度(I3/I10)**:模式由 `data-jz-halfwidth` 屬性**所在子樹**決定(CSS
後代選擇器)——`render()` 在全域 margin 時自動掛於 root;亦可手寫於任一祖先作逐區塊
/逐語言控制(含 `margin` 頁中嵌 `halt` 島之 halt-reset,憑來源順序勝出)。bias
class 恆於 charify 標記,halt 模式不讀,故模式切換為純 CSS、無需重 render。實測:
方正書宋 GBK(開明式 zh-Hans)下同句行寬 `24.0emhalt 無效)→ 19.7emmargin 生效)`、
單一句內點號 `1.0em → 0.5em`。
- **切片**:涵蓋並超越 v1 Pass D(開明式),加入全角式、lang 切換、行內連續與
行端擠壓、`halt``margin` 雙渲染。
### 6.4 禁則(Jinze,避頭尾)+ justify 綁定 — 切片 1(基礎)/後續(完整)
- **Han**`h-jinze` 綁定行首行尾不可出現之標點與鄰字,避免標點落行首/行尾。
- **我們**`jz-jinze`CSS `display:inline-block; white-space:nowrap`)綁定不可斷
單元。**避頭尾已實作**`jinze.ts`):因 jiya 把標點原子化為 inline-block,原子
邊界成可斷點、破壞瀏覽器原生 UAX#14 避頭尾,故補回——把「不可置行首」之標點
(閉類/點號/間隔/連接省略)綁到**前一字**、「不可置行末」之開類綁到**後一字**,
且**只隔離單一邊界字**(長引文中段仍正常斷行,不溢出)。實作為兩階段:依原始
相鄰算禁斷邊界→切邊界字;重掃併入 nowrap 群組。亦為 §8 justify 綁定載體。
- **行內元素最小綁定(`core/split.ts`,下游 Bug 修復)**:邊界字落於**樣式行內元素**
`strong``em``span` 等)內時,舊版整個元素入 `jz-jinze`——`<strong>很長一串</strong>`
會把整串粗體塞進 `nowrap` inline-block,行末放不下即**整體掉行、上行留巨大空隙**。
改以 `isolateBoundaryToken(el, side)` **克隆切分**:沿「文本節點→邊界元素」祖先鏈逐層
把邊界詞元剝入一個淺克隆(保留標籤/class/style/lang,去 id),只綁該克隆(最小綁定),
原元素其餘部分留原位、渲染視覺無縫。克隆標 `data-jz-kind="splitoff"``revertPass` 之**延後
階段**(先解包 wrap、後併 splitoff,確保前兄弟為原元素而非 wrap 殼)併回原元素、還原一致
(I6)。僅純樣式標籤白名單可切,且 `canSplit` **諮詢 finder 三分類**(§4.1):`a``abbr`
`ruby`/帶 id 者,以及 **pill(整體)/isolate(隔離)/avoid 自身命中者**,一律退化為
整元素綁定(安全)——pill 即「作為原子鄰字單位整體綁入 `jz-jinze`」之期望行為(下游問題六:
KaTeX 全樹皆 SPAN,舊版僅憑標籤白名單曾整棵下潛肢解公式);isolate(如背景色 `mark`)切分
有可見接縫故整綁;`MARK` 並自白名單移除。
`canSplit` 須對「切點 `textNode` 至 `top` 之**整條**祖先鏈」成立、非僅 `top`:下潛由
`firstText``lastText` 守(遇非可切回 `null`),**攀升由 `splitTreeAfter` 之對稱守衛守**
(切分前先驗祖先鏈全可切,任一層 opaquepillisolate/帶 id 即放棄、退化整綁)。二者
缺一則 `splitElementAt`orphan:切點取自 `collectSubst`,可穿透 opaque `button`)會令
中間 opaque 祖先被逐層克隆肢解——下游 `.selection-bar-message` 病例。
- **切片**:切片 1 完成行首行末避頭尾(閉類/點號避頭、開類避尾);行首縮排聯動、
更細之禁則對象列後續,沿用同一 `jz-jinze` 結構。
- **已知交互(待瀏覽器精修)**:`lineedge` 之行端偵測需跨 `jz-jinze` 群組邊界向上
攀爬找流內鄰字(已於 `edgeRect` 處理 jz-* 包裹之攀出);屬 layout 層,jsdom 無法
驗,須瀏覽器目視。
### 6.5 標點懸掛 + 垂懸字避免 + 行端禁則 — 整體實作(已落地)
三個相關機制之**整體實作**(同步引入),皆處理「行端/段末之標點與字」;其分層職責須先
釐清,以免重複處理或衝突。**前置**為「功能分級」(見 6.5.0a),因懸掛/垂懸字屬段落級、
不應作用於標題/按鈕/單行文本。
> **實作狀態(2026-06**6.5.0a 功能分級、6.5.1 垂懸字(方案 C,預設開)已落地且工作良好。
> 6.5.2 行端僅做半形擠壓(squeeze,行首開類收左/行尾閉類收右);**標點懸掛 hang 經實測為機制級
> 死路、已移除**(負 margin 之 lineedge 程式碼、CSS、`lineEnd``LineEndMode``HANG_ENABLED`
> 等一併刪除;`hanging` 選項保留為 no-op)。
>
> **行歸屬判定 bug 修正**(squeeze 之改善,保留):原以「行盒 top 差 > EPS=4px」判同/異行,
> 但 jz-char 為 inline-block,其行盒 top 與相鄰純文本字形 top 因基線差約 5px、遠小於行高卻超
> 舊容差,致同行誤判為下一行。改判「垂直區間」:下一行=`b.top ≥ a.bottom`、上一行=
> `b.bottom ≤ a.top`(容 2px 亞像素)。
>
> **標點懸掛移除之根因**(純 CSS 之寬度守恆矛盾,headless + 真實瀏覽器實測):行端標點盒若
> 保持非零寬,墨色出去多少、版心內就空出多少(行末實義字無法齊版心右緣);若收為零寬,釋放
> 一整字寬、斷行器把下一字拉上本行與標點重疊(且 justifyAtoms 預設之 inline-block 下負 margin
> 被 justify 吸收)。**CSS hack 接觸不到斷行器,此恒等式無解**。唯一正解為原生
> `hanging-punctuation`(僅 Safari 支援、且 jz-char 為 inline-block 原子非文本,原生懸掛不作用),
> 屬遠期「原生管線」新功能、與已移除程式碼無共享。詳見 6.5.6。
#### 6.5.0a 功能分級:文本級 vs 段落級 + 元素標記(前置基礎設施)
**動機**:懸掛/垂懸字/禁則等為**段落級**功能(依賴行/段布局),不應作用於標題、按鈕、單行
或單元素文本;而中西間隙、標點挤压為**文本級**,任意文本片段皆適用且想要。下游須能**方便標記**
元素屬哪級。此為貫穿性基礎設施(已實作:`Finder.levelFor`/`levelAllows`、各 pass `level`
元數據、選項 `level.text`/屬性 `data-jz-level`)。
**分類(含現有與待加功能)**
| 級別 | 功能 | 判據/依賴 |
|---|---|---|
| **文本級** | `spacing`(中西間隙)、`jiya` baseline 政策半形、`jiya` 連續挤压(adjacency) | 僅字符鄰接,對單行/單元素文本即有意義 |
| **段落級** | `jinze` 避頭尾、行端(半形/懸掛)、`gapTrim`、`orphan` 垂懸字、`longWord` 斷詞 | 需行/段布局(換行、行端、段末、justify)才有意義 |
| 貫穿 | `charClass`、原子透明、normalize | 基礎設施,不分級 |
直覺:文本級=「字符級排版」(間隙、標點形態);段落級=「行/段級排版」(換行、行端、段末)。
**標記 API(下游)**
- 預設推斷:塊級元素(`blockTags`p/div/li/td…)→ **段落級(全功能)**。
- 顯式降級:`data-jz-level="text"` 於元素/祖先 → 該子樹**僅文本級**(段落級功能自動關);
`data-jz-level="paragraph"`(或不標)→ 全功能。
- 選項 `level?: { text?: string }`:選擇器一鍵標記(如 `level:{ text: "h1,h2,h3,button,.ui" }`)。
- 典型:標題/按鈕/UI 文字標 **text-level**(要間隙與標點半形、不要懸掛/避孤字/避頭尾);
正文 `<p>` 預設全功能。
**實現**
- 每個 pass 加元數據 `level: "text" | "paragraph"`。
- `finder.levelFor(el)`:查 `data-jz-level` 祖先 選項 `level.text` 選擇器 預設(塊→段落)。
- gate:段落級 pass 對 text-level 元素跳過;文本級 pass 不受限。與現有 `blocks`
`featureEnabledFor` 疊加(level=粗粒度語義組先判,blocks=細粒度逐功能後調)。
- **文本級功能須支持非塊/單元素**:`spacing` 現用 `eachBlock`,須確認 scope root 為 inline
單元素時亦能處理(或放寬走訪),使單行標題等之中西間隙、標點半形生效。
**與本批關係**:先落地分級(pass 標 level、`levelFor`、`data-jz-level`/選項)→ orphanhanging
註冊為段落級,自動只作用段落級元素、跳過 text-level 者。實作順序插於 6.5.5 之首。
#### 6.5.0 四機制之分工與邊界(核心約定,實作前必讀)
| 機制 | 作用層 | 唯一職責 | 手段 | 狀態 |
|---|---|---|---|---|
| 禁則(§6.4 jinze | **斷行層** | 把標點趕到正確的邊(點號/閉類→行末、開類→行首;連續標點不拆) | nowrap `jz-jinze` | 已實作 |
| 行端半形(lineedge 現有) | **渲染層·紧排** | 行首開類/行末閉類**收半形** | `jz-half-le`(halt) | 已實作(將併入行端模式) |
| 標點懸掛 | **渲染層·凸出** | 行末點號/閉類**右凸**、行首開類**左凸**出版心 | ~~負 `margin`~~ | ❌ 已移除(機制級死路,見 6.5.6) |
| 垂懸字避免 | **斷行層** | 段末行 **CJK 實義字數 ≥ N** | nowrap `jz-orphan` | 已實作 |
- **正交性**:斷行層(禁則/垂懸字,管「哪些字符在哪行、不可斷」)與渲染層(半形/懸掛,管
「邊緣標點凸出/收縮」)作用於不同 CSS 維度(`white-space` vs `margin`),可疊加共存。
- **協同**:禁則決定標點落行首或行末 → 半形/懸掛對落在邊緣者施收縮/凸出。
- **行端僅半形擠壓**lineedge 行端處理只做 squeeze(行首開類收左、行尾閉類收右半形)。
原計劃之「懸掛」模式已移除(機制級死路,§6.5.6),故無「半形 vs 懸掛」之模式切換。
- **垂懸字 vs 懸掛邊界**(歷史記錄):曾研究兩者於「純標點末行」之重疊(懸掛吸回 vs 垂懸字
拉字)。懸掛移除後此協同不再適用;方案 C(無測量)**附帶**消除純標點末行即可(副作用,
現有益無害)。
#### 6.5.1 垂懸字避免(方案 C,斷行層,無測量)— 已實作
- **目標**:段末行 CJK 實義字數 ≥ N(預設 2;標點/符號不計)。單行段無效。
- **機制**standalone pass,遍歷作用域段落;自段末向前數 N 個 CJK 實義字(`ruleset.cjk` 判定、
跳過標點/空白/西文計數),把「第 N 個實義字起至段末」(含其間/尾隨標點)包入 `<jz-orphan>`
CSS `white-space: nowrap`)。
- **保證(數學、免測 layout)**:該整體不可斷 → 末行恒含之 ⇒ 末行實義字 ≥ N,任意寬度/字型/
瀏覽器成立。平時無害(末行本多字時不改變),僅末行將塌為 <N 字時「拉字下來」。
- **選項**`orphan?: boolean | { chars?: number }`feature gate**預設開 chars=2**per-block
經 `blocks``data-jz`;段落級,`data-jz-level="text"` 子樹跳過)。lang 無關。
- **邊界**:段落實義字 < N → 不綁。極窄容器(<N 字寬)→ 整體可能溢出(罕見,文檔提示)。
- **僅葉塊**:含塊級後代之容器(其實義字屬內層段落)跳過、另由內層處理。**效能(S2)**:舊版
逐塊 `block.querySelector(sel)` 掃整棵子樹判葉塊,累加 O(Σ塊子樹)、深塊嵌套下 O(n²);改以一趟
上攀預計算「非葉塊」Set(某塊為另一塊之最近塊祖先 ⟺ 含塊級後代),scope 無涉、與原 querySelector
同口徑,行為等價。
- **order=70**charify 20 後、layout pass 前;longWord 60 後)。
- **與禁則協同**:段末範圍可能含 jinze 已綁之 `jz-jinze`;置於 jinze(40) 後,將 jz-jinze 整體
納入 jz-orphan,並處理邊界字定位**跨 jz-jinze 攀爬**(實作細節)。免重複包裹。
- **CSS**`jz-orphan { white-space: nowrap; }`。`revert`wrap-class,隨 `revertPass` 還原。
- **測試**:jsdom 結構(末 N 實義字+尾標點入 jz-orphan;符號不計;單行段/實義字<N 不綁);
headless 窄容器拖動驗末行恒 ≥N。
#### 6.5.2 標點懸掛(Hanging)— ❌ 已移除(機制級死路)
> **已移除(2026-06)**:原計劃之懸掛(行端標點負 margin 凸出版心)經 headless + 真實瀏覽器
> 實測確認為**機制級死路**(寬度守恆矛盾,純 CSS 接觸不到斷行器),程式碼與 CSS 已刪。完整
> 論證、已移除清單、未來原生路徑見 **§6.5.6**。`hanging` 選項保留為 no-op(API 相容)。
lineedge 行端處理只餘 **squeeze**(行端半形):量測各 jz-char 是否在行首(開類)/行末(閉類),
加 `jz-half-le`(CSS 同 halt)。兩階段免自我應驗(先 `clearEdge`、自然佈局量測全部、再一次套用);
行歸屬以「垂直區間」`lowerLine``higherLine` 判定(吸收 inline-block 行盒與純文本基線差 ~5px)。
order `90 lineEdge``revert` 清 `jz-half-le`。純版面、jsdom 跳過、須瀏覽器目視。
#### 6.5.3 行端禁則(§6.4review
- 禁則避頭尾與 squeeze 行端半形協同(禁則趕標點到邊、行端收半形);jz-orphan 與 jz-jinze 嵌套
已處理(邊界字跨 jz-jinze 攀爬)。lineedge 偵測已跨 jz-jinze 攀爬。jinze 本身未大改。
(懸掛已停用,故無「懸掛凸出最外側」之協同需求。)
#### 6.5.4 統一 pass order
`20 charify → 25 jiyaAdj → 40 jinze → 50 spacing → 60 longWord → 70 orphan(垂懸字)→
90 lineEdge(行端 squeeze 半形)→ 92 gapTrim`。
#### 6.5.5 實作順序與結果
0. ✅ **功能分級(前置,6.5.0a**pass 標 `level`、`finder.levelFor`/`levelAllows`、
`data-jz-level`/選項 `level.text`;段落級 pass gate 跳過 text-level 元素。
1. ✅ **垂懸字(方案 C**standalone pass、order 70、純結構無 layout、預設開 chars=2。工作良好。
2. ✅ **lineedge 行端半形(squeeze)**:量測行首開類/行末閉類加 `jz-half-le`;連帶修「行歸屬
判定」bug(垂直區間取代 top 差)。保留。
3. ❌ **懸掛 hang**:實作後經 headless + 真實瀏覽器實測確認為**機制級死路、已移除**(負 margin
程式碼/CSS`LineEndMode``lineEnd``features.hanging` 一併刪;`hanging` 選項保留 no-op)。
論證見 6.5.6。
4. ✅ **邊界落地**:純標點末行由 orphan 之尾段綁定(含尾隨標點)全包消除。
5. ✅ **行內透明性修復(下游 Bug)**:pill 前標點誤隙(finder 透明)、行內元素整體掉行
core/split 克隆切分)、行端攀爬集派生(T4)等,見 §4.1/§6.4。
6. ✅ **文檔/docsdemo**ARCHITECTUREREADMEdocs 分支記懸掛移除;demo 同步。
#### 6.5.6 標點懸掛移除記錄與未來路徑
- **移除結論(寬度守恆論證)**:純 CSS 手段(負 margin/相對位移/零寬盒)存在守恆矛盾,
機制級不可行。設行末標點墨色須落版心外、且行末實義字須齊版心右緣:
- 行末盒**保持非零寬**(負 margin 收一部分、或 `position:relative` 純視覺外移)→ 墨色可移出,
但盒仍佔行末寬 → 版心內留等寬空缺,「行末齊邊」不成立(0.5em 實測之「看不出懸掛」即此相)。
- 行末盒**收為零寬**margin-right:-1em 等)→ 釋放一整字寬 → 斷行器把下一字拉上本行與標點
重疊(headless 實測每標點疊一字);且 `justifyAtoms:true` 之 inline-block 下負 margin 被
justify 吸收。
**墨色出去多少、版心內就空出多少;想不空就得讓斷行器重排這塊寬度,而 CSS hack 接觸不到
斷行器。** 補償墊片、兩階段試錯都改變不了此恒等式,只會重蹈 ResizeObserver 標記錯亂之覆轍。
- **已移除之物**`lineedge.ts` 之 `jz-hang-*` class`HANG_*` 常量/`applyEdge` hang 分支/
`modeForChar``HANG_ENABLED``_jiya.css` 之 `jz-hang-*` 規則;`types.ts` 之 `LineEndMode`
`ResolvedOptions.lineEnd``features.hanging``index.ts` 之 `lineEnd` 鎖。行端僅餘半形擠壓
`jz-half-le`)。`hanging` 公開選項保留為 **@deprecated no-op**API 相容;測試覆蓋
optiondata-juzhenblocks 各路徑皆不產生 jz-hang-* 且輸出與不傳一致)。
- **未來路徑(與已移除碼無共享)**:原生 CSS `hanging-punctuation` 為唯一正解,惟 Chrome
Firefox 至今不支援(僅 Safari);且本庫 `jz-char` 為 inline-block 原子、非文本,原生懸掛
不作用之。未來若做,須是**繞開 jz-char 的「原生管線」新功能**——`text-spacing-trim` 接管
擠壓 `hanging-punctuation` 接管懸掛,按 `CSS.supports` 能力門控,整體退出 jz-char 機制。
與已移除之負 margin 程式碼**沒有一行可複用**,故不保留休眠碼。
### 6.6 標點修正(Biaodian normalize)— 後續(opt-in
- **Han**`subst``biaodian` 修正標點變體、位置。
- **我們**opt-in pass,預設**只分類+打 class**(不改文字,I1);顯式開啟才做
變體替換(簡↔繁標點、全半形修正),且可 `revert`。
- **預留**`order=10` 最先;產物為 `jz-char` 之 class/屬性,與 charify 銜接。
### 6.7 著重號(Emphasis)— 後續
- **Han**:逐字包裝加圈點。
- **我們**:優先用原生 `text-emphasis`ChromeFFSafari 支援良好);需精細
控制時退回 `jz-em` 逐字包裝 + CSS 圓點。lang 感知(位置簡繁略異)。
- **預留**charify 能力直接複用。
### 6.8 注音/Ruby — 後續(最複雜)
- **我們**:基於原生 `<ruby>` + `jz-ruby/jz-rt` 處理注音符號直排與聲調定位。
- **預留**:獨立 pass`order=80`;不影響其餘。
### 6.9 樣式正常化/字型歸屬 — 貫穿(CSS)
- **normalize**`juzhen.css` 內以 `@supports``:where()` 提供 CJK normalize(關閉
與託管擠壓重複作用之原生 `text-spacing-trim``text-autospace`、間隙 `user-select`
等),限 `.juzhen` 域、非破壞。
- **字型不歸聚珍管(設計決策,下游回饋)**:曾內建依 `:lang` 之 Noto 字型堆疊作「後備」,
但字型屬消費端領域、內建即越權(且曾蓋掉消費端字型釀 bug)。故**移除 `_lang.css`**
逐語言 `font-family` 完全交消費端 CSS。聚珍僅守三項非管理性本分:① `_normalize.css`
之 `jz-* { font: inherit }` 使原子繼承消費端字型、不強加;② `_jiya.css` 半形之
`font-feature-settings` 顯式併入 `"locl" 1`(連同 `halt`),確保泛 CJK 字型之地域標點
字形不因設 halt 而失;③ render 只讀不改 `lang`。body 文本之 `locl` 本就不受聚珍影響
(未設任何 feature),由消費端字型+正確 `lang` 決定。消費端範式見 README「字型與 locl」。
- **B 案(字型參數)之否決**:以 JS 參數管字型須注入 CSS、違反「JS 注入結構/CSS 給視覺」
分工;而「缺 halt 字型須逐字型微調 margin」之指引由既有 `jiya.halfWidth``jiya.bias`
參數+文檔承擔,無需新參數。
- **跨瀏覽器**`:where()``:lang()``font-feature-settings` 普遍支援。
### 6.10 計數器/緊排(counterwell-knit)— 低優先
- 後續以純 CSS 模組加入,不涉 JS 引擎。
---
## 7. 第一垂直切片(v1 對等)
**範圍**:核心基座(finderunicodelocaledompass 管線)+ 以下功能 pass
| v1 | 切片 1 對應 | 機制 |
|---|---|---|
| Pass A 中西間隙 | `spacing` | `jz-hws`copy-cleanjustify 安全 |
| Pass B pill 邊界 | `spacing`(同引擎) | avoid + 鄰接判定 |
| Pass C 長英文詞 | `longWord` | `<span lang>` + `hyphens` |
| Pass D 開明式擠壓 | `jiya` + `jinze` | 跨瀏覽器負 margin + `:lang` 全角/開明 + justify 綁定 |
**交付**:可 `build` 出 ESMIIFECSSd.tsjsdom 測試覆蓋 DOM 變換正確性
(包裝、class、邊界、冪等、copy-clean、lang 政策);justify/簡繁 demo 頁供瀏覽器
目視驗證版面(layout 行為無法於 jsdom 驗證,見 §12)。
**擴充保證**:切片 1 所用之 findercharifypass 管線/`jz-*` 詞彙/CSS-JS 契約,
即後續所有 Han 功能所用者;新增功能 = 新增一個遵守 §2 不變量的 pass,不改切片 1。
---
## 8. justify 句末標點單份間距解法(要求 #3)
**問題**`text-align: justify` 下,UA 在「對齊機會點」插入伸縮量。全角句末標點
(如 ``)字面只占字框一部分、自帶半形留白;若 UA 在其**兩側**都開伸縮,標點
看似分到「兩份」間距(伸縮 gap + 自身留白)。
**解法**CSS 主導,I2I5):
1. charify 句末/句逗標點為 `<jz-char class="bd …"><jz-inner>。</jz-inner>`。
2. `jz-char` 設 `display: inline-block`:使其成為 justify 的**單一原子項**,內部
不開伸縮 gap,標點與其字面留白同屬一項,對齊伸縮只落在**項與項之間** → 標點
只貢獻一份。
3. **開明式**下,`jz-inner` 以負 letter-spacing 擠去半形留白,該項本身即半形,
justify 一份 gap 即足。
4. **全角式**下,留白保留於原子項內,仍只算一份;以 `jz-jinze` 綁定確保不在留白
側額外開機會點。
> inline-block 影響基線對齊與行內換行,須以 CSS 校正(`vertical-align`、行高),
> 並**跨 ChromeSafariFF 目視驗證**(§12);此為切片 1 的重點驗證項。
**分頁器相容(下游回饋問題二)**:常規流式版面 inline-block 原子正常,但**分頁器**
(Paged.js)之對齊引擎會把每個 inline-block 邊界當伸縮點、與 CJK inter-ideograph
疊加,於窄欄混排散架。故提供 `options.justifyAtoms`(預設 true);設 false 時 render
於 root 掛 `data-jz-atoms="inline"`CSS 令 `jz-char``jz-jinze` 改 `display:inline`
halt 半形與 jinze nowrap 仍保留)。此即 R1 預案之一(解耦對齊原子化),切換僅 CSS、
不動 JS。注意:`margin` 後備半形依賴 inline-block 之 `width:0.5em`,與
`justifyAtoms:false` 不可兼得——分頁器場景須用含 `halt` 字型。
**原子透明原則(繼承屬性漏溢,下游回饋)**:`jz-char``jz-jinze`(及 `margin` 模式之
`jz-inner`)為 inline-block,而 inline-block 自成區塊容器。凡「作用於區塊容器首/末行」之
**繼承屬性**,會被每個單字標點原子各自重新套用一次,使「用聚珍」與「未用聚珍」表現不一致。
**作用範圍判斷**:此類間距/位移源於我們生成之原子,屬本庫責任,由庫內統一免疫(消費端無需
配置);且須以「同類機制」整體界定範圍——要麼全修、要麼全交下游,不做逐案補丁。修法
`_normalize.css`)於原子上歸零此類屬性,只切斷其向原子內部之再套用,段落本身之同名屬性不
受影響(仍作用於段落自身行盒)。
經 headless Chrome 逐屬性實測,屬此類者僅兩項,皆已歸零:
- `text-indent: 0`——段落首行縮排傳入後,每原子內部首行各縮排一次,shrink-to-fit 寬膨脹為
`縮排量+字形`,標點附近冒出巨大間距。實測:歸零後段落首行縮排仍保持 2em,原子寬由 ~3em
回到 0.5em1em。
- `hanging-punctuation: none`——同屬區塊容器首/末行機制,每原子各自懸掛自身標點。Chrome
未實作(測不出)、Safari 會觸發,且與規劃中之「標點懸掛」(§任務 6)相衝,故按機制一併歸零。
**不歸零者**(實測不洩漏或本應逐字穿透):`letter-spacing``word-spacing`(原子邊界處理
一致、無翻倍,實測 delta 與基線同);`text-align``text-align-last``text-justify`
halt 為 shrink-to-fit、margin 為內容溢出,皆無對齊餘量,兩模式實測 `jz-inner` 偏移不變);
`text-transform``line-height``color``font`(逐字內容或縱向屬性,須穿透原子);
`word-break``overflow-wrap``hyphens``white-space``tab-size``text-emphasis`
`text-shadow``font-variant``font-stretch`(逐屬性實測 delta 皆與基線同,不洩漏)。
**透明之另一面——欠套用(`text-decoration`**:與上述「過套用」相反,`text-decoration`
**非繼承**屬性,由段落之「裝飾盒」統一繪製並跨後代延伸;但原子級 inline-block 會被裝飾盒
**跳過**,致段落下劃線在每個標點原子下斷缺(像素掃描實測:純文字 coverage 1.0、聚珍僅
~0.25)。故反向處理——令原子 `text-decoration: inherit` 承段落裝飾自繪,因共用基線與字型
度量而無縫接續(`_normalize.css` 設 `jz-char``jz-jinze``margin` 模式 `jz-inner` 亦為
inline-block,於 `_jiya.css` 補一條)。實測:haltmargin 兩模式 coverage 皆回 1.0,且
`margin` 下劃線隨半形收為 0.5em advance。**此為「同類問題」整體界定之一部分**:凡因原子化
而生之版面瑕疵,要麼庫內統一修復、要麼統一交下游;本庫選前者,使原子對消費端樣式透明。
---
## 9. 跨瀏覽器策略
- **一致性關鍵層**(間隙、擠壓、justify):只用 `margin`、`inline-block`、
`user-select`、`white-space`、`:where()`、`hyphens`,及字型 OpenType
`font-feature-settings:"halt"`(標點半形;各引擎一致,效果僅依字型是否含 halt,
Noto CJK 具備,缺則優雅降級為全形)——三大引擎(Blink/WebKitGecko)皆穩定
支援。**注意**:標點半形預設不用負 `margin`(對居中字形會重疊),改用 `halt`(見
§6.3 修訂)。**缺 `halt` 字型之後備**`jiya.halfWidth:"margin"` 以 `width:0.5em`
字身依偏側(`jz-ink-*`)溢出定位,於 CSS 重現 halt、真半形 advance(見 §6.3)。
- **漸進增強(非必要)**`text-spacing-trim`Blink)、`hanging-punctuation`
WebKit)、`text-emphasis`、`text-autospace`——僅在支援時錦上添花,缺席不影響
基本排版(I4)。
- **驗證矩陣**ChromeBlink,含 VSCodeElectron)、SafariWebKit、Firefox
(Gecko)。demo 頁含三引擎可開的測試案例。
### 9.1 顯式禁用不一致原語(I4)
當原生原語在各引擎支援不一,或會與我們託管之版面**重複作用**(雙重擠壓/間隙)
時,在 `.juzhen` 範圍內以 `@supports` 偵測並**主動關閉**,使所有引擎收斂於我們的
一致路徑:
```css
@supports (text-spacing-trim: space-all) {
.juzhen { text-spacing-trim: space-all; } /* 關閉 Blink 原生 CJK 標點 kerning */
}
@supports (text-autospace: no-autospace) {
.juzhen { text-autospace: no-autospace; } /* 關閉原生中西自動間距 */
}
```
政策:凡屬「一致性關鍵層」者(間隙、擠壓),一律由我們統一處理並禁用對應原生
原語;凡屬「漸進增強」且不致衝突者(如 Safari 之 `hanging-punctuation`,在我們未
託管懸掛時),方任其生效。此清單隨功能演進維護於 `css/_normalize.css`。
---
## 10. 模組/檔案佈局
```
src/
index.ts createJuzhen / Juzhen / pass 註冊 / render / revert
core/
pass.ts Pass 介面 + 管線執行器
finder.ts 遍歷/avoidcharify/邊界感知 replace
unicode.ts 字元類 + 標點子類 + 規則正則表
locale.ts 有效 lang / 分類 / 全角開明政策
dom.ts jz-* 工廠 / wrap / split / marker / revert
typeset/
spacing.ts 中西間隙 [切片 1]
longword.ts 長英文詞包裝 [切片 1]
jiya.ts 標點擠壓 + 全角開明 [切片 1]
jinze.ts 禁則 + justify 綁定 [切片 1 基礎]
hanging.ts 標點懸掛 [後續]
biaodian.ts 標點修正 [後續]
emphasis.ts 著重號 [後續]
ruby.ts 注音/ruby [後續]
css/
juzhen.css 入口(@import 各 partial
_normalize.css _spacing.css _jiya.css _jinze.css
_hanging.css _emphasis.css _ruby.css (字型不內建,交消費端)
test/ jsdom DOM 正確性測試
demo/ 瀏覽器 layoutjustify/簡繁 目視驗證頁
build.mjs esbuildESM + IIFE + CSStscd.ts
```
---
## 11. 構建與發佈
- **源碼**TypeScriptstrict),模組化。
- **構建**`build.mjs` 以 esbuild 產出
- `dist/juzhen.mjs`ESM,供打包器/TS
- `dist/juzhen.iife.js`IIFE,全域 `Juzhen`,原生 `<script>` 直跑——保留
build.shNvim peek 之單檔串接消費)
- `dist/juzhen.css`(合併之樣式)
- `tsc --emitDeclarationOnly` → `dist/juzhen.d.ts`
- **發佈物**`dist/` 提交入庫(消費端可直接取單檔/CSS)。
---
## 12. 測試與驗證策略
- **jsdom(自動)**:驗證 DOM 變換——包裝結構、`bd-*` class、邊界處理、冪等、
`revert` 還原、copy-clean`textContent` 不被注入污染)、lang 政策分支。
- **瀏覽器(目視,demo 頁)**layout 相關**無法**於 jsdom 驗證——justify 單份
間距、擠壓視覺量、行端折疊、懸掛、inline-block 基線。demo 頁載 Noto CJK,簡繁
並列、可切 justify、附選取/複製測試區,於 Chrome/SafariFF 目視核對。
- 文件明確標注哪些結論來自自動測試、哪些待瀏覽器目視。
---
## 13. 擴充性保證(如何加新功能而不返工)
新增任一 Han 功能點,標準流程:
1. 在 `unicode.ts` 補該功能所需字元類/正則(集中、不散落)。
2. 新增 `typeset/<feature>.ts`,實作 `Pass``render``revert``order`)。
3. 在 `index.ts` 註冊,加 `options.<feature>` 開關。
4. 在 `css/_<feature>.css` 寫視覺規則(I2:尺寸只在 CSS)。
5. 補 jsdom 測試 + demo 案例。
因所有功能共用 findercharifylocale`jz-*`CSS-JS 契約,且順序由 `order`
集中編排,新增不觸動既有 pass——此即「預留完整復刻 Han.css 能力」之具體保證。
---
## 14. 風險與未決項
原則:**風險上線後持續觀察,但下列預案先行設計就緒**,一旦觀察指標踩線即可切換,
不致臨時返工。
- **R1 justifyinline-block 基線**(§8)。觀察:句末標點在三引擎 justify 下是否
單份間距、inline-block 是否擾動基線/行高/行內換行。**預案**:改採 Han 路線之
`jz-cs` 補償空白法(標點維持 inline,以行邊界可折疊之 `jz-cs` 控制伸縮側),
結構已預留,切換僅換 CSS 機制不動 JS。
- **R2 行端擠壓免量測**。觀察:純 CSS(`jz-cs` 折疊)能否在三引擎一致達成行首行尾
擠壓。**預案**:退化為「連續+行內擠壓用 CSS(一致)+ 行端擠壓交由 layout pass
量測或漸進增強原語」,行端與連續解耦,互不影響。
- **R3 標點修正改寫文字 vs I1**。觀察:opt-in 開啟後複製/序列化是否仍可接受。
**預案**:預設只分類不改寫;改寫模式強制可 `revert`,並提供「僅視覺替換(CSS
字符替身)不動文字」之次級模式作折衷。
- **R4 注音/ruby 複雜度**。觀察:原生 `<ruby>` 對注音直排/聲調定位之不足。
**預案**:獨立 pass、最後做;先以原生 `<ruby>` 覆蓋多數,注音特例再以 `jz-ruby`
子結構補強,不影響其餘 pass。
- **R5 DOM 重量**。觀察:長文 charify 之節點數與 render 耗時。**預案**:兩階段批次
變更(§3.4)+ 僅包裝必要標點 + 可選分塊/`requestIdleCallback` 漸進渲染;demo
量測超標才啟用分塊。
---
*本文件為實作契約。任何偏離(尤其 §2 不變量)須先更新本文件再改碼。*