fix(web): 修復排版邊界與增量生命週期

This commit is contained in:
2026-10-02 19:08:02 +08:00
parent 1c2fb0ed59
commit b1a16c4969
39 changed files with 4763 additions and 673 deletions
+61 -17
View File
@@ -57,8 +57,8 @@ CSS box、observer、瀏覽器量測,以及 TeX node/glue/penalty/macro
`inline-block`/`user-select` 等普遍原語。`text-spacing-trim`/
`hanging-punctuation`/`text-emphasis` 等僅作漸進增強,絕不作為唯一機制。**且當
某原生原語在各引擎支援不一致、會與我們託管之版面衝突或重複作用時,須在
`.juzhen` 範圍內以 `@supports` 顯式禁用之**(如關閉 Blink 的 `text-spacing-trim`/
`text-autospace`),使三引擎行為收斂於同一路徑(見 §9.1)。
`.juzhen` 範圍內以 `@supports` 顯式禁用之**(現行關閉 `text-autospace`;
`text-spacing-trim` 不設定),使三引擎行為收斂於同一路徑(見 §9.1)。
- **I5 justify 單份間距**:間隙與擠壓的結構設計,須保證每個標點在
`text-align: justify` 下只貢獻**一份**對齊伸縮量(見 §8)。
- **I6 冪等與可還原**:每個 pass 對其產物打標記,重跑跳過;提供 `revert` **乾淨移除聚珍注入之
@@ -101,6 +101,10 @@ jz.disconnect(); // 停止觀察
變化 → 自動 `revert`+`render` 最近塊、新增之塊直接 `render`(免全容器重掃);render/revert 期間
**暫停觀察**,故 juzhen 自身之 jz-* 注入絕不自觸發。實測編輯 400 段中之一段,局部恢復較全樹快數
百倍。**元素變化正確性**見 §4.1 末「快取生命週期與失效」。
變更目標與待新增塊皆以交付時的觀察根包含關係檢查;整根未掛入 document 仍可更新,
同批移出根的節點與已離根的舊記錄則跳過,不以 `isConnected` 代替作用域判斷。
搬入既有塊時,以同批移除來源、私有產物或已知 render 根辨識;先 revert 再 render
撤回舊祖先的語言/級別/功能效果。純新增塊直接 render,但插入子樹的快取先失效。
**增量粒度契約(下游說明八)**:增量更新須以**塊**為粒度——換塊內容(`textContent`/
`innerHTML`)、增刪塊、改分類屬性,皆與全量重繪一致(差分複驗逐位元相同)。**不支持**對聚珍
@@ -208,19 +212,23 @@ ownerToken, setOwnedClass }`。各 pass 共用同一 `finder` 與 resolved optio
執行器先篩出已啟用的 pass,再按 `kind` 分組。所有 `CharifyPass` 共用一次
`finder.eachTextNode(root)`:對每個文本節點依 `order` 呼叫各 pass 的 `collect`,
先把節點與 `WrapRequest` 累積為 work,遍歷結束後才由管線執行器統一套用,避免走訪途中改 DOM。
走訪前可執行 `prepare(ctx)`;jiya 用此步驟先建立跨透明 Text 的完整標點原子,
一般同節點標點與長詞仍共用上述 collect/apply 流程。
之後 `StandalonePass` 依 `order` 各自呼叫 `render(ctx)`:
```
charify = enabled.filter(kind == "charify").sort(order)
standalone = enabled.filter(kind == "standalone").sort(order)
for pass in charify: pass.prepare?.(ctx)
walk(root) 一次:對每個 Text,依序把 charify 的 WrapRequest 收入 work
for item in work: applyRequests(item.node, item.requests, ownedElements)
for pass in standalone: pass.render(ctx)
```
聚合僅是**執行優化**:邏輯上仍「一功能一 pass」、可獨立開關與 `revert`(I6)。
需要自訂掃描、跨節點 run 或 layout 的功能採 standalone,不假裝參與 charify 聚合。
需要完整獨立掃描或 layout 的功能採 standalone;跨節點標點的 prepare 是明列的
前置掃描,並不算入共享文本走訪的成本。
### 3.5 v1 向下相容層 — 已移除(I8 退役)
@@ -279,6 +287,12 @@ Nvim peek/Claude 模板等 v1 消費端之過渡期。**最後一批 v1 消費
做正則 find,回呼決定如何包裝/插入標記。樣式行內元素(`strong/em/a/span/…`)
透明穿越;block/pill/avoid 中斷邏輯串。此即 v1 Pass A「剝離樣式收集邏輯文本
再反映射」之一般化,提升為可重用引擎。
- **完整 Unicode 原子**:`collectRuns` 將跨透明 Text 的 surrogate pair 先合成完整碼點,
再將 base 與至多一個 variation selector 合為原子;`map.parts` 保留所有來源跨度。
間隙、禁則與避孤字共用此映射,不在原子中間建間隙/斷點、不把 selector 另算實義字。
排除文本(含空 Text)、作用域及元素分類邊界中斷 run,不跨邊界拼接。
`collectRuns` 與鄰字查詢可傳入功能名;禁用 Text/空透明元素中斷該功能脈絡,
最近子白名單仍可重新啟用。spacing 與 jiya 的跨節點預處理共用此口徑。
設計要點:finder 不知道任何具體排版規則;它只提供「在何處、跨何種邊界、對哪些
字元」的遍歷與安全變更原語。規則由各 pass 以 unicode 表 + 回呼注入。
@@ -330,7 +344,8 @@ avoid」用組合入口 `isAvoided`(=自身或祖先,階段二歸一,免
漏一半致自身命中 avoid 時仍被穿越)。`adjacentLogicalChar` 之 `descend` 改三態
(邊界字/無內容續找兄弟/**blocked** 終止——遇 pill/isolate/avoid 不可跳過續找
更遠之字,否則重蹈「越過 `jz-char` 取更前字 → 誤隙」之同型錯誤);`core/split` 之
`canSplit` 諮詢 finder(pill/isolate/avoid 一律不可切、退化整綁)。
`canSplit` 諮詢 finder(pill/isolate/avoid 一律不可切);整綁仍須通過禁則/避孤字的
參與範圍檢查,avoid 不參與整綁。
**「行內透明」判定之分層(階段二確立)**:「透明穿越」曾有兩套口徑散落——`collectRuns`
默認透明、`isTransparent`/`adjacentLogicalChar` 默認邊界——同一未登記行內標籤(如 `<button>`)
@@ -439,6 +454,14 @@ class 約定以現有 CSS/pass 為準:標點採 `bd-open`/`bd-close`/`bd
### 6.1 中西間隙(HWS,漢字↔西文)— 切片 1
- **逐來源功能與行內作用域**:間隙及作者空格刪除前,run/pill 查詢先以 spacing
門控建立有效邊界。不能只查 block,也不能只禁止生成 gap 卻仍刪空格。
`scope.include` 所選的行內子樹另作處理根;已由入選祖先處理者略過,避免巢狀重掃。
- **搬移回退與多實例**:只對仍有效的真實載體恢復來源,克隆/刪除後不復活文字。
真正移動的載體及完整 render 根脫離 document 仍可還原。來源父若由本庫已驗證所有權
的解包/splitoff 合併撤回,dom 私有弱映射登記舊父→新父;spacing/cross-atom
沿傳遞鏈解析。作者自行解包、屬性偽造或 anchor 移動不會建立可信重定向。
已撤回空殼重新作作者容器時使舊位置記錄失效;解析有環檢測,循環來源不搬文字。
- **Han**:插入 `<h-hws hidden>`,內含**真實空格** + `::before{content:' '}`,
**未加 `user-select:none`** → 選取/複製會帶上空格(其已知缺陷)。
- **我們(margin 模型)**:finder 邊界感知地把邊界**左側之一個字**(CJK 或西文)
@@ -448,6 +471,11 @@ class 約定以現有 CSS/pass 為準:標點採 `bd-open`/`bd-close`/`bd
- **跨節點 IVS**:當 base 與 variation selector 分居文本節點時,`jz-hws` 承載完整
base+VS 序列;僅此類 gap 以 `::after` 生成 `U+200B` 斷行機會,並只對該偽元素設
`user-select:none`。它不是 DOM 文本,不改變 `textContent` 或複製結果。
- **源文本與內部標記分離**:每個 run 從未出現於源文本、且不屬規則 CJK/西文字集的
候選字符中選四個標記,僅用於間隙/刪除/複合詞遮罩回放;私用區原文與貌似
placeholder 的字串保持作者內容。搜索有固定上界,候選耗盡時在間隙回放前報錯。
- **pill 鄰接**:鄰字按完整碼點/base+VS 查找,含跨透明 Text 的 surrogate pair;可越過
一個 ASCII 作者空格並吸收為受管間隙,但不越過第二個空格、換行或分類/作用域邊界。
- **為何不用空白字元/inline-block(justify 三難)**:純 CSS 下,CJK↔西文間隙在
`justify` 必有一個邊緣瑕疵——①真實空白→被當詞間空白**暴增**;②inline-block 固定
寬→罕見落**行首縮排**;③`margin-right`→**行末右緣內縮 0.25em**。選 ③(margin),
@@ -535,6 +563,12 @@ class 約定以現有 CSS/pass 為準:標點採 `bd-open`/`bd-close`/`bd
### 6.3 標點擠壓(Jiya)— 切片 1
- **完整標點原子**:base、surrogate pair 或 VS 被拆到透明 Text 時,prepare 依
`collectRuns` 的完整原子映射建立一個 `jz-char`/`jz-inner`,鄰接擠壓不在原子內斷開。
只搬移文本,作者 Element 身份與監聽器保留;來源/載體註解錨點避免其他 pass 的
normalize 合併來源節點。回退由私有搬移記錄還原分布;局部回退任一端也撤回完整原子。
克隆或刪除載體、替換來源父節點時不依舊記錄復活文字;真正移動的原載體與整根脫離
document 的回退仍可處理。自訂標點集合以完整碼點匹配,不把 surrogate 半字當成成員。
- **Han**:charify 標點為 `<h-char class="bd-jiya …"><h-inner>`,以負
`letter-spacing`/`margin` 擠壓;連續、行端、簡繁差異以 CSS + `:lang()` 落實
(`bd-cop:lang(zh-Hant)` → 不擠=全角式)。
@@ -584,10 +618,11 @@ class 約定以現有 CSS/pass 為準:標點採 `bd-open`/`bd-close`/`bd
`“”`/角括號 `「」`/尖括號 `〈〉` 墨強偏一側→開 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。實測:
- **激活與粒度(I3/I10)**:最近有效 `data-jz-halfwidth="halt|margin"` 屬性透過
可繼承 CSS 變數決定模式;`render()` 在全域 margin 時自動掛於 root。可在任意祖先
逐區塊/逐語言指定並交替巢狀,切回 halt 會撤銷 margin 盒寬、內盒及偏移。bias
class 恆於 charify 標記,但偏側僅作用於 margin 中已收半的標點,全形標點不偏移。
模式切換為純 CSS、無需重 render。歷史實測:
方正書宋 GBK(開明式 zh-Hans)下同句行寬 `24.0em(halt 無效)→ 19.7em(margin 生效)`、
單一句內點號 `1.0em → 0.5em`。
- **切片**:涵蓋並超越 v1 Pass D(開明式),加入全角式、lang 切換、行內連續與
@@ -610,10 +645,13 @@ class 約定以現有 CSS/pass 為準:標點採 `bd-open`/`bd-close`/`bd
原元素其餘部分留原位、渲染視覺無縫。克隆標 `data-jz-kind="splitoff"`,`revertPass` 之**延後
階段**(先解包 wrap、後併 splitoff,確保前兄弟為原元素而非 wrap 殼)併回原元素、還原一致
(I6)。僅純樣式標籤白名單可切,且 `canSplit` **諮詢 finder 三分類**(§4.1):`a`/`abbr`/
`ruby`/帶 id 者,以及 **pill(整體)/isolate(隔離)/avoid 自身命中者**,一律退化為
`ruby`/帶 id 者,以及 **pill(整體)/isolate(隔離)**,可退化為
整元素綁定(安全)——pill 即「作為原子鄰字單位整體綁入 `jz-jinze`」之期望行為(下游問題六:
KaTeX 全樹皆 SPAN,舊版僅憑標籤白名單曾整棵下潛肢解公式);isolate(如背景色 `mark`)切分
有可見接縫故整綁;`MARK` 並自白名單移除。
兩側邊界及切分後的整個候選單元都須符合作用域、paragraph 級別與 `jinze` 功能閘;
avoid/text/off 後代不得因不可切而被整綁帶入。攀升不超過本次 render 根。
跨 Text 的完整碼點/base+VS 邊界與標點禁斷一併綁定;群組中的註解錨點保持身份及次序。
`canSplit` 須對「切點 `textNode` 至 `top` 之**整條**祖先鏈」成立、非僅 `top`:下潛由
`firstText`/`lastText` 守(遇非可切回 `null`),**攀升由 `splitTreeAfter` 之對稱守衛守**
(切分前先驗祖先鏈全可切,任一層 opaque/pill/isolate/帶 id 即放棄、退化整綁)。二者
@@ -716,15 +754,18 @@ class 約定以現有 CSS/pass 為準:標點採 `bd-open`/`bd-close`/`bd
#### 6.5.1 垂懸字避免(方案 C,斷行層,無測量)— 已實作
- **目標**:段末行 CJK 實義字數 ≥ N(預設 2;標點/符號不計)。單行段無效。
- **目標**:段末可處理的連續區域 CJK 實義字數 ≥ N(預設 2;標點/符號不計)。單行段無害。
- **機制**:standalone pass,遍歷作用域段落;自段末向前數 N 個 CJK 實義字(`ruleset.cjk` 判定、
跳過標點/空白/西文計數),把「第 N 個實義字起至段末」(含其間/尾隨標點)包入 `<jz-orphan>`
(CSS `white-space: nowrap`)。
- **保證(數學、免測 layout)**:該整體不可斷 → 末行恒含之 ⇒ 末行實義字 ≥ N,任意寬度/字型/
- **保證(成功建立尾部綁定時,免測 layout)**:該整體不可斷 → 末行恒含之 ⇒ 末行實義字 ≥ N,任意寬度/字型/
瀏覽器成立。平時無害(末行本多字時不改變),僅末行將塌為 <N 字時「拉字下來」。
- **選項**:`orphan?: boolean | { chars?: number }`(feature gate;**預設開 chars=2**;per-block
經 `blocks`/`data-jz`;段落級,`data-jz-level="text"` 子樹跳過)。lang 無關。
- **邊界**:段落實義字 < N → 不綁。極窄容器(<N 字寬)→ 整體可能溢出(罕見,文檔提示)。
- **邊界**:avoid/text/`orphan` 關閉/`isSkipped` 區域清空先前計數,只綁其後的合法
段末區域;不足 N 字則不綁。完整碼點及 base+VS 只算一個字。pill 不計內部字,仍可
作完整外部單元參與綁定;不可切且含排除內容的容器不退化整綁。極窄容器(<N 字寬)
仍可能溢出。
- **僅葉塊**:含塊級後代之容器(其實義字屬內層段落)跳過、另由內層處理。**效能(S2)**:舊版
逐塊 `block.querySelector(sel)` 掃整棵子樹判葉塊,累加 O(Σ塊子樹)、深塊嵌套下 O(n²);改以一趟
上攀預計算「非葉塊」Set(某塊為另一塊之最近塊祖先 ⟺ 含塊級後代),scope 無涉、與原 querySelector
@@ -748,6 +789,10 @@ lineedge 行端處理只餘 **squeeze**(行端半形):量測各 jz-char
行歸屬以「垂直區間」`lowerLine`/`higherLine` 判定(吸收 inline-block 行盒與純文本基線差 ~5px)。
order `90 jiyaLineEdge`,`revert` 清 `jz-half-le`。純版面、jsdom 跳過、須瀏覽器目視。
中西間隙的行末去隙亦以 `lowerLine` 比較垂直區間;同行小字/下標的 top 可能更低,
不得僅以 top 差誤判換行。layout 安裝按根/實例/pass 保留目前 observer 身份;重裝或
回退後,舊字型 Promise 及已排隊回調不再進入量測,目前安裝仍照常重算。
#### 6.5.3 行端禁則(§6.4)review
- 禁則避頭尾與 squeeze 行端半形協同(禁則趕標點到邊、行端收半形);jz-orphan 與 jz-jinze 嵌套
@@ -822,7 +867,7 @@ order `90 jiyaLineEdge`,`revert` 清 `jz-half-le`。純版面、jsdom 跳過
### 6.9 樣式正常化/字型歸屬 — 貫穿(CSS)
- **normalize**:`juzhen.css` 內以 `@supports`/`:where()` 提供 CJK normalize(關閉
與託管擠壓重複作用之原生 `text-spacing-trim`/`text-autospace` 等),限 `.juzhen`
與託管間隙重複作用之原生 `text-autospace`),限 `.juzhen`
域、非破壞。間隙本身不插入空白;只有跨節點 IVS 的 `U+200B` 偽元素設
`user-select:none`。
- **字型不歸聚珍管(設計決策,下游回饋)**:曾內建依 `:lang` 之 Noto 字型堆疊作「後備」,
@@ -947,16 +992,14 @@ inline-block,於 `_jiya.css` 補一條)。實測:halt/margin 兩模式 c
一致路徑:
```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; } /* 關閉原生中西自動間距 */
}
```
政策:凡屬「一致性關鍵層」者(間隙、擠壓),一律由我們統一處理並禁用對應原生
原語。`hanging-punctuation` 等未來設計若要加入,須另立能力門控與三引擎驗收,不能
原語。現行不設定 `text-spacing-trim`:標點由 inline-block 與 halt/margin 處理;
設 `space-all` 反可能抵銷 halt。`hanging-punctuation` 等未來設計若要加入,須另立能力門控與三引擎驗收,不能
從目前保留的 no-op 選項推定已實作。此清單隨功能演進維護於 `css/_normalize.css`。
---
@@ -969,6 +1012,7 @@ src/
types.ts 公開選項、RenderContext、CharifyPass / StandalonePass 型別
core/
pass.ts 兩類 pass 之分組、排序與管線執行器
cross-atom.ts 跨 Text 標點原子搬移/私有來源記錄/回退
finder.ts 遍歷/avoid/charify/邊界感知與跨節點 run
unicode.ts 字元類 + 標點子類 + 規則正則表
locale.ts 有效 lang / 分類 / 全角開明政策