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`/ `inline-block`/`user-select` 等普遍原語。`text-spacing-trim`/
`hanging-punctuation`/`text-emphasis` 等僅作漸進增強,絕不作為唯一機制。**且當 `hanging-punctuation`/`text-emphasis` 等僅作漸進增強,絕不作為唯一機制。**且當
某原生原語在各引擎支援不一致、會與我們託管之版面衝突或重複作用時,須在 某原生原語在各引擎支援不一致、會與我們託管之版面衝突或重複作用時,須在
`.juzhen` 範圍內以 `@supports` 顯式禁用之**(如關閉 Blink 的 `text-spacing-trim`/ `.juzhen` 範圍內以 `@supports` 顯式禁用之**(現行關閉 `text-autospace`;
`text-autospace`),使三引擎行為收斂於同一路徑(見 §9.1)。 `text-spacing-trim` 不設定),使三引擎行為收斂於同一路徑(見 §9.1)。
- **I5 justify 單份間距**:間隙與擠壓的結構設計,須保證每個標點在 - **I5 justify 單份間距**:間隙與擠壓的結構設計,須保證每個標點在
`text-align: justify` 下只貢獻**一份**對齊伸縮量(見 §8)。 `text-align: justify` 下只貢獻**一份**對齊伸縮量(見 §8)。
- **I6 冪等與可還原**:每個 pass 對其產物打標記,重跑跳過;提供 `revert` **乾淨移除聚珍注入之 - **I6 冪等與可還原**:每個 pass 對其產物打標記,重跑跳過;提供 `revert` **乾淨移除聚珍注入之
@@ -101,6 +101,10 @@ jz.disconnect(); // 停止觀察
變化 → 自動 `revert`+`render` 最近塊、新增之塊直接 `render`(免全容器重掃);render/revert 期間 變化 → 自動 `revert`+`render` 最近塊、新增之塊直接 `render`(免全容器重掃);render/revert 期間
**暫停觀察**,故 juzhen 自身之 jz-* 注入絕不自觸發。實測編輯 400 段中之一段,局部恢復較全樹快數 **暫停觀察**,故 juzhen 自身之 jz-* 注入絕不自觸發。實測編輯 400 段中之一段,局部恢復較全樹快數
百倍。**元素變化正確性**見 §4.1 末「快取生命週期與失效」。 百倍。**元素變化正確性**見 §4.1 末「快取生命週期與失效」。
變更目標與待新增塊皆以交付時的觀察根包含關係檢查;整根未掛入 document 仍可更新,
同批移出根的節點與已離根的舊記錄則跳過,不以 `isConnected` 代替作用域判斷。
搬入既有塊時,以同批移除來源、私有產物或已知 render 根辨識;先 revert 再 render
撤回舊祖先的語言/級別/功能效果。純新增塊直接 render,但插入子樹的快取先失效。
**增量粒度契約(下游說明八)**:增量更新須以**塊**為粒度——換塊內容(`textContent`/ **增量粒度契約(下游說明八)**:增量更新須以**塊**為粒度——換塊內容(`textContent`/
`innerHTML`)、增刪塊、改分類屬性,皆與全量重繪一致(差分複驗逐位元相同)。**不支持**對聚珍 `innerHTML`)、增刪塊、改分類屬性,皆與全量重繪一致(差分複驗逐位元相同)。**不支持**對聚珍
@@ -208,19 +212,23 @@ ownerToken, setOwnedClass }`。各 pass 共用同一 `finder` 與 resolved optio
執行器先篩出已啟用的 pass,再按 `kind` 分組。所有 `CharifyPass` 共用一次 執行器先篩出已啟用的 pass,再按 `kind` 分組。所有 `CharifyPass` 共用一次
`finder.eachTextNode(root)`:對每個文本節點依 `order` 呼叫各 pass 的 `collect`, `finder.eachTextNode(root)`:對每個文本節點依 `order` 呼叫各 pass 的 `collect`,
先把節點與 `WrapRequest` 累積為 work,遍歷結束後才由管線執行器統一套用,避免走訪途中改 DOM。 先把節點與 `WrapRequest` 累積為 work,遍歷結束後才由管線執行器統一套用,避免走訪途中改 DOM。
走訪前可執行 `prepare(ctx)`;jiya 用此步驟先建立跨透明 Text 的完整標點原子,
一般同節點標點與長詞仍共用上述 collect/apply 流程。
之後 `StandalonePass` 依 `order` 各自呼叫 `render(ctx)`: 之後 `StandalonePass` 依 `order` 各自呼叫 `render(ctx)`:
``` ```
charify = enabled.filter(kind == "charify").sort(order) charify = enabled.filter(kind == "charify").sort(order)
standalone = enabled.filter(kind == "standalone").sort(order) standalone = enabled.filter(kind == "standalone").sort(order)
for pass in charify: pass.prepare?.(ctx)
walk(root) 一次:對每個 Text,依序把 charify 的 WrapRequest 收入 work walk(root) 一次:對每個 Text,依序把 charify 的 WrapRequest 收入 work
for item in work: applyRequests(item.node, item.requests, ownedElements) for item in work: applyRequests(item.node, item.requests, ownedElements)
for pass in standalone: pass.render(ctx) for pass in standalone: pass.render(ctx)
``` ```
聚合僅是**執行優化**:邏輯上仍「一功能一 pass」、可獨立開關與 `revert`(I6)。 聚合僅是**執行優化**:邏輯上仍「一功能一 pass」、可獨立開關與 `revert`(I6)。
需要自訂掃描、跨節點 run 或 layout 的功能採 standalone,不假裝參與 charify 聚合。 需要完整獨立掃描或 layout 的功能採 standalone;跨節點標點的 prepare 是明列的
前置掃描,並不算入共享文本走訪的成本。
### 3.5 v1 向下相容層 — 已移除(I8 退役) ### 3.5 v1 向下相容層 — 已移除(I8 退役)
@@ -279,6 +287,12 @@ Nvim peek/Claude 模板等 v1 消費端之過渡期。**最後一批 v1 消費
做正則 find,回呼決定如何包裝/插入標記。樣式行內元素(`strong/em/a/span/…`) 做正則 find,回呼決定如何包裝/插入標記。樣式行內元素(`strong/em/a/span/…`)
透明穿越;block/pill/avoid 中斷邏輯串。此即 v1 Pass A「剝離樣式收集邏輯文本 透明穿越;block/pill/avoid 中斷邏輯串。此即 v1 Pass A「剝離樣式收集邏輯文本
再反映射」之一般化,提升為可重用引擎。 再反映射」之一般化,提升為可重用引擎。
- **完整 Unicode 原子**:`collectRuns` 將跨透明 Text 的 surrogate pair 先合成完整碼點,
再將 base 與至多一個 variation selector 合為原子;`map.parts` 保留所有來源跨度。
間隙、禁則與避孤字共用此映射,不在原子中間建間隙/斷點、不把 selector 另算實義字。
排除文本(含空 Text)、作用域及元素分類邊界中斷 run,不跨邊界拼接。
`collectRuns` 與鄰字查詢可傳入功能名;禁用 Text/空透明元素中斷該功能脈絡,
最近子白名單仍可重新啟用。spacing 與 jiya 的跨節點預處理共用此口徑。
設計要點:finder 不知道任何具體排版規則;它只提供「在何處、跨何種邊界、對哪些 設計要點:finder 不知道任何具體排版規則;它只提供「在何處、跨何種邊界、對哪些
字元」的遍歷與安全變更原語。規則由各 pass 以 unicode 表 + 回呼注入。 字元」的遍歷與安全變更原語。規則由各 pass 以 unicode 表 + 回呼注入。
@@ -330,7 +344,8 @@ avoid」用組合入口 `isAvoided`(=自身或祖先,階段二歸一,免
漏一半致自身命中 avoid 時仍被穿越)。`adjacentLogicalChar` 之 `descend` 改三態 漏一半致自身命中 avoid 時仍被穿越)。`adjacentLogicalChar` 之 `descend` 改三態
(邊界字/無內容續找兄弟/**blocked** 終止——遇 pill/isolate/avoid 不可跳過續找 (邊界字/無內容續找兄弟/**blocked** 終止——遇 pill/isolate/avoid 不可跳過續找
更遠之字,否則重蹈「越過 `jz-char` 取更前字 → 誤隙」之同型錯誤);`core/split` 之 更遠之字,否則重蹈「越過 `jz-char` 取更前字 → 誤隙」之同型錯誤);`core/split` 之
`canSplit` 諮詢 finder(pill/isolate/avoid 一律不可切、退化整綁)。 `canSplit` 諮詢 finder(pill/isolate/avoid 一律不可切);整綁仍須通過禁則/避孤字的
參與範圍檢查,avoid 不參與整綁。
**「行內透明」判定之分層(階段二確立)**:「透明穿越」曾有兩套口徑散落——`collectRuns` **「行內透明」判定之分層(階段二確立)**:「透明穿越」曾有兩套口徑散落——`collectRuns`
默認透明、`isTransparent`/`adjacentLogicalChar` 默認邊界——同一未登記行內標籤(如 `<button>`) 默認透明、`isTransparent`/`adjacentLogicalChar` 默認邊界——同一未登記行內標籤(如 `<button>`)
@@ -439,6 +454,14 @@ class 約定以現有 CSS/pass 為準:標點採 `bd-open`/`bd-close`/`bd
### 6.1 中西間隙(HWS,漢字↔西文)— 切片 1 ### 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:' '}`, - **Han**:插入 `<h-hws hidden>`,內含**真實空格** + `::before{content:' '}`,
**未加 `user-select:none`** → 選取/複製會帶上空格(其已知缺陷)。 **未加 `user-select:none`** → 選取/複製會帶上空格(其已知缺陷)。
- **我們(margin 模型)**:finder 邊界感知地把邊界**左側之一個字**(CJK 或西文) - **我們(margin 模型)**:finder 邊界感知地把邊界**左側之一個字**(CJK 或西文)
@@ -448,6 +471,11 @@ class 約定以現有 CSS/pass 為準:標點採 `bd-open`/`bd-close`/`bd
- **跨節點 IVS**:當 base 與 variation selector 分居文本節點時,`jz-hws` 承載完整 - **跨節點 IVS**:當 base 與 variation selector 分居文本節點時,`jz-hws` 承載完整
base+VS 序列;僅此類 gap 以 `::after` 生成 `U+200B` 斷行機會,並只對該偽元素設 base+VS 序列;僅此類 gap 以 `::after` 生成 `U+200B` 斷行機會,並只對該偽元素設
`user-select:none`。它不是 DOM 文本,不改變 `textContent` 或複製結果。 `user-select:none`。它不是 DOM 文本,不改變 `textContent` 或複製結果。
- **源文本與內部標記分離**:每個 run 從未出現於源文本、且不屬規則 CJK/西文字集的
候選字符中選四個標記,僅用於間隙/刪除/複合詞遮罩回放;私用區原文與貌似
placeholder 的字串保持作者內容。搜索有固定上界,候選耗盡時在間隙回放前報錯。
- **pill 鄰接**:鄰字按完整碼點/base+VS 查找,含跨透明 Text 的 surrogate pair;可越過
一個 ASCII 作者空格並吸收為受管間隙,但不越過第二個空格、換行或分類/作用域邊界。
- **為何不用空白字元/inline-block(justify 三難)**:純 CSS 下,CJK↔西文間隙在 - **為何不用空白字元/inline-block(justify 三難)**:純 CSS 下,CJK↔西文間隙在
`justify` 必有一個邊緣瑕疵——①真實空白→被當詞間空白**暴增**;②inline-block 固定 `justify` 必有一個邊緣瑕疵——①真實空白→被當詞間空白**暴增**;②inline-block 固定
寬→罕見落**行首縮排**;③`margin-right`→**行末右緣內縮 0.25em**。選 ③(margin), 寬→罕見落**行首縮排**;③`margin-right`→**行末右緣內縮 0.25em**。選 ③(margin),
@@ -535,6 +563,12 @@ class 約定以現有 CSS/pass 為準:標點採 `bd-open`/`bd-close`/`bd
### 6.3 標點擠壓(Jiya)— 切片 1 ### 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>`,以負 - **Han**:charify 標點為 `<h-char class="bd-jiya …"><h-inner>`,以負
`letter-spacing`/`margin` 擠壓;連續、行端、簡繁差異以 CSS + `:lang()` 落實 `letter-spacing`/`margin` 擠壓;連續、行端、簡繁差異以 CSS + `:lang()` 落實
(`bd-cop:lang(zh-Hant)` → 不擠=全角式)。 (`bd-cop:lang(zh-Hant)` → 不擠=全角式)。
@@ -584,10 +618,11 @@ class 約定以現有 CSS/pass 為準:標點採 `bd-open`/`bd-close`/`bd
`“”`/角括號 `「」`/尖括號 `〈〉` 墨強偏一側→開 right、閉 left;圓方書名括號 `“”`/角括號 `「」`/尖括號 `〈〉` 墨強偏一側→開 right、閉 left;圓方書名括號
`()【】《》…` 近中→center),charify 時標為 `jz-ink-{left,center,right}`。消費端可 `()【】《》…` 近中→center),charify 時標為 `jz-ink-{left,center,right}`。消費端可
經 `jiya.bias` 逐 lang 逐字覆寫;配字工作流與量測工具 `tools/measure-bias.py` 見 README。 經 `jiya.bias` 逐 lang 逐字覆寫;配字工作流與量測工具 `tools/measure-bias.py` 見 README。
- **激活與粒度(I3/I10)**:模式由 `data-jz-halfwidth` 屬性**所在子樹**決定(CSS - **激活與粒度(I3/I10)**:最近有效 `data-jz-halfwidth="halt|margin"` 屬性透過
後代選擇器)——`render()` 在全域 margin 時自動掛於 root;亦可手寫於任一祖先作逐區塊 可繼承 CSS 變數決定模式;`render()` 在全域 margin 時自動掛於 root。可在任意祖先
/逐語言控制(含 `margin` 頁中嵌 `halt` 島之 halt-reset,憑來源順序勝出)。bias 逐區塊/逐語言指定並交替巢狀,切回 halt 會撤銷 margin 盒寬、內盒及偏移。bias
class 恆於 charify 標記,halt 模式不讀,故模式切換為純 CSS、無需重 render。實測: class 恆於 charify 標記,但偏側僅作用於 margin 中已收半的標點,全形標點不偏移。
模式切換為純 CSS、無需重 render。歷史實測:
方正書宋 GBK(開明式 zh-Hans)下同句行寬 `24.0em(halt 無效)→ 19.7em(margin 生效)`、 方正書宋 GBK(開明式 zh-Hans)下同句行寬 `24.0em(halt 無效)→ 19.7em(margin 生效)`、
單一句內點號 `1.0em → 0.5em`。 單一句內點號 `1.0em → 0.5em`。
- **切片**:涵蓋並超越 v1 Pass D(開明式),加入全角式、lang 切換、行內連續與 - **切片**:涵蓋並超越 v1 Pass D(開明式),加入全角式、lang 切換、行內連續與
@@ -610,10 +645,13 @@ class 約定以現有 CSS/pass 為準:標點採 `bd-open`/`bd-close`/`bd
原元素其餘部分留原位、渲染視覺無縫。克隆標 `data-jz-kind="splitoff"`,`revertPass` 之**延後 原元素其餘部分留原位、渲染視覺無縫。克隆標 `data-jz-kind="splitoff"`,`revertPass` 之**延後
階段**(先解包 wrap、後併 splitoff,確保前兄弟為原元素而非 wrap 殼)併回原元素、還原一致 階段**(先解包 wrap、後併 splitoff,確保前兄弟為原元素而非 wrap 殼)併回原元素、還原一致
(I6)。僅純樣式標籤白名單可切,且 `canSplit` **諮詢 finder 三分類**(§4.1):`a`/`abbr`/ (I6)。僅純樣式標籤白名單可切,且 `canSplit` **諮詢 finder 三分類**(§4.1):`a`/`abbr`/
`ruby`/帶 id 者,以及 **pill(整體)/isolate(隔離)/avoid 自身命中者**,一律退化為 `ruby`/帶 id 者,以及 **pill(整體)/isolate(隔離)**,可退化為
整元素綁定(安全)——pill 即「作為原子鄰字單位整體綁入 `jz-jinze`」之期望行為(下游問題六: 整元素綁定(安全)——pill 即「作為原子鄰字單位整體綁入 `jz-jinze`」之期望行為(下游問題六:
KaTeX 全樹皆 SPAN,舊版僅憑標籤白名單曾整棵下潛肢解公式);isolate(如背景色 `mark`)切分 KaTeX 全樹皆 SPAN,舊版僅憑標籤白名單曾整棵下潛肢解公式);isolate(如背景色 `mark`)切分
有可見接縫故整綁;`MARK` 並自白名單移除。 有可見接縫故整綁;`MARK` 並自白名單移除。
兩側邊界及切分後的整個候選單元都須符合作用域、paragraph 級別與 `jinze` 功能閘;
avoid/text/off 後代不得因不可切而被整綁帶入。攀升不超過本次 render 根。
跨 Text 的完整碼點/base+VS 邊界與標點禁斷一併綁定;群組中的註解錨點保持身份及次序。
`canSplit` 須對「切點 `textNode` 至 `top` 之**整條**祖先鏈」成立、非僅 `top`:下潛由 `canSplit` 須對「切點 `textNode` 至 `top` 之**整條**祖先鏈」成立、非僅 `top`:下潛由
`firstText`/`lastText` 守(遇非可切回 `null`),**攀升由 `splitTreeAfter` 之對稱守衛守** `firstText`/`lastText` 守(遇非可切回 `null`),**攀升由 `splitTreeAfter` 之對稱守衛守**
(切分前先驗祖先鏈全可切,任一層 opaque/pill/isolate/帶 id 即放棄、退化整綁)。二者 (切分前先驗祖先鏈全可切,任一層 opaque/pill/isolate/帶 id 即放棄、退化整綁)。二者
@@ -716,15 +754,18 @@ class 約定以現有 CSS/pass 為準:標點採 `bd-open`/`bd-close`/`bd
#### 6.5.1 垂懸字避免(方案 C,斷行層,無測量)— 已實作 #### 6.5.1 垂懸字避免(方案 C,斷行層,無測量)— 已實作
- **目標**:段末行 CJK 實義字數 ≥ N(預設 2;標點/符號不計)。單行段無效。 - **目標**:段末可處理的連續區域 CJK 實義字數 ≥ N(預設 2;標點/符號不計)。單行段無害。
- **機制**:standalone pass,遍歷作用域段落;自段末向前數 N 個 CJK 實義字(`ruleset.cjk` 判定、 - **機制**:standalone pass,遍歷作用域段落;自段末向前數 N 個 CJK 實義字(`ruleset.cjk` 判定、
跳過標點/空白/西文計數),把「第 N 個實義字起至段末」(含其間/尾隨標點)包入 `<jz-orphan>` 跳過標點/空白/西文計數),把「第 N 個實義字起至段末」(含其間/尾隨標點)包入 `<jz-orphan>`
(CSS `white-space: nowrap`)。 (CSS `white-space: nowrap`)。
- **保證(數學、免測 layout)**:該整體不可斷 → 末行恒含之 ⇒ 末行實義字 ≥ N,任意寬度/字型/ - **保證(成功建立尾部綁定時,免測 layout)**:該整體不可斷 → 末行恒含之 ⇒ 末行實義字 ≥ N,任意寬度/字型/
瀏覽器成立。平時無害(末行本多字時不改變),僅末行將塌為 <N 字時「拉字下來」。 瀏覽器成立。平時無害(末行本多字時不改變),僅末行將塌為 <N 字時「拉字下來」。
- **選項**:`orphan?: boolean | { chars?: number }`(feature gate;**預設開 chars=2**;per-block - **選項**:`orphan?: boolean | { chars?: number }`(feature gate;**預設開 chars=2**;per-block
經 `blocks`/`data-jz`;段落級,`data-jz-level="text"` 子樹跳過)。lang 無關。 經 `blocks`/`data-jz`;段落級,`data-jz-level="text"` 子樹跳過)。lang 無關。
- **邊界**:段落實義字 < N → 不綁。極窄容器(<N 字寬)→ 整體可能溢出(罕見,文檔提示)。 - **邊界**:avoid/text/`orphan` 關閉/`isSkipped` 區域清空先前計數,只綁其後的合法
段末區域;不足 N 字則不綁。完整碼點及 base+VS 只算一個字。pill 不計內部字,仍可
作完整外部單元參與綁定;不可切且含排除內容的容器不退化整綁。極窄容器(<N 字寬)
仍可能溢出。
- **僅葉塊**:含塊級後代之容器(其實義字屬內層段落)跳過、另由內層處理。**效能(S2)**:舊版 - **僅葉塊**:含塊級後代之容器(其實義字屬內層段落)跳過、另由內層處理。**效能(S2)**:舊版
逐塊 `block.querySelector(sel)` 掃整棵子樹判葉塊,累加 O(Σ塊子樹)、深塊嵌套下 O(n²);改以一趟 逐塊 `block.querySelector(sel)` 掃整棵子樹判葉塊,累加 O(Σ塊子樹)、深塊嵌套下 O(n²);改以一趟
上攀預計算「非葉塊」Set(某塊為另一塊之最近塊祖先 ⟺ 含塊級後代),scope 無涉、與原 querySelector 上攀預計算「非葉塊」Set(某塊為另一塊之最近塊祖先 ⟺ 含塊級後代),scope 無涉、與原 querySelector
@@ -748,6 +789,10 @@ lineedge 行端處理只餘 **squeeze**(行端半形):量測各 jz-char
行歸屬以「垂直區間」`lowerLine`/`higherLine` 判定(吸收 inline-block 行盒與純文本基線差 ~5px)。 行歸屬以「垂直區間」`lowerLine`/`higherLine` 判定(吸收 inline-block 行盒與純文本基線差 ~5px)。
order `90 jiyaLineEdge`,`revert` 清 `jz-half-le`。純版面、jsdom 跳過、須瀏覽器目視。 order `90 jiyaLineEdge`,`revert` 清 `jz-half-le`。純版面、jsdom 跳過、須瀏覽器目視。
中西間隙的行末去隙亦以 `lowerLine` 比較垂直區間;同行小字/下標的 top 可能更低,
不得僅以 top 差誤判換行。layout 安裝按根/實例/pass 保留目前 observer 身份;重裝或
回退後,舊字型 Promise 及已排隊回調不再進入量測,目前安裝仍照常重算。
#### 6.5.3 行端禁則(§6.4)review #### 6.5.3 行端禁則(§6.4)review
- 禁則避頭尾與 squeeze 行端半形協同(禁則趕標點到邊、行端收半形);jz-orphan 與 jz-jinze 嵌套 - 禁則避頭尾與 squeeze 行端半形協同(禁則趕標點到邊、行端收半形);jz-orphan 與 jz-jinze 嵌套
@@ -822,7 +867,7 @@ order `90 jiyaLineEdge`,`revert` 清 `jz-half-le`。純版面、jsdom 跳過
### 6.9 樣式正常化/字型歸屬 — 貫穿(CSS) ### 6.9 樣式正常化/字型歸屬 — 貫穿(CSS)
- **normalize**:`juzhen.css` 內以 `@supports`/`:where()` 提供 CJK normalize(關閉 - **normalize**:`juzhen.css` 內以 `@supports`/`:where()` 提供 CJK normalize(關閉
與託管擠壓重複作用之原生 `text-spacing-trim`/`text-autospace` 等),限 `.juzhen` 與託管間隙重複作用之原生 `text-autospace`),限 `.juzhen`
域、非破壞。間隙本身不插入空白;只有跨節點 IVS 的 `U+200B` 偽元素設 域、非破壞。間隙本身不插入空白;只有跨節點 IVS 的 `U+200B` 偽元素設
`user-select:none`。 `user-select:none`。
- **字型不歸聚珍管(設計決策,下游回饋)**:曾內建依 `:lang` 之 Noto 字型堆疊作「後備」, - **字型不歸聚珍管(設計決策,下游回饋)**:曾內建依 `:lang` 之 Noto 字型堆疊作「後備」,
@@ -947,16 +992,14 @@ inline-block,於 `_jiya.css` 補一條)。實測:halt/margin 兩模式 c
一致路徑: 一致路徑:
```css ```css
@supports (text-spacing-trim: space-all) {
.juzhen { text-spacing-trim: space-all; } /* 關閉 Blink 原生 CJK 標點 kerning */
}
@supports (text-autospace: no-autospace) { @supports (text-autospace: no-autospace) {
.juzhen { 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`。 從目前保留的 no-op 選項推定已實作。此清單隨功能演進維護於 `css/_normalize.css`。
--- ---
@@ -969,6 +1012,7 @@ src/
types.ts 公開選項、RenderContext、CharifyPass / StandalonePass 型別 types.ts 公開選項、RenderContext、CharifyPass / StandalonePass 型別
core/ core/
pass.ts 兩類 pass 之分組、排序與管線執行器 pass.ts 兩類 pass 之分組、排序與管線執行器
cross-atom.ts 跨 Text 標點原子搬移/私有來源記錄/回退
finder.ts 遍歷/avoid/charify/邊界感知與跨節點 run finder.ts 遍歷/avoid/charify/邊界感知與跨節點 run
unicode.ts 字元類 + 標點子類 + 規則正則表 unicode.ts 字元類 + 標點子類 + 規則正則表
locale.ts 有效 lang / 分類 / 全角開明政策 locale.ts 有效 lang / 分類 / 全角開明政策
+32 -13
View File
@@ -6,7 +6,8 @@
> (Chrome/Edge/Safari/WebKit/Firefox)。 > (Chrome/Edge/Safari/WebKit/Firefox)。
倉庫名仍為 `cjk-autospace`;公開 API 為 `createJuzhen` / 全域 `Juzhen`;注入之 倉庫名仍為 `cjk-autospace`;公開 API 為 `createJuzhen` / 全域 `Juzhen`;注入之
自訂元素前綴 `jz-`。完整設計見 [`ARCHITECTURE.md`](./ARCHITECTURE.md)。 自訂元素前綴 `jz-`。消費端契約見本文的[用法](#用法)與[標記說明](#行內元素三分類透明隔離整體),
升級影響見 [`UPDATES.md`](./UPDATES.md)。完整設計見 [`ARCHITECTURE.md`](./ARCHITECTURE.md)。
> 本版(2.x)為從零重寫。v1 之 `createCjkAutospace` 相容層**已移除**(最後一批 > 本版(2.x)為從零重寫。v1 之 `createCjkAutospace` 相容層**已移除**(最後一批
> v1 消費端已遷移 v2 API);v1 原始碼保存於 `v1-autospace` 分支。 > v1 消費端已遷移 v2 API);v1 原始碼保存於 `v1-autospace` 分支。
@@ -71,6 +72,7 @@ import vectorsSchema from "cjk-autospace/rules/vectors-schema" with { type: "jso
```html ```html
<link rel="stylesheet" href="dist/juzhen.css"> <link rel="stylesheet" href="dist/juzhen.css">
<main class="prose juzhen" lang="zh-Hant">需要排版的正文。</main>
<script src="dist/juzhen.iife.js"></script> <script src="dist/juzhen.iife.js"></script>
<script> <script>
const jz = Juzhen.createJuzhen({ scope: { include: ".prose" } }); const jz = Juzhen.createJuzhen({ scope: { include: ".prose" } });
@@ -90,7 +92,7 @@ jz.render(document.querySelector("main"));
``` ```
**JS 與 CSS 缺一不可**:JS 只注入 `jz-*` 結構,所有視覺尺寸(間隙寬度、擠壓量、 **JS 與 CSS 缺一不可**:JS 只注入 `jz-*` 結構,所有視覺尺寸(間隙寬度、擠壓量、
`justify` 原子化、字體堆疊)都在 `juzhen.css`。 `justify` 原子化)都在 `juzhen.css`;字型由呼叫端 CSS 設定。
### 動態頁面:增量恢復(局部變化免全樹重掃) ### 動態頁面:增量恢復(局部變化免全樹重掃)
@@ -114,6 +116,13 @@ jz.observe(document.body); // 之後 DOM 變動即自動增量恢復
- **`observe(root?)`/`disconnect()`**:以 `MutationObserver` 自動增量。juzhen 自身之注入 - **`observe(root?)`/`disconnect()`**:以 `MutationObserver` 自動增量。juzhen 自身之注入
於處理期間暫停觀察、**絕不自觸發**;新增之塊直接 render(免全容器重掃)。需 `MutationObserver` 於處理期間暫停觀察、**絕不自觸發**;新增之塊直接 render(免全容器重掃)。需 `MutationObserver`
(瀏覽器/jsdom 有;無則 no-op)。 (瀏覽器/jsdom 有;無則 no-op)。
觀察根可尚未掛入 document;更新按目前是否仍在該根內判定。同批新增後移出根的節點,
或已離開根的舊變更目標,不會被此 observer 排版。
搬入的既有塊先撤回本實例於舊父環境的產物,再依新祖先的級別、功能及語言重排;
純新增塊仍直接 render。插入子樹的祖先快取也會失效。
手動 `render`/`revert`/`rerender` 暫停觀察前會先結算已排隊的變更(可能觸發其他區塊
的排版),再執行本次手動操作;因此局部回退仍最後生效,也不會丟失兄弟區塊更新。
結算中的 observer 錯誤仍由宿主非同步報告,手動操作自己的錯誤則照常同步拋出。
- **`invalidate(root?)`**:清該子樹之內部快取。**`revert` 已自動失效其子樹**,故僅在「改了既有 - **`invalidate(root?)`**:清該子樹之內部快取。**`revert` 已自動失效其子樹**,故僅在「改了既有
元素之分類相關屬性(`class`/`data-jz-*`/`lang` 等)**但不走 `revert`**」時才需顯式調用,以 元素之分類相關屬性(`class`/`data-jz-*`/`lang` 等)**但不走 `revert`**」時才需顯式調用,以
免下次 render 沿用陳舊分類。內容(子節點/文本)變化不改分類、無需 `invalidate`(但須 免下次 render 沿用陳舊分類。內容(子節點/文本)變化不改分類、無需 `invalidate`(但須
@@ -137,6 +146,10 @@ jz.observe(document.body); // 之後 DOM 變動即自動增量恢復
> 一致、無 `jz-*` 殘留,唯該空格已正規化(此差異穩定、不隨反覆 render/revert 累積)。若消費端需 > 一致、無 `jz-*` 殘留,唯該空格已正規化(此差異穩定、不隨反覆 render/revert 累積)。若消費端需
> 保留原始作者空格以供編輯,應自存原文、勿以 `revert` 當「還原到未處理源碼」。 > 保留原始作者空格以供編輯,應自存原文、勿以 `revert` 當「還原到未處理源碼」。
外部克隆/替換/刪除跨節點載體後,`revert` 保留作者當前內容,不補回已刪文字;
克隆不繼承本實例所有權。欲修改原文,先 `revert` 再修改,或直接替換整塊內容。
其他聚珍實例合法撤回包裹時,來源記錄會沿受控解包鏈更新,任意回退次序不丟 base/VS。
--- ---
## 切片 1 已實作功能 ## 切片 1 已實作功能
@@ -146,8 +159,8 @@ jz.observe(document.body); // 之後 DOM 變動即自動增量恢復
| **中西間隙** `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 標籤)剝除不留隙。 | | **中西間隙** `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 無連字符 → 複製不污染)。 | | **長英文詞** `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` 後備模式**(見下節)。 | | **標點擠壓** `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`,絕不下潛肢解其內部。 | | **禁則** `jinze` | 行首行末避頭尾:閉類/點號綁前一字(不置行首)、開類綁後一字(不置行末),只隔離邊界字故長引文中段仍可斷。亦為 `justify` 綁定載體。邊界字落於**透明**行內樣式元素(`strong`/`em` 等)內時**克隆切分**只綁邊界詞元(避免整個元素入 `nowrap` 致行末整體掉行、上行留巨大空隙),切出之克隆標 `data-jz-kind="splitoff"`、`revert` 時自動併回原元素;不可切元素(`a`/`abbr`/`ruby`/帶 `id`,以及 **pill/隔離**——見「行內元素三分類」)退化為**整元素綁定**——pill(如行內公式 `.katex`)即作為原子鄰字單位整體綁入 `jz-jinze`,絕不下潛肢解其內部。兩側須符合作用域、paragraph 級別與 `jinze` 功能閘;不可切時也不把 avoid/text/off 後代整綁帶入。 |
| **垂懸字避免** `orphan` | 段末行 CJK 實義字 ≥ N(預設 2;標點/符號不計)。方案 C(斷行層、無測量):把段末「第 N 個實義字起至段末」包入 `jz-orphan`(`white-space:nowrap`),整體不可斷 → 末行恒含之 ⇒ 末行實義字 ≥ N,任意寬度/字型成立。單行段無害。邊界字落於行內元素內時同樣**克隆切分**(只綁末 N 字、避免整個長元素入 `nowrap` 溢出容器;機制同禁則)。**預設開**(`orphan:false` 關,`{chars:n}` 調)。段落級功能。 | | **垂懸字避免** `orphan` | 段末可處理的連續區域 CJK 實義字 ≥ N(預設 2;標點/符號不計,完整碼點及 base+VS 算一字)。方案 C(斷行層、無測量):把段末「第 N 個實義字起至段末」包入 `jz-orphan`(`white-space:nowrap`),整體不可斷 → 末行恒含之 ⇒ 末行實義字 ≥ N,任意寬度/字型成立。單行段無害。邊界字落於行內元素內時同樣**克隆切分**(只綁末 N 字、避免整個長元素入 `nowrap` 溢出容器;機制同禁則)。**預設開**(`orphan:false` 關,`{chars:n}` 調)。段落級功能。avoid/text/off/`isSkipped` 區域中斷計數,只綁其後的合法尾部;不足 N 字或不可切容器含排除內容時不綁。 |
| **功能分級** `level` | 功能分文本級(中西間隙、標點擠壓——任意文本片段適用)與段落級(禁則、垂懸字、長詞——需行/段布局)。塊級元素預設全功能;`data-jz-level="text"` 子樹或選項 `level:{text:"選擇器"}`(如標題/按鈕)僅跑文本級。 | | **功能分級** `level` | 功能分文本級(中西間隙、標點擠壓——任意文本片段適用)與段落級(禁則、垂懸字、長詞——需行/段布局)。塊級元素預設全功能;`data-jz-level="text"` 子樹或選項 `level:{text:"選擇器"}`(如標題/按鈕)僅跑文本級。 |
| **justify 單份間距** | 每個 `jz-char` 為 `inline-block` 原子項,句末標點只貢獻一份對齊伸縮量。 | | **justify 單份間距** | 每個 `jz-char` 為 `inline-block` 原子項,句末標點只貢獻一份對齊伸縮量。 |
@@ -215,10 +228,10 @@ createJuzhen({
| 選項 | 型別 | 預設 | 作用 | | 選項 | 型別 | 預設 | 作用 |
|---|---|---|---| |---|---|---|---|
| `spacing` | bool | `true` | 中西間隙 | | `spacing` | bool | `true` | 中西間隙 |
| `longWord` | bool|`{minLength,lang}` | `true`(`{6,"en"}`) | 長英文詞斷字 | | `longWord` | bool|`{minLength,lang}` | `true`(`{6,"en"}`) | 長英文詞斷字;`minLength` 須為 ≥2 的有限安全整數,無效值回退 6 |
| `jiya` | bool|`{halfWidth,bias}` | `true` | 標點擠壓(*詳見「標點半形」*) | | `jiya` | bool|`{halfWidth,bias}` | `true` | 標點擠壓(*詳見「標點半形」*) |
| `jinze` | bool | `true` | 禁則避頭尾 | | `jinze` | bool | `true` | 禁則避頭尾 |
| `orphan` | bool|`{chars}` | `true`(`{chars:2}`) | 垂懸字避免 | | `orphan` | bool|`{chars}` | `true`(`{chars:2}`) | 垂懸字避免;`chars` 須有限且 ≥1,向下取整,無效值回退 2 |
| `justifyAtoms` | bool | `true` | inline-block 對齊原子(分頁器設 `false`) | | `justifyAtoms` | bool | `true` | inline-block 對齊原子(分頁器設 `false`) |
| `level.text` | 選擇器 | `null` | 命中子樹僅跑文本級(*詳見「功能分級」*) | | `level.text` | 選擇器 | `null` | 命中子樹僅跑文本級(*詳見「功能分級」*) |
| `blocks` | `{selector,features}[]` | `[]` | 分塊功能選擇 | | `blocks` | `{selector,features}[]` | `[]` | 分塊功能選擇 |
@@ -367,6 +380,10 @@ jz.render();
因此同一元素同時寫白名單與黑名單時,**白名單優先**;`data-juzhen=""` 是空白名單, 因此同一元素同時寫白名單與黑名單時,**白名單優先**;`data-juzhen=""` 是空白名單,
不是恢復預設。全域停用的功能不會被局部白名單重新啟用。 不是恢復預設。全域停用的功能不會被局部白名單重新啟用。
`spacing` 與 `jiya` 的門控也作用於普通行內內容;禁用區域不刪作者空格、不參與
外部間隙/標點鄰接,空禁用元素同樣中斷邊界。子白名單可在自身子樹重新啟用。
`scope.include` 可選行內子樹;文本級間隙不要求其父塊也命中選擇器。
### 功能分級:文本級 vs 段落級(`level`/`data-jz-level`) ### 功能分級:文本級 vs 段落級(`level`/`data-jz-level`)
**text/paragraph 決定允許哪些排版功能,不是 HTML 標籤分類,也不設定 CSS display。** **text/paragraph 決定允許哪些排版功能,不是 HTML 標籤分類,也不設定 CSS display。**
@@ -485,12 +502,14 @@ CSS 皆刪)。`hanging` 選項仍被接受(API 相容)但**無任何效果
### 激活與粒度 ### 激活與粒度
模式由 **`data-jz-halfwidth` 屬性所在子樹**決定(CSS 後代選擇器),故粒度極靈活: 模式由**最近的有效 `data-jz-halfwidth="halt|margin"` 標記**決定;CSS 繼承模式參數,
不依標記距離競爭選擇器。未標記時用 halt;非法值忽略並繼承祖先模式。
- **全域**:`jiya: { halfWidth: "margin" }` → `render()` 於處理 root 自動掛屬性。 - **全域**:`jiya: { halfWidth: "margin" }` → `render()` 於處理 root 自動掛屬性。
- **逐區塊/逐語言**:在 HTML 任一祖先手寫 `data-jz-halfwidth="margin"`(或 - **逐區塊/逐語言**:在 HTML 任一祖先手寫 `data-jz-halfwidth="margin"`(或
`"halt"`),即令該子樹用對應機制——無需另建實例。常見如「全頁 `halt`,某用 `"halt"`),即令該子樹用對應機制——無需另建實例。可任意交替巢狀 halt/margin;
無 `halt` 字型之區塊掛 `margin`」,或反之(`margin` 頁中嵌一塊 `halt` 島)。 切回 halt 時同時撤銷 margin 盒寬、內盒與墨色偏移。未壓縮的全形標點不套偏側位移。
模式切換是純 CSS,變更此屬性即可,無須 rerender。
```html ```html
<!-- 全頁 halt(預設),此塊改用無 halt 字型 → 局部 margin 後備 --> <!-- 全頁 halt(預設),此塊改用無 halt 字型 → 局部 margin 後備 -->
@@ -564,9 +583,9 @@ JS 只注入 `jz-*` 結構,**所有視覺尺寸在 `juzhen.css`**——故 CSS
`import "cjk-autospace/css"`)。缺此則間隙/擠壓/justify 原子化全部失效。 `import "cjk-autospace/css"`)。缺此則間隙/擠壓/justify 原子化全部失效。
2. **設定 `lang`(強烈建議)**:在內容容器標 `lang="zh-Hans"`/`"zh-Hant"`/`"ja"`, 2. **設定 `lang`(強烈建議)**:在內容容器標 `lang="zh-Hans"`/`"zh-Hant"`/`"ja"`,
驅動全角/開明政策與字形 `locl`。無 `lang` 時回退 `options.lang.default`。 驅動全角/開明政策與字形 `locl`。無 `lang` 時回退 `options.lang.default`。
3. **作用域類(用 `scope.include` 時)**:若設 `scope.include: ".juzhen"`,須給內容 3. **作用域類**:受管內容容器須加 `class="juzhen"`,以啟用內建原生間距禁用樣式。
容器加對應 class(如 `class="juzhen"`);`juzhen.css` 之 normalize 亦收斂於此域, `scope.include` 只控制 JS 處理範圍,不會改寫 CSS 選擇器;若使用 `.prose` 等自訂
不外溢。 選擇器,請同時加類別(如 `class="prose juzhen"`)。
4. **長英文詞斷字**:`longWord` 包出 `<span lang="en">` 後,須由消費端 CSS 提供 4. **長英文詞斷字**:`longWord` 包出 `<span lang="en">` 後,須由消費端 CSS 提供
`hyphens: auto`(含 `-webkit-hyphens`)才會原生斷詞顯示連字符。 `hyphens: auto`(含 `-webkit-hyphens`)才會原生斷詞顯示連字符。
5. **`justify`(選用)**:欲兩端對齊則自行設 `text-align: justify`;句末標點單份間距 5. **`justify`(選用)**:欲兩端對齊則自行設 `text-align: justify`;句末標點單份間距
@@ -578,7 +597,7 @@ JS 只注入 `jz-*` 結構,**所有視覺尺寸在 `juzhen.css`**——故 CSS
之字型**(如方正書宋 GBK)須改用 `jiya.halfWidth:"margin"` 後備,並按字型以 之字型**(如方正書宋 GBK)須改用 `jiya.halfWidth:"margin"` 後備,並按字型以
`jiya.bias` 逐字微調墨色偏側(見「字型適配工作流」)。 `jiya.bias` 逐字微調墨色偏側(見「字型適配工作流」)。
7. **自動處理、無需消費端介入**:`juzhen.css` 已在 `.juzhen` 範圍內以 `@supports` 7. **自動處理、無需消費端介入**:`juzhen.css` 已在 `.juzhen` 範圍內以 `@supports`
關閉 Blink 原生 `text-spacing-trim`/`text-autospace`(避免與託管擠壓重複作用), 關閉原生 `text-autospace`(避免與受管間隙重複作用);不設定 `text-spacing-trim`,
間隙不注入空白字元;僅跨節點 IVS gap 之 `U+200B ::after` 間隙不注入空白字元;僅跨節點 IVS gap 之 `U+200B ::after`
設 `user-select:none`,避免該斷行提示進入選取/複製。 設 `user-select:none`,避免該斷行提示進入選取/複製。
8. **段落屬性照常設、原子透明**:聚珍把標點拆成 inline-block 原子,但已確保這些原子對 8. **段落屬性照常設、原子透明**:聚珍把標點拆成 inline-block 原子,但已確保這些原子對
+118
View File
@@ -0,0 +1,118 @@
# 聚珍消費端更新
目前用法與標記契約以 [README](./README.md#用法) 為準;本檔只記錄升級影響與所需動作。
## 2026-10-02:Web 模組邊界、半形模式與局部生命週期修復
- **基線/範圍**:以 `1c2fb0e`(套件 2.0.0、ruleset 1.0.0)為修復前基線;本次為
Web/CSS adapter 修復,公開入口、選項與共享規則版本不變。
- **交付狀態**:本次維護變更隨 Git 提交交付,未進行 npm 發佈。下列結果不代表既有
npm 安裝或任何下游網站已更新。
### 半形模式
舊版以後代選擇器切換 `data-jz-halfwidth`,外層 halt 會蓋過內層 margin;從 margin
切回 halt 也會殘留 inline-block 內盒。現在由最近有效標記決定模式,可交替巢狀,
切回 halt 同時復原內盒與偏移。margin 的偏側位移僅作用於已壓縮標點;全形標點不再偏移。
**受影響者**:混用含 halt 與缺 halt 字型、巢狀半形模式,或 margin 區域含全形標點的消費端。
標記與呼叫方式無需更換。若曾加 CSS workaround 抵銷舊版行為,更新後須確認其是否仍需要。
### 局部回退與觀察更新
舊版局部 `revert` 子樹含獨立 render 根時,可能只還原子根而漏掉由祖根處理的兄弟內容。
現在撤回完整請求子樹中本實例的產物,保留作者及其他實例的所有權。
舊版手動 `render`/`revert`/`rerender` 暫停 observer 時會丟掉未交付的變更。
現在先結算待交付更新,再執行手動操作;兄弟區塊仍更新,手動回退不被舊記錄重新打開。
**時機注意**:手動操作可能先處理其他已變動區塊,因此消費端的 `isSkipped` 等鉤子也可能
在該呼叫期間執行。observer 結算失敗仍走宿主非同步錯誤通道,不成為手動 API 的同步例外。
增量更新按目前觀察根的包含關係判定,未掛入 document 的根仍可更新;同批新增後
移到根外,或舊記錄目標已離開根時不再排版。舊版分別會漏掉前者、誤處理後者。
### 元素參與、Unicode 原子與源文本
舊版 standalone 遍歷可能進入 pill 內部的塊;禁則與避孤字也可能把 avoid、text 或
關閉該功能的內容帶入 `nowrap`。現在 pill 內部保持原樣,禁則同時檢查兩側及整綁
範圍,行內 render 根不向外綁定。避孤字只計算排除邊界之後的合法段末區域;不足
N 字,或不可切容器還含排除內容時不綁。pill 仍可作完整單元參與外部交界。
跨透明文本節點的 surrogate pair 及 base+VS 現在共用完整原子映射,間隙建於完整
字符之後,禁則不在原子內造斷點,避孤字只計一字。pill 兩側隔著一個 ASCII 作者
空格的鄰字也以完整原子查找;第二個空格、換行或排除邊界仍中斷。
標點 charify 也先將跨透明 Text 的 base+VS/surrogate 合為一個標點載體,讓鄰接
擠壓作用於完整字符。作者元素與監聽器保留,回退任一來源端會還原完整原子的分布。
作者克隆/刪除載體或替換來源父節點後,舊回退記錄不會復活已移除文字。
自訂標點集合含非 BMP 字符時,孤立 surrogate 不再被錯認為該字符;被排除的空 Text
也會中斷標點鄰接,普通空 Text 仍透明。
舊版固定內部標記可能把原文私用區字符(如 U+E000~U+E003)或貌似 placeholder
的內容誤當排版指令。現在每個 run 選擇源文本未使用、且字符規則不會匹配的標記,
原文保持作者內容。若自訂字符規則/源文本用盡全部候選,間隙回放前報錯。
**受影響者**:pill 中有塊級內容、混用參與標記、Unicode 字符被模板拆到不同節點,
或源文本含私用區字符的消費端。API 與標記無需變更;依賴舊版越界綁定的頁面,
須重新檢查段末斷行。
### 語言腳本匹配
`Hans`/`Hant` 以完整子標籤匹配,避免 `zh-CN-x-shant`/`zh-TW-x-hansford` 等
標籤被局部字串誤判為相反腳本。一般 `zh-Hans`/`zh-Hant`、地區回退及共享規則不變。
### 混合字號的行末去隙及 layout 生命週期
舊版去隙以相鄰矩形 top 差判斷換行,同行小字或下標也可能被判為下一行,導致
中西間隙消失。現在與行端標點一致,以垂直區間判斷;實際下一行仍去隙。
重排/回退也使舊 layout 安裝的字型完成及已排隊回調失效,避免再次掃描新產物。
**受影響者**:正文中混合字號、下標,或字型仍在載入時反覆重排的消費端。
有效選項與呼叫方式無需變更;曾以額外空格或 CSS 補償間隙的頁面須檢查原 workaround。
### 數值選項防護
`longWord.minLength` 現在要求 ≥2 的有限安全整數,無效值回退既有預設 6;舊版
小數/Infinity 等可能被拼成無效計數量詞,讓長詞功能靜默失效。`orphan.chars`
排除非有限值,無效值回退 2;合法小數仍照既有行為向下取整。
使用合法選項的消費端無需修改。若由外部設定輸入閾值,請依 README 的範圍檢查。
### 系統交互補查
已排版塊搬到不同級別/功能/語言的祖先後,舊版可能保留原產物與祖先快取。
現在觀察器先撤回搬入塊的舊效果,再按目前環境排版;純新增塊仍只處理自身範圍。
舊版 spacing 只按 block 門控,普通行內白名單/黑名單仍會被補隙或刪作者空格,
禁用塊中的子白名單也未能恢復間隙。現在以逐來源功能邊界處理;pill 鄰字查詢也
遵守兩側門控。空功能禁用元素會中斷 spacing/jiya,重新啟用的子樹自成脈絡。
`scope.include` 選到行內子樹時,間隙不再因父塊未命中而漏處理。
跨節點間隙載體被作者克隆/刪除、來源父被替換後,舊版回退可能重複或復活文字;
現在保留作者當前內容。這也修正舊回歸中「刪除載體後回退另一端便恢復整顆字」的
預期:**刪除載體即刪除其中原文,revert 不補回**。若要撤回排版再編輯原文,先 revert,
再修改;整根脫離文檔與正常搬移的恢復仍保留。
其他實例合法解包來源父時,本庫現在透過私有受控解包鏈更新記錄;兩/三實例
任意回退次序不再因舊父已撤回而丟 VS。作者自行解包或可偽造屬性不會獲得此憑證。
重用已撤回容器會使舊位置記錄失效;重定向鏈有環檢測,不能以循環來源指引恢復。
**受影響者**:搬移已排版 DOM、行內功能標記/opt-in、外部替換載體及多實例
疊加的消費端。公開入口、選項及共享規則不變;段落搬移仍使用既有 observe 或 rerender。
### 消費端動作與驗證
1. 透過既有安裝/打包方式一併更新 JS 與 CSS;只換 JS 不會得到半形模式修復。
2. 保留既有 pill/isolate/avoid、text/paragraph 及半形模式標記。
3. 自訂 `scope.include` 的容器仍須加 `.juzhen` 以使用內建原生間距禁用樣式,
如 `class="prose juzhen"`;JS 選擇器不會自動改寫 CSS 作用域。
4. 用 [E2E 頁面](./demo/e2e.html) 檢查嵌套模式、全形偏移、混合根回退與待交付更新;
macOS 原生入口為 `npm run test:wkwebview`。DOM 回歸見 `test/observer-recovery.test.mjs`
與 `test/revert-ownership.test.mjs`;模組邊界見 `test/participation-boundaries.test.mjs`、
`test/spacing-boundaries.test.mjs` 與 `test/shared-rules.test.mjs`。本地維護候選已通過
353 項 Node 測試、發佈構建及原生 WKWebView 53 項驗收;layout/數值回歸見
`test/layout-lifecycle.test.mjs` 與 `test/numeric-options.test.mjs`;觀察根及跨節點標點
回歸見 `test/observer-boundaries.test.mjs` 與 `test/punctuation-atoms.test.mjs`。
系統交互新增 42 項回歸見 `test/systematic-interactions.test.mjs`;另有 32 組功能
開關×10 類結構共 320 個 DOM 組合及 splitoff 多實例 36 個組合通過。
真實下游頁面仍須自行驗證其字型與版面。
+336
View File
@@ -62,6 +62,46 @@
<p id="observer-case">观察初始内容</p> <p id="observer-case">观察初始内容</p>
</main> </main>
<section id="halfwidth-fixture" class="panel juzhen" lang="zh-Hant" data-jz-level="text">
<div data-jz-halfwidth="halt">
<p id="halt-to-margin" data-jz-halfwidth="margin" data-jz-style="banjiao">甲,乙</p>
</div>
<div data-jz-halfwidth="margin">
<div data-jz-halfwidth="halt">
<p id="margin-to-halt" data-jz-style="banjiao">甲,乙</p>
<p id="nested-margin" data-jz-halfwidth="margin" data-jz-style="banjiao" style="text-decoration:underline">甲,乙</p>
</div>
<p id="fullwidth-bias" data-jz-style="quanjiao">甲,乙</p>
</div>
</section>
<section id="lifecycle-fixture" class="panel juzhen" lang="zh-Hans" data-jz-level="text">
<div id="partial-root"><p id="pending-a">原文,甲。</p><p id="pending-b">原文,乙。</p></div>
</section>
<section id="module-fixture" class="panel juzhen" lang="zh-Hant" data-jz-level="text">
<p id="source-pua">漢&#xE000;&#xE003;&#xE001;0&#xE002;GPT-4中</p>
<div class="module-pill"><p>甲ABC乙,丙。</p></div>
<p id="module-boundary" data-jz-level="paragraph"><span data-jz-skip>自管</span>,正常文字<span data-jz-level="text">界面文字</span></p>
<p id="pill-ivs-space"><strong>&#x20000;</strong><em>&#xE0100;</em> <code>x</code> <span>文</span></p>
<p id="jinze-ivs" data-jz-level="paragraph">前文<strong>&#x20000;</strong><em>&#xE0100;</em>,</p>
<p id="surrogate-spacing"><strong></strong><em></em><i></i><b></b>A</p>
<p id="locale-subtag-hans" lang="zh-CN-x-shant">甲,乙</p>
<p id="locale-subtag-hant" lang="zh-TW-x-hansford">甲,乙</p>
</section>
<section class="panel juzhen"><p id="inline-scope">甲<strong id="inline-root">,乙。</strong>後文</p></section>
<section id="layout-fixture" class="panel juzhen" lang="zh-Hant">
<p id="gap-small-neighbor" style="font-size:32px;text-align:center;white-space:nowrap">漢<span style="font-size:8px">A</span></p>
<p id="gap-sub-neighbor" style="font-size:32px;text-align:center;white-space:nowrap">漢<sub>A</sub></p>
</section>
<section id="punctuation-atom-fixture" class="panel juzhen" lang="zh-Hant">
<p id="punctuation-vs">甲<strong id="punctuation-base">,</strong><em id="punctuation-selector">&#xFE00;</em>「乙丙」</p>
<p id="punctuation-surrogate"><strong></strong><em></em><i></i><b></b>「甲</p>
<p id="punctuation-isolated"></p>
</section>
<script src="../dist/juzhen.iife.js"></script> <script src="../dist/juzhen.iife.js"></script>
<script> <script>
(function () (function ()
@@ -137,6 +177,256 @@
}); });
jz.render(fixture); jz.render(fixture);
var modeFixture = document.getElementById("halfwidth-fixture");
var modeJz = Juzhen.createJuzhen();
modeJz.render(modeFixture);
[ "halt-to-margin", "nested-margin" ].forEach(function (id)
{
var punctuation = document.getElementById(id).querySelector("jz-char");
var innerStyle = getComputedStyle(punctuation.querySelector("jz-inner"));
var widthEm = punctuation.getBoundingClientRect().width
/ parseFloat(getComputedStyle(punctuation).fontSize);
record(
id + ":最近 margin 标记控制半形宽度与字形",
Math.abs(widthEm - 0.5) <= 0.01
&& innerStyle.fontFeatureSettings === "normal"
&& innerStyle.display === "inline-block",
"width=" + widthEm.toFixed(3) + "em,features=" + innerStyle.fontFeatureSettings,
);
});
var haltInnerStyle = getComputedStyle(
document.getElementById("margin-to-halt").querySelector("jz-inner"),
);
record(
"margin-to-halt:恢复 halt 时撤销 margin 内盒与偏移",
haltInnerStyle.display === "inline"
&& parseFloat(haltInnerStyle.marginLeft) === 0
&& haltInnerStyle.fontFeatureSettings.includes('"halt"'),
);
var underline = getComputedStyle(
document.getElementById("nested-margin").querySelector("jz-inner"),
).textDecorationLine;
record(
"嵌套 margin 内盒保留段落下划线",
underline.includes("underline"),
"text-decoration-line=" + underline,
);
var fullwidth = document.getElementById("fullwidth-bias").querySelector("jz-char");
record(
"margin 模式不移动未压缩的全形标点",
!fullwidth.classList.contains("jz-half")
&& !fullwidth.classList.contains("jz-half-le")
&& parseFloat(getComputedStyle(fullwidth.querySelector("jz-inner")).marginLeft) === 0,
);
var atomFixture = document.getElementById("punctuation-atom-fixture");
var atomSurrogate = document.getElementById("punctuation-surrogate");
[ "\uD83D", "\uDE00", "\uDB40", "\uDD00" ].forEach(function (part, index)
{
atomSurrogate.children[index].textContent = part;
});
document.getElementById("punctuation-isolated").textContent = "\uD83DA\uDE00";
var atomOriginal = atomFixture.innerHTML;
var atomSelector = document.getElementById("punctuation-selector");
var atomClicks = 0;
atomSelector.addEventListener("click", function () { atomClicks += 1; });
var atomJz = Juzhen.createJuzhen({ charClass: { biaodian: { pause: "😀" } } });
atomJz.render(atomFixture);
var atomComma = document.getElementById("punctuation-vs").querySelector("jz-char.bd-pause");
record("跨节点标点与 VS 为完整原子并参与邻接挤压",
atomComma.textContent === ",\uFE00" && atomComma.classList.contains("jz-half")
&& document.getElementById("punctuation-vs").querySelector("jz-char.bd-open").classList.contains("jz-half"));
record("跨节点 surrogate 标点和 VS 不生成半字原子",
atomSurrogate.querySelector("jz-char.bd-pause").textContent === "😀\uDB40\uDD00"
&& !document.getElementById("punctuation-isolated").querySelector("jz-char"));
atomSelector.click();
record("跨标点原子保留作者元素身份及监听器",
document.getElementById("punctuation-selector") === atomSelector && atomClicks === 1);
atomJz.revert(atomFixture);
record("跨标点原子回退恢复来源分布", atomFixture.innerHTML === atomOriginal);
atomJz.disconnect();
var spacingOnlyOptions = { jiya: false, jinze: false, orphan: false, longWord: false };
var interactionRoot = document.createElement("p");
interactionRoot.setAttribute("data-jz-level", "text");
interactionRoot.innerHTML = '<span data-juzhen-off="spacing">甲 ABC 乙</span>'
+ '<code>x</code><span data-juzhen-off="spacing"> 丙</span>';
var interactionJz = Juzhen.createJuzhen(spacingOnlyOptions);
var interactionOriginal = interactionRoot.innerHTML;
interactionJz.render(interactionRoot);
record("行内 spacing 关闭时保留原文、作者空格与 pill 邻界",
interactionRoot.innerHTML === interactionOriginal);
interactionJz.revert(interactionRoot);
interactionRoot.innerHTML = '外ABC<span class="prose">甲DEF</span>文XYZ';
var includeJz = Juzhen.createJuzhen(Object.assign({}, spacingOnlyOptions,
{ scope: { include: ".prose" } }));
includeJz.render(interactionRoot);
record("行内 scope.include 子树获得间隙,外部保持原样",
interactionRoot.querySelectorAll("jz-hws").length === 1
&& interactionRoot.querySelector(".prose jz-hws").textContent === "甲"
&& interactionRoot.firstChild.textContent === "外ABC"
&& interactionRoot.lastChild.textContent === "文XYZ");
includeJz.revert(interactionRoot);
includeJz.disconnect();
interactionRoot.innerHTML = ',<span data-juzhen-off="jiya"></span>「';
interactionRoot.setAttribute("lang", "zh-Hant");
var disabledJiya = Juzhen.createJuzhen({ spacing: false, jinze: false, orphan: false, longWord: false });
disabledJiya.render(interactionRoot);
record("空功能禁用元素中断标点邻接",
interactionRoot.querySelectorAll("jz-char").length === 2
&& !interactionRoot.querySelector("jz-char.jz-half"));
disabledJiya.revert(interactionRoot);
disabledJiya.disconnect();
var carrierFailures = [];
[ "clone", "remove" ].forEach(function (change)
{
interactionRoot.innerHTML = '<strong>禰</strong><em>\uDB40\uDD00</em>A';
interactionJz.render(interactionRoot);
var gap = interactionRoot.querySelector("jz-hws");
if (change === "clone") { gap.replaceWith(gap.cloneNode(true)); }
else { gap.remove(); }
var authorText = interactionRoot.textContent;
interactionJz.revert(interactionRoot);
if (interactionRoot.textContent !== authorText) { carrierFailures.push(change); }
});
record("间隙载体克隆与删除后不重复或复活文字", carrierFailures.length === 0,
carrierFailures.join(","));
interactionJz.disconnect();
var ownerFailures = [];
[ [0,1,2], [1,0,2], [0,2,1], [1,2,0], [2,0,1], [2,1,0] ].forEach(function (order)
{
var paragraph = document.createElement("p");
[ "甲", "「", "禰", "\uFE00", "A" ].forEach(function (text)
{
paragraph.appendChild(document.createTextNode(text));
});
var owners = [
Juzhen.createJuzhen({ spacing: false, orphan: false, longWord: false }),
Juzhen.createJuzhen({ spacing: false, jiya: false, jinze: false, longWord: false }),
Juzhen.createJuzhen(spacingOnlyOptions),
];
owners.forEach(function (owner) { owner.render(paragraph); });
order.forEach(function (index) { owners[index].revert(paragraph); });
if (paragraph.textContent !== "甲「禰\uFE00A" || paragraph.querySelector("[data-jz]"))
{
ownerFailures.push(order.join("→"));
}
owners.forEach(function (owner) { owner.disconnect(); });
});
record("多实例传递解包的六种回退顺序保留 VS", ownerFailures.length === 0,
ownerFailures.join(","));
var reusedRoot = document.createElement("p");
[ "甲", "「", "禰", "\uFE00", "A" ].forEach(function (text)
{
reusedRoot.appendChild(document.createTextNode(text));
});
var reusedBinding = Juzhen.createJuzhen({ spacing: false, orphan: false, longWord: false });
var reusedOrphan = Juzhen.createJuzhen({ spacing: false, jiya: false, jinze: false, longWord: false });
var reusedSpacing = Juzhen.createJuzhen(spacingOnlyOptions);
reusedBinding.render(reusedRoot);
var retiredBinding = reusedRoot.querySelector("jz-jinze");
reusedOrphan.render(reusedRoot);
var retiredOrphan = reusedRoot.querySelector("jz-orphan");
reusedSpacing.render(reusedRoot);
reusedBinding.revert(reusedRoot);
retiredBinding.appendChild(retiredOrphan);
reusedOrphan.revert(retiredBinding);
var reusedText = retiredBinding.textContent;
reusedSpacing.revert(retiredBinding);
record("重新使用已撤回容器时回退有限结束并保留当前文字",
retiredBinding.textContent === reusedText);
[ reusedBinding, reusedOrphan, reusedSpacing ].forEach(function (owner) { owner.disconnect(); });
var layoutFixture = document.getElementById("layout-fixture");
var layoutJz = Juzhen.createJuzhen({ jiya: false, orphan: false, jinze: false, longWord: false });
layoutJz.render(layoutFixture);
[ "gap-small-neighbor", "gap-sub-neighbor" ].forEach(function (id)
{
var paragraph = document.getElementById(id);
var gap = paragraph.querySelector("jz-hws");
var neighbor = paragraph.querySelector("span, sub");
var gapRect = gap.getBoundingClientRect();
var nextRect = neighbor.getBoundingClientRect();
var overlaps = nextRect.top < gapRect.bottom - 2 && nextRect.bottom > gapRect.top + 2;
record(id + ":同行不同字号或基线保留间隙",
overlaps && !gap.classList.contains("jz-hws-trim"),
"gap=" + gapRect.top.toFixed(1) + ".." + gapRect.bottom.toFixed(1)
+ ",next=" + nextRect.top.toFixed(1) + ".." + nextRect.bottom.toFixed(1));
});
layoutJz.revert(layoutFixture);
layoutJz.disconnect();
var moduleFixture = document.getElementById("module-fixture");
var splitSurrogate = document.getElementById("surrogate-spacing");
[ "\uD840", "\uDC00", "\uDB40", "\uDD00" ].forEach(function (part, index)
{
splitSurrogate.children[index].textContent = part;
});
var moduleHtml = moduleFixture.innerHTML
.replace("</em> <code>", "</em><code>")
.replace("</code> <span>", "</code><span>");
var puaSource = document.getElementById("source-pua").textContent;
var modulePill = moduleFixture.querySelector(".module-pill");
var modulePillHtml = modulePill.innerHTML;
var moduleJz = Juzhen.createJuzhen({ pillSelector: "code, kbd, .module-pill" });
moduleJz.render(moduleFixture);
record("作者私用区字符与伪 placeholder 保持原文",
document.getElementById("source-pua").textContent === puaSource);
record("pill 内部的块不被排版", modulePill.innerHTML === modulePillHtml);
var protectedRegion = document.getElementById("module-boundary");
record("段落绑定不进入 avoid 或 text 子树",
!protectedRegion.querySelector("[data-jz-skip]").closest("jz-jinze")
&& !protectedRegion.querySelector('[data-jz-level="text"]').closest("jz-orphan"));
var pillIvs = document.getElementById("pill-ivs-space");
record("pill 两侧跨透明节点空格与完整 IVS 间隙",
pillIvs.textContent === "\uD840\uDC00\uDB40\uDD00x文"
&& pillIvs.querySelectorAll("jz-hws").length === 2
&& pillIvs.querySelector("jz-hws").textContent === "\uD840\uDC00\uDB40\uDD00");
record("禁则连同跨节点 base 与 VS 绑定到闭标点",
document.getElementById("jinze-ivs").querySelector("jz-jinze").textContent
=== "\uD840\uDC00\uDB40\uDD00,");
record("跨节点 surrogate 的间隙位于完整原子之后",
splitSurrogate.querySelector("jz-hws").textContent === "\uD840\uDC00\uDB40\uDD00"
&& splitSurrogate.textContent === "\uD840\uDC00\uDB40\uDD00A");
record("语言脚本按完整子标签匹配",
document.getElementById("locale-subtag-hans").querySelector("jz-char").classList.contains("jz-half")
&& !document.getElementById("locale-subtag-hant").querySelector("jz-char").classList.contains("jz-half"));
moduleJz.revert(moduleFixture);
record("模块回退精确恢复作者结构,保留已规范化空格规则",
moduleFixture.innerHTML === moduleHtml);
var inlineRoot = document.getElementById("inline-root");
var inlineParent = inlineRoot.parentNode;
var inlineHtml = inlineParent.innerHTML;
var inlineJz = Juzhen.createJuzhen({ spacing: false, orphan: false, longWord: false });
inlineJz.render(inlineRoot);
var inlineContained = inlineRoot.parentNode === inlineParent
&& !inlineParent.querySelector(":scope > jz-jinze");
inlineJz.revert(inlineRoot);
record("行内 render 根的禁则不越界并可完整回退",
inlineContained && inlineParent.innerHTML === inlineHtml);
modeJz.revert(modeFixture);
var lifecycleFixture = document.getElementById("lifecycle-fixture");
var partialRoot = document.getElementById("partial-root");
var pendingA = document.getElementById("pending-a");
var pendingB = document.getElementById("pending-b");
var lifecycleJz = Juzhen.createJuzhen();
lifecycleJz.render(lifecycleFixture);
lifecycleJz.render(pendingB);
lifecycleJz.revert(partialRoot);
record(
"局部 revert 同时撤回祖根与子根生成的产物",
!partialRoot.querySelector("[data-jz]")
&& !partialRoot.querySelector("[data-jz-block-level]"),
);
lifecycleJz.render(lifecycleFixture);
lifecycleJz.observe(lifecycleFixture);
pendingA.textContent = "改后,新文。";
lifecycleJz.rerender(pendingB);
await delay();
record(
"手动局部重排保留兄弟块待交付的外部变化",
pendingA.querySelectorAll("jz-char").length === 2,
);
lifecycleJz.disconnect();
lifecycleJz.revert(lifecycleFixture);
var firstGapCount = fixture.querySelectorAll("jz-hws").length; var firstGapCount = fixture.querySelectorAll("jz-hws").length;
var firstCharCount = fixture.querySelectorAll("jz-char").length; var firstCharCount = fixture.querySelectorAll("jz-char").length;
record("已生成中西间隙", firstGapCount > 0, firstGapCount + " 个"); record("已生成中西间隙", firstGapCount > 0, firstGapCount + " 个");
@@ -326,6 +616,52 @@
document.getElementById("observer-case").textContent = "观察Observer恢复"; document.getElementById("observer-case").textContent = "观察Observer恢复";
await delay(); await delay();
await delay(); await delay();
var detachedRoot = document.createElement("main");
detachedRoot.innerHTML = '<p lang="zh-Hant">原文,甲乙。</p>';
var detachedJz = Juzhen.createJuzhen();
detachedJz.render(detachedRoot);
detachedJz.observe(detachedRoot);
detachedRoot.querySelector("p").textContent = "修改ABC,丙丁。";
var scopeRoot = document.createElement("main");
var scopeOutside = document.createElement("aside");
document.body.append(scopeRoot, scopeOutside);
var scopeJz = Juzhen.createJuzhen();
scopeJz.observe(scopeRoot);
var escaped = document.createElement("p");
escaped.textContent = "移出ABC,作者內容。";
scopeRoot.appendChild(escaped);
scopeOutside.appendChild(escaped);
var escapedOriginal = escaped.outerHTML;
await delay();
await delay();
record("未挂入 document 的观察根仍增量排版",
!!detachedRoot.querySelector("jz-hws") && !!detachedRoot.querySelector("jz-char"));
record("同批移出观察根的段落不被新增排版", escaped.outerHTML === escapedOriginal);
detachedJz.revert(detachedRoot);
detachedJz.disconnect();
scopeJz.revert(scopeRoot);
scopeJz.disconnect();
scopeRoot.remove();
scopeOutside.remove();
var relocateRoot = document.createElement("main");
relocateRoot.innerHTML = '<section><p>文字ABCDEF,甲乙。</p></section>'
+ '<section data-jz-level="text"></section>';
var relocateJz = Juzhen.createJuzhen();
relocateJz.render(relocateRoot);
relocateJz.observe(relocateRoot);
var relocatedParagraph = relocateRoot.querySelector("p");
relocateRoot.lastElementChild.appendChild(relocatedParagraph);
await delay();
record("已排版段落移入 text 范围后撤回旧段落约束",
relocatedParagraph.getAttribute("data-jz-block-level") === "text"
&& !relocatedParagraph.querySelector("jz-jinze,jz-orphan,[data-jz=longWord]")
&& !!relocatedParagraph.querySelector("jz-hws"));
var relocatedResult = relocatedParagraph.outerHTML;
relocateJz.disconnect();
relocateJz.rerender(relocateRoot);
record("移动段落的增量结果与完整重排一致",
relocatedResult === relocatedParagraph.outerHTML);
relocateJz.revert(relocateRoot);
record( record(
"MutationObserver 增量重排", "MutationObserver 增量重排",
document.getElementById("observer-case").querySelectorAll("jz-hws").length > 0, document.getElementById("observer-case").querySelectorAll("jz-hws").length > 0,
+6
View File
@@ -0,0 +1,6 @@
import type { CharSpan } from "./finder.js";
import type { RenderContext } from "../types.js";
/** span 按逆文件序處理,來源 offset 不受後方切分影響。outer/inner 須為本 pass 新產物。 */
export declare function wrapCrossAtom(span: CharSpan, outer: HTMLElement, inner: HTMLElement, pass: string, ctx: RenderContext): void;
/** 局部回退也涵蓋原子之另一端;只還原私有記錄,不採信可偽造的作者屬性。 */
export declare function revertCrossAtoms(pass: string, ctx: RenderContext): Set<Node>;
+4
View File
@@ -7,6 +7,10 @@ export interface CreateOptions {
/** 文本內容。 */ /** 文本內容。 */
text?: string; text?: string;
} }
/** 完成受控解包後登記;只接受真實、已移除且空的受管產物。 */
export declare function recordUnwrappedParent(el: Element, parent: Node, ownedElements: WeakSet<Element>): void;
/** 其他 pass/實例合法撤回來源包裹後,搬移記錄沿受控解包鏈查詢現父。 */
export declare function resolveUnwrappedParent(parent: Node): Node | null;
/** 造一個帶 pass 標記之 jz-* 元素。 */ /** 造一個帶 pass 標記之 jz-* 元素。 */
export declare function createJz(ownedElements: WeakSet<Element>, tag: JzTag, pass: string, kind: JzKind, opts?: CreateOptions): HTMLElement; export declare function createJz(ownedElements: WeakSet<Element>, tag: JzTag, pass: string, kind: JzKind, opts?: CreateOptions): HTMLElement;
/** 記錄後寫屬性;同實例重寫時仍保留最初原值。 */ /** 記錄後寫屬性;同實例重寫時仍保留最初原值。 */
+11 -8
View File
@@ -25,6 +25,7 @@ export interface AdjacentChar {
textNode: Text; textNode: Text;
offset: number; offset: number;
ch: string; ch: string;
parts: TextSpan[];
} }
/** 行內元素對排版之口徑分類(§4.1)——恰屬其一,優先序見 Finder.category。 */ /** 行內元素對排版之口徑分類(§4.1)——恰屬其一,優先序見 Finder.category。 */
export type InlineCategory = "avoid" | "pill" | "isolate" | "block" | "transparent" | "opaque"; export type InlineCategory = "avoid" | "pill" | "isolate" | "block" | "transparent" | "opaque";
@@ -115,19 +116,21 @@ export declare class Finder {
inScope(node: Node): boolean; inScope(node: Node): boolean;
/** 走訪 root 下所有「可處理」文本節點(已過濾 avoid / pill 內部 / 非作用域)。 */ /** 走訪 root 下所有「可處理」文本節點(已過濾 avoid / pill 內部 / 非作用域)。 */
eachTextNode(root: Element, cb: (node: Text) => void): void; eachTextNode(root: Element, cb: (node: Text) => void): void;
/** 走訪 root 下所有 block 元素(含 root 本身若為 block)。 */ /** 走訪 root 下可處理之 block(含 root 本身若為 block);pill/avoid 內部不可見。 */
eachBlock(root: Element, cb: (block: Element) => void): void; eachBlock(root: Element, cb: (block: Element) => void): void;
/** /**
* 收集 block 之邏輯文本 run(架構 §4.1,I7):透明行內(isTransparent:styleInlines * 收集 block 之邏輯文本 run(架構 §4.1,I7):透明行內(isTransparent:styleInlines
* + wrap 類 jz-*)穿越、run 延續;block/pill/avoid 中斷 run;isolate 與**未知行內 * + wrap 類 jz-*)穿越、run 延續;block/pill/avoid 中斷 run;isolate 與**未知行內
* 標籤**(嚴格白名單)中斷 run 且內部自成獨立 run。回傳 text.length >= 2 之 run。 * 標籤**(嚴格白名單)中斷 run 且內部自成獨立 run。預設至少兩原子;includeSingletons
* 供段落邊界查詢保留單原子 run,不改間隙處理之預設。
*/ */
collectRuns(block: Element): LogicalRun[]; collectRuns(block: Element, includeSingletons?: boolean, feature?: string): LogicalRun[];
/** /** 從節點邊界或 Text offset 取相鄰碼點;只穿越透明、域內內容,scopeRoot 為硬邊界。 */
* pill 之邏輯鄰字(架構 §4.1):經樣式鏈遞迴,遇 pill/block/avoid/間隙 private adjacentTextCodePoint;
* marker 即止。direction = -1 取前鄰字,+1 取後鄰字。 /** 補全透明 Text 邊界拆開的 surrogate pair;孤立碼元保留自身,不跨任何邊界拼接。 */
*/ private adjacentCodePoint;
adjacentLogicalChar(el: Element, direction: -1 | 1): AdjacentChar | null; /** pill 之完整鄰字(base+至多一個 VS);after 供查單個作者空格另一側,不越 scopeRoot。 */
adjacentLogicalChar(el: Element, direction: -1 | 1, scopeRoot?: Element, after?: AdjacentChar, feature?: string): AdjacentChar | null;
/** /**
* 節點之功能級別(§6.5.0a)。自 node 向上找**最近**之顯式信號:元素屬性 * 節點之功能級別(§6.5.0a)。自 node 向上找**最近**之顯式信號:元素屬性
* `data-jz-level="text|paragraph"`(最優先),或命中 `level.text` 選擇器(→ text); * `data-jz-level="text|paragraph"`(最優先),或命中 `level.text` 選擇器(→ text);
+35 -36
View File
@@ -47,49 +47,48 @@ jz-inner {
} }
jz-char.jz-half jz-inner, jz-char.jz-half jz-inner,
jz-char.jz-half-le jz-inner { jz-char.jz-half-le jz-inner {
font-feature-settings: "locl" 1, "halt" 1; font-feature-settings: var(--jz-half-features, "locl" 1, "halt" 1);
-webkit-font-feature-settings: "locl" 1, "halt" 1; -webkit-font-feature-settings: var(--jz-half-features, "locl" 1, "halt" 1);
} }
[data-jz-halfwidth=margin] jz-char.jz-half jz-inner, [data-jz-halfwidth=margin] {
[data-jz-halfwidth=margin] jz-char.jz-half-le jz-inner { --jz-half-features: normal;
font-feature-settings: normal; --jz-half-width: 0.5em;
-webkit-font-feature-settings: normal; --jz-half-inner-display: inline-block;
--jz-half-center-offset: -0.25em;
--jz-half-right-offset: -0.5em;
} }
[data-jz-halfwidth=margin] jz-char.jz-half.jz-ink-left, [data-jz-halfwidth=halt] {
[data-jz-halfwidth=margin] jz-char.jz-half.jz-ink-center, --jz-half-features: "locl" 1, "halt" 1;
[data-jz-halfwidth=margin] jz-char.jz-half.jz-ink-right, --jz-half-width: auto;
[data-jz-halfwidth=margin] jz-char.jz-half-le.jz-ink-left, --jz-half-inner-display: inline;
[data-jz-halfwidth=margin] jz-char.jz-half-le.jz-ink-center, --jz-half-center-offset: 0;
[data-jz-halfwidth=margin] jz-char.jz-half-le.jz-ink-right { --jz-half-right-offset: 0;
width: 0.5em; }
jz-char.jz-half.jz-ink-left,
jz-char.jz-half.jz-ink-center,
jz-char.jz-half.jz-ink-right,
jz-char.jz-half-le.jz-ink-left,
jz-char.jz-half-le.jz-ink-center,
jz-char.jz-half-le.jz-ink-right {
width: var(--jz-half-width, auto);
overflow: visible; overflow: visible;
} }
[data-jz-halfwidth=margin] jz-char.jz-half.jz-ink-left jz-inner, jz-char.jz-half.jz-ink-left jz-inner,
[data-jz-halfwidth=margin] jz-char.jz-half.jz-ink-center jz-inner, jz-char.jz-half.jz-ink-center jz-inner,
[data-jz-halfwidth=margin] jz-char.jz-half.jz-ink-right jz-inner, jz-char.jz-half.jz-ink-right jz-inner,
[data-jz-halfwidth=margin] jz-char.jz-half-le.jz-ink-left jz-inner, jz-char.jz-half-le.jz-ink-left jz-inner,
[data-jz-halfwidth=margin] jz-char.jz-half-le.jz-ink-center jz-inner, jz-char.jz-half-le.jz-ink-center jz-inner,
[data-jz-halfwidth=margin] jz-char.jz-half-le.jz-ink-right jz-inner { jz-char.jz-half-le.jz-ink-right jz-inner {
display: inline-block; display: var(--jz-half-inner-display, inline);
}
[data-jz-halfwidth=margin] jz-char.jz-ink-center jz-inner {
margin-left: -0.25em;
}
[data-jz-halfwidth=margin] jz-char.jz-ink-right jz-inner {
margin-left: -0.5em;
}
[data-jz-halfwidth=margin] jz-char jz-inner {
text-decoration: inherit; text-decoration: inherit;
} }
[data-jz-halfwidth=halt] jz-char.jz-half jz-inner, jz-char.jz-half.jz-ink-center jz-inner,
[data-jz-halfwidth=halt] jz-char.jz-half-le jz-inner { jz-char.jz-half-le.jz-ink-center jz-inner {
font-feature-settings: "locl" 1, "halt" 1; margin-left: var(--jz-half-center-offset, 0);
-webkit-font-feature-settings: "locl" 1, "halt" 1;
margin-left: 0;
} }
[data-jz-halfwidth=halt] jz-char.jz-half, jz-char.jz-half.jz-ink-right jz-inner,
[data-jz-halfwidth=halt] jz-char.jz-half-le { jz-char.jz-half-le.jz-ink-right jz-inner {
width: auto; margin-left: var(--jz-half-right-offset, 0);
} }
/* src/css/_jinze.css */ /* src/css/_jinze.css */
+778 -157
View File
File diff suppressed because it is too large Load Diff
+778 -157
View File
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -4,19 +4,21 @@ import type { Bias, BiasTable, BdSubclass, CharClassOverride, Ruleset } from "./
export type { LangClass, PunctStyle, StyleRule, Bias, BiasTable, BdSubclass, CharClassOverride, Ruleset }; export type { LangClass, PunctStyle, StyleRule, Bias, BiasTable, BdSubclass, CharClassOverride, Ruleset };
/** 長英文詞包裝設定。 */ /** 長英文詞包裝設定。 */
export interface LongWordOptions { export interface LongWordOptions {
/** 有限安全整數且 ≥2;無效值回退 6。 */
minLength?: number; minLength?: number;
lang?: string; lang?: string;
} }
/** 垂懸字避免設定(§6.5.1)。布林 true 等同 { chars: 2 }。 */ /** 垂懸字避免設定(§6.5.1)。布林 true 等同 { chars: 2 }。 */
export interface OrphanOptions { export interface OrphanOptions {
/** 段末行至少保留之 CJK 實義字數(標點/符號不計;預設 2)。 */ /** 段末行至少保留之 CJK 實義字數(標點/符號不計;預設 2)。
* 有限值且 ≥1,向下取整;無效值回退 2。 */
chars?: number; chars?: number;
} }
/** 標點擠壓設定(架構 §6.3)。布林 true 等同 { halfWidth: "halt" }。 */ /** 標點擠壓設定(架構 §6.3)。布林 true 等同 { halfWidth: "halt" }。 */
export interface JiyaOptions { export interface JiyaOptions {
/** 半形渲染機制: /** 半形渲染機制:
* - "halt"(預設)— 用字型 OpenType `halt`,由字型按字形正確定位,需字型支援; * - "halt"(預設)— 用字型 OpenType `halt`,由字型按字形正確定位,需字型支援;
* - "margin" — 負 margin 後備模式,供缺 halt 之字型,依字形偏側表收半形。 */ * - "margin" — 0.5em 盒寬後備模式,供缺 halt 之字型,依字形偏側表定位。 */
halfWidth?: "halt" | "margin"; halfWidth?: "halt" | "margin";
/** margin 後備模式之字形偏側覆寫表(逐 lang 逐字),覆寫內建參考預設。 /** margin 後備模式之字形偏側覆寫表(逐 lang 逐字),覆寫內建參考預設。
* halt 模式不讀此欄。 */ * halt 模式不讀此欄。 */
@@ -179,6 +181,8 @@ interface BasePass {
/** 共享 P1 走訪之字元包裝 pass。 */ /** 共享 P1 走訪之字元包裝 pass。 */
export interface CharifyPass extends BasePass { export interface CharifyPass extends BasePass {
kind: "charify"; kind: "charify";
/** 在逐 Text 收集前處理不可被 DOM 分割的跨節點原子。 */
prepare?: (ctx: RenderContext) => void;
collect: (node: Text, ctx: RenderContext) => WrapRequest[]; collect: (node: Text, ctx: RenderContext) => WrapRequest[];
} }
/** 自跑走訪/版面後處理之 pass。 */ /** 自跑走訪/版面後處理之 pass。 */
+2 -1
View File
@@ -23,7 +23,8 @@
"src", "src",
"rules", "rules",
"ARCHITECTURE.md", "ARCHITECTURE.md",
"README.md" "README.md",
"UPDATES.md"
], ],
"scripts": { "scripts": {
"build": "node build.mjs && tsc --emitDeclarationOnly", "build": "node build.mjs && tsc --emitDeclarationOnly",
+198
View File
@@ -0,0 +1,198 @@
// 跨透明 Text 原子之來源搬移:保留作者 Element,僅搬 Text,回退按私有記錄還原。
import type { CharSpan, TextSpan } from "./finder.js";
import type { RenderContext } from "../types.js";
import { recordUnwrappedParent, resolveUnwrappedParent } from "./dom.js";
interface SourcePart
{
text: Text;
anchor: Comment;
innerAnchor: Comment;
parent: Node;
previous: Node | null;
next: Node | null;
}
interface AtomMove
{
pass: string;
outer: HTMLElement;
inner: HTMLElement;
parent: Node;
previous: Node | null;
next: Node | null;
root: Element;
ancestors: Element[];
parts: SourcePart[];
}
const MOVES = new WeakMap<object, Set<AtomMove>>();
function isolate(part: TextSpan): Text
{
let text = part.textNode;
if (part.start > 0) { text = text.splitText(part.start); }
if (text.length > part.end - part.start) { text.splitText(part.end - part.start); }
return text;
}
function insertionPoint(part: SourcePart): Node | null | undefined
{
if (part.anchor.parentNode === part.parent) { return part.anchor; }
if (part.next?.parentNode === part.parent) { return part.next; }
if (part.next !== null) { return undefined; }
if (part.previous?.parentNode === part.parent)
{
return part.previous === part.parent.lastChild ? null : undefined;
}
if (part.previous === null && part.next === null && part.anchor.parentNode !== null
&& part.anchor.parentNode !== part.parent && !part.parent.hasChildNodes()) { return null; }
return undefined; // 原父被作者取代時,不能把過時文本復活。
}
function restorePart(part: SourcePart, inner: HTMLElement): boolean
{
if (part.text.parentNode === part.parent) { return true; }
if (part.text.parentNode !== inner) { return false; }
const before = insertionPoint(part);
if (before === undefined) { return false; }
part.parent.insertBefore(part.text, before);
return true;
}
/** span 按逆文件序處理,來源 offset 不受後方切分影響。outer/inner 須為本 pass 新產物。 */
export function wrapCrossAtom(
span: CharSpan, outer: HTMLElement, inner: HTMLElement, pass: string, ctx: RenderContext,
): void
{
const first = isolate(span.parts[0]!);
const parent = first.parentNode;
if (!parent) { return; }
const move: AtomMove = {
pass, outer, inner, parent, previous: first.previousSibling, next: first.nextSibling,
root: ctx.root, ancestors: [], parts: [],
};
for (let p = ctx.root.parentElement; p; p = p.parentElement) { move.ancestors.push(p); }
outer.appendChild(inner);
parent.insertBefore(outer, first);
inner.appendChild(first);
try
{
for (const source of span.parts.slice(1))
{
const text = isolate(source);
const sourceParent = text.parentNode;
if (!sourceParent) { throw new Error("juzhen: 跨節點標點來源已脫離。"); }
const anchor = text.ownerDocument.createComment("jz-" + pass + "-atom-anchor");
// 其他 pass 的 normalize 會遞迴合併 Text;載體錨點保留搬移節點身份。
const innerAnchor = text.ownerDocument.createComment("jz-" + pass + "-atom-part");
const part = { text, anchor, innerAnchor, parent: sourceParent, previous: text.previousSibling, next: text.nextSibling };
move.parts.push(part);
sourceParent.insertBefore(anchor, text);
inner.append(innerAnchor, text);
}
let moves = MOVES.get(ctx.ownerToken);
if (!moves) { moves = new Set(); MOVES.set(ctx.ownerToken, moves); }
moves.add(move);
}
catch (error)
{
for (const part of move.parts)
{
restorePart(part, inner);
part.anchor.remove();
part.innerAnchor.remove();
}
while (inner.firstChild) { parent.insertBefore(inner.firstChild, outer); }
outer.remove();
ctx.ownedElements.delete(inner);
ctx.ownedElements.delete(outer);
throw error;
}
}
function ownedWrap(el: HTMLElement, tag: string, move: AtomMove, ctx: RenderContext): boolean
{
return ctx.ownedElements.has(el) && el.nodeName === tag
&& el.getAttribute("data-jz") === move.pass && el.getAttribute("data-jz-kind") === "wrap";
}
function placeOuter(move: AtomMove): boolean
{
if (move.outer.parentNode === move.parent) { return true; }
let before: Node | null = null;
if (move.next?.parentNode === move.parent) { before = move.next; }
else if (move.next !== null) { return false; }
else if (move.previous?.parentNode === move.parent)
{
if (move.previous !== move.parent.lastChild) { return false; }
}
else if (move.parent.hasChildNodes()) { return false; }
move.parent.insertBefore(move.outer, before);
return true;
}
/** 局部回退也涵蓋原子之另一端;只還原私有記錄,不採信可偽造的作者屬性。 */
export function revertCrossAtoms(pass: string, ctx: RenderContext): Set<Node>
{
const touched = new Set<Node>();
const moves = MOVES.get(ctx.ownerToken);
if (!moves) { return touched; }
const within = (node: Node): boolean => node === ctx.root || ctx.root.contains(node);
for (const move of Array.from(moves))
{
if (move.pass !== pass) { continue; }
if (!(within(move.root) || within(move.outer) || within(move.parent)
|| move.ancestors.includes(ctx.root)
|| move.parts.some((part) => within(part.parent) || within(part.anchor)))) { continue; }
const parent = resolveUnwrappedParent(move.parent);
const sources = move.parts.map((part) => resolveUnwrappedParent(part.parent));
if (!parent || sources.some((source) => source === null))
{
for (const part of move.parts) { part.anchor.remove(); part.innerAnchor.remove(); }
moves.delete(move);
continue; // 不可信來源鏈不搬文字;仍有效的載體由正常 revertPass 原地解包。
}
move.parent = parent;
move.parts.forEach((part, index) => { part.parent = sources[index]!; });
// 作者克隆/刪除載體或其來源祖先後,舊記錄不能復活已移除的文字。
// 整個 render 根脫離 document 仍可回退;真正移至其他連接位置的載體亦保留。
const live = (node: Node): boolean => move.root.contains(node) || node.isConnected;
const validCarrier = ownedWrap(move.outer, "JZ-CHAR", move, ctx)
&& ownedWrap(move.inner, "JZ-INNER", move, ctx)
&& move.inner.parentNode === move.outer && live(move.outer);
if (!validCarrier)
{
for (const part of move.parts) { part.anchor.remove(); part.innerAnchor.remove(); }
ctx.ownedElements.delete(move.inner);
if (!live(move.outer)) { ctx.ownedElements.delete(move.outer); }
moves.delete(move);
continue;
}
if (validCarrier)
{
for (const part of move.parts)
{
if (live(part.parent) && restorePart(part, move.inner)) { touched.add(part.parent); }
else if (part.text.parentNode === move.inner) { part.text.remove(); }
}
}
for (const part of move.parts) { part.anchor.remove(); part.innerAnchor.remove(); }
if (ownedWrap(move.outer, "JZ-CHAR", move, ctx) && placeOuter(move))
{
if (move.inner.parentNode === move.outer && ownedWrap(move.inner, "JZ-INNER", move, ctx))
{
while (move.inner.firstChild) { move.outer.insertBefore(move.inner.firstChild, move.inner); }
move.inner.remove();
recordUnwrappedParent(move.inner, move.outer, ctx.ownedElements);
ctx.ownedElements.delete(move.inner);
}
while (move.outer.firstChild) { move.parent.insertBefore(move.outer.firstChild, move.outer); }
move.outer.remove();
recordUnwrappedParent(move.outer, move.parent, ctx.ownedElements);
ctx.ownedElements.delete(move.outer);
touched.add(move.parent);
}
moves.delete(move);
}
if (!moves.size) { MOVES.delete(ctx.ownerToken); }
return touched;
}
+34
View File
@@ -58,6 +58,37 @@ interface AttributeState
const ATTRIBUTE_WRITERS = new WeakMap<Element, Map<string, AttributeState>>(); const ATTRIBUTE_WRITERS = new WeakMap<Element, Map<string, AttributeState>>();
const OWNER_ATTRIBUTES = new WeakMap<object, Map<string, Set<Element>>>(); const OWNER_ATTRIBUTES = new WeakMap<object, Map<string, Set<Element>>>();
// 僅本模組已驗證所有權的解包/合併可留下來源父重定向;作者外改不登記。
const UNWRAPPED_PARENTS = new WeakMap<Node, Node>();
/** 完成受控解包後登記;只接受真實、已移除且空的受管產物。 */
export function recordUnwrappedParent(el: Element, parent: Node, ownedElements: WeakSet<Element>): void
{
const pass = el.getAttribute(PASS_ATTR);
if (ownedElements.has(el) && pass && knownProduct(pass, el.getAttribute(KIND_ATTR), el)
&& !el.parentNode && !el.hasChildNodes()) { UNWRAPPED_PARENTS.set(el, parent); }
}
/** 其他 pass/實例合法撤回來源包裹後,搬移記錄沿受控解包鏈查詢現父。 */
export function resolveUnwrappedParent(parent: Node): Node | null
{
let current = parent;
const visited = new Set<Node>();
for (;;)
{
if (visited.has(current)) { return null; } // 過時來源鏈不能循環,亦不能任選環中一點。
visited.add(current);
const next = UNWRAPPED_PARENTS.get(current);
if (!next) { return current; }
if (current.parentNode || current.hasChildNodes())
{
// 被撤回的空殼已重新作作者容器,原解包位置不再代表它的當前身份。
UNWRAPPED_PARENTS.delete(current);
return current;
}
current = next;
}
}
function trackOwnedAttribute(owner: object, name: string, el: Element): void function trackOwnedAttribute(owner: object, name: string, el: Element): void
{ {
@@ -230,6 +261,7 @@ export function revertPass(
parent.insertBefore(el.firstChild, el); parent.insertBefore(el.firstChild, el);
} }
parent.removeChild(el); parent.removeChild(el);
recordUnwrappedParent(el, parent, ownedElements);
ownedElements.delete(el); ownedElements.delete(el);
} }
// 第二階段:splitoff 併回(此時 wrap 已全解包,前兄弟即原元素)。文件序,使同一 // 第二階段:splitoff 併回(此時 wrap 已全解包,前兄弟即原元素)。文件序,使同一
@@ -250,12 +282,14 @@ export function revertPass(
// 併入前兄弟(同標籤原元素)尾部。 // 併入前兄弟(同標籤原元素)尾部。
while (so.firstChild) { prev.appendChild(so.firstChild); } while (so.firstChild) { prev.appendChild(so.firstChild); }
parent.removeChild(so); parent.removeChild(so);
recordUnwrappedParent(so, prev, ownedElements);
} }
else else
{ {
// 前兄弟非預期同標籤元素(罕見,結構曾被外力改動):原地解包,仍移除標記。 // 前兄弟非預期同標籤元素(罕見,結構曾被外力改動):原地解包,仍移除標記。
while (so.firstChild) { parent.insertBefore(so.firstChild, so); } while (so.firstChild) { parent.insertBefore(so.firstChild, so); }
parent.removeChild(so); parent.removeChild(so);
recordUnwrappedParent(so, parent, ownedElements);
} }
ownedElements.delete(so); ownedElements.delete(so);
} }
+168 -72
View File
@@ -21,7 +21,6 @@
import type { FeatureLevel, ResolvedOptions } from "../types.js"; import type { FeatureLevel, ResolvedOptions } from "../types.js";
import { import {
codePointAtUtf16, isVariationSelector, previousCodePointStart, codePointAtUtf16, isVariationSelector, previousCodePointStart,
previousVariationSequenceStart, variationSequenceAtUtf16,
} from "./unicode.js"; } from "./unicode.js";
/** 邏輯 run 中之單一 base+optional-VS 來源 UTF-16 位置。 */ /** 邏輯 run 中之單一 base+optional-VS 來源 UTF-16 位置。 */
@@ -59,6 +58,7 @@ export interface AdjacentChar
textNode: Text; textNode: Text;
offset: number; offset: number;
ch: string; ch: string;
parts: TextSpan[];
} }
/** 行內元素對排版之口徑分類(§4.1)——恰屬其一,優先序見 Finder.category。 */ /** 行內元素對排版之口徑分類(§4.1)——恰屬其一,優先序見 Finder.category。 */
@@ -67,6 +67,32 @@ export type InlineCategory =
const SVG_NS = "http://www.w3.org/2000/svg"; const SVG_NS = "http://www.w3.org/2000/svg";
function isHighSurrogate(ch: string): boolean
{
return ch.length === 1 && ch.charCodeAt(0) >= 0xD800 && ch.charCodeAt(0) <= 0xDBFF;
}
function isLowSurrogate(ch: string): boolean
{
return ch.length === 1 && ch.charCodeAt(0) >= 0xDC00 && ch.charCodeAt(0) <= 0xDFFF;
}
function appendParts(parts: TextSpan[], extra: readonly TextSpan[]): void
{
for (const part of extra)
{
const previous = parts[parts.length - 1];
if (previous?.textNode === part.textNode && previous.end === part.start) { previous.end = part.end; }
else { parts.push({ ...part }); }
}
}
function acceptsVariationSelector(atom: CharSpan): boolean
{
return !isVariationSelector(codePointAtUtf16(atom.text, 0))
&& !isVariationSelector(codePointAtUtf16(atom.text, previousCodePointStart(atom.text, atom.text.length)));
}
export class Finder export class Finder
{ {
private readonly opts: ResolvedOptions; private readonly opts: ResolvedOptions;
@@ -314,7 +340,7 @@ export class Finder
for (const node of nodes) { cb(node); } for (const node of nodes) { cb(node); }
} }
/** 走訪 root 下所有 block 元素(含 root 本身若為 block)。 */ /** 走訪 root 下可處理之 block(含 root 本身若為 block);pill/avoid 內部不可見。 */
eachBlock(root: Element, cb: (block: Element) => void): void eachBlock(root: Element, cb: (block: Element) => void): void
{ {
const sel = Array.from(this.blockSet) const sel = Array.from(this.blockSet)
@@ -331,6 +357,7 @@ export class Finder
} }
for (const b of blocks) for (const b of blocks)
{ {
if (this.isAvoided(b) || this.insidePill(b) || this.pillMatches(b)) { continue; }
if (this.includeSelector && !(b.closest && b.closest(this.includeSelector))) if (this.includeSelector && !(b.closest && b.closest(this.includeSelector)))
{ {
continue; continue;
@@ -342,9 +369,10 @@ export class Finder
/** /**
* 收集 block 之邏輯文本 run(架構 §4.1,I7):透明行內(isTransparent:styleInlines * 收集 block 之邏輯文本 run(架構 §4.1,I7):透明行內(isTransparent:styleInlines
* + wrap 類 jz-*)穿越、run 延續;block/pill/avoid 中斷 run;isolate 與**未知行內 * + wrap 類 jz-*)穿越、run 延續;block/pill/avoid 中斷 run;isolate 與**未知行內
* 標籤**(嚴格白名單)中斷 run 且內部自成獨立 run。回傳 text.length >= 2 之 run。 * 標籤**(嚴格白名單)中斷 run 且內部自成獨立 run。預設至少兩原子;includeSingletons
* 供段落邊界查詢保留單原子 run,不改間隙處理之預設。
*/ */
collectRuns(block: Element): LogicalRun[] collectRuns(block: Element, includeSingletons = false, feature?: string): LogicalRun[]
{ {
const runs: LogicalRun[] = []; const runs: LogicalRun[] = [];
let curText = ""; let curText = "";
@@ -352,7 +380,7 @@ export class Finder
const flush = (): void => const flush = (): void =>
{ {
if (curMap.length >= 2) { runs.push({ text: curText, map: curMap }); } if (curMap.length >= (includeSingletons ? 1 : 2)) { runs.push({ text: curText, map: curMap }); }
curText = ""; curText = "";
curMap = []; curMap = [];
}; };
@@ -363,26 +391,35 @@ export class Finder
if (node.nodeType === 3) if (node.nodeType === 3)
{ {
const t = node as Text; const t = node as Text;
if (this.isAvoided(t) || !this.inScope(t)) { return; } if (this.isAvoided(t) || !this.inScope(t)
|| (feature && !this.featureEnabledFor(t, feature))) { flush(); return; }
const data = t.data; const data = t.data;
for (let i = 0; i < data.length;) for (let i = 0; i < data.length;)
{ {
const ch = variationSequenceAtUtf16(data, i); const ch = codePointAtUtf16(data, i);
curText += ch; curText += ch;
const part = { textNode: t, start: i, end: i + ch.length }; const part = { textNode: t, start: i, end: i + ch.length };
const previous = curMap[curMap.length - 1]; const previous = curMap[curMap.length - 1];
if (isVariationSelector(ch) && previous if (previous && isHighSurrogate(previous.text) && isLowSurrogate(ch))
&& !isVariationSelector(codePointAtUtf16(previous.text, 0)) {
&& !isVariationSelector(codePointAtUtf16( // DOM Text 邊界可能切開 surrogate pair;先合成完整碼點,才能判 VS。
previous.text, previous.text += ch;
previousCodePointStart(previous.text, previous.text.length), appendParts(previous.parts, [ part ]);
))) const base = curMap[curMap.length - 2];
if (isVariationSelector(previous.text) && base && acceptsVariationSelector(base))
{
base.text += previous.text;
appendParts(base.parts, previous.parts);
curMap.pop();
}
}
else if (isVariationSelector(ch) && previous && acceptsVariationSelector(previous))
{ {
// 透明 inline 可令 base 與 VS 分居不同 Text 節點。map 必須與 // 透明 inline 可令 base 與 VS 分居不同 Text 節點。map 必須與
// 拼接 run 之 variation-sequence 分組同構,否則後續刪除/包裝 // 拼接 run 之 variation-sequence 分組同構,否則後續刪除/包裝
// 會錯用 selector 作為下一字元之 DOM 位置。 // 會錯用 selector 作為下一字元之 DOM 位置。
previous.text += ch; previous.text += ch;
previous.parts.push(part); appendParts(previous.parts, [ part ]);
} }
else else
{ {
@@ -413,7 +450,10 @@ export class Finder
{ {
// 透明:遞迴穿越、run 延續。jz-hws 以 margin-right 包裹左側邊界字, // 透明:遞迴穿越、run 延續。jz-hws 以 margin-right 包裹左側邊界字,
// 其內之字仍計入邏輯 run。 // 其內之字仍計入邏輯 run。
recurse(); const boundary = feature && !this.featureEnabledFor(el, feature);
if (boundary) { flush(); }
recurse(); // 子白名單仍可重新啟用,在自身脈絡內處理。
if (boundary) { flush(); }
return; return;
} }
default: // "isolate" | "opaque"(未知行內,嚴格白名單) default: // "isolate" | "opaque"(未知行內,嚴格白名單)
@@ -433,84 +473,140 @@ export class Finder
return runs; return runs;
} }
/** /** 從節點邊界或 Text offset 取相鄰碼點;只穿越透明、域內內容,scopeRoot 為硬邊界。 */
* pill 之邏輯鄰字(架構 §4.1):經樣式鏈遞迴,遇 pill/block/avoid/間隙 private adjacentTextCodePoint(
* marker 即止。direction = -1 取前鄰字,+1 取後鄰字。 from: Node,
*/ direction: -1 | 1,
adjacentLogicalChar(el: Element, direction: -1 | 1): AdjacentChar | null scopeRoot?: Element,
offset?: number,
feature?: string,
): TextSpan | null
{ {
const pickEnd = (tn: Text): AdjacentChar => if (scopeRoot && (from === scopeRoot || !scopeRoot.contains(from))) { return null; }
const pick = (tn: Text, at: number): TextSpan | null =>
{ {
if (this.isAvoided(tn) || !this.inScope(tn)
|| (feature && !this.featureEnabledFor(tn, feature))) { return null; }
if (direction === -1) if (direction === -1)
{ {
const offset = previousVariationSequenceStart(tn.data, tn.data.length); if (at <= 0) { return null; }
return { return { textNode: tn, start: previousCodePointStart(tn.data, at), end: at };
textNode: tn, }
offset, if (at >= tn.data.length) { return null; }
ch: variationSequenceAtUtf16(tn.data, offset), return { textNode: tn, start: at, end: at + codePointAtUtf16(tn.data, at).length };
}; };
} if (from.nodeType === 3 && offset !== undefined)
return { textNode: tn, offset: 0, ch: variationSequenceAtUtf16(tn.data, 0) };
};
// descend 三態:邊界字/null(子樹無內容,續找兄弟)/"blocked"(遇 pill/
// isolate/avoid/block 邊界——邏輯鄰居非「字」,終止整個查找)。不可把邊界
// 當無內容跳過續找更遠之字:那是「越過 jz-char 取更前字 → 誤隙」之同型錯誤。
const descend = (into: Element): AdjacentChar | "blocked" | null =>
{ {
let n: Node | null = direction === -1 ? into.lastChild : into.firstChild; const tn = from as Text;
while (n) if (this.isAvoided(tn) || !this.inScope(tn)
|| (feature && !this.featureEnabledFor(tn, feature))) { return null; }
if (direction === -1 ? offset > 0 : offset < tn.data.length)
{ {
if (n.nodeType === 3 && (n as Text).data.length > 0) return pick(tn, offset);
{
return pickEnd(n as Text);
} }
if (n.nodeType === 1)
{
if (!this.isTransparent(n as Element)) { return "blocked"; }
const r = descend(n as Element);
if (r) { return r; } // 字或 blocked 皆上拋
} }
n = direction === -1 ? n.previousSibling : n.nextSibling; let cursor: Node = from;
}
return null;
};
let n: Node | null = direction === -1 ? el.previousSibling : el.nextSibling;
let p: Node | null = el.parentNode;
for (;;) for (;;)
{ {
while (n) if (cursor === scopeRoot) { return null; }
let next: Node | null = direction === -1 ? cursor.previousSibling : cursor.nextSibling;
while (!next)
{ {
if (n.nodeType === 3) const parent = cursor.parentElement;
if (!parent || parent === scopeRoot || !this.isTransparent(parent)
|| !this.inScope(parent)
|| (feature && !this.featureEnabledFor(parent, feature))) { return null; }
cursor = parent;
next = direction === -1 ? cursor.previousSibling : cursor.nextSibling;
}
// 空文本/註解/空透明元素可穿越;不可把實際邊界當作空內容略過。
for (;;)
{ {
if ((n as Text).data.length > 0) { return pickEnd(n as Text); } if (scopeRoot && !scopeRoot.contains(next)) { return null; }
n = direction === -1 ? n.previousSibling : n.nextSibling; if (next.nodeType === 3)
continue;
}
if (n.nodeType === 1)
{ {
const e = n as Element; const tn = next as Text;
if (!this.isTransparent(e)) if (this.isAvoided(tn) || !this.inScope(tn)
|| (feature && !this.featureEnabledFor(tn, feature))) { return null; }
if (tn.data.length) { return pick(tn, direction === -1 ? tn.data.length : 0); }
}
else if (next.nodeType === 1)
{ {
return null; // pill/isolate/avoid/block — 邊界,止。 const el = next as Element;
if (!this.isTransparent(el) || !this.inScope(el)
|| (feature && !this.featureEnabledFor(el, feature))) { return null; }
const child = direction === -1 ? el.lastChild : el.firstChild;
if (child) { next = child; continue; }
} }
const r = descend(e); cursor = next;
if (r === "blocked") { return null; } break;
if (r) { return r; }
n = direction === -1 ? e.previousSibling : e.nextSibling;
continue;
} }
n = direction === -1 ? n.previousSibling : n.nextSibling;
} }
if (!p || p.nodeType !== 1 || !this.isTransparent(p as Element)) }
/** 補全透明 Text 邊界拆開的 surrogate pair;孤立碼元保留自身,不跨任何邊界拼接。 */
private adjacentCodePoint(
from: Node,
direction: -1 | 1,
scopeRoot?: Element,
offset?: number,
feature?: string,
): CharSpan | null
{ {
return null; const first = this.adjacentTextCodePoint(from, direction, scopeRoot, offset, feature);
if (!first) { return null; }
const char = (part: TextSpan): string => part.textNode.data.slice(part.start, part.end);
const firstChar = char(first);
let parts = [ first ];
if (direction === -1 ? isLowSurrogate(firstChar) : isHighSurrogate(firstChar))
{
const second = this.adjacentTextCodePoint(
first.textNode, direction, scopeRoot, direction === -1 ? first.start : first.end,
feature,
);
if (second && (direction === -1 ? isHighSurrogate(char(second)) : isLowSurrogate(char(second))))
{
parts = direction === -1 ? [ second, first ] : [ first, second ];
} }
const pe = p as Element;
n = direction === -1 ? pe.previousSibling : pe.nextSibling;
p = pe.parentNode;
} }
return { text: parts.map(char).join(""), parts };
}
/** pill 之完整鄰字(base+至多一個 VS);after 供查單個作者空格另一側,不越 scopeRoot。 */
adjacentLogicalChar(
el: Element,
direction: -1 | 1,
scopeRoot?: Element,
after?: AdjacentChar,
feature?: string,
): AdjacentChar | null
{
const edge = after?.parts[direction === -1 ? 0 : after.parts.length - 1];
const first = this.adjacentCodePoint(
edge?.textNode || el, direction, scopeRoot,
edge ? (direction === -1 ? edge.start : edge.end) : undefined,
feature,
);
if (!first) { return null; }
let atoms = [ first ];
const firstChar = first.text;
if ((direction === -1 && isVariationSelector(firstChar))
|| (direction === 1 && !isVariationSelector(firstChar)))
{
const edge = first.parts[direction === -1 ? 0 : first.parts.length - 1]!;
const second = this.adjacentCodePoint(
edge.textNode, direction, scopeRoot, direction === -1 ? edge.start : edge.end,
feature,
);
if (second && (direction === -1 ? !isVariationSelector(second.text) : isVariationSelector(second.text)))
{
atoms = direction === -1 ? [ second, first ] : [ first, second ];
}
}
const ch = atoms.map((atom) => atom.text).join("");
const parts: TextSpan[] = [];
for (const atom of atoms) { appendParts(parts, atom.parts); }
return { textNode: parts[0]!.textNode, offset: parts[0]!.start, ch, parts };
} }
/** /**
+2 -2
View File
@@ -64,8 +64,8 @@ export function classify(lang: string): LangClass
if (l === "zh" || l.indexOf("zh-") === 0) if (l === "zh" || l.indexOf("zh-") === 0)
{ {
// 顯式子標籤優先。 // 顯式子標籤優先。
if (l.indexOf("hant") >= 0) { return "zh-Hant"; } if (/(?:^|-)hant(?:-|$)/.test(l)) { return "zh-Hant"; }
if (l.indexOf("hans") >= 0) { return "zh-Hans"; } if (/(?:^|-)hans(?:-|$)/.test(l)) { return "zh-Hans"; }
// 地區推斷:台灣/香港/澳門 → 繁體;其餘(含 cn/sg)→ 簡體。 // 地區推斷:台灣/香港/澳門 → 繁體;其餘(含 cn/sg)→ 簡體。
if (/-(tw|hk|mo)\b/.test(l)) { return "zh-Hant"; } if (/-(tw|hk|mo)\b/.test(l)) { return "zh-Hant"; }
if (l === "zh") { return "zh-Hant"; } if (l === "zh") { return "zh-Hant"; }
+1
View File
@@ -42,6 +42,7 @@ export function revertPasses(passes: Pass[], ctx: RenderContext): void
function runCharify(passes: CharifyPass[], ctx: RenderContext): void function runCharify(passes: CharifyPass[], ctx: RenderContext): void
{ {
for (const pass of passes) { pass.prepare?.(ctx); }
const work: NodeWork[] = []; const work: NodeWork[] = [];
ctx.finder.eachTextNode(ctx.root, (node) => ctx.finder.eachTextNode(ctx.root, (node) =>
+13 -1
View File
@@ -237,9 +237,21 @@ export function classifyBiaodian(
): string | null ): string | null
{ {
const base = codePointAtUtf16(ch, 0); const base = codePointAtUtf16(ch, 0);
if (!base) { return null; }
for (const sub of BD_ORDER) for (const sub of BD_ORDER)
{ {
if (biaodian[sub].indexOf(base) >= 0) { return "bd-" + sub; } // 保留原生字串搜尋,但命中位置須在碼點邊界,不能匹配 astral 標點的半字。
const chars = biaodian[sub];
let at = chars.indexOf(base);
while (at >= 0)
{
const previous = chars.charCodeAt(at - 1);
const first = base.charCodeAt(0);
const startsInsidePair = previous >= 0xD800 && previous <= 0xDBFF
&& first >= 0xDC00 && first <= 0xDFFF;
if (!startsInsidePair && codePointAtUtf16(chars, at) === base) { return "bd-" + sub; }
at = chars.indexOf(base, at + 1);
}
} }
return null; return null;
} }
+43 -51
View File
@@ -40,8 +40,8 @@ jz-inner
jz-char.jz-half jz-inner, jz-char.jz-half jz-inner,
jz-char.jz-half-le jz-inner jz-char.jz-half-le jz-inner
{ {
font-feature-settings: "locl" 1, "halt" 1; font-feature-settings: var(--jz-half-features, "locl" 1, "halt" 1);
-webkit-font-feature-settings: "locl" 1, "halt" 1; -webkit-font-feature-settings: var(--jz-half-features, "locl" 1, "halt" 1);
} }
/* margin 後備模式(.feedback):消費端字型缺 halt 時,render() 於 root 設 /* margin 後備模式(.feedback):消費端字型缺 halt 時,render() 於 root 設
@@ -55,70 +55,62 @@ jz-char.jz-half-le jz-inner
偏側由 JS 依「ink 中心參考表+消費端 options.jiya.bias 覆寫」於 charify 標為 偏側由 JS 依「ink 中心參考表+消費端 options.jiya.bias 覆寫」於 charify 標為
jz-ink-{left,center,right}(見 core/unicode.ts inkBias)。先關 halt 免雙重作用。 jz-ink-{left,center,right}(見 core/unicode.ts inkBias)。先關 halt 免雙重作用。
只命中帶 jz-ink-* 者;bias=full(無 ink class)不收、維持全形。 */ 只命中帶 jz-ink-* 者;bias=full(無 ink class)不收、維持全形。 */
[data-jz-halfwidth="margin"] jz-char.jz-half jz-inner, /* 在模式標記本身宣告可繼承變數,讓最近的有效祖先決定模式;後代選擇器無法
[data-jz-halfwidth="margin"] jz-char.jz-half-le jz-inner 表達距離,原有 halt-reset 會蓋過更內層的 margin,亦漏撤銷內盒 display。
所有變數均以 halt 為無標記之後備;非法屬性值不覆寫祖先模式。 */
[data-jz-halfwidth="margin"]
{ {
font-feature-settings: normal; --jz-half-features: normal;
-webkit-font-feature-settings: normal; --jz-half-width: 0.5em;
--jz-half-inner-display: inline-block;
--jz-half-center-offset: -0.25em;
--jz-half-right-offset: -0.5em;
} }
[data-jz-halfwidth="margin"] jz-char.jz-half.jz-ink-left, [data-jz-halfwidth="halt"]
[data-jz-halfwidth="margin"] jz-char.jz-half.jz-ink-center,
[data-jz-halfwidth="margin"] jz-char.jz-half.jz-ink-right,
[data-jz-halfwidth="margin"] jz-char.jz-half-le.jz-ink-left,
[data-jz-halfwidth="margin"] jz-char.jz-half-le.jz-ink-center,
[data-jz-halfwidth="margin"] jz-char.jz-half-le.jz-ink-right
{ {
width: 0.5em; --jz-half-features: "locl" 1, "halt" 1;
--jz-half-width: auto;
--jz-half-inner-display: inline;
--jz-half-center-offset: 0;
--jz-half-right-offset: 0;
}
jz-char.jz-half.jz-ink-left,
jz-char.jz-half.jz-ink-center,
jz-char.jz-half.jz-ink-right,
jz-char.jz-half-le.jz-ink-left,
jz-char.jz-half-le.jz-ink-center,
jz-char.jz-half-le.jz-ink-right
{
width: var(--jz-half-width, auto);
overflow: visible; overflow: visible;
} }
[data-jz-halfwidth="margin"] jz-char.jz-half.jz-ink-left jz-inner, jz-char.jz-half.jz-ink-left jz-inner,
[data-jz-halfwidth="margin"] jz-char.jz-half.jz-ink-center jz-inner, jz-char.jz-half.jz-ink-center jz-inner,
[data-jz-halfwidth="margin"] jz-char.jz-half.jz-ink-right jz-inner, jz-char.jz-half.jz-ink-right jz-inner,
[data-jz-halfwidth="margin"] jz-char.jz-half-le.jz-ink-left jz-inner, jz-char.jz-half-le.jz-ink-left jz-inner,
[data-jz-halfwidth="margin"] jz-char.jz-half-le.jz-ink-center jz-inner, jz-char.jz-half-le.jz-ink-center jz-inner,
[data-jz-halfwidth="margin"] jz-char.jz-half-le.jz-ink-right jz-inner jz-char.jz-half-le.jz-ink-right jz-inner
{
display: inline-block;
}
[data-jz-halfwidth="margin"] jz-char.jz-ink-center jz-inner
{
margin-left: -0.25em;
}
[data-jz-halfwidth="margin"] jz-char.jz-ink-right jz-inner
{
margin-left: -0.5em;
}
/* margin 模式 jz-inner 為 inline-block,會被段落裝飾盒跳過致下劃線於盒內斷缺
(見 _normalize.css「原子透明之另一面」)。令其承段落裝飾自繪、接續下劃線。
halt 模式 jz-inner 為 inline、裝飾自然穿透,故僅此模式需要。 */
[data-jz-halfwidth="margin"] jz-char jz-inner
{ {
display: var(--jz-half-inner-display, inline);
/* margin 內盒須自繪段落裝飾;兩種模式皆沿用父原子的裝飾。
不以變數傳遞 inherit,因其在自訂屬性中會先被當作繼承指令。 */
text-decoration: inherit; text-decoration: inherit;
} }
/* 粒度控制:模式由 data-jz-halfwidth **屬性所在子樹** 決定(CSS 後代選擇器), /* 偏側僅用於確實壓縮的標點;全形標點不能因有 ink class 而移動字身。 */
故可掛於任意祖先——root(JS 全域預設)、某 lang 容器、單一段落皆可,無需重設 jz-char.jz-half.jz-ink-center jz-inner,
實例。下列 halt-reset 規則置於 margin 規則之後:當 margin 頁面中**巢狀**一塊 jz-char.jz-half-le.jz-ink-center jz-inner
data-jz-halfwidth="halt"(如該塊改用含 halt 之字型)時,憑來源順序勝出,於該
島內復原 halt、撤銷半形盒。常見情境(halt 頁中嵌 margin 塊、或 margin 頁中嵌
halt 島)皆成立。 */
[data-jz-halfwidth="halt"] jz-char.jz-half jz-inner,
[data-jz-halfwidth="halt"] jz-char.jz-half-le jz-inner
{ {
font-feature-settings: "locl" 1, "halt" 1; margin-left: var(--jz-half-center-offset, 0);
-webkit-font-feature-settings: "locl" 1, "halt" 1;
margin-left: 0;
} }
[data-jz-halfwidth="halt"] jz-char.jz-half, jz-char.jz-half.jz-ink-right jz-inner,
[data-jz-halfwidth="halt"] jz-char.jz-half-le jz-char.jz-half-le.jz-ink-right jz-inner
{ {
width: auto; margin-left: var(--jz-half-right-offset, 0);
} }
/* 註:原標點懸掛(jz-hang-* 負 margin 凸出版心)已移除——純 CSS 機制為機制級死路 /* 註:原標點懸掛(jz-hang-* 負 margin 凸出版心)已移除——純 CSS 機制為機制級死路
+2 -2
View File
@@ -53,8 +53,8 @@ jz-em
斷缺(實測:純文字 coverage 1.0、聚珍僅 ~0.25)。令原子 text-decoration: inherit, 斷缺(實測:純文字 coverage 1.0、聚珍僅 ~0.25)。令原子 text-decoration: inherit,
使其承段落之裝飾自繪——因共用基線與字型度量,與漢字下劃線無縫接續(headless Chrome 使其承段落之裝飾自繪——因共用基線與字型度量,與漢字下劃線無縫接續(headless Chrome
像素掃描實測:halt/margin 兩模式 coverage 皆回 1.0,且 margin 下劃線隨半形收為 像素掃描實測:halt/margin 兩模式 coverage 皆回 1.0,且 margin 下劃線隨半形收為
0.5em advance,正確)。margin 模式 jz-inner 亦為 inline-block、同遭跳過,故於該模式 0.5em advance,正確)。margin 模式 jz-inner 亦為 inline-block、同遭跳過,故半形
補一條(halt 模式 jz-inner 為 inline、裝飾自然穿透,無需)。 */ 內盒在 _jiya.css 也繼承裝飾;halt 模式內盒為 inline,同樣保留父原子裝飾。 */
jz-char, jz-char,
jz-jinze jz-jinze
{ {
+47 -11
View File
@@ -63,7 +63,9 @@ function resolveLongWord(v: boolean | LongWordOptions | undefined): { minLength:
{ {
if (v === false) { return { minLength: 6, lang: "en" }; } // 值不用(enabled 為 false) if (v === false) { return { minLength: 6, lang: "en" }; } // 值不用(enabled 為 false)
if (v === undefined || v === true || v === null) { return { minLength: 6, lang: "en" }; } if (v === undefined || v === true || v === null) { return { minLength: 6, lang: "en" }; }
const minLength = typeof v.minLength === "number" && v.minLength >= 2 ? v.minLength : 6; // 閾值會直接成為 RegExp 計數量詞;小數/非有限數不能構造合法量詞。
const minLength = typeof v.minLength === "number" && Number.isSafeInteger(v.minLength)
&& v.minLength >= 2 ? v.minLength : 6;
const lang = typeof v.lang === "string" && v.lang ? v.lang : "en"; const lang = typeof v.lang === "string" && v.lang ? v.lang : "en";
return { minLength, lang }; return { minLength, lang };
} }
@@ -72,7 +74,8 @@ function resolveOrphan(v: boolean | OrphanOptions | undefined): { chars: number
{ {
if (v && typeof v === "object") if (v && typeof v === "object")
{ {
const chars = typeof v.chars === "number" && v.chars >= 1 ? Math.floor(v.chars) : 2; const chars = typeof v.chars === "number" && Number.isFinite(v.chars) && v.chars >= 1
? Math.floor(v.chars) : 2;
return { chars }; return { chars };
} }
return { chars: 2 }; return { chars: 2 };
@@ -252,12 +255,12 @@ export function createJuzhen(opts: JuzhenOptions = {}): Juzhen
function revertImpl(el: Element): void function revertImpl(el: Element): void
{ {
const roots = rootsWithin(el); const roots = rootsWithin(el);
if (roots.length === 0) unwindRoots(roots);
if (!roots.includes(el))
{ {
// 允許對已 render 根之子樹作局部還原。 // 獨立子根以外,仍可能有祖根寫入此子樹的產物,須完整還原請求範圍。
revertPasses(PASSES, makeCtx(el)); revertPasses(PASSES, makeCtx(el));
} }
else { unwindRoots(roots); }
// 失效(安全預設):清 el 子樹之元素鍵快取,使後續 render 對「屬性已變」之元素重新分類。 // 失效(安全預設):清 el 子樹之元素鍵快取,使後續 render 對「屬性已變」之元素重新分類。
// 內容變化雖不改分類(此清屬多餘),但廉價且無害,且 render 本就將重掃該子樹。 // 內容變化雖不改分類(此清屬多餘),但廉價且無害,且 render 本就將重掃該子樹。
finder.invalidate(el); finder.invalidate(el);
@@ -363,13 +366,29 @@ export function createJuzhen(opts: JuzhenOptions = {}): Juzhen
// 深度計數使重入 render 不會於外層未完成時提前恢復觀察。 // 深度計數使重入 render 不會於外層未完成時提前恢復觀察。
function pauseObserver(): void function pauseObserver(): void
{ {
if (observerPauseDepth === 0) observerPauseDepth += 1;
if (observerPauseDepth === 1)
{ {
// disconnect 會丟棄未交付記錄。先結算既有外部變更,再執行本次手動操作,
// 使局部 revert 仍最後生效,且祖先變更不會漏掉其他兄弟塊。
// 深度先增加,結算中重入的 render/observe 不會提前恢復觀察。
const records = observer ? observer.takeRecords() : [];
const errorWindow = observeRoot?.ownerDocument.defaultView;
observerActive = false; observerActive = false;
if (observer) { observer.disconnect(); } if (observer) { observer.disconnect(); }
clearExpectedClassMutations(); try
{
if (records.length) { onMutations(records); }
}
catch (error)
{
// 原 observer callback 的失敗仍由宿主非同步報告,不冒充手動 API 的例外。
const report = (): void => { throw error; };
if (errorWindow) { errorWindow.queueMicrotask(report); }
else { queueMicrotask(report); }
}
finally { clearExpectedClassMutations(); }
} }
observerPauseDepth += 1;
} }
function resumeObserver(): void function resumeObserver(): void
{ {
@@ -391,9 +410,15 @@ export function createJuzhen(opts: JuzhenOptions = {}): Juzhen
finally { resumeObserver(); } finally { resumeObserver(); }
} }
/** node 之最近塊祖先(含自身若為塊);達 observeRoot 仍無塊 → 回 observeRoot。 */ function withinObservedRoot(node: Node): boolean
{
return !!observeRoot && (node === observeRoot || observeRoot.contains(node));
}
/** node 之最近塊祖先(含自身若為塊);已移出觀察根回 null,無塊則回 observeRoot。 */
function nearestBlock(node: Node): Element | null function nearestBlock(node: Node): Element | null
{ {
if (!withinObservedRoot(node)) { return null; }
let e: Element | null = node.nodeType === 1 ? (node as Element) : node.parentElement; let e: Element | null = node.nodeType === 1 ? (node as Element) : node.parentElement;
while (e) while (e)
{ {
@@ -409,6 +434,8 @@ export function createJuzhen(opts: JuzhenOptions = {}): Juzhen
const rerenderBlocks = new Set<Element>(); // 既有塊:revert+render const rerenderBlocks = new Set<Element>(); // 既有塊:revert+render
const renderNew: Element[] = []; // 新增之塊:直接 render(無須 revert) const renderNew: Element[] = []; // 新增之塊:直接 render(無須 revert)
const removedRoots = new Set<Element>(); // 已脫離之獨立 render 根:先就地清理 const removedRoots = new Set<Element>(); // 已脫離之獨立 render 根:先就地清理
const removedSubtrees = records.flatMap((record) =>
record.type === "childList" ? Array.from(record.removedNodes) : []);
const classNewValues = classMutationNewValues(records); const classNewValues = classMutationNewValues(records);
try try
{ {
@@ -422,6 +449,8 @@ export function createJuzhen(opts: JuzhenOptions = {}): Juzhen
{ {
m.addedNodes.forEach((n) => m.addedNodes.forEach((n) =>
{ {
// 同批記錄可能先加入再移出;以目前觀察範圍判定,不能排版外部父/子樹。
if (!withinObservedRoot(n)) { return; }
if (n.nodeType === 1 && options.finder.blockTags.has(n.nodeName)) if (n.nodeType === 1 && options.finder.blockTags.has(n.nodeName))
{ {
// 新增之塊:**通常**獨立 render(串流追加免全容器重掃)。例外:若其塊級 // 新增之塊:**通常**獨立 render(串流追加免全容器重掃)。例外:若其塊級
@@ -472,13 +501,20 @@ export function createJuzhen(opts: JuzhenOptions = {}): Juzhen
unwindRoots(Array.from(removedRoots) unwindRoots(Array.from(removedRoots)
.sort((a, b) => a.contains(b) ? 1 : b.contains(a) ? -1 : 0)); .sort((a, b) => a.contains(b) ? 1 : b.contains(a) ? -1 : 0));
// 去嵌套:只保留極大塊(移除為他者後代者),免重複重處理(重複亦正確、僅浪費)。 // 去嵌套:只保留極大塊(移除為他者後代者),免重複重處理(重複亦正確、僅浪費)。
const all = Array.from(rerenderBlocks).filter((b) => b.isConnected); // 未接入 document 的 Element 也可被觀察;生命週期以 observeRoot 的包含關係為準。
const all = Array.from(rerenderBlocks).filter(withinObservedRoot);
const blocks = all.filter((b) => !all.some((o) => o !== b && o.contains(b))); const blocks = all.filter((b) => !all.some((o) => o !== b && o.contains(b)));
for (const b of blocks) { revertImpl(b); renderImpl(b); } for (const b of blocks) { revertImpl(b); renderImpl(b); }
for (const el of renderNew) for (const el of renderNew)
{ {
if (!el.isConnected) { continue; } // 同批又被移除 if (!withinObservedRoot(el)) { continue; } // 同批移出觀察範圍
if (blocks.some((b) => b.contains(el))) { continue; } // 已由塊重處理涵蓋 if (blocks.some((b) => b.contains(el))) { continue; } // 已由塊重處理涵蓋
// 搬入的既有塊須撤回舊祖先環境的產物;純新增塊仍直接 render。
const relocated = removedSubtrees.some((node) => node === el || node.contains(el));
const hasOwnedContent = ownedElements.has(el)
|| Array.from(el.querySelectorAll("[data-jz]")).some((node) => ownedElements.has(node));
if (relocated || hasOwnedContent || rootsWithin(el).length) { revertImpl(el); }
else { finder.invalidate(el); } // 曾在其他父鏈查詢過的新插入內容也不能沿用快取。
renderImpl(el); renderImpl(el);
} }
}); });
+6 -2
View File
@@ -9,6 +9,7 @@ export type { LangClass, PunctStyle, StyleRule, Bias, BiasTable, BdSubclass, Cha
/** 長英文詞包裝設定。 */ /** 長英文詞包裝設定。 */
export interface LongWordOptions export interface LongWordOptions
{ {
/** 有限安全整數且 ≥2;無效值回退 6。 */
minLength?: number; minLength?: number;
lang?: string; lang?: string;
} }
@@ -16,7 +17,8 @@ export interface LongWordOptions
/** 垂懸字避免設定(§6.5.1)。布林 true 等同 { chars: 2 }。 */ /** 垂懸字避免設定(§6.5.1)。布林 true 等同 { chars: 2 }。 */
export interface OrphanOptions export interface OrphanOptions
{ {
/** 段末行至少保留之 CJK 實義字數(標點/符號不計;預設 2)。 */ /** 段末行至少保留之 CJK 實義字數(標點/符號不計;預設 2)。
* 有限值且 ≥1,向下取整;無效值回退 2。 */
chars?: number; chars?: number;
} }
@@ -25,7 +27,7 @@ export interface JiyaOptions
{ {
/** 半形渲染機制: /** 半形渲染機制:
* - "halt"(預設)— 用字型 OpenType `halt`,由字型按字形正確定位,需字型支援; * - "halt"(預設)— 用字型 OpenType `halt`,由字型按字形正確定位,需字型支援;
* - "margin" — 負 margin 後備模式,供缺 halt 之字型,依字形偏側表收半形。 */ * - "margin" — 0.5em 盒寬後備模式,供缺 halt 之字型,依字形偏側表定位。 */
halfWidth?: "halt" | "margin"; halfWidth?: "halt" | "margin";
/** margin 後備模式之字形偏側覆寫表(逐 lang 逐字),覆寫內建參考預設。 /** margin 後備模式之字形偏側覆寫表(逐 lang 逐字),覆寫內建參考預設。
* halt 模式不讀此欄。 */ * halt 模式不讀此欄。 */
@@ -212,6 +214,8 @@ interface BasePass
export interface CharifyPass extends BasePass export interface CharifyPass extends BasePass
{ {
kind: "charify"; kind: "charify";
/** 在逐 Text 收集前處理不可被 DOM 分割的跨節點原子。 */
prepare?: (ctx: RenderContext) => void;
collect: (node: Text, ctx: RenderContext) => WrapRequest[]; collect: (node: Text, ctx: RenderContext) => WrapRequest[];
} }
+106 -12
View File
@@ -36,6 +36,7 @@ function firstMeaningfulChild(el: Element): Node | null
for (let c = el.firstChild; c; c = c.nextSibling) for (let c = el.firstChild; c; c = c.nextSibling)
{ {
if (c.nodeType === 3 && (c as Text).data.length === 0) { continue; } if (c.nodeType === 3 && (c as Text).data.length === 0) { continue; }
if (c.nodeType !== 1 && c.nodeType !== 3) { continue; }
return c; return c;
} }
return null; return null;
@@ -47,6 +48,7 @@ function lastMeaningfulChild(el: Element): Node | null
for (let c = el.lastChild; c; c = c.previousSibling) for (let c = el.lastChild; c; c = c.previousSibling)
{ {
if (c.nodeType === 3 && (c as Text).data.length === 0) { continue; } if (c.nodeType === 3 && (c as Text).data.length === 0) { continue; }
if (c.nodeType !== 1 && c.nodeType !== 3) { continue; }
return c; return c;
} }
return null; return null;
@@ -73,13 +75,90 @@ function edgeCharEl(node: Node, side: "head" | "tail", finder: Finder): Element
return null; return null;
} }
/** 相鄰兩節點之間是否禁止斷行。由「邊界 jz-char」之類別決定;邊界字可落於透明行內 /** 檢查候選單元之段落/功能/作用域閘;side 僅查邊界,未指定則查整個綁定範圍。 */
* 元素之邊緣(跨 <strong> 等、與外部內容不同 DOM parent),故經 edgeCharEl 下探定位, function bindingEnabled(node: Node, finder: Finder, side?: "head" | "tail"): boolean
* 非僅認直接 jz-char——否則「你好<strong>。世界</strong>」之句末點號漏綁、誤置行首
* (下游審計 #3)。綁定端由 processParent 之 isolateBoundaryToken 落實(可切則最小綁定、
* 不可切則整綁),與此偵測同以透明判定為據。 */
function forbiddenBreak(a: Node, b: Node, finder: Finder): boolean
{ {
if (node.nodeType === 1 || node.nodeType === 3)
{
if (!finder.inScope(node)) { return false; }
}
if (finder.isAvoided(node)) { return false; }
if (node.nodeType === 3)
{
return finder.levelAllows(node, "paragraph") && finder.featureEnabledFor(node, PASS);
}
if (node.nodeType !== 1) { return true; }
const el = node as Element;
const category = finder.category(el);
if (category === "block") { return false; }
if (category === "pill" || (side && category !== "transparent"))
{
// pill 之內部不判定;它以完整原子參與外部交界。
return finder.levelAllows(el, "paragraph") && finder.featureEnabledFor(el, PASS);
}
if (side)
{
const child = side === "head" ? firstMeaningfulChild(el) : lastMeaningfulChild(el);
return child ? bindingEnabled(child, finder, side) : true;
}
for (let c = el.firstChild; c; c = c.nextSibling)
{
if (c.nodeType === 3 && (c as Text).data.length === 0) { continue; }
if (!bindingEnabled(c, finder)) { return false; }
}
return true;
}
/** 將完整碼點及 base+VS 跨度映射為直接子節點間的禁斷邊界。 */
function atomicBoundaries(parent: Element, finder: Finder): Map<Node, Set<Node>>
{
const boundaries = new Map<Node, Set<Node>>();
const directChildren = new Map<Node, Node | null>();
const directChild = (node: Node): Node | null =>
{
const chain: Node[] = [];
let current: Node | null = node;
let result: Node | null = null;
while (current && current !== parent)
{
if (directChildren.has(current)) { result = directChildren.get(current)!; break; }
chain.push(current);
if (current.parentNode === parent) { result = current; break; }
current = current.parentNode;
}
for (const part of chain) { directChildren.set(part, result); }
return result;
};
for (const run of finder.collectRuns(parent, true))
{
for (const atom of run.map)
{
let previous: Node | null = null;
for (const part of atom.parts)
{
const current = directChild(part.textNode);
if (previous && current && previous !== current)
{
let next = boundaries.get(previous);
if (!next) { next = new Set(); boundaries.set(previous, next); }
next.add(current);
}
previous = current;
}
}
}
return boundaries;
}
/** 邊界標點及跨 Text 原子決定禁斷;透明行內之邊緣標點也參與判定。 */
function forbiddenBreak(
a: Node, b: Node, finder: Finder, atoms: Map<Node, Set<Node>>, wholeUnits = false,
): boolean
{
// 先判雙側邊界;切分後再判整個候選原子,避免不可切退化帶入排除內容。
if (!bindingEnabled(a, finder, wholeUnits ? undefined : "tail")
|| !bindingEnabled(b, finder, wholeUnits ? undefined : "head")) { return false; }
if (atoms.get(a)?.has(b)) { return true; } // 不在跨 Text 的 surrogate/base+VS 原子內造斷點。
const head = edgeCharEl(b, "head", finder); const head = edgeCharEl(b, "head", finder);
if (head) if (head)
{ {
@@ -182,9 +261,10 @@ function processParent(
if (n < 2) { return; } if (n < 2) { return; }
const forbidden: boolean[] = []; const forbidden: boolean[] = [];
const atoms = atomicBoundaries(parent as Element, finder);
for (let i = 0; i < n - 1; i += 1) for (let i = 0; i < n - 1; i += 1)
{ {
forbidden.push(forbiddenBreak(units[i]!, units[i + 1]!, finder)); forbidden.push(forbiddenBreak(units[i]!, units[i + 1]!, finder, atoms));
} }
for (let i = 0; i < n; i += 1) for (let i = 0; i < n; i += 1)
@@ -202,8 +282,8 @@ function processParent(
// 元素單元(樣式行內如 <strong>)最小綁定(下游 Bug:整元素入 nowrap inline-block // 元素單元(樣式行內如 <strong>)最小綁定(下游 Bug:整元素入 nowrap inline-block
// 致行末整體掉行):只隔離與 jz-char 相鄰側之邊界詞元,餘下留原位。雙側禁斷時先 // 致行末整體掉行):只隔離與 jz-char 相鄰側之邊界詞元,餘下留原位。雙側禁斷時先
// tail 後 head——head 作用於切剩之左半(仍為原元素 u)。不可切之元素(含 pill/ // tail 後 head——head 作用於切剩之左半(仍為原元素 u)。不可切之元素(含 pill/
// isolate/avoid,由 canSplit 諮詢 finder 判定)退化整綁——pill 即「作為原子 // isolate,由 canSplit 諮詢 finder 判定)可退化整綁;階段 2 再檢查整個單元,
// 鄰字單位整體綁入 jz-jinze」之期望行為(下游問題六)。 // 避免把 avoid/text/off 後代一併帶入。pill 以完整原子參與外部交界。
const el = u as Element; const el = u as Element;
if (needLast) if (needLast)
{ {
@@ -218,13 +298,17 @@ function processParent(
// 階段 2:重掃當前子節點,把禁斷相連之相鄰原子併成 jz-jinze。 // 階段 2:重掃當前子節點,把禁斷相連之相鄰原子併成 jz-jinze。
const kids: Node[] = []; const kids: Node[] = [];
for (let c = parent.firstChild; c; c = c.nextSibling) { kids.push(c); } for (let c = parent.firstChild; c; c = c.nextSibling)
{
if (c.nodeType === 1 || (c.nodeType === 3 && (c as Text).data.length > 0)) { kids.push(c); }
}
const splitAtoms = atomicBoundaries(parent as Element, finder);
let i = 0; let i = 0;
while (i < kids.length) while (i < kids.length)
{ {
const run: Node[] = [ kids[i]! ]; const run: Node[] = [ kids[i]! ];
while (i + 1 < kids.length && forbiddenBreak(kids[i]!, kids[i + 1]!, finder)) while (i + 1 < kids.length && forbiddenBreak(kids[i]!, kids[i + 1]!, finder, splitAtoms, true))
{ {
run.push(kids[i + 1]!); run.push(kids[i + 1]!);
i += 1; i += 1;
@@ -233,7 +317,16 @@ function processParent(
{ {
const group = createJz(ownedElements, "jz-jinze", PASS, "wrap"); const group = createJz(ownedElements, "jz-jinze", PASS, "wrap");
parent.insertBefore(group, run[0]!); parent.insertBefore(group, run[0]!);
for (const node of run) { group.appendChild(node); } // 一併搬入單元間註解/空 Text,保持模板錨點和原始次序,回退可完整復原。
const last = run[run.length - 1]!;
let node: Node | null = run[0]!;
while (node)
{
const next: Node | null = node.nextSibling;
group.appendChild(node);
if (node === last) { break; }
node = next;
}
} }
i += 1; i += 1;
} }
@@ -265,6 +358,7 @@ export const jinzePass: StandalonePass = {
{ {
parents.add(parent); parents.add(parent);
const pe = parent as Element; const pe = parent as Element;
if (pe === ctx.root) { break; } // 單一行內 root 不得攀出 render 作用域。
if (!finder.isTransparent(pe)) { break; } if (!finder.isTransparent(pe)) { break; }
if (!atEdge(child, pe, "head") && !atEdge(child, pe, "tail")) { break; } if (!atEdge(child, pe, "head") && !atEdge(child, pe, "tail")) { break; }
child = pe; child = pe;
+46 -5
View File
@@ -1,6 +1,6 @@
// 標點擠壓(架構 §6.3)。完整 clreq 擠壓之「行內」部分——三條規則並集, // 標點擠壓(架構 §6.3)。完整 clreq 擠壓之「行內」部分——三條規則並集,
// 以方向性 jz-cl(收左半形空白)/jz-cr(收右)落地,CSS 以負 margin 渲染, // 以 jz-half 標記半形,CSS 以 halt 或 margin 後備渲染,
// 跨瀏覽器一致(I4),不依賴 halt/text-spacing-trim。 // 不依賴瀏覽器自動 text-spacing-trim。
// //
// 機制 C baseline 寬度政策(依 lang,I3): // 機制 C baseline 寬度政策(依 lang,I3):
// 全角式 quanjiao — 一律不收; // 全角式 quanjiao — 一律不收;
@@ -14,6 +14,7 @@
// 同時為 §8:所有 jz-char 經 CSS 設 inline-block,成 justify 單一原子項(I5)。 // 同時為 §8:所有 jz-char 經 CSS 設 inline-block,成 justify 單一原子項(I5)。
import { bdClassOf, createJz, revertPass } from "../core/dom.js"; import { bdClassOf, createJz, revertPass } from "../core/dom.js";
import { revertCrossAtoms, wrapCrossAtom } from "../core/cross-atom.js";
import { classify, effectiveLang, resolveStyle } from "../core/locale.js"; import { classify, effectiveLang, resolveStyle } from "../core/locale.js";
import type { LangClass, PunctStyle } from "../core/locale.js"; import type { LangClass, PunctStyle } from "../core/locale.js";
import { import {
@@ -59,6 +60,37 @@ export const jiyaPass: CharifyPass = {
order: 20, order: 20,
level: "text", level: "text",
enabled: (o) => o.features.jiya, enabled: (o) => o.features.jiya,
prepare(ctx): void
{
const { finder, options } = ctx;
const roots = [ ctx.root ];
const selector = Array.from(options.finder.blockTags).map((tag) => tag.toLowerCase()).join(",");
if (selector) { roots.push(...Array.from(ctx.root.querySelectorAll(selector))); }
for (const root of roots)
{
if (finder.isAvoided(root) || finder.insidePill(root) || finder.pillMatches(root)) { continue; }
for (const run of finder.collectRuns(root, true, PASS))
{
for (let i = run.map.length - 1; i >= 0; i -= 1)
{
const atom = run.map[i]!;
if (atom.parts.length < 2 || atom.parts.some((part) =>
inJzChar(part.textNode) || !finder.featureEnabledFor(part.textNode, PASS))) { continue; }
const first = atom.parts[0]!.textNode;
const bdClass = classifyBiaodian(atom.text, options.ruleset.biaodian);
if (!bdClass) { continue; }
const style = resolveStyle(first, options.locale);
const lang = classify(effectiveLang(first, options.locale.default));
const classes = baselineHalf(style, bdClass) ? [ bdClass, "jz-half" ] : [ bdClass ];
const bias = inkBias(codePointAtUtf16(atom.text, 0), lang, options.jiyaConfig.bias);
if (bias) { classes.push("jz-ink-" + bias); }
const outer = createJz(ctx.ownedElements, "jz-char", PASS, "wrap", { classes });
const inner = createJz(ctx.ownedElements, "jz-inner", PASS, "wrap");
wrapCrossAtom(atom, outer, inner, PASS, ctx);
}
}
}
},
collect(node, ctx): WrapRequest[] collect(node, ctx): WrapRequest[]
{ {
if (inJzChar(node)) { return []; } if (inJzChar(node)) { return []; }
@@ -109,7 +141,9 @@ export const jiyaPass: CharifyPass = {
}, },
revert(ctx: RenderContext): void revert(ctx: RenderContext): void
{ {
const touched = revertCrossAtoms(PASS, ctx);
revertPass(PASS, ctx.root, ctx.ownedElements); revertPass(PASS, ctx.root, ctx.ownedElements);
for (const parent of touched) { (parent as Element).normalize?.(); }
}, },
}; };
@@ -143,7 +177,7 @@ export const jiyaAdjacencyPass: StandalonePass = {
const squeeze = (L: Element, R: Element): void => const squeeze = (L: Element, R: Element): void =>
{ {
if (!finder.featureEnabledFor(L, PASS)) { return; } if (!finder.featureEnabledFor(L, PASS) || !finder.featureEnabledFor(R, PASS)) { return; }
const lClass = bdClassOf(L); const lClass = bdClassOf(L);
const rClass = bdClassOf(R); const rClass = bdClassOf(R);
const lSides = blankSides(lClass); const lSides = blankSides(lClass);
@@ -161,7 +195,11 @@ export const jiyaAdjacencyPass: StandalonePass = {
{ {
if (c.nodeType === 3) if (c.nodeType === 3)
{ {
if ((c as Text).data.length > 0) { prev = null; } // 文本斷相鄰 if (finder.isAvoided(c) || !finder.inScope(c)
|| !finder.featureEnabledFor(c, PASS) || (c as Text).data.length > 0)
{
prev = null; // 排除空 Text 也是邊界,普通空 Text 仍透明。
}
continue; continue;
} }
if (c.nodeType !== 1) { continue; } // 註解等:略過、不斷 if (c.nodeType !== 1) { continue; } // 註解等:略過、不斷
@@ -169,7 +207,7 @@ export const jiyaAdjacencyPass: StandalonePass = {
const nm = el.nodeName; const nm = el.nodeName;
if (nm === "JZ-CHAR") if (nm === "JZ-CHAR")
{ {
if (!ctx.ownedElements.has(el)) { prev = null; continue; } if (!ctx.ownedElements.has(el) || !finder.featureEnabledFor(el, PASS)) { prev = null; continue; }
if (prev) { squeeze(prev, el); } if (prev) { squeeze(prev, el); }
prev = el; // jz-char 為原子,不下探(內含 jz-inner) prev = el; // jz-char 為原子,不下探(內含 jz-inner)
} }
@@ -185,7 +223,10 @@ export const jiyaAdjacencyPass: StandalonePass = {
} }
case "transparent": case "transparent":
{ {
const boundary = !finder.featureEnabledFor(el, PASS);
if (boundary) { prev = null; }
visit(el); // 穿越、相鄰性延續 visit(el); // 穿越、相鄰性延續
if (boundary) { prev = null; }
break; break;
} }
default: // isolate/block/opaque(未知行內) default: // isolate/block/opaque(未知行內)
+7 -4
View File
@@ -61,13 +61,15 @@ function installLayout(
if (!m) { m = new Map(); owners.set(owner, m); } if (!m) { m = new Map(); owners.set(owner, m); }
const existing = m.get(key); const existing = m.get(key);
if (existing) { existing.disconnect(); } if (existing) { existing.disconnect(); }
const ro = new ResizeObserver(() => run()); // 字型 Promise/已排隊回調可能晚於 revert 或重裝;只允許目前安裝執行。
ro.observe(root); const runCurrent = (): void => { if (m!.get(key) === ro) { run(); } };
const ro = new ResizeObserver(runCurrent);
m.set(key, ro); m.set(key, ro);
ro.observe(root);
const fonts = (root.ownerDocument as Document & { fonts?: { ready?: Promise<unknown> } }).fonts; const fonts = (root.ownerDocument as Document & { fonts?: { ready?: Promise<unknown> } }).fonts;
if (fonts && fonts.ready && typeof fonts.ready.then === "function") if (fonts && fonts.ready && typeof fonts.ready.then === "function")
{ {
fonts.ready.then(run, () => undefined); fonts.ready.then(runCurrent, () => undefined);
} }
} }
@@ -331,7 +333,8 @@ function trimGaps(
{ {
const r = lastRect(g); const r = lastRect(g);
const next = r ? edgeRect(g.nextSibling, "next", g, wrapSet) : null; const next = r ? edgeRect(g.nextSibling, "next", g, wrapSet) : null;
const atLineEnd = !r || !next || next.top > r.top + EPS; // 同行小字/下標的 top 亦會較低;須整體落到下一行才算行末。
const atLineEnd = !r || !next || lowerLine(r, next);
if (!atLineEnd) { ctx.setOwnedClass(g, TRIM, false); } if (!atLineEnd) { ctx.setOwnedClass(g, TRIM, false); }
} }
} }
+50 -13
View File
@@ -1,6 +1,7 @@
// 垂懸字避免(架構 §6.5.1,方案 C:斷行層、無測量)。standalone 段落級 pass。 // 垂懸字避免(架構 §6.5.1,方案 C:斷行層、無測量)。standalone 段落級 pass。
// //
// 目標:段末行之 CJK 實義字數 ≥ N(預設 2;標點/符號不計)。單行段天然無害。 // 目標:可處理之段末連續區域 CJK 實義字數 ≥ N(預設 2;標點/符號不計)。
// avoid/text/off 區域中斷計數;不可切且含排除內容的容器不退化整綁。
// 機制:自段末向前數 N 個 CJK 實義字,把「第 N 個實義字起至段末」(含其間/尾隨 // 機制:自段末向前數 N 個 CJK 實義字,把「第 N 個實義字起至段末」(含其間/尾隨
// 標點)包入 <jz-orphan>(CSS white-space:nowrap)。該整體不可斷 → 末行恒含之 ⇒ // 標點)包入 <jz-orphan>(CSS white-space:nowrap)。該整體不可斷 → 末行恒含之 ⇒
// 末行實義字 ≥ N,任意寬度/字型/瀏覽器成立,無需量測 layout(異於行端/懸掛)。 // 末行實義字 ≥ N,任意寬度/字型/瀏覽器成立,無需量測 layout(異於行端/懸掛)。
@@ -15,7 +16,7 @@
import { createJz, revertPass } from "../core/dom.js"; import { createJz, revertPass } from "../core/dom.js";
import type { Finder } from "../core/finder.js"; import type { Finder } from "../core/finder.js";
import { splitElementAt } from "../core/split.js"; import { splitElementAt } from "../core/split.js";
import { classifyBiaodian, variationSequenceAtUtf16 } from "../core/unicode.js"; import { classifyBiaodian } from "../core/unicode.js";
import type { RenderContext, Ruleset, StandalonePass } from "../types.js"; import type { RenderContext, Ruleset, StandalonePass } from "../types.js";
type Biaodian = Ruleset["biaodian"]; type Biaodian = Ruleset["biaodian"];
@@ -40,32 +41,54 @@ function hasOrphan(block: Element): boolean
return false; return false;
} }
/** 收集 block 內各 CJK 實義字之定位(文件序);descend 透明行內,遇塊/pill/avoid 即止。 */ /** 收集段末可處理區域之完整 CJK 原子定位;排除邊界清空先前計數,pill 不計內部字。 */
function collectSubst( function collectSubst(
block: Element, block: Element,
finder: Finder, finder: Finder,
blockSet: Set<string>, blockSet: Set<string>,
anyCjk: RegExp, anyCjk: RegExp,
biaodian: Biaodian, biaodian: Biaodian,
): SubstPos[] ): { subst: SubstPos[]; blockedChildren: Set<Node> }
{ {
const out: SubstPos[] = []; const out: SubstPos[] = [];
const atoms = new Map<Text, Map<number, string>>();
for (const run of finder.collectRuns(block, true))
{
for (const atom of run.map)
{
// 每個完整原子只在第一個來源位置計數;後續 surrogate/VS 部件不另算字。
const first = atom.parts[0]!;
let starts = atoms.get(first.textNode);
if (!starts) { starts = new Map(); atoms.set(first.textNode, starts); }
starts.set(first.start, atom.text);
}
}
const blockedChildren = new Set<Node>();
const boundary = (directChild: Node): void =>
{
// 只約束最後一段可處理的文本;不得把排除區域夾進 nowrap 尾部。
out.length = 0;
blockedChildren.add(directChild);
};
const visit = (node: Node, directChild: Node): void => const visit = (node: Node, directChild: Node): void =>
{ {
if (node.nodeType === 3) if (node.nodeType === 3)
{ {
const t = node as Text; const t = node as Text;
const d = t.data; if (finder.isAvoided(t) || !finder.levelAllows(t, "paragraph")
for (let i = 0; i < d.length;) || !finder.featureEnabledFor(t, PASS))
{
boundary(directChild);
return;
}
for (const [ offset, ch ] of atoms.get(t) || [])
{ {
const ch = variationSequenceAtUtf16(d, i);
// 實義字:屬 CJK 字集且非標點(標點/符號不計)。 // 實義字:屬 CJK 字集且非標點(標點/符號不計)。
if (anyCjk.test(ch) && !classifyBiaodian(ch, biaodian)) if (anyCjk.test(ch) && !classifyBiaodian(ch, biaodian))
{ {
out.push({ directChild, textNode: t, offset: i }); out.push({ directChild, textNode: t, offset });
} }
i += ch.length;
} }
return; return;
} }
@@ -73,13 +96,22 @@ function collectSubst(
const el = node as Element; const el = node as Element;
// avoid 以自身判定(舊版僅查祖先,自身命中 avoid 之元素被下潛);pill 整體 // avoid 以自身判定(舊版僅查祖先,自身命中 avoid 之元素被下潛);pill 整體
// 不計不下探;isolate 內部為正常文本,照常下探計數(綁定時不可切、整綁)。 // 不計不下探;isolate 內部為正常文本,照常下探計數(綁定時不可切、整綁)。
if (finder.avoidsSelf(el)) { return; } if (finder.avoidsSelf(el) || blockSet.has(el.nodeName))
if (blockSet.has(el.nodeName) || finder.pillMatches(el)) { return; } // 邊界:不計、不下探 {
boundary(directChild);
return;
}
if (finder.pillMatches(el)) { return; } // pill 不計內部字,但仍可整體參與外部綁定。
if (!finder.levelAllows(el, "paragraph") || !finder.featureEnabledFor(el, PASS))
{
boundary(directChild);
// 子元素可有自己的合法功能/級別信號,故仍下探;文本節點再判實際閘。
}
for (let c = el.firstChild; c; c = c.nextSibling) { visit(c, directChild); } for (let c = el.firstChild; c; c = c.nextSibling) { visit(c, directChild); }
}; };
for (let dc = block.firstChild; dc; dc = dc.nextSibling) { visit(dc, dc); } for (let dc = block.firstChild; dc; dc = dc.nextSibling) { visit(dc, dc); }
return out; return { subst: out, blockedChildren };
} }
function processBlock( function processBlock(
@@ -94,7 +126,7 @@ function processBlock(
{ {
if (hasOrphan(block)) { return; } if (hasOrphan(block)) { return; }
const subst = collectSubst(block, finder, blockSet, anyCjk, biaodian); const { subst, blockedChildren } = collectSubst(block, finder, blockSet, anyCjk, biaodian);
// 段落實義字 < N → 不綁(無法保證、亦無意義)。單行段:方案 C 之 nowrap 本就無害。 // 段落實義字 < N → 不綁(無法保證、亦無意義)。單行段:方案 C 之 nowrap 本就無害。
if (subst.length < chars) { return; } if (subst.length < chars) { return; }
@@ -123,6 +155,11 @@ function processBlock(
ownedElements, ownedElements,
); );
if (clone) { startChild = clone; } if (clone) { startChild = clone; }
else if (blockedChildren.has(startChild))
{
// 不可切容器若還含排除內容,整綁會越界;寧可不綁此段末尾。
return;
}
} }
// 把 startChild 及其後全部直接子搬入 jz-orphan。 // 把 startChild 及其後全部直接子搬入 jz-orphan。
+130 -72
View File
@@ -9,19 +9,14 @@
import { import {
ALNUM, ALPHA, VARIATION_SELECTORS, ALNUM, ALPHA, VARIATION_SELECTORS,
previousVariationSequenceStart, splitVariationSequences, variationSequenceAtUtf16, splitVariationSequences,
} from "../core/unicode.js"; } from "../core/unicode.js";
import { createJz, revertPass } from "../core/dom.js"; import { createJz, recordUnwrappedParent, resolveUnwrappedParent, revertPass } from "../core/dom.js";
import type { AdjacentChar, CharSpan, Finder, TextSpan } from "../core/finder.js"; import type { AdjacentChar, CharSpan, Finder, TextSpan } from "../core/finder.js";
import type { RenderContext, Ruleset, StandalonePass } from "../types.js"; import type { RenderContext, Ruleset, StandalonePass } from "../types.js";
const PASS = "spacing"; const PASS = "spacing";
// 內部標記字元(PUA,不撞正常文本字形)。
const MARK = "";
const PH_OPEN = "";
const PH_CLOSE = "";
// ----- Pangu 規則正則(沿用 v1)----- // ----- Pangu 規則正則(沿用 v1)-----
const OPS_HYPHEN = "\\+\\*=&\\-"; const OPS_HYPHEN = "\\+\\*=&\\-";
const OPS_GRADE = "\\+\\-\\*"; const OPS_GRADE = "\\+\\-\\*";
@@ -31,14 +26,18 @@ const RBR_BASIC = "\\)\\]\\}";
const LBR_EXT = "\\(\\[\\{<>"; const LBR_EXT = "\\(\\[\\{<>";
const RBR_EXT = "\\)\\]\\}<>"; const RBR_EXT = "\\)\\]\\}<>";
// 純刪除哨符(U+E003):標記「此處原作者空格應刪除、不留隙」(CJK↔CJK,問題 1b)。 interface SpacingMarkers
// 與空格 1:1 替換以保持 spaced/text 對齊(processBlock 據此刪空格而不插 jz-hws)。 {
const DEL = String.fromCharCode(0xE003); gap: string;
compoundOpen: string;
compoundClose: string;
deletion: string;
}
/** 由規則集編譯之 per-instance 間隙正則集。 */ /** 由規則集編譯之 per-instance 間隙正則集。 */
interface Rules interface Rules
{ {
anyCjk: RegExp; pillNeighbor: RegExp; anyCjk: RegExp; pillNeighbor: RegExp; markerForbidden: RegExp;
STRIP_CJK_SPACE_ANS: RegExp; STRIP_ANS_SPACE_CJK: RegExp; STRIP_CJK_SPACE_CJK: RegExp; STRIP_CJK_SPACE_ANS: RegExp; STRIP_ANS_SPACE_CJK: RegExp; STRIP_CJK_SPACE_CJK: RegExp;
DOTS_CJK: RegExp; CJK_PUNCTUATION: RegExp; AN_PUNCTUATION_CJK: RegExp; DOTS_CJK: RegExp; CJK_PUNCTUATION: RegExp; AN_PUNCTUATION_CJK: RegExp;
CJK_TILDE: RegExp; CJK_TILDE_EQUALS: RegExp; CJK_PERIOD: RegExp; CJK_TILDE: RegExp; CJK_TILDE_EQUALS: RegExp; CJK_PERIOD: RegExp;
@@ -73,6 +72,7 @@ function makeRules(rs: Ruleset): Rules
return { return {
anyCjk: new RegExp(CJK_ATOM, "u"), anyCjk: new RegExp(CJK_ATOM, "u"),
pillNeighbor: new RegExp("^(?:[" + ALNUM + "]|" + CJK_ATOM + ")$", "u"), pillNeighbor: new RegExp("^(?:[" + ALNUM + "]|" + CJK_ATOM + ")$", "u"),
markerForbidden: new RegExp("[" + CJK + ANS_AFTER + ANS_BEFORE + "]", "u"),
STRIP_CJK_SPACE_ANS: new RegExp("(" + CJK_ATOM + ") ([" + ANS_AFTER + "])", "gu"), STRIP_CJK_SPACE_ANS: new RegExp("(" + CJK_ATOM + ") ([" + ANS_AFTER + "])", "gu"),
STRIP_ANS_SPACE_CJK: new RegExp("([" + ANS_BEFORE + "]) (" + CJK_ATOM + ")", "gu"), STRIP_ANS_SPACE_CJK: new RegExp("([" + ANS_BEFORE + "]) (" + CJK_ATOM + ")", "gu"),
STRIP_CJK_SPACE_CJK: new RegExp("(" + CJK_ATOM + ") (?=" + CJK_ATOM + ")", "gu"), STRIP_CJK_SPACE_CJK: new RegExp("(" + CJK_ATOM + ") (?=" + CJK_ATOM + ")", "gu"),
@@ -114,9 +114,44 @@ function makeRules(rs: Ruleset): Rules
// 複合詞(GPT-4 / state-of-the-art)不可被運算符間隙楔入:先遮罩,運算符 // 複合詞(GPT-4 / state-of-the-art)不可被運算符間隙楔入:先遮罩,運算符
// 過後、括號 / CJK_ANS / ANS_CJK 過前還原。 // 過後、括號 / CJK_ANS / ANS_CJK 過前還原。
const COMPOUND_WORD = /\b(?:[A-Za-z0-9]*[a-z][A-Za-z0-9]*-[A-Za-z0-9]+|[A-Za-z0-9]+-[A-Za-z0-9]*[a-z][A-Za-z0-9]*|[A-Za-z]+-[0-9]+|[A-Za-z]+[0-9]+-[A-Za-z0-9]+)(?:-[A-Za-z0-9]+)*\b/g; const COMPOUND_WORD = /\b(?:[A-Za-z0-9]*[a-z][A-Za-z0-9]*-[A-Za-z0-9]+|[A-Za-z0-9]+-[A-Za-z0-9]*[a-z][A-Za-z0-9]*|[A-Za-z]+-[0-9]+|[A-Za-z]+[0-9]+-[A-Za-z0-9]+)(?:-[A-Za-z0-9]+)*\b/g;
const PH_PATTERN = new RegExp(PH_OPEN + "(\\d+)" + PH_CLOSE, "g");
const ASTRAL_CJK_SHADOW = "漢"; const ASTRAL_CJK_SHADOW = "漢";
/** 每個 run 使用源文本未佔用、且規則不識別為 CJK/西文的標記,絕不寫回 DOM。 */
function markersFor(text: string, R: Rules): SpacingMarkers
{
const occupied = new Set(text);
const markers: string[] = [];
const tryRange = (start: number, end: number): boolean =>
{
for (let cp = start; cp <= end; cp += 1)
{
const ch = String.fromCodePoint(cp);
if (occupied.has(ch) || R.markerForbidden.test(ch)) { continue; }
markers.push(ch);
if (markers.length === 4) { return true; }
}
return false;
};
// 私用區及非字符區皆可能出現在作者源中,須逐一檢查;搜索有固定上界。
let found = tryRange(0xE000, 0xF8FF)
|| tryRange(0xF0000, 0xFFFFD)
|| tryRange(0x100000, 0x10FFFD)
|| tryRange(0xFDD0, 0xFDEF);
for (let plane = 0; !found && plane <= 16; plane += 1)
{
found = tryRange(plane * 0x10000 + 0xFFFE, plane * 0x10000 + 0xFFFF);
}
if (!found)
{
// 無無歧義標記時停在回放之前;不得把作者字符誤當刪除指令。
throw new Error("juzhen: 源文本與字符規則已佔用全部間隙標記候選。");
}
return {
gap: markers[0]!, compoundOpen: markers[1]!,
compoundClose: markers[2]!, deletion: markers[3]!,
};
}
/** /**
* Safari 26.5.2 於實際 CJK 大字元類中,會漏掉非零 UTF-16 起點之補充平面 * Safari 26.5.2 於實際 CJK 大字元類中,會漏掉非零 UTF-16 起點之補充平面
* CJK(global 與 sticky 皆同)。規則層不需原字,只需與 DOM map 同構之邏輯 atom * CJK(global 與 sticky 皆同)。規則層不需原字,只需與 DOM map 同構之邏輯 atom
@@ -136,9 +171,14 @@ function shadowAstralCjkAtoms(atoms: readonly string[], R: Rules): string
return shadowed.join(""); return shadowed.join("");
} }
function panguSpace(text: string, R: Rules): string function panguSpace(text: string, R: Rules, markers: SpacingMarkers): string
{ {
if (splitVariationSequences(text).length <= 1 || !R.anyCjk.test(text)) { return text; } const MARK = markers.gap;
const PH_OPEN = markers.compoundOpen;
const PH_CLOSE = markers.compoundClose;
// 刪除標記與原作者空格 1:1 替換,保持 DOM map 對齊。
const DEL = markers.deletion;
const PH_PATTERN = new RegExp(PH_OPEN + "(\\d+)" + PH_CLOSE, "g");
let s = text; let s = text;
s = s.replace(R.STRIP_CJK_SPACE_ANS, "$1$2"); s = s.replace(R.STRIP_CJK_SPACE_ANS, "$1$2");
@@ -514,6 +554,7 @@ function unwrapCrossGap(
if (!parent) { ownedElements.delete(gap); return; } if (!parent) { ownedElements.delete(gap); return; }
while (gap.firstChild) { parent.insertBefore(gap.firstChild, gap); } while (gap.firstChild) { parent.insertBefore(gap.firstChild, gap); }
parent.removeChild(gap); parent.removeChild(gap);
recordUnwrappedParent(gap, parent, ownedElements);
ownedElements.delete(gap); ownedElements.delete(gap);
touched.add(parent); touched.add(parent);
} }
@@ -527,7 +568,29 @@ function restoreMovedParts(ctx: RenderContext): Set<Node>
for (const record of Array.from(records)) for (const record of Array.from(records))
{ {
if (!recordWithinRoot(record, ctx.root)) { continue; } if (!recordWithinRoot(record, ctx.root)) { continue; }
const restored = restoreMovedText(record); const sourceParent = resolveUnwrappedParent(record.originalParent);
const gapParent = resolveUnwrappedParent(record.gapOriginalParent);
if (!sourceParent || !gapParent)
{
// 循環/失效来源不能指引文本搬移;保留當前文字,由正常還原清理可驗證載體。
record.anchor.remove();
record.gapAnchor.remove();
records.delete(record);
continue;
}
record.originalParent = sourceParent;
record.gapOriginalParent = gapParent;
const live = (node: Node): boolean => record.scopeRoot.contains(node) || node.isConnected;
if (!isOwnedCrossGap(record.gap, ctx.ownedElements) || !live(record.gap))
{
// 刪除/克隆載體後不復活原文字;真正移動與整根脫離 document 仍保留。
record.anchor.remove();
record.gapAnchor.remove();
if (!live(record.gap)) { ctx.ownedElements.delete(record.gap); }
records.delete(record);
continue;
}
const restored = live(record.originalParent) && restoreMovedText(record);
if (!restored && record.textNode.parentNode === record.gap) if (!restored && record.textNode.parentNode === record.gap)
{ {
// 原父已以 textContent/replaceChildren/innerHTML 取代:舊 selector // 原父已以 textContent/replaceChildren/innerHTML 取代:舊 selector
@@ -567,27 +630,29 @@ function processBlock(
ownerToken: object, ownerToken: object,
): void ): void
{ {
const runs = finder.collectRuns(block); const runs = finder.collectRuns(block, false, PASS);
for (const run of runs) for (const run of runs)
{ {
const { map } = run; const { map } = run;
const originalChars = map.map((span) => span.text); const originalChars = map.map((span) => span.text);
const ruleText = shadowAstralCjkAtoms(originalChars, R); const ruleText = shadowAstralCjkAtoms(originalChars, R);
const spaced = panguSpace(ruleText, R); if (originalChars.length < 2 || !R.anyCjk.test(ruleText)) { continue; }
const markers = markersFor(ruleText, R);
const spaced = panguSpace(ruleText, R, markers);
if (spaced === ruleText) { continue; } if (spaced === ruleText) { continue; }
const actions: SpaceAction[] = []; const actions: SpaceAction[] = [];
let origIdx = 0; let origIdx = 0;
for (const sc of splitVariationSequences(spaced)) for (const sc of splitVariationSequences(spaced))
{ {
if (sc === DEL) if (sc === markers.deletion)
{ {
// CJK↔CJK 贅餘空格:純刪除、不留隙(before 留空)。 // CJK↔CJK 贅餘空格:純刪除、不留隙(before 留空)。
actions.push({ before: undefined, space: map[origIdx]! }); actions.push({ before: undefined, space: map[origIdx]! });
origIdx += 1; origIdx += 1;
continue; continue;
} }
if (sc === MARK) if (sc === markers.gap)
{ {
const oc = origIdx < originalChars.length ? originalChars[origIdx]! : null; const oc = origIdx < originalChars.length ? originalChars[origIdx]! : null;
if (oc === " ") if (oc === " ")
@@ -645,16 +710,16 @@ function processPills(
if (!finder.featureEnabledFor(pill, PASS)) { return; } if (!finder.featureEnabledFor(pill, PASS)) { return; }
if (!pill.parentNode) { return; } if (!pill.parentNode) { return; }
const prev = finder.adjacentLogicalChar(pill, -1); const prev = finder.adjacentLogicalChar(pill, -1, root, undefined, PASS);
if (prev) if (prev)
{ {
handlePillSide(prev, pill, -1, pillNeighbor, ownedElements, ownerToken, root); handlePillSide(prev, pill, -1, finder, pillNeighbor, ownedElements, ownerToken, root);
} }
const next = finder.adjacentLogicalChar(pill, 1); const next = finder.adjacentLogicalChar(pill, 1, root, undefined, PASS);
if (next) if (next)
{ {
handlePillSide(next, pill, 1, pillNeighbor, ownedElements, ownerToken, root); handlePillSide(next, pill, 1, finder, pillNeighbor, ownedElements, ownerToken, root);
} }
}); });
} }
@@ -665,58 +730,41 @@ function handlePillSide(
adj: AdjacentChar, adj: AdjacentChar,
pill: Element, pill: Element,
dir: -1 | 1, dir: -1 | 1,
finder: Finder,
pillNeighbor: RegExp, pillNeighbor: RegExp,
ownedElements: WeakSet<Element>, ownedElements: WeakSet<Element>,
ownerToken: object, ownerToken: object,
scopeRoot: Element, scopeRoot: Element,
): void ): void
{ {
if (!adj.parts.every((part) => finder.featureEnabledFor(part.textNode, PASS))) { return; }
let neighbor = adj;
if (adj.ch === " ")
{
// 只剝一個 ASCII 作者空格;再次查詢仍受透明/作用域/排除邊界限制。
const beyond = finder.adjacentLogicalChar(pill, dir, scopeRoot, adj, PASS);
if (!beyond || !pillNeighbor.test(beyond.ch)
|| !beyond.parts.every((part) => finder.featureEnabledFor(part.textNode, PASS))) { return; }
const space = adj.parts[0]!;
deleteSpan({ text: " ", parts: adj.parts });
for (const part of beyond.parts)
{
if (part.textNode === space.textNode && part.start >= space.end)
{
part.start -= 1;
part.end -= 1;
}
}
beyond.offset = beyond.parts[0]!.start;
neighbor = beyond;
}
if (!pillNeighbor.test(neighbor.ch)) { return; }
if (dir === -1) if (dir === -1)
{ {
if (pillNeighbor.test(adj.ch)) wrapGapLeft({ text: neighbor.ch, parts: neighbor.parts },
{ ownedElements, ownerToken, pill.parentElement ?? scopeRoot, scopeRoot);
wrapGapLeft({
text: adj.ch,
parts: [ {
textNode: adj.textNode,
start: adj.offset,
end: adj.offset + adj.ch.length,
} ],
}, ownedElements, ownerToken, pill.parentElement ?? scopeRoot, scopeRoot);
return;
}
if (adj.ch === " " && adj.offset >= 1)
{
const tn = adj.textNode;
const beforeOffset = previousVariationSequenceStart(tn.data, adj.offset);
const bc = variationSequenceAtUtf16(tn.data, beforeOffset);
if (bc !== " " && pillNeighbor.test(bc))
{
tn.data = tn.data.slice(0, adj.offset) + tn.data.slice(adj.offset + 1);
wrapGapLeft({
text: bc,
parts: [ { textNode: tn, start: beforeOffset, end: beforeOffset + bc.length } ],
}, ownedElements, ownerToken, pill.parentElement ?? scopeRoot, scopeRoot);
}
}
}
else
{
if (pillNeighbor.test(adj.ch))
{
wrapNodeInGap(pill, ownedElements);
return;
}
if (adj.ch === " " && adj.textNode.data.length > 1)
{
const ac = variationSequenceAtUtf16(adj.textNode.data, 1);
if (ac !== " " && pillNeighbor.test(ac))
{
adj.textNode.data = adj.textNode.data.slice(1);
wrapNodeInGap(pill, ownedElements);
}
}
} }
else { wrapNodeInGap(pill, ownedElements); }
} }
export const spacingPass: StandalonePass = { export const spacingPass: StandalonePass = {
@@ -730,20 +778,30 @@ export const spacingPass: StandalonePass = {
const { finder, options } = ctx; const { finder, options } = ctx;
// 由規則集編譯間隙正則(per-instance;含 charClass 覆寫)。 // 由規則集編譯間隙正則(per-instance;含 charClass 覆寫)。
const R = makeRules(options.ruleset); const R = makeRules(options.ruleset);
// Pass A:逐 block 處理(功能閘以 block 為粒度)。 // Pass A:逐 block 收集,但功能閘逐 Text/完整原子;子白名單可重新啟用。
const processedRoots = new Set<Element>();
ctx.finder.eachBlock(ctx.root, (block) => ctx.finder.eachBlock(ctx.root, (block) =>
{ {
if (!finder.featureEnabledFor(block, PASS)) { return; }
processBlock(block, ctx.root, finder, R, ctx.ownedElements, ctx.ownerToken); processBlock(block, ctx.root, finder, R, ctx.ownedElements, ctx.ownerToken);
processedRoots.add(block);
}); });
// 文本級功能須支持單元素(§6.5.0a):root 為行內/單一非塊元素(如消費端對 // 文本級功能須支持單元素(§6.5.0a):root 為行內/單一非塊元素(如消費端對
// <span> 標題呼叫 render)時 eachBlock 不含之,補處理 root 自身(collectRuns // <span> 標題呼叫 render)時 eachBlock 不含之,補處理 root 自身(collectRuns
// 遇巢狀 block 仍會 flush,故與上面逐 block 不重複;行內 root 通常無 block 子)。 // 遇巢狀 block 仍會 flush,故與上面逐 block 不重複;行內 root 通常無 block 子)。
if (!options.finder.blockTags.has(ctx.root.nodeName) const inlineRoots = [ ctx.root ];
&& finder.featureEnabledFor(ctx.root, PASS) if (options.scope.include) { inlineRoots.push(...Array.from(ctx.root.querySelectorAll(options.scope.include))); }
&& finder.inScope(ctx.root) && !finder.isAvoided(ctx.root)) for (const root of inlineRoots)
{ {
processBlock(ctx.root, ctx.root, finder, R, ctx.ownedElements, ctx.ownerToken); let covered = processedRoots.has(root);
for (let parent = root.parentElement; !covered && parent; parent = parent.parentElement)
{
covered = processedRoots.has(parent);
if (parent === ctx.root) { break; }
}
if (options.finder.blockTags.has(root.nodeName) || covered || !finder.inScope(root) || finder.isAvoided(root)
|| finder.category(root) === "pill" || finder.insidePill(root)) { continue; }
processBlock(root, ctx.root, finder, R, ctx.ownedElements, ctx.ownerToken);
processedRoots.add(root);
} }
// Pass B:pill/isolate 邊界。 // Pass B:pill/isolate 邊界。
const boundarySelector = [ options.finder.pillSelector, options.finder.isolateSelector ] const boundarySelector = [ options.finder.pillSelector, options.finder.isolateSelector ]
+7 -3
View File
@@ -691,7 +691,7 @@ test("moved IVS ownership: anchor and text-move throws roll back exactly", () =>
} }
}); });
test("moved IVS ownership: partial roots and detached links restore as one unit", () => test("moved IVS ownership: partial roots restore as one unit; deleted carrier stays deleted", () =>
{ {
const original = '<strong id="partial-base">\u{20000}</strong>' const original = '<strong id="partial-base">\u{20000}</strong>'
+ '<em id="partial-selector">\u{E0100}A</em>'; + '<em id="partial-selector">\u{E0100}A</em>';
@@ -746,10 +746,14 @@ test("moved IVS ownership: partial roots and detached links restore as one unit"
} }
jz.revert(revertRoot); jz.revert(revertRoot);
assert.equal(paragraph.innerHTML, original, `${kind} restores exact author DOM`); // 移除整個載體即刪除其原文;不能因回退另一端而復活已刪 base/VS。
const expected = kind === "detached-gap"
? '<strong id="partial-base"></strong><em id="partial-selector">A</em>' : original;
assert.equal(paragraph.innerHTML, expected, `${kind} restores or preserves exact author DOM`);
assert.equal(paragraph.querySelector("#partial-base"), base, `${kind} base identity`); assert.equal(paragraph.querySelector("#partial-base"), base, `${kind} base identity`);
assert.equal(paragraph.querySelector("#partial-selector"), selectorParent, `${kind} parent identity`); assert.equal(paragraph.querySelector("#partial-selector"), selectorParent, `${kind} parent identity`);
assert.equal(selectorParent.textContent, "\u{E0100}A", `${kind} selector original parent`); assert.equal(selectorParent.textContent, kind === "detached-gap" ? "A" : "\u{E0100}A",
`${kind} selector original parent`);
assert.equal(countTag(paragraph, "jz-hws"), 0, `${kind} removes linked gap`); assert.equal(countTag(paragraph, "jz-hws"), 0, `${kind} removes linked gap`);
assert.equal( assert.equal(
Array.from(outside.childNodes).filter((node) => node.nodeType === 8).length, Array.from(outside.childNodes).filter((node) => node.nodeType === 8).length,
+4 -1
View File
@@ -774,7 +774,10 @@ test("CSS:聚珍不內建 font-family(字型交消費端),半形顯式
const cssPath = fileURLToPath(new URL("../dist/juzhen.css", import.meta.url)); const cssPath = fileURLToPath(new URL("../dist/juzhen.css", import.meta.url));
const css = readFileSync(cssPath, "utf8"); const css = readFileSync(cssPath, "utf8");
assert.equal(/font-family/.test(css), false, "聚珍 CSS 不應內建任何 font-family"); assert.equal(/font-family/.test(css), false, "聚珍 CSS 不應內建任何 font-family");
assert.match(css, /font-feature-settings:\s*"locl"\s+1,\s*"halt"\s+1/, "半形 feature 應顯式保留 locl"); assert.match(css, /font-feature-settings:\s*var\(--jz-half-features,\s*"locl"\s+1,\s*"halt"\s+1\)/,
"半形 feature 的預設值應顯式保留 locl");
assert.match(css, /--jz-half-features:\s*"locl"\s+1,\s*"halt"\s+1/,
"halt 模式應顯式保留 locl");
}); });
// ----- charClass:自定義字符分類規則集(向下兼容+擴充)----- // ----- charClass:自定義字符分類規則集(向下兼容+擴充)-----
+92
View File
@@ -0,0 +1,92 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { setupDom } from "./helpers.mjs";
import { createJuzhen } from "../dist/juzhen.mjs";
function layoutHarness(document)
{
const originalResizeObserver = globalThis.ResizeObserver;
const originalRects = window.Element.prototype.getClientRects;
const observers = [];
let measurements = 0;
let resolveFonts;
const ready = new Promise((resolve) => { resolveFonts = resolve; });
Object.defineProperty(document, "fonts", { configurable: true, value: { ready } });
globalThis.ResizeObserver = class
{
constructor(callback) { this.callback = callback; this.disconnected = false; observers.push(this); }
observe() {}
disconnect() { this.disconnected = true; }
fire() { this.callback([], this); }
};
window.Element.prototype.getClientRects = function ()
{
measurements += 1;
return [ { top: 0, bottom: 16, left: 0, right: 16, width: 16, height: 16 } ];
};
return {
observers,
resolveFonts,
count: () => measurements,
restore()
{
window.Element.prototype.getClientRects = originalRects;
if (originalResizeObserver === undefined) { delete globalThis.ResizeObserver; }
else { globalThis.ResizeObserver = originalResizeObserver; }
},
};
}
for (const signal of [ "resize", "fonts" ])
{
test("layout lifecycle:重排後舊 " + signal + " 回調不得量測新產物", async () =>
{
const { dom, document } = setupDom('<p lang="zh-Hant">「甲乙</p>');
const root = document.querySelector("p");
const layout = layoutHarness(document);
const jz = createJuzhen({ spacing: false, orphan: false, jinze: false, longWord: false });
try
{
jz.render(root);
jz.rerender(root);
assert.equal(layout.observers.length, 2);
assert.equal(layout.observers[0].disconnected, true);
const before = layout.count();
if (signal === "resize")
{
layout.observers[0].fire();
assert.equal(layout.count(), before, "已卸載回調不碰目前產物");
layout.observers[1].fire();
assert.equal(layout.count(), before + 1, "目前回調正常執行");
}
else
{
layout.resolveFonts();
await Promise.resolve();
assert.equal(layout.count(), before + 1, "只執行目前安裝的字型回調");
}
}
finally { jz.revert(root); jz.disconnect(); layout.restore(); dom.window.close(); }
});
}
test("layout lifecycle:回退後字型 Promise 不再查詢根子樹", async () =>
{
const { dom, document } = setupDom('<p lang="zh-Hant">「甲乙</p>');
const root = document.querySelector("p");
const layout = layoutHarness(document);
const jz = createJuzhen({ spacing: false, orphan: false, jinze: false, longWord: false });
const nativeQuery = root.querySelectorAll;
let queries = 0;
try
{
jz.render(root);
jz.revert(root);
root.querySelectorAll = function (...args) { queries += 1; return nativeQuery.apply(this, args); };
layout.resolveFonts();
await Promise.resolve();
assert.equal(queries, 0, "卸載後不再進入 layout 工作");
assert.equal(root.innerHTML, "「甲乙");
}
finally { root.querySelectorAll = nativeQuery; jz.disconnect(); layout.restore(); dom.window.close(); }
});
+58
View File
@@ -0,0 +1,58 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { setupDom } from "./helpers.mjs";
import { createJuzhen } from "../dist/juzhen.mjs";
for (const minLength of [ 6.5, Infinity, NaN, -Infinity, 1e21 ])
{
test("longWord:無效計數 " + minLength + " 回退六字,不靜默停用", () =>
{
const { dom, document } = setupDom('<p>configuration</p>');
const jz = createJuzhen({ jiya: false, spacing: false, jinze: false, orphan: false,
longWord: { minLength } });
try
{
jz.render(document.body);
assert.equal(jz.options.longWord.minLength, 6);
assert.equal(document.querySelectorAll('[data-jz="longWord"]').length, 1);
assert.equal(document.body.textContent, "configuration");
jz.revert(document.body);
assert.equal(document.querySelector("p").innerHTML, "configuration");
}
finally { jz.disconnect(); dom.window.close(); }
});
}
for (const chars of [ Infinity, NaN, -Infinity ])
{
test("orphan:非有限計數 " + chars + " 回退兩字", () =>
{
const { dom, document } = setupDom('<p>甲乙丙丁</p>');
const jz = createJuzhen({ jiya: false, spacing: false, jinze: false, longWord: false,
orphan: { chars } });
try
{
jz.render(document.body);
assert.equal(jz.options.orphan.chars, 2);
assert.equal(document.querySelector("jz-orphan").textContent, "丙丁");
jz.revert(document.body);
assert.equal(document.querySelector("p").innerHTML, "甲乙丙丁");
}
finally { jz.disconnect(); dom.window.close(); }
});
}
test("有效計數:longWord 整數閾值及 orphan 小數向下取整維持既有行為", () =>
{
const { dom, document } = setupDom('<p>abcdef abcdefg甲乙丙丁</p>');
const jz = createJuzhen({ jiya: false, spacing: false, jinze: false,
longWord: { minLength: 7 }, orphan: { chars: 2.5 } });
try
{
jz.render(document.body);
assert.equal(jz.options.orphan.chars, 2);
assert.deepEqual(Array.from(document.querySelectorAll('[data-jz="longWord"]'), (el) => el.textContent), [ "abcdefg" ]);
assert.equal(document.querySelector("jz-orphan").textContent, "丙丁");
}
finally { jz.revert(document.body); jz.disconnect(); dom.window.close(); }
});
+79
View File
@@ -0,0 +1,79 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { setupDom } from "./helpers.mjs";
import { createJuzhen } from "../dist/juzhen.mjs";
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
for (const scenario of [ "move-added-block", "detached-target-record" ])
{
test("observer scope:" + scenario + " 不排版已移出觀察根的作者內容", async () =>
{
const { dom, document } = setupDom('<main id="root"></main><aside id="outside"></aside>');
const root = document.getElementById("root");
const outside = document.getElementById("outside");
const p = document.createElement("p");
p.innerHTML = "新增ABC,作者文字。";
const jz = createJuzhen();
try
{
if (scenario === "detached-target-record") { root.appendChild(p); }
jz.observe(root);
if (scenario === "move-added-block") { root.appendChild(p); }
outside.appendChild(p);
if (scenario === "detached-target-record") { p.setAttribute("lang", "zh-Hans"); }
const original = p.outerHTML;
await tick();
assert.equal(p.outerHTML, original, "已移出內容不能因尚未派發的記錄被新排版");
const active = document.createElement("p");
active.textContent = "根內ABC,仍處理。";
root.appendChild(active);
await tick();
assert.ok(active.querySelector("jz-char"), "觀察根內後續更新保持正常");
}
finally { jz.revert(root); jz.disconnect(); dom.window.close(); }
});
}
for (const change of [ "text", "attribute", "new-block" ])
{
test("observer detached root:" + change + " 在未掛入 document 的根仍增量更新", async () =>
{
const { dom, document } = setupDom();
const root = document.createElement("main");
root.innerHTML = '<p lang="zh-Hant">原文,甲乙。</p>';
const jz = createJuzhen();
try
{
jz.render(root);
jz.observe(root);
const p = root.querySelector("p");
if (change === "text") { p.textContent = "新文ABC,丙丁。"; }
else if (change === "attribute") { p.setAttribute("data-jz-level", "text"); }
else
{
const added = document.createElement("p");
added.textContent = "追加ABC,戊己。";
root.appendChild(added);
}
await tick();
assert.equal(root.isConnected, false);
if (change === "attribute")
{
assert.equal(p.getAttribute("data-jz-block-level"), "text");
assert.equal(p.querySelector("jz-orphan"), null);
}
else
{
const target = change === "new-block" ? root.lastElementChild : p;
assert.ok(target.querySelector("jz-hws"));
assert.ok(target.querySelector("jz-char"));
}
const currentText = root.textContent;
jz.revert(root);
assert.equal(root.querySelector("[data-jz]"), null);
assert.equal(root.textContent, currentText);
}
finally { jz.revert(root); jz.disconnect(); dom.window.close(); }
});
}
+88
View File
@@ -730,3 +730,91 @@ test("observer provenance: transition inference and queue consumption scale line
} }
context.diagnostic("linear probes complete " + JSON.stringify(reports)); context.diagnostic("linear probes complete " + JSON.stringify(reports));
}); });
test("observer:手動局部操作先結算其他兄弟塊的待交付變更", async () =>
{
for (const operation of [ "render", "rerender", "revert" ])
{
const { dom, document } = setupDom('<main><p id="a">原文,甲。</p><p id="b">原文,乙。</p></main>');
const root = document.querySelector("main");
const a = document.getElementById("a");
const b = document.getElementById("b");
const jz = createJuzhen({ jinze: false, orphan: false, longWord: false });
try
{
jz.render(root);
jz.observe(root);
a.textContent = "改後,新文。";
jz[operation](b);
await tick();
assert.equal(a.querySelectorAll("jz-char").length, 2, operation + " 不丟棄兄弟變更");
if (operation === "revert") { assert.equal(b.querySelector("[data-jz]"), null); }
a.textContent = "再次,更新。";
await tick();
assert.equal(a.querySelectorAll("jz-char").length, 2, "結算後繼續觀察");
}
finally { jz.disconnect(); dom.window.close(); }
}
});
test("observer:祖先分類待更新時手動 revert 子塊仍最後生效", async () =>
{
const { dom, document } = setupDom('<main><p id="a">甲文,內容。</p><p id="b">乙文,內容。</p></main>');
const root = document.querySelector("main");
const a = document.getElementById("a");
const b = document.getElementById("b");
const jz = createJuzhen();
try
{
jz.render(root);
jz.observe(root);
root.setAttribute("data-jz-level", "text");
jz.revert(b);
await tick();
assert.equal(a.getAttribute("data-jz-block-level"), "text");
assert.equal(a.querySelector("jz-orphan"), null, "兄弟塊採新祖先級別");
assert.equal(b.querySelector("[data-jz]"), null, "舊祖先記錄不重新 render 已回退子塊");
assert.equal(b.hasAttribute("data-jz-block-level"), false);
}
finally { jz.disconnect(); dom.window.close(); }
});
test("observer:暫停前結算失敗仍非同步報錯,不阻擋手動操作及後續觀察", async () =>
{
const { dom, document } = setupDom('<main><p id="a">甲文,內容。</p><p id="b">乙文,內容。</p></main>');
const root = document.querySelector("main");
const a = document.getElementById("a");
const b = document.getElementById("b");
const expected = new Error("pending mutation failure");
let shouldThrow = false;
const errors = [];
dom.window.addEventListener("error", (event) =>
{
errors.push(event.error);
event.preventDefault();
});
const jz = createJuzhen({
jinze: false, orphan: false, longWord: false,
isSkipped(node)
{
if (shouldThrow && node === a) { shouldThrow = false; throw expected; }
return false;
},
});
try
{
jz.render(root);
jz.observe(root);
a.textContent = "改後,新文。";
shouldThrow = true;
assert.doesNotThrow(() => jz.revert(b), "舊 observer 錯誤不變成同步 API 例外");
assert.equal(errors.length, 0);
await tick();
assert.deepEqual(errors, [ expected ]);
assert.equal(b.querySelector("[data-jz]"), null);
a.textContent = "再次,更新。";
await tick();
assert.equal(a.querySelectorAll("jz-char").length, 2);
}
finally { jz.disconnect(); dom.window.close(); }
});
+247
View File
@@ -0,0 +1,247 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { setupDom } from "./helpers.mjs";
import { createJuzhen } from "../dist/juzhen.mjs";
test("pill:塊本身與其後代塊的內部不進入 standalone pass", () =>
{
for (const html of [
'<main><div class="pill"><p>甲ABC乙,丙。</p></div><p>正常ABC,文字。</p></main>',
'<main><p class="pill">甲ABC乙,丙。</p><p>正常ABC,文字。</p></main>',
])
{
const { dom, document } = setupDom(html);
const original = document.body.innerHTML;
const pill = document.querySelector(".pill");
const before = pill.innerHTML;
const jz = createJuzhen({ pillSelector: ".pill" });
try
{
jz.render(document.body);
assert.equal(pill.innerHTML, before, "pill 內部保持原樣");
assert.ok(document.querySelector("main > p:not(.pill) jz-hws"), "正常兄弟塊仍處理");
jz.revert(document.body);
assert.equal(pill.innerHTML, before);
}
finally { jz.disconnect(); dom.window.close(); }
}
});
const exclusions = [
[ "avoid", "data-jz-skip" ],
[ "text", 'data-jz-level="text"' ],
[ "off", 'data-juzhen-off="orphan jinze"' ],
[ "predicate", 'class="skip-text"' ],
];
const skippedText = (node) => node.nodeType === 3
&& node.parentElement?.classList.contains("skip-text");
test("jinze:排除或關閉段落功能的兩側不建立禁斷綁定", () =>
{
for (const [ label, attr ] of exclusions)
{
for (const side of [ "before-close", "after-open" ])
{
const protectedHtml = '<span ' + attr + '>自管</span>';
const html = side === "before-close"
? '<p>' + protectedHtml + ',正常文字。</p>'
: '<p>正常文字(' + protectedHtml + '</p>';
const { dom, document } = setupDom(html);
const original = document.body.innerHTML;
const protectedEl = document.querySelector("span");
const jz = createJuzhen({ spacing: false, orphan: false, longWord: false,
isSkipped: skippedText });
try
{
jz.render(document.body);
assert.equal(protectedEl.closest("jz-jinze"), null, label + "/" + side);
assert.equal(protectedEl.innerHTML, "自管");
jz.revert(document.body);
assert.equal(document.body.innerHTML, original);
}
finally { jz.disconnect(); dom.window.close(); }
}
}
});
test("jinze:透明元素內的排除內容不可被整元素退化綁定帶入", () =>
{
const html = '<p><a href="#">甲<span data-jz-skip>自管</span>乙</a>,正常。</p>';
const { dom, document } = setupDom(html);
const original = document.body.innerHTML;
const protectedEl = document.querySelector("[data-jz-skip]");
const jz = createJuzhen({ spacing: false, orphan: false, longWord: false });
try
{
jz.render(document.body);
assert.equal(protectedEl.closest("jz-jinze"), null);
jz.revert(document.body);
assert.equal(document.body.innerHTML, original);
}
finally { jz.disconnect(); dom.window.close(); }
});
test("jinze:註解與空 Text 不阻斷禁則,回退保留註解身份和次序", () =>
{
const html = '<p>甲<!--anchor-->,乙。</p>';
const { dom, document } = setupDom(html);
const original = document.body.innerHTML;
const p = document.querySelector("p");
const anchor = p.childNodes[1];
const empty = document.createTextNode("");
p.insertBefore(empty, anchor);
const jz = createJuzhen({ spacing: false, orphan: false, longWord: false });
try
{
jz.render(p);
const group = p.querySelector("jz-jinze");
assert.equal(group.textContent, "甲,");
assert.ok(group.contains(anchor));
assert.ok(group.contains(empty));
jz.revert(p);
assert.equal(p.childNodes[1], anchor);
assert.equal(anchor.previousSibling.textContent, "甲");
assert.equal(anchor.nextSibling.textContent, ",乙。");
assert.equal(document.body.innerHTML, original);
}
finally { jz.disconnect(); dom.window.close(); }
});
test("jinze:行內 render 根不把外部兄弟搬入生成群組", () =>
{
const html = '<p>甲<strong id="root">,乙。</strong>後文</p>';
const { dom, document } = setupDom(html);
const original = document.body.innerHTML;
const root = document.getElementById("root");
const parent = root.parentNode;
const before = root.previousSibling;
const after = root.nextSibling;
const jz = createJuzhen({ spacing: false, orphan: false, longWord: false });
try
{
jz.render(root);
assert.equal(root.parentNode, parent);
assert.equal(root.previousSibling, before);
assert.equal(root.nextSibling, after);
assert.equal(parent.querySelectorAll(":scope > jz-jinze").length, 0);
jz.revert(root);
assert.equal(document.body.innerHTML, original);
}
finally { jz.disconnect(); dom.window.close(); }
});
test("orphan:排除/text/off 的尾部內容不計數,也不包裹", () =>
{
for (const [ label, attr ] of exclusions)
{
const html = '<p>正常文字<span ' + attr + '>自管文字</span></p>';
const { dom, document } = setupDom(html);
const original = document.body.innerHTML;
const jz = createJuzhen({ spacing: false, jiya: false, jinze: false, longWord: false,
isSkipped: skippedText });
try
{
jz.render(document.body);
assert.equal(document.querySelector("jz-orphan"), null, label + " 尾部是綁定邊界");
jz.revert(document.body);
assert.equal(document.body.innerHTML, original);
}
finally { jz.disconnect(); dom.window.close(); }
}
});
test("orphan:只綁最後排除邊界之後的合資格末尾", () =>
{
for (const [ label, attr ] of exclusions)
{
for (const tail of [ "末", "最後" ])
{
const html = '<p>前文<span ' + attr + '>自管文字</span>' + tail + '</p>';
const { dom, document } = setupDom(html);
const original = document.body.innerHTML;
const jz = createJuzhen({ spacing: false, jiya: false, jinze: false, longWord: false,
isSkipped: skippedText });
try
{
jz.render(document.body);
const orphan = document.querySelector("jz-orphan");
assert.equal(orphan?.textContent ?? null, tail === "末" ? null : tail, label + "/" + tail);
assert.equal(document.querySelector("span").closest("jz-orphan"), null);
jz.revert(document.body);
assert.equal(document.body.innerHTML, original);
}
finally { jz.disconnect(); dom.window.close(); }
}
}
});
test("orphan:不可切容器不以整綁繞過排除邊界,可切容器只切合資格末尾", () =>
{
for (const tag of [ 'span', 'a href="#"' ])
{
const closeTag = tag.split(" ")[0];
const html = '<p>前文<' + tag + '><span data-jz-skip>自管</span>最後</' + closeTag + '></p>';
const { dom, document } = setupDom(html);
const original = document.body.innerHTML;
const protectedEl = document.querySelector("[data-jz-skip]");
const jz = createJuzhen({ spacing: false, jiya: false, jinze: false, longWord: false });
try
{
jz.render(document.body);
assert.equal(protectedEl.closest("jz-orphan"), null);
assert.equal(document.querySelector("jz-orphan")?.textContent ?? null,
closeTag === "span" ? "最後" : null);
jz.revert(document.body);
assert.equal(document.body.innerHTML, original);
}
finally { jz.disconnect(); dom.window.close(); }
}
});
test("jinze:完整跨節點 base/VS/surrogate 原子一起綁到閉標點", () =>
{
for (const parts of [ [ "𠀀", "\u{E0100}" ], [ "\uD840", "\uDC00", "\uDB40", "\uDD00" ] ])
{
const { dom, document } = setupDom("<p></p>");
const p = document.querySelector("p");
p.append("前文");
const authors = parts.map((part) =>
{
const span = document.createElement("span");
span.textContent = part;
p.appendChild(span);
return span;
});
p.append(",");
const original = p.innerHTML;
const jz = createJuzhen({ spacing: false, orphan: false, longWord: false });
try
{
jz.render(p);
assert.equal(p.querySelector("jz-jinze").textContent, "𠀀\u{E0100},");
const rendered = p.innerHTML;
jz.render(p);
assert.equal(p.innerHTML, rendered);
jz.revert(p);
assert.equal(p.innerHTML, original);
assert.deepEqual(Array.from(p.querySelectorAll("span")), authors);
}
finally { jz.disconnect(); dom.window.close(); }
}
});
test("orphan:split surrogate 的補充平面字計為一個實義字", () =>
{
const { dom, document } = setupDom('<p>前文<strong>\uD840</strong><em>\uDC00</em>末</p>');
const p = document.querySelector("p");
const original = p.innerHTML;
const jz = createJuzhen({ spacing: false, jiya: false, jinze: false, longWord: false });
try
{
jz.render(p);
assert.equal(p.querySelector("jz-orphan").textContent, "𠀀末");
jz.revert(p);
assert.equal(p.innerHTML, original);
}
finally { jz.disconnect(); dom.window.close(); }
});
+226
View File
@@ -0,0 +1,226 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { setupDom } from "./helpers.mjs";
import { createJuzhen } from "../dist/juzhen.mjs";
const ONLY_JIYA = { spacing: false, jinze: false, orphan: false, longWord: false };
for (const selector of [ "\uFE00", "\u{E0100}" ])
{
for (const full of [ false, true ])
{
test("jiya atom:跨透明節點 VS " + selector.codePointAt(0).toString(16) + " full=" + full, () =>
{
const { dom, document } = setupDom('<p lang="zh-Hant">甲<strong id="base">,</strong><em id="vs">' + selector + '</em>「乙丙」</p>');
const root = document.querySelector("p");
const original = root.innerHTML;
const text = root.textContent;
const base = document.getElementById("base");
const vs = document.getElementById("vs");
let clicks = 0;
vs.addEventListener("click", () => { clicks += 1; });
const jz = createJuzhen(full ? {} : ONLY_JIYA);
try
{
jz.render(root);
const comma = root.querySelector("jz-char.bd-pause");
assert.equal(comma.textContent, "," + selector);
assert.equal(comma.querySelector("jz-inner").textContent, "," + selector);
assert.equal(comma.classList.contains("jz-half"), true);
assert.equal(root.querySelector("jz-char.bd-open").classList.contains("jz-half"), true);
assert.equal(document.getElementById("base"), base);
assert.equal(document.getElementById("vs"), vs);
vs.dispatchEvent(new dom.window.Event("click"));
assert.equal(clicks, 1);
assert.equal(root.textContent, text);
const rendered = root.innerHTML;
jz.render(root);
assert.equal(root.innerHTML, rendered, "重複 render 不增殖");
jz.revert(root);
assert.equal(root.innerHTML, original);
}
finally { jz.revert(root); jz.disconnect(); dom.window.close(); }
});
}
}
test("jiya atom:astral base 與 VS 的四個 surrogate 半字合成一個原子", () =>
{
const { dom, document } = setupDom('<p lang="zh-Hant"><strong></strong><em></em><i></i><b></b>「甲</p>');
const root = document.querySelector("p");
[ "\uD83D", "\uDE00", "\uDB40", "\uDD00" ].forEach((text, i) => { root.children[i].textContent = text; });
const original = root.innerHTML;
const jz = createJuzhen({ ...ONLY_JIYA, charClass: { biaodian: { pause: "😀" } } });
try
{
jz.render(root);
const chars = root.querySelectorAll("jz-char");
assert.equal(chars.length, 2);
assert.equal(chars[0].textContent, "😀\u{E0100}");
assert.equal(chars[0].classList.contains("jz-half"), true);
jz.revert(root);
assert.equal(root.innerHTML, original);
}
finally { jz.revert(root); jz.disconnect(); dom.window.close(); }
});
test("jiya 分類:astral 字集不包含原文中孤立的 surrogate 半字", () =>
{
const { dom, document } = setupDom('<p></p>');
const root = document.querySelector("p");
root.textContent = "\uD83DA\uDE00";
const original = root.textContent;
const jz = createJuzhen({ ...ONLY_JIYA, charClass: { biaodian: { pause: "😀" } } });
try
{
jz.render(root);
assert.equal(root.querySelector("jz-char"), null);
assert.equal(root.textContent, original);
}
finally { jz.revert(root); jz.disconnect(); dom.window.close(); }
});
for (const excluded of [ false, true ])
{
test("jiya 邊界:空 Text excluded=" + excluded, () =>
{
const { dom, document } = setupDom('<p lang="zh-Hant">,<strong></strong>「</p>');
const root = document.querySelector("p");
const empty = document.createTextNode("");
root.querySelector("strong").appendChild(empty);
const jz = createJuzhen({ ...ONLY_JIYA, isSkipped: (node) => excluded && node === empty });
try
{
jz.render(root);
assert.equal(root.querySelectorAll("jz-char.jz-half").length, excluded ? 0 : 2);
}
finally { jz.revert(root); jz.disconnect(); dom.window.close(); }
});
}
for (const side of [ "base", "vs" ])
{
test("jiya 跨原子局部回退:" + side + " 端保持原文及另一端位置", () =>
{
const { dom, document } = setupDom('<p lang="zh-Hant"><strong id="base">,</strong><em id="vs">\uFE00</em>「</p>');
const root = document.querySelector("p");
const jz = createJuzhen(ONLY_JIYA);
try
{
jz.render(root);
jz.revert(document.getElementById(side));
assert.equal(document.getElementById("base").textContent, ",");
assert.equal(document.getElementById("vs").textContent, "\uFE00");
assert.equal(root.textContent, ",\uFE00「");
jz.revert(root);
assert.equal(root.innerHTML, '<strong id="base">,</strong><em id="vs">\uFE00</em>「');
}
finally { jz.revert(root); jz.disconnect(); dom.window.close(); }
});
}
for (const mutation of [ "replace-source", "move-carrier", "move-author-text", "foreign-revert" ])
{
test("jiya 原子回退:" + mutation + " 保留作者外部修改/實例所有權", () =>
{
const { dom, document } = setupDom('<main><p lang="zh-Hant"><strong id="base">,</strong><em id="vs">\uFE00</em>「</p><aside></aside></main>');
const root = document.querySelector("p");
const jz = createJuzhen(ONLY_JIYA);
const other = createJuzhen(ONLY_JIYA);
const aside = document.querySelector("aside");
try
{
jz.render(root);
const comma = root.querySelector("jz-char.bd-pause");
const selector = comma.querySelector("jz-inner").lastChild;
if (mutation === "replace-source") { document.getElementById("vs").textContent = "作者新文"; }
else if (mutation === "move-carrier") { aside.appendChild(comma); }
else if (mutation === "move-author-text") { aside.appendChild(selector); }
else
{
const html = root.innerHTML;
other.revert(root);
assert.equal(root.innerHTML, html);
}
jz.revert(root);
assert.equal(document.getElementById("base").textContent, ",");
if (mutation === "replace-source")
{
assert.equal(document.getElementById("vs").textContent, "作者新文");
assert.equal(root.textContent, ",作者新文「");
}
else if (mutation === "move-author-text") { assert.equal(aside.textContent, "\uFE00"); }
else
{
assert.equal(document.getElementById("vs").textContent, "\uFE00");
assert.equal(root.querySelector("[data-jz]"), null);
assert.equal(aside.querySelector("[data-jz]"), null);
}
}
finally { jz.revert(document.body); jz.disconnect(); other.disconnect(); dom.window.close(); }
});
}
for (const mutation of [ "clone-outer", "clone-inner", "clone-base", "remove-outer" ])
{
test("jiya 原子失效:" + mutation + " 不重複或復活已刪內容", () =>
{
const { dom, document } = setupDom('<p lang="zh-Hant"><strong id="base">,</strong><em id="vs">\uFE00</em>「</p>');
const root = document.querySelector("p");
const jz = createJuzhen(ONLY_JIYA);
try
{
jz.render(root);
const outer = root.querySelector("jz-char.bd-pause");
const inner = outer.querySelector("jz-inner");
const base = document.getElementById("base");
if (mutation === "clone-outer") { outer.replaceWith(outer.cloneNode(true)); }
else if (mutation === "clone-inner") { inner.replaceWith(inner.cloneNode(true)); }
else if (mutation === "clone-base") { base.replaceWith(base.cloneNode(true)); }
else { outer.remove(); }
const before = root.textContent;
jz.revert(root);
assert.equal(root.textContent, before, "回退不能從失效的私有來源補回舊內容");
if (mutation !== "clone-inner")
{
const holder = document.createElement("aside");
holder.appendChild(outer);
jz.revert(holder);
assert.equal(holder.firstChild, outer, "失效舊產物撤銷所有權,重插入也不重新取得");
}
}
finally { jz.revert(root); jz.disconnect(); dom.window.close(); }
});
}
test("jiya 原子失效:來源父被替換不把 VS 放回已脫離的作者舊父", () =>
{
const { dom, document } = setupDom('<p lang="zh-Hant"><strong>,</strong><em id="vs">\uFE00</em>「</p>');
const root = document.querySelector("p");
const source = document.getElementById("vs");
const jz = createJuzhen(ONLY_JIYA);
try
{
jz.render(root);
source.replaceWith(source.cloneNode(true));
jz.revert(root);
assert.equal(source.textContent, "");
assert.equal(root.textContent, ",「");
}
finally { jz.revert(root); jz.disconnect(); dom.window.close(); }
});
test("jiya 原子生命週期:完整 render 根脫離文檔仍精確回退", () =>
{
const { dom, document } = setupDom('<p lang="zh-Hant"><strong>,</strong><em>\uFE00</em>「</p>');
const root = document.querySelector("p");
const original = root.innerHTML;
const jz = createJuzhen(ONLY_JIYA);
try
{
jz.render(root);
root.remove();
jz.revert(root);
assert.equal(root.innerHTML, original);
}
finally { jz.disconnect(); dom.window.close(); }
});
+52
View File
@@ -129,6 +129,58 @@ test("所有權:子根 render 後對祖先 revert 完整清理", () =>
assert.equal(document.body.innerHTML, before); assert.equal(document.body.innerHTML, before);
}); });
test("所有權:局部 revert 完整撤回祖根與獨立子根的混合產物", () =>
{
const { document } = setupDom('<main><section id="part">'
+ '<p id="a">甲文,內容。</p><p id="b">乙文,內容。</p></section></main>');
const main = document.querySelector("main");
const part = document.getElementById("part");
const a = document.getElementById("a");
const b = document.getElementById("b");
const before = part.innerHTML;
const jz = createJuzhen({ jiya: { halfWidth: "margin" }, justifyAtoms: false });
jz.render(main);
jz.render(b);
assert.ok(a.querySelector("jz-char"));
assert.ok(b.querySelector("jz-char"));
assert.equal(b.getAttribute("data-jz-halfwidth"), "margin");
jz.revert(part);
assert.equal(part.innerHTML, before, "祖根寫入的兄弟產物與子根屬性皆撤回");
assert.equal(main.getAttribute("data-jz-halfwidth"), "margin", "請求範圍外祖根屬性保留");
jz.render(part);
assert.ok(a.querySelector("jz-char"), "還原後可再次渲染");
jz.revert(main);
assert.equal(part.innerHTML, before);
});
test("所有權:混合根局部 revert 保留作者與其他實例的產物", () =>
{
const { document } = setupDom('<main><section id="part">'
+ '<p id="a" data-jz-block-level="author-level">甲文,內容。</p>'
+ '<p id="b">乙文,內容。</p><p id="foreign">他文,內容。</p>'
+ '<span data-jz-skip><jz-hws data-jz="spacing" data-jz-kind="wrap">作者</jz-hws></span>'
+ '</section></main>');
const part = document.getElementById("part");
const foreign = document.getElementById("foreign");
const peer = createJuzhen();
peer.render(foreign);
const before = part.innerHTML;
const foreignBefore = foreign.innerHTML;
const author = part.querySelector("span > jz-hws");
const local = createJuzhen({ scope: { avoid: "#foreign" } });
local.render(document.querySelector("main"));
local.render(document.getElementById("b"));
local.revert(part);
assert.equal(part.innerHTML, before, "本實例全清且作者/peer 產物完整保留");
assert.equal(foreign.innerHTML, foreignBefore);
assert.equal(part.querySelector("span > jz-hws"), author, "作者元素身份保留");
assert.equal(document.getElementById("a").getAttribute("data-jz-block-level"), "author-level");
assert.equal(foreign.getAttribute("data-jz-block-level"), "paragraph");
peer.revert(foreign);
});
test("所有權:本實例完整還原 wrapper 與 splitoff", () => test("所有權:本實例完整還原 wrapper 與 splitoff", () =>
{ {
const html = '<p lang="zh-Hans">前文<strong>粗体文字</strong>,后续</p>'; const html = '<p lang="zh-Hans">前文<strong>粗体文字</strong>,后续</p>';
+14
View File
@@ -298,6 +298,20 @@ test("shared rules: canonical character classes equal the Web source constants",
assert.equal(canonical.overrideSemantics.reclassificationAllowed, false); assert.equal(canonical.overrideSemantics.reclassificationAllowed, false);
}); });
test("shared rules: explicit script identifiers match whole subtags", () =>
{
for (const [ lang, expected ] of [
[ "zh-CN-x-shant", "zh-Hans" ],
[ "zh-TW-x-hansford", "zh-Hant" ],
[ "ZH-CN-X-EHANT", "zh-Hans" ],
[ "zh-Hant-CN", "zh-Hant" ],
[ "zh-Hans-TW", "zh-Hans" ],
])
{
assert.equal(adapter.classify(lang), expected, lang);
}
});
test("shared rules: locale defaults and all language vectors match the Web adapter", () => test("shared rules: locale defaults and all language vectors match the Web adapter", () =>
{ {
const config = { default: "zh-Hant", policy: {} }; const config = { default: "zh-Hant", policy: {} };
+519
View File
@@ -0,0 +1,519 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { createJuzhen } from "../dist/juzhen.mjs";
import { setupDom } from "./helpers.mjs";
const TEXT_ONLY = { jiya: false, jinze: false, orphan: false, longWord: false };
for (const cp of [ 0xE000, 0xE001, 0xE002, 0xE003 ])
{
test(`spacing source: U+${cp.toString(16).toUpperCase()} remains author text`, () =>
{
const text = "漢" + String.fromCodePoint(cp) + "A中";
const { dom, document } = setupDom("<p></p>");
const paragraph = document.querySelector("p");
paragraph.textContent = text;
const jz = createJuzhen(TEXT_ONLY);
try
{
for (let cycle = 0; cycle < 3; cycle += 1)
{
jz.render(paragraph);
assert.equal(paragraph.textContent, text);
assert.deepEqual(Array.from(paragraph.querySelectorAll("jz-hws"), (gap) => gap.textContent), [ "A" ]);
const rendered = paragraph.innerHTML;
jz.render(paragraph);
assert.equal(paragraph.innerHTML, rendered, "repeat render stays idempotent");
jz.revert(paragraph);
assert.equal(paragraph.textContent, text);
assert.equal(paragraph.querySelector("[data-jz]"), null);
}
}
finally { jz.revert(paragraph); dom.window.close(); }
});
}
for (const fakeIndex of [ "0", "9" ])
{
test(`spacing source: fake compound placeholder ${fakeIndex} does not collide with GPT-4`, () =>
{
const text = "漢\uE001" + fakeIndex + "\uE002 GPT-4中";
const { dom, document } = setupDom("<p></p>");
const paragraph = document.querySelector("p");
paragraph.textContent = text;
const jz = createJuzhen(TEXT_ONLY);
try
{
jz.render(paragraph);
assert.equal(paragraph.textContent, text);
assert.deepEqual(Array.from(paragraph.querySelectorAll("jz-hws"), (gap) => gap.textContent), [ "4" ]);
jz.revert(paragraph);
assert.equal(paragraph.textContent, text);
}
finally { jz.revert(paragraph); dom.window.close(); }
});
}
test("spacing source: reserved characters across transparent nodes preserve IVS and space normalization", () =>
{
const { dom, document } = setupDom('<p><strong>漢\uE000\uE001</strong>'
+ '<em>0\uE002\uE003\uE004</em> GPT-4<span>\u{20000}</span>'
+ '<i>\u{E0100}</i> A<span>甲</span> <b>乙</b></p>');
const paragraph = document.querySelector("p");
const source = paragraph.textContent;
const expected = source.replace("\u{E0100} A", "\u{E0100}A").replace("甲 乙", "甲乙");
const authors = Array.from(paragraph.querySelectorAll("strong,em,span,i,b"));
const jz = createJuzhen(TEXT_ONLY);
try
{
for (let cycle = 0; cycle < 3; cycle += 1)
{
jz.render(paragraph);
assert.equal(paragraph.textContent, expected, "only contracted author spaces are removed");
assert.deepEqual(Array.from(paragraph.querySelectorAll("jz-hws"), (gap) => gap.textContent),
[ "4", "\u{20000}\u{E0100}", "A" ]);
jz.revert(paragraph);
assert.equal(paragraph.textContent, expected);
assert.equal(paragraph.querySelector("[data-jz]"), null);
assert.deepEqual(Array.from(paragraph.querySelectorAll("strong,em,span,i,b")), authors);
}
}
finally { jz.revert(paragraph); dom.window.close(); }
});
test("spacing source: candidate markers respect custom CJK and western classes", () =>
{
const { dom, document } = setupDom("<p>漢A中</p>");
const paragraph = document.querySelector("p");
const jz = createJuzhen({ ...TEXT_ONLY, charClass: {
cjk: "\\uE000-\\uE003", western: "\\uE004-\\uE007",
} });
try
{
jz.render(paragraph);
assert.equal(paragraph.textContent, "漢A中");
assert.deepEqual(Array.from(paragraph.querySelectorAll("jz-hws"), (gap) => gap.textContent), [ "漢", "A" ]);
jz.revert(paragraph);
assert.equal(paragraph.textContent, "漢A中");
}
finally { jz.revert(paragraph); dom.window.close(); }
});
test("spacing source: supplementary markers keep source PUA and DOM atom offsets intact", () =>
{
const { dom, document } = setupDom("<p>漢\u{F0000}A中</p>");
const paragraph = document.querySelector("p");
const text = paragraph.textContent;
const jz = createJuzhen({ ...TEXT_ONLY, charClass: { cjk: "\\uE000-\\uF8FF" } });
try
{
jz.render(paragraph);
assert.equal(paragraph.textContent, text);
assert.deepEqual(Array.from(paragraph.querySelectorAll("jz-hws"), (gap) => gap.textContent), [ "A" ]);
jz.revert(paragraph);
assert.equal(paragraph.textContent, text);
}
finally { jz.revert(paragraph); dom.window.close(); }
});
test("spacing source: exhausted marker candidates fail before author text playback", () =>
{
const { dom, document } = setupDom("<p>漢 A中</p>");
const paragraph = document.querySelector("p");
const before = paragraph.innerHTML;
const jz = createJuzhen({ ...TEXT_ONLY, charClass: { cjk: "\\u0000-\\u{10FFFF}" } });
try
{
assert.throws(() => jz.render(paragraph), /已佔用全部間隙標記候選/);
assert.equal(paragraph.innerHTML, before, "no source atom or author space was replayed");
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, before);
}
finally { jz.revert(paragraph); dom.window.close(); }
});
for (const selector of [ "code", "code span" ])
{
test(`spacing root: directly rendering ${selector} preserves pill contents`, () =>
{
const { dom, document } = setupDom('<p>外<code><span>漢A</span> 字B</code>文</p>');
const root = document.querySelector(selector);
const before = document.body.innerHTML;
const jz = createJuzhen(TEXT_ONLY);
try
{
jz.render(root);
assert.equal(root.querySelector("jz-hws"), null);
jz.render(root);
jz.revert(root);
assert.equal(document.body.innerHTML, before);
}
finally { jz.revert(root); dom.window.close(); }
});
}
test("spacing pill: a single author space across transparent nodes normalizes on both sides", () =>
{
const { dom, document } = setupDom('<p><span>漢</span><em> </em><code>x</code>'
+ '<i> </i><strong>文</strong></p>');
const paragraph = document.querySelector("p");
const authors = Array.from(paragraph.querySelectorAll("span,em,code,i,strong"));
const expected = paragraph.innerHTML.replace("<em> </em>", "<em></em>").replace("<i> </i>", "<i></i>");
const jz = createJuzhen(TEXT_ONLY);
try
{
for (let cycle = 0; cycle < 3; cycle += 1)
{
jz.render(paragraph);
assert.equal(paragraph.textContent, "漢x文");
assert.deepEqual(Array.from(paragraph.querySelectorAll("jz-hws"), (gap) => gap.textContent), [ "漢", "x" ]);
const rendered = paragraph.innerHTML;
jz.render(paragraph);
assert.equal(paragraph.innerHTML, rendered);
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, expected);
assert.deepEqual(Array.from(paragraph.querySelectorAll("span,em,code,i,strong")), authors);
}
}
finally { jz.revert(paragraph); dom.window.close(); }
});
for (const base of [ "漢", "\u{20000}" ])
{
for (const withSpace of [ false, true ])
{
test(`spacing pill: split ${base.codePointAt(0).toString(16)} IVS with space=${withSpace} stays one atom`, () =>
{
const vs = "\u{E0100}";
const space = withSpace ? " " : "";
const { dom, document } = setupDom(`<p><strong>${base}</strong><em>${vs}</em>`
+ `<span>${space}</span><code>x</code><i>${space}</i><b>${base}</b><u>${vs}</u></p>`);
const paragraph = document.querySelector("p");
const expected = paragraph.innerHTML.replace("<span> </span>", "<span></span>").replace("<i> </i>", "<i></i>");
const authors = Array.from(paragraph.children);
const jz = createJuzhen(TEXT_ONLY);
try
{
for (let cycle = 0; cycle < 3; cycle += 1)
{
jz.render(paragraph);
assert.equal(paragraph.textContent, base + vs + "x" + base + vs);
assert.deepEqual(Array.from(paragraph.querySelectorAll("jz-hws"), (gap) => gap.textContent), [ base + vs, "x" ]);
const rendered = paragraph.innerHTML;
jz.render(paragraph);
assert.equal(paragraph.innerHTML, rendered);
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, expected, "cross-node selector returns to its author element");
assert.deepEqual(Array.from(paragraph.children), authors);
}
}
finally { jz.revert(paragraph); dom.window.close(); }
});
}
}
for (const whitespace of [ " ", "\n", "\t", " \n" ])
{
test(`spacing pill: whitespace ${JSON.stringify(whitespace)} is a boundary, not a single removable space`, () =>
{
const { dom, document } = setupDom(`<p><span>漢</span><i>${whitespace}</i>`
+ `<code>x</code><em>${whitespace}</em><span>文</span></p>`);
const paragraph = document.querySelector("p");
const before = paragraph.innerHTML;
const jz = createJuzhen(TEXT_ONLY);
try
{
jz.render(paragraph);
assert.equal(paragraph.querySelector("jz-hws"), null);
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, before);
}
finally { jz.revert(paragraph); dom.window.close(); }
});
}
for (const barrier of [
'<mark data-juzhen-off="spacing"></mark>',
'<code data-juzhen-off="spacing"></code>',
'<span data-jz-skip></span>',
"<br>",
"<x-opaque></x-opaque>",
])
{
test(`spacing pill: empty ${barrier} stops lookup beyond one space on both sides`, () =>
{
const { dom, document } = setupDom(`<p><span>漢</span>${barrier}<i> </i>`
+ `<code>x</code><em> </em>${barrier}<span>文</span></p>`);
const paragraph = document.querySelector("p");
const before = paragraph.innerHTML;
const jz = createJuzhen(TEXT_ONLY);
try
{
jz.render(paragraph);
assert.equal(paragraph.querySelector("jz-hws"), null);
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, before);
}
finally { jz.revert(paragraph); dom.window.close(); }
});
}
test("spacing pill: out-of-scope neighbors stop space normalization", () =>
{
const { dom, document } = setupDom('<p><span>漢</span><i class="prose"> </i>'
+ '<code class="prose">x</code><em class="prose"> </em><span>文</span></p>');
const paragraph = document.querySelector("p");
const before = paragraph.innerHTML;
const jz = createJuzhen({ ...TEXT_ONLY, scope: { include: ".prose" } });
try
{
jz.render(paragraph);
assert.equal(paragraph.querySelector("jz-hws"), null);
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, before);
}
finally { jz.revert(paragraph); dom.window.close(); }
});
test("spacing pill: local render cannot move an outside IVS base or delete an inside trailing space", () =>
{
const { dom, document } = setupDom('<p><strong>\u{20000}</strong><span id="root">'
+ '<em>\u{E0100}</em><code>x</code><i> </i></span><b>文</b></p>');
const root = document.getElementById("root");
const before = document.body.innerHTML;
const jz = createJuzhen(TEXT_ONLY);
try
{
jz.render(root);
assert.equal(document.body.innerHTML, before);
jz.render(root);
jz.revert(root);
assert.equal(document.body.innerHTML, before);
}
finally { jz.revert(root); dom.window.close(); }
});
test("spacing pill: a local transparent root encloses only its own neighbor", () =>
{
const { dom, document } = setupDom('<p>外<span id="root"><strong>漢</strong>'
+ '<code>x</code></span> 文</p>');
const root = document.getElementById("root");
const before = document.body.innerHTML;
const jz = createJuzhen(TEXT_ONLY);
try
{
jz.render(root);
assert.deepEqual(Array.from(root.querySelectorAll("jz-hws"), (gap) => gap.textContent), [ "漢" ]);
assert.equal(document.querySelector("p").lastChild.data, " 文", "root-external space remains author text");
jz.revert(root);
assert.equal(document.body.innerHTML, before);
}
finally { jz.revert(root); dom.window.close(); }
});
function appendTextChunks(parent, chunks)
{
const tags = [ "strong", "em", "i", "span", "b", "u" ];
return chunks.map((chunk, index) =>
{
const element = parent.ownerDocument.createElement(tags[index % tags.length]);
element.textContent = chunk;
parent.appendChild(element);
return element;
});
}
const SPLIT_CODEPOINTS = [
[ "\uD840", "\uDC00" ],
[ "\uD840", "\uDC00", "\uDB40", "\uDD00" ],
[ "\uD840", "\uDC00\uDB40", "\uDD00" ],
[ "\u{20000}", "\uDB40", "\uDD00" ],
[ "漢", "\uDB40", "\uDD00" ],
];
for (const [index, chunks] of SPLIT_CODEPOINTS.entries())
{
test(`spacing source: split surrogate codepoint case ${index} maps to one complete gap`, () =>
{
const { dom, document } = setupDom("<p></p>");
const paragraph = document.querySelector("p");
const authors = appendTextChunks(paragraph, [ ...chunks, "A" ]);
const before = paragraph.innerHTML;
const atom = chunks.join("");
const jz = createJuzhen(TEXT_ONLY);
try
{
for (let cycle = 0; cycle < 3; cycle += 1)
{
jz.render(paragraph);
assert.equal(paragraph.textContent, atom + "A");
assert.deepEqual(Array.from(paragraph.querySelectorAll("jz-hws"), (gap) => gap.textContent), [ atom ]);
const rendered = paragraph.innerHTML;
jz.render(paragraph);
assert.equal(paragraph.innerHTML, rendered);
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, before);
assert.deepEqual(Array.from(paragraph.children), authors);
}
}
finally { jz.revert(paragraph); dom.window.close(); }
});
for (const withSpace of [ false, true ])
{
test(`spacing pill: split surrogate case ${index}, space=${withSpace} remains complete on both sides`, () =>
{
const { dom, document } = setupDom("<p></p>");
const paragraph = document.querySelector("p");
appendTextChunks(paragraph, chunks);
if (withSpace) { appendTextChunks(paragraph, [ " " ]); }
const code = document.createElement("code");
code.textContent = "x";
paragraph.appendChild(code);
if (withSpace) { appendTextChunks(paragraph, [ " " ]); }
appendTextChunks(paragraph, chunks);
const authors = Array.from(paragraph.children);
const before = paragraph.innerHTML.replaceAll("<strong> </strong>", "<strong></strong>");
const atom = chunks.join("");
const jz = createJuzhen(TEXT_ONLY);
try
{
for (let cycle = 0; cycle < 3; cycle += 1)
{
jz.render(paragraph);
assert.equal(paragraph.textContent, atom + "x" + atom);
assert.deepEqual(Array.from(paragraph.querySelectorAll("jz-hws"), (gap) => gap.textContent), [ atom, "x" ]);
const rendered = paragraph.innerHTML;
jz.render(paragraph);
assert.equal(paragraph.innerHTML, rendered);
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, before);
assert.deepEqual(Array.from(paragraph.children), authors);
}
}
finally { jz.revert(paragraph); dom.window.close(); }
});
}
}
for (const barrier of [
'<mark data-juzhen-off="spacing"></mark>',
'<code data-juzhen-off="spacing"></code>',
'<span data-jz-skip></span>',
"<br>",
"<x-opaque></x-opaque>",
])
{
test(`spacing surrogate: ${barrier} prevents joining adjacent high and low halves`, () =>
{
const { dom, document } = setupDom("<p></p>");
const paragraph = document.querySelector("p");
appendTextChunks(paragraph, [ "\uD840" ]);
paragraph.insertAdjacentHTML("beforeend", barrier);
appendTextChunks(paragraph, [ "\uDC00" ]);
const code = document.createElement("code");
code.textContent = "x";
paragraph.appendChild(code);
appendTextChunks(paragraph, [ "\uD840" ]);
paragraph.insertAdjacentHTML("beforeend", barrier);
appendTextChunks(paragraph, [ "\uDC00", "A" ]);
const before = paragraph.innerHTML;
const text = paragraph.textContent;
const jz = createJuzhen(TEXT_ONLY);
try
{
jz.render(paragraph);
assert.equal(paragraph.textContent, text);
assert.equal(paragraph.querySelector("jz-hws"), null);
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, before);
}
finally { jz.revert(paragraph); dom.window.close(); }
});
}
test("spacing surrogate: actual isolated UTF-16 halves stay author text", () =>
{
const { dom, document } = setupDom("<p></p>");
const paragraph = document.querySelector("p");
appendTextChunks(paragraph, [ "\uD840", "A" ]);
const code = document.createElement("code");
code.textContent = "x";
paragraph.appendChild(code);
appendTextChunks(paragraph, [ "\uDC00" ]);
const text = paragraph.textContent;
const before = paragraph.innerHTML;
const jz = createJuzhen(TEXT_ONLY);
try
{
jz.render(paragraph);
assert.equal(paragraph.textContent, text);
assert.deepEqual(Array.from(paragraph.querySelectorAll("jz-hws"), (gap) => gap.textContent), [ "A" ]);
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, before);
}
finally { jz.revert(paragraph); dom.window.close(); }
});
test("spacing surrogate: a local root cannot join either outside surrogate half", () =>
{
const { dom, document } = setupDom('<p><strong></strong><span id="root">'
+ '<em></em><code>x</code><i></i></span><b></b></p>');
document.querySelector("strong").textContent = "\uD840";
document.querySelector("em").textContent = "\uDC00";
document.querySelector("i").textContent = "\uD840";
document.querySelector("b").textContent = "\uDC00";
const root = document.getElementById("root");
const before = document.body.innerHTML;
const jz = createJuzhen(TEXT_ONLY);
try
{
jz.render(root);
assert.equal(document.body.innerHTML, before);
jz.revert(root);
assert.equal(document.body.innerHTML, before);
}
finally { jz.revert(root); dom.window.close(); }
});
test("spacing surrogate: an excluded Text is a run boundary even when empty", () =>
{
const { dom, document } = setupDom("<p></p>");
const paragraph = document.querySelector("p");
appendTextChunks(paragraph, [ "\uD840" ]);
const excluded = document.createTextNode("");
paragraph.appendChild(excluded);
appendTextChunks(paragraph, [ "\uDC00", "A" ]);
const before = paragraph.innerHTML;
const jz = createJuzhen({ ...TEXT_ONLY, isSkipped: (node) => node === excluded });
try
{
jz.render(paragraph);
assert.equal(paragraph.querySelector("jz-hws"), null);
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, before);
}
finally { jz.revert(paragraph); dom.window.close(); }
});
test("spacing surrogate: complete codepoints across source scope boundaries are not joined", () =>
{
const { dom, document } = setupDom('<p><strong></strong><em class="prose"></em>'
+ '<code class="prose">x</code><i class="prose"></i><b></b></p>');
document.querySelector("strong").textContent = "\uD840";
document.querySelector("em").textContent = "\uDC00";
document.querySelector("i").textContent = "\uD840";
document.querySelector("b").textContent = "\uDC00";
const paragraph = document.querySelector("p");
const before = paragraph.innerHTML;
const jz = createJuzhen({ ...TEXT_ONLY, scope: { include: ".prose" } });
try
{
jz.render(paragraph);
assert.equal(paragraph.querySelector("jz-hws"), null);
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, before);
}
finally { jz.revert(paragraph); dom.window.close(); }
});
+326
View File
@@ -0,0 +1,326 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { execFileSync } from "node:child_process";
import { setupDom } from "./helpers.mjs";
import { createJuzhen } from "../dist/juzhen.mjs";
const ONLY_SPACING = { jiya: false, jinze: false, orphan: false, longWord: false };
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
for (const emptied of [ false, true ])
{
test("revert provenance:重用已撤回容器 emptied=" + emptied + " 不循環或丟字", () =>
{
// 有界子進程使來源鏈循環的回歸失敗可終止,不能卡住整套測試。
const script = `
import assert from 'node:assert/strict';
import { setupDom } from ${JSON.stringify(new URL("./helpers.mjs", import.meta.url).href)};
import { createJuzhen } from ${JSON.stringify(new URL("../dist/juzhen.mjs", import.meta.url).href)};
const {dom,document}=setupDom('<p></p>');const root=document.querySelector('p');
for(const t of ['甲','「','禰','\\uFE00','A'])root.append(document.createTextNode(t));
const B=createJuzhen({spacing:false,orphan:false,longWord:false});
const C=createJuzhen({spacing:false,jiya:false,jinze:false,longWord:false});
const A=createJuzhen({jiya:false,jinze:false,orphan:false,longWord:false});
B.render(root);const oldJ=root.querySelector('jz-jinze');
C.render(root);const oldO=root.querySelector('jz-orphan');A.render(root);
B.revert(root);oldJ.append(oldO);C.revert(oldJ);
let request=oldJ;
if(${emptied}){root.append(oldJ.querySelector('jz-hws'));oldJ.replaceChildren();request=root;}
const text=request.textContent;A.revert(request);assert.equal(request.textContent,text);
A.disconnect();B.disconnect();C.disconnect();dom.window.close();
console.log('bounded revert PASS');
`;
const output = execFileSync(process.execPath, [ "--input-type=module", "-e", script ],
{ encoding: "utf8", timeout: 5000 });
assert.match(output, /bounded revert PASS/);
});
}
for (const [name, attrs, options] of [
[ "level", 'data-jz-level="text"', {} ],
[ "off", 'data-juzhen-off="jiya orphan jinze longWord spacing"', {} ],
[ "avoid", "data-jz-skip", {} ],
[ "lang", 'lang="zh-Hans"', {} ],
[ "pill", 'class="pill"', { pillSelector: ".pill" } ],
])
{
test("observer relocate:" + name + " 的增量結果與完整重排一致", async () =>
{
const { dom, document } = setupDom('<main><section id="from"><p>文字ABCDEF,甲乙。</p></section>'
+ '<section id="to" ' + attrs + '></section></main>');
const root = document.querySelector("main");
const paragraph = root.querySelector("p");
const jz = createJuzhen(options);
try
{
jz.render(root);
jz.observe(root);
document.getElementById("to").append(paragraph);
await tick();
jz.disconnect();
const incremental = paragraph.outerHTML;
jz.rerender(root);
assert.equal(incremental, paragraph.outerHTML);
assert.equal(paragraph.textContent, "文字ABCDEF,甲乙。");
}
finally { jz.revert(root); jz.disconnect(); dom.window.close(); }
});
}
test("observer relocate:舊 avoid 快取不帶入新父範圍", async () =>
{
const { dom, document } = setupDom('<main><section data-jz-skip><p>文字ABC,甲乙。</p></section><section id="to"></section></main>');
const root = document.querySelector("main");
const paragraph = root.querySelector("p");
const jz = createJuzhen();
try
{
jz.render(root);
jz.observe(root);
document.getElementById("to").append(paragraph);
await tick();
jz.disconnect();
const incremental = paragraph.outerHTML;
jz.rerender(root);
assert.equal(incremental, paragraph.outerHTML);
assert.ok(paragraph.querySelector("jz-char"));
}
finally { jz.revert(root); jz.disconnect(); dom.window.close(); }
});
for (const [name, marker, options] of [
[ "off", 'data-juzhen-off="spacing"', {} ],
[ "whitelist", 'data-juzhen="jiya"', {} ],
[ "blocks", 'class="disabled"', { blocks: [ { selector: ".disabled", features: [ "jiya" ] } ] } ],
])
{
test("spacing inline feature:" + name + " 保留禁用內容及作者空格", () =>
{
const { dom, document } = setupDom('<p>外ABC<span ' + marker + '>甲 ABC 乙</span>文XYZ</p>');
const root = document.querySelector("p");
const disabled = root.querySelector("span");
const original = root.textContent;
const jz = createJuzhen({ ...ONLY_SPACING, ...options });
try
{
jz.render(root);
assert.equal(disabled.innerHTML, "甲 ABC 乙");
assert.equal(root.textContent, original);
assert.equal(root.querySelectorAll("jz-hws").length, 2, "外部啟用區仍留隙");
jz.revert(root);
assert.equal(root.textContent, original);
}
finally { jz.revert(root); dom.window.close(); }
});
}
for (const marker of [ 'data-juzhen-off="spacing"', 'data-juzhen="jiya"' ])
{
test("spacing feature:禁用塊內最近子白名單可重新啟用 " + marker, () =>
{
const { dom, document } = setupDom('<p ' + marker + '>甲 ABC<span data-juzhen="spacing">乙 DEF</span>丙 GHI</p>');
const root = document.querySelector("p");
const jz = createJuzhen(ONLY_SPACING);
try
{
jz.render(root);
assert.equal(root.textContent, "甲 ABC乙DEF丙 GHI");
assert.equal(root.querySelectorAll("jz-hws").length, 1);
assert.equal(root.querySelector("span jz-hws").textContent, "乙");
}
finally { jz.revert(root); dom.window.close(); }
});
}
for (const side of [ "left", "right" ])
{
test("spacing pill feature:" + side + " 鄰字禁用時不刪空格或造隙", () =>
{
const html = side === "left" ? '<span data-juzhen-off="spacing">甲 </span><code>x</code>'
: '<code>x</code><span data-juzhen-off="spacing"> 甲</span>';
const { dom, document } = setupDom("<p>" + html + "</p>");
const root = document.querySelector("p");
const jz = createJuzhen(ONLY_SPACING);
try { jz.render(root); assert.equal(root.innerHTML, html); }
finally { jz.revert(root); dom.window.close(); }
});
}
for (const separator of [ '<span data-juzhen-off="spacing"></span>', '<span data-juzhen=""></span>' ])
{
test("spacing feature:空禁用元素中斷 run " + separator, () =>
{
const { dom, document } = setupDom("<p>甲" + separator + "ABC</p>");
const root = document.querySelector("p");
const jz = createJuzhen(ONLY_SPACING);
try { jz.render(root); assert.equal(root.querySelector("jz-hws"), null); }
finally { jz.revert(root); dom.window.close(); }
});
}
for (const full of [ false, true ])
{
for (const mutation of [ "clone-gap", "clone-base", "remove-gap", "replace-source-parent" ])
{
test("spacing carrier integrity:" + mutation + " full=" + full, () =>
{
const { dom, document } = setupDom('<p><strong id="base">禰</strong><em id="vs">\u{E0100}</em>A</p>');
const root = document.querySelector("p");
const source = root.querySelector("em");
const jz = createJuzhen(full ? {} : ONLY_SPACING);
try
{
jz.render(root);
const gap = root.querySelector("jz-hws");
const base = document.getElementById("base");
if (mutation === "clone-gap") { gap.replaceWith(gap.cloneNode(true)); }
else if (mutation === "clone-base") { base.replaceWith(base.cloneNode(true)); }
else if (mutation === "remove-gap") { gap.remove(); }
else { source.replaceWith(source.cloneNode(true)); }
const before = root.textContent;
jz.revert(root);
assert.equal(root.textContent, mutation === "replace-source-parent" ? "禰A" : before);
if (mutation === "replace-source-parent") { assert.equal(source.textContent, ""); }
}
finally { jz.revert(root); dom.window.close(); }
});
}
}
for (const order of [ "binding-first", "spacing-first" ])
{
test("spacing multi-owner:" + order + " 回退受控父包裹不丟 VS", () =>
{
const { dom, document } = setupDom("<p></p>");
const root = document.querySelector("p");
for (const value of [ "「", "禰", "\uFE00", "A" ]) { root.appendChild(document.createTextNode(value)); }
const binding = createJuzhen({ spacing: false, orphan: false, longWord: false });
const spacing = createJuzhen(ONLY_SPACING);
try
{
binding.render(root);
spacing.render(root);
if (order === "binding-first") { binding.revert(root); spacing.revert(root); }
else { spacing.revert(root); binding.revert(root); }
assert.equal(root.textContent, "「禰\uFE00A");
assert.equal(root.querySelector("[data-jz]"), null);
}
finally { binding.revert(root); spacing.revert(root); dom.window.close(); }
});
}
for (const side of [ "left", "right" ])
{
test("spacing pill feature:空禁用元素在 " + side + " 阻斷鄰字查詢", () =>
{
const barrier = '<span data-juzhen-off="spacing"></span>';
const html = side === "left" ? "甲" + barrier + "<code>x</code>"
: "<code>x</code>" + barrier + "甲";
const { dom, document } = setupDom("<p>" + html + "</p>");
const root = document.querySelector("p");
const jz = createJuzhen(ONLY_SPACING);
try { jz.render(root); assert.equal(root.innerHTML, html); }
finally { jz.revert(root); dom.window.close(); }
});
}
for (const order of [ [0,1,2], [1,0,2], [0,2,1], [1,2,0], [2,0,1], [2,1,0] ])
{
test("spacing multi-owner:傳遞解包鏈 " + order.join("→") + " 保留來源原子", () =>
{
const { dom, document } = setupDom("<p></p>");
const root = document.querySelector("p");
for (const value of [ "甲", "「", "禰", "\uFE00", "A" ]) { root.appendChild(document.createTextNode(value)); }
const instances = [
createJuzhen({ spacing: false, orphan: false, longWord: false }),
createJuzhen({ spacing: false, jiya: false, jinze: false, longWord: false }),
createJuzhen(ONLY_SPACING),
];
try
{
for (const instance of instances) { instance.render(root); }
for (const index of order) { instances[index].revert(root); }
assert.equal(root.textContent, "甲「禰\uFE00A");
assert.equal(root.querySelector("[data-jz]"), null);
}
finally { for (const instance of instances) { instance.revert(root); } dom.window.close(); }
});
}
for (const order of [ "punctuation-first", "spacing-first" ])
{
test("spacing multi-owner:跨 Text 標點父解包 " + order + " 保留 VS", () =>
{
const { dom, document } = setupDom('<p><strong>,</strong><em>\uFE00</em>A</p>');
const root = document.querySelector("p");
const punctuation = createJuzhen({ spacing: false, jinze: false, orphan: false, longWord: false });
const spacing = createJuzhen({ ...ONLY_SPACING, charClass: { cjk: "," } });
try
{
punctuation.render(root);
spacing.render(root);
if (order === "punctuation-first") { punctuation.revert(root); spacing.revert(root); }
else { spacing.revert(root); punctuation.revert(root); }
assert.equal(root.textContent, ",\uFE00A");
assert.equal(root.querySelector("[data-jz]"), null);
}
finally { punctuation.revert(root); spacing.revert(root); dom.window.close(); }
});
}
for (const mode of [ "all", "spacing-only" ])
{
test("spacing include:行內 opt-in " + mode + " 只處理選中子樹", () =>
{
const html = '<p>外ABC<span class="prose">甲DEF<span class="prose">乙GHI</span>丙</span>文XYZ</p>';
const { dom, document } = setupDom(html);
const root = document.querySelector("p");
const jz = createJuzhen({ ...(mode === "all" ? {} : ONLY_SPACING), scope: { include: ".prose" } });
try
{
jz.render(root);
const gaps = Array.from(root.querySelectorAll("jz-hws"));
assert.deepEqual(gaps.map((gap) => gap.textContent), [ "甲", "F", "乙", "I" ]);
assert.equal(root.firstChild.textContent, "外ABC");
assert.equal(root.lastChild.textContent, "文XYZ");
const rendered = root.innerHTML;
jz.render(root);
assert.equal(root.innerHTML, rendered);
jz.revert(root);
assert.equal(root.outerHTML, html);
}
finally { jz.revert(root); dom.window.close(); }
});
}
for (const marker of [ 'data-juzhen-off="jiya"', 'data-juzhen="spacing"' ])
{
test("jiya feature:空禁用元素中斷標點鄰接 " + marker, () =>
{
const { dom, document } = setupDom('<p lang="zh-Hant">,<span ' + marker + '></span>「</p>');
const root = document.querySelector("p");
const jz = createJuzhen({ spacing: false, orphan: false, jinze: false, longWord: false });
try
{
jz.render(root);
assert.equal(root.querySelectorAll("jz-char").length, 2);
assert.equal(root.querySelectorAll("jz-char.jz-half").length, 0);
}
finally { jz.revert(root); dom.window.close(); }
});
}
test("jiya feature:跨 Text 原子不越空禁用元素搬移 VS", () =>
{
const { dom, document } = setupDom('<p lang="zh-Hant"><strong>,</strong><span data-juzhen-off="jiya"></span><em>\uFE00</em>「</p>');
const root = document.querySelector("p");
const jz = createJuzhen({ spacing: false, orphan: false, jinze: false, longWord: false });
try
{
jz.render(root);
assert.equal(root.querySelector("jz-char").textContent, ",");
assert.equal(root.querySelector("em").textContent, "\uFE00");
assert.equal(root.querySelector("jz-char.jz-half"), null);
}
finally { jz.revert(root); dom.window.close(); }
});