Files
cjk-autospace/ARCHITECTURE.md
T
admin 2890549f44 fix: 行內排版透明性——pill 前標點誤隙+行內元素整體掉行+審計(Bug1/Bug2/T4-T6)
統一「行內元素對排版透明」原則在各遍歷原語之口徑,修復兩個下游 Bug 及三項同根隱患。

Bug1(pill 前標點誤加間隙):
  isTransparentInline 補 JZ-CHAR/JZ-INNER。`漢字。<code>` 經禁則綁定後,
  adjacentLogicalChar 之 descend 遇不透明 jz-char 既不進入亦不終止、而跳過它越過
  標點取到「字」誤補 jz-hws;透明後回傳標點「。」(非 pill 鄰字)→ 不補隙,與
  collectRuns 既有透明遞迴對齊。

Bug2(行內樣式元素整體掉行):
  新增 core/split.ts 之 isolateBoundaryToken/splitElementAt——沿祖先鏈逐層把邊界
  詞元剝入淺克隆(保留標籤/class/style/lang、去 id;標 data-jz-kind="splitoff"),
  jinze/orphan 改最小綁定:`<strong>長串</strong>,` 只綁末字+逗號(非整串入 nowrap
  inline-block 致行末整體掉行);段末長 strong 不再整綁溢出容器。revertPass 增延後
  階段把 splitoff 併回原元素(先解包 wrap、後併 splitoff),render→revert 還原一致。
  純樣式標籤白名單可切,a/abbr/ruby/帶 id 退化整綁。

審計(同根隱患):
  T4 lineedge edgeRect 攀爬集改由 options.finder.styleInlines 派生(棄硬編碼子集,
     免遺漏 ins/del/sub 等致行端誤判)。
  T5 jiyaAdjacency 遇 pill(pillMatches)斷相鄰、不穿越(內容對排版不可見)。
  T6 spacing processPills 補 per-node 功能閘(data-juzhen-off=spacing 區域內不補隙)。

文檔:README 切片表(spacing/jinze/orphan 三行)+ARCHITECTURE §4.1 透明性原則、
  §4.4 splitoff 還原、§6.4 行內元素最小綁定、§5 jz-orphan 詞彙。

測試 90→102(Bug1×4、Bug2×6、T5/T6×2)。headless Chrome 實證 Bug2:段末 strong
最小切分、orphan 寬 60px 不溢出版心。
2026-06-11 16:06:17 +08:00

940 lines
63 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` 的語義;若某 pass 必須改寫文字(如標點替換),則須為
顯式 opt-in 且可 `revert` 還原。預設「只包裝、不改寫文字」。
- **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` 乾淨
移除,DOM 回到 pass 前狀態(至少邏輯等價)。
- **I7 邊界感知**:跨樣式行內元素的鄰接(如 `<strong>是</strong>A`)須能識別並
正確處理——由核心 finder 統一提供,各 pass 不自行重複造輪。
- **I8 向下相容 v1**:既有 `createCjkAutospace(options).apply(root)` 消費端
build.sh 單檔串接、Nvim peek、Claude 模板)**不得 break、無功能倒退**。行為
**不要求與 v1 完全一致**,但須等價可用;以相容層達成(見 §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?); // 還原
// 全域: window.Juzhen.createJuzhen(...)
```
`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: "code, kbd, pre, samp, textarea, [contenteditable]", // 追加於內建 avoid
},
// 全域逐功能開關(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(options)`,把 v1 API 映射到 `createJuzhen`**保證不
break、無功能倒退**,行為等價但不要求完全一致:
| v1 選項/行為 | 映射 |
|---|---|
| `.apply(root)` | → `.render(root)` |
| `pillSelector` | → pill 鄰接處理 + avoid |
| `styleInlines``blockTags``skipTags` | → finder 設定 |
| `autospaceClass`(預設 `cjk-autospace` | → 套用於 `jz-hws` 之 class,使既有
`.cjk-autospace{…}` CSS 仍命中間隙元素 |
| `skipAttribute``isSkipped` | → avoid 判定 |
| `longWordWrap` | → `longWord` |
| `punctuationSqueeze`v1 halt 句內半角) | → `jiya`(margin 開明/全角;行為近似,
非逐位等同——符合「不要求完全一致」) |
IIFE 構建額外掛 `globalThis.createCjkAutospace` 別名,使
`globalThis.createCjkAutospace` 之消費端(Nvim peek/模板)零改動可跑。相容層之
差異(間隙元素由 `span.cjk-autospace` 變為 `jz-hws[class]`、擠壓由 halt 變 margin
於 README 明列。
### 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 名單之子樹(內建:`code/kbd/pre/samp/textarea/script/
style/SVG/[contenteditable]/[data-jz-skip]`,可由 `scope.avoid` 追加)。
- **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 表 + 回呼注入。
**「行內透明」原則之一致性(下游 Bug 修復)**:「邏輯文本應透明穿越之行內元素」一語
須在所有遍歷原語中**口徑一致**,否則同一段文本在不同原語下被切成不同邏輯串、生成相
互矛盾之結果。`isTransparentInline` 為單一真相源,列入:樣式行內標籤、`jz-hws`
`jz-jinze`wrap 群組)、**`jz-char``jz-inner`(標點原子)**。後者必須透明,否則
`adjacentLogicalChar` 之 `descend` 遇標點原子既不進入亦不終止、而是**跳過**它續找更前
之兄弟——`漢字。<code>` 經禁則綁定後,pill 之左鄰字應為「。」(標點,非 pill 鄰字、不
補間隙),舊邏輯卻越過 `jz-char` 取到「字」誤補 `jz-hws`。修復後與 `collectRuns` 之既
有透明遞迴對齊。同理 `lineedge` 之 `edgeRect` 攀爬集由 `options.finder.styleInlines`
**派生**(非硬編碼子集),免遺漏 `ins``del``sub` 等致行端誤判(T4)。
### 4.2 `unicode.ts`——字元分類與規則表
集中所有 Unicode 範圍與標點子類,供各 pass 共用,杜絕分類散落不一致:
- **CJK**:表意文字、假名、注音符號等範圍(沿用並擴充 v1 之 `PANGU_CJK`)。
- **西文**:拉丁字母、數字、常見符號(v1 之 `AN_CHARS` 等)。
- **標點(biaodian)子類**(對應 CSS class):
- `bd-open` 開始括號/引號:`「『(《〈【〖〔[{` 等
- `bd-close` 結束括號/引號:`」』)》〉】〗〕]}` 等
- `bd-cop` 句逗號(comma/period,字面偏移因簡繁而異):`,。.`
- `bd-stop` 句末其餘:`!?;:⁇‼` 等
- `bd-sep` 分隔/間隔號:`·・‧`
- `bd-liga` 連續省略/破折:`…—` 等
- `quote` 彎引號:`“”‘’`
- **規則正則**:中西間隙邊界、擠壓相鄰組合、禁則對象等,皆以此表組裝。
**規則集抽象(`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`)。
- `style(lang)`:回傳該語言之標點寬度政策——`quanjiao`(全角式)|`kaiming`
(開明式)|`banjiao`(半角式)。預設:`zh-Hant`/`zh-HK` → 全角式;`zh-Hans`
→ 開明式;可由 `options.lang.policy` 覆寫(即你要的「依 lang 自由選擇」)。
CSS 端對應以 `:lang()` 選擇器落實同一政策(I2/I3)。
### 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 與鄰接判定處理)。
- **邊界正規化一致性(下游回饋 1a/1b 修)**:
- **1a pill 穿越 jz-jinze**jinzeorder 40)先於 spacingorder 50),會把尾隨
標點之 pill 納入 `jz-jinze`。`adjacentLogicalChar` 須**透明穿越 `jz-jinze`**
(與 `jz-hws`、樣式行內同列,見 `finder.isTransparentInline`),否則 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+0370037D 補全至整塊 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 齊右不變)。
### 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(不宜挤之例外)。多標點鏈逐對處理。`jiyaAdjacencyPass` 以**從 root 之透明遍歷**(穿越 strong
em… 等樣式行內;block 邊界與 avoid 子樹斷相鄰;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)。僅純樣式標籤白名單可切;`a``abbr``ruby`/帶 id 者退化為整元素綁定(安全)。
- **切片**:切片 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 行端模式重構為 squeezehangnonesqueeze 為現有行端半形、保留並改善);**惟標點懸掛
> hang 經實測確認不可行、已全面停用**(仍保留休眠程式碼)。
>
> **行歸屬判定 bug 修正**(連帶改善 squeeze,保留):原以「行盒 top 差 > EPS=4px」判同/異行,
> 但 jz-char 為 inline-block,其行盒 top 與相鄰純文本字形 top 因基線差約 5px、遠小於行高卻超
> 舊容差,致同行誤判為下一行。改判「垂直區間」:下一行=`b.top ≥ a.bottom`、上一行=
> `b.bottom ≤ a.top`(容 2px 亞像素)。
>
> **標點懸掛停用之原因**(純負 margin 機制不可行,headless + 真實瀏覽器實測):
> 1. 句號等標點墨色偏字框左下角;安全凸出量(< 1 個 CJK 字寬 1em)只把空白半框推出版心、
> **墨色仍在版心內**(視覺不懸掛)。
> 2. 凸出滿 1em 又**釋放一整字寬**、把下一字拉上本行、與懸掛標點**重疊**。
> 3. 懸掛與 inline-block 對齊原子**互斥**justifyAtoms:true 下負 margin 被 justify 吸收)。
> 4. `ResizeObserver` 重排時,「自然佈局標記→施加 margin→佈局再變」致標記**錯亂**。
>
> 真正的解是原生 CSS `hanging-punctuation`,惟 Chrome 不支援、且與本庫 inline-block 原子衝突。
> 故 hang 之 lineedge 程式碼與 CSS 保留休眠,並設**三重防逃逸**(`lineEnd` 鎖死 squeeze、
> `modeForChar` 降級、`applyEdge` 之 `HANG_ENABLED=false` 總開關)——縱使下游以任何方式要求亦
> 不啟用。詳見 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`),可疊加共存。
- **協同**:禁則決定標點落行首或行末 → 半形/懸掛對落在邊緣者施收縮/凸出。
- **行端「半形 vs 懸掛」互斥**(同屬渲染層):將 lineedge 行端處理升級為可配模式
`lineEnd: "squeeze"(現有半形)|"hang"(懸掛)|"none"`,二者共用行端標點偵測碼。
- **垂懸字 vs 懸掛邊界**(已研究):垂懸字限「末行實義字 ≥ N」、懸掛限「邊緣標點凸出」。唯一
重疊=**純標點末行**(實義字=0):懸掛可「吸回」(不動正文)、垂懸字綁定可「拉字下來」。
約定:方案 C(無測量)**附帶**消除純標點末行(副作用,現有益無害);未來若改由懸掛吸回
(更優),把 C 綁定**條件化**(僅末行實義字 ∈ [1,N−1] 才綁),借懸掛 layout pass 之測量協同
判定,C 不獨立測。末行實義字 ∈ [1,N−1] 僅垂懸字(換行)可解(懸掛不增實義字)。
#### 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 字寬)→ 整體可能溢出(罕見,文檔提示)。
- **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,渲染層,layout)— ⚠ 已實作後停用
> **停用(2026-06)**:本節原設計之懸掛經實測**不可行、已全面停用**(原因見 6.5 開頭與 6.5.6
> 三重防逃逸見 6.5.6)。lineedge 之 squeeze 模式(行端半形)保留並改善。下文為原設計記錄,
> 供日後改用原生 `hanging-punctuation` 重啟時參考。
- **目標**:行末 bd-stoppauseclose **右凸**出右緣;行首 bd-open **左凸**出左緣。文字邊緣齊。
- **不用 CSS `hanging-punctuation`**:瀏覽器支援差(僅 Safari 較全)+聚珍「原子透明」已對 jz-*
`hanging-punctuation: none`。故**自管負 margin**。
- **機制**layout pass(複用 lineedge `installLayout`:立即跑+`ResizeObserver(root)`
`fonts.ready` 重算)。量測各標點 jz-char 是否在行首/行末(同 lineedge `edgeRect`/`firstRect`
跨 jz-* 包裹攀爬)。行末點號/閉類加 `jz-hang-r`→`margin-right: -<凸出量>`;行首開類加
`jz-hang-l`→`margin-left: -<凸出量>`。
- **與現有行端半形整合**(已實作):抽出「行端標點偵測」共用,按 `lineEnd` 模式(由
`options.hanging` 解析:開→`hang`、關→`squeeze`)於 `relayout` 加 `jz-half-le`squeeze
或 `jz-hang-*`hang)。squeeze 行為不變(回歸保綠)。
- **凸出量按類**headless 實測校準,Noto Sans CJK SC):句末/句內點號(。,等墨色小、留白
多)近全形 `--jz-hang-stop`(預設 1em,整體落版心外、正文齊邊);括號引號半形
`--jz-hang-bracket`(預設 0.5em,墨色偏側、留白半框)。實測句號右凸 +1.005em、開括號左凸
+0.5em,正確。CSS 變數可逐字型調校。
- **已 baseline 半形者不懸掛**jz-char 帶 `jz-half`(開明/半角式之括號等)已收為半形、無留白
可凸,JS 端 relayout 跳過(否則負 margin 把墨色整體拉出)。故 SC 開明式之括號維持半形不掛、
句末點號(不收半形)才掛。
- ⚠ **懸掛與 inline-block 對齊原子互斥(實測,重要約束)**:預設 `justifyAtoms:true`jz-char
為 inline-block)下,懸掛之負 margin 被 justify 重分配吸收、標點**不凸出**headless 量測
凸出量 0);改 `justifyAtoms:false`jz-char 為 inline)則正確凸出(+1em)。故欲懸掛須設
`justifyAtoms:false`。二者皆渲染層、不可兼得(如同 squeeze/hang 互斥)。容器宜留右
padding ≥1em 容納凸出標點。
- **lang 閘**(已實作):繁體橫排預設不懸掛(`relayout` 之 `modeForChar` 對 `zh-Hant` 降回
squeezeclreq);簡體可掛。`options.hanging` 預設**關**(顯式開)。
- **自我應驗**(已解,§6.5 開頭發現 4):採**兩階段** relayout——先 `clearEdge` 清全部行端
margin/半形,於自然(無行端效果)佈局量測**全部**標點之行端狀態(不變更 DOM),再一次性
套用。每次量測皆見無污染佈局(仿 gapTrim「起始全清」教訓),終態穩定。
- **行歸屬判定**(已修,§6.5 開頭發現 1):以「垂直區間」`lowerLine`/`higherLine` 取代舊
「top 差 > EPS」,吸收 inline-block 行盒與純文本字形之基線差(~5px),免同行誤判。
- **order**`90 lineEdge`squeeze/hang 共此 pass)。`revert` 清 `jz-half-le`/`jz-hang-*`。
- **跨瀏覽器**:負 margin 一致;須 ChromeSafariFF 目視(R1/R2)。headlessChrome)已驗
行末標點凸出右緣、行首凸出左緣、繁體不掛、無中段誤觸。
#### 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(行端 squeezehang 已停用)→ 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 行為不變、回歸保綠;連帶修「行歸屬判定」
bug(垂直區間取代 top 差)。保留。
3. ⛔ **懸掛 hang 模式**:實作後經 headless + 真實瀏覽器實測**確認不可行、全面停用**(原因見
6.5 開頭 + 6.5.6)。程式碼休眠 + 三重防逃逸。
4. ✅ **邊界落地**:純標點末行由 orphan 之尾段綁定(含尾隨標點)全包消除。
5. ✅ **文檔/docsdemo**ARCHITECTUREREADMEdocs 分支標記懸掛停用;demoshowcase
resize-check)移除懸掛、保留功能分級與垂懸字。
#### 6.5.6 標點懸掛停用記錄與未來路徑
- **停用結論**:純負 `margin` 機制無法視覺懸掛——(1) 句號墨色偏框左下,安全凸出量(< 1em)
只推出空白半框、墨色仍在版心內;(2) 凸出滿 1em 釋放一整字寬、拉升後字致重疊;(3) 與
inline-block 對齊原子互斥(justifyAtoms:true 下 margin 被 justify 吸收);(4) ResizeObserver
重排致兩階段標記錯亂。四者皆實測確認。
- **三重防逃逸**(確保下游無法以任何方式啟用):① `index.ts` 鎖 `lineEnd: "squeeze"`(忽略
`hanging` 選項);② `lineedge.ts` `modeForChar` 無條件降 squeeze;③ `applyEdge` 之
`HANG_ENABLED = false` 總開關。三層任一即足,疊加為冗餘保險。`hanging` 選項仍被接受(API
相容)但無效果。單元測試覆蓋 optiondata-juzhenblocks 各路徑皆不產生 jz-hang-*。
- **未來路徑**:原生 CSS `hanging-punctuation``allow-end`/`first`/`last`)為正解,待 Chrome
支援(現僅 Safari),且須解決與 inline-block 原子之衝突(屆時或令行端標點局部退原子化)。
hang 之 lineedge 偵測碼、CSS`jz-hang-*`)、`LineEndMode` 型別皆保留休眠,重啟成本低。
### 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 不變量)須先更新本文件再改碼。*