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 不溢出版心。
This commit is contained in:
2026-06-11 16:06:17 +08:00
parent f3fe2399a7
commit 2890549f44
14 changed files with 985 additions and 89 deletions
+24
View File
@@ -248,6 +248,16 @@ IIFE 構建額外掛 `globalThis.createCjkAutospace` 別名,使
設計要點: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 共用,杜絕分類散落不一致:
@@ -301,6 +311,11 @@ CSS 端對應以 `:lang()` 選擇器落實同一政策(I2/I3)。
冪等標記(`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-*`
@@ -315,6 +330,7 @@ CSS 端對應以 `:lang()` 選擇器落實同一政策(I2/I3)。
| `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/注音 |
@@ -474,6 +490,14 @@ class 約定:`bd`(標點)、`bd-open/close/cop/stop/sep/liga`、`cjk`、`j
(閉類/點號/間隔/連接省略)綁到**前一字**、「不可置行末」之開類綁到**後一字**,
且**只隔離單一邊界字**(長引文中段仍正常斷行,不溢出)。實作為兩階段:依原始
相鄰算禁斷邊界→切邊界字;重掃併入 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` 群組邊界向上