fix(web): 修復排版邊界與增量生命週期
This commit is contained in:
+61
-17
@@ -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 / 分類 / 全角開明政策
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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">漢0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>𠀀</strong><em>󠄀</em> <code>x</code> <span>文</span></p>
|
||||||
|
<p id="jinze-ivs" data-jz-level="paragraph">前文<strong>𠀀</strong><em>󠄀</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">︀</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,
|
||||||
|
|||||||
Vendored
+6
@@ -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>;
|
||||||
Vendored
+4
@@ -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;
|
||||||
/** 記錄後寫屬性;同實例重寫時仍保留最初原值。 */
|
/** 記錄後寫屬性;同實例重寫時仍保留最初原值。 */
|
||||||
|
|||||||
Vendored
+11
-8
@@ -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);
|
||||||
|
|||||||
Vendored
+35
-36
@@ -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 */
|
||||||
|
|||||||
Vendored
+778
-157
File diff suppressed because it is too large
Load Diff
Vendored
+778
-157
File diff suppressed because it is too large
Load Diff
Vendored
+6
-2
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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"; }
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 機制為機制級死路
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(未知行內)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 ]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:自定義字符分類規則集(向下兼容+擴充)-----
|
||||||
|
|||||||
@@ -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(); }
|
||||||
|
});
|
||||||
@@ -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(); }
|
||||||
|
});
|
||||||
@@ -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(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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(); }
|
||||||
|
});
|
||||||
|
|||||||
@@ -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(); }
|
||||||
|
});
|
||||||
@@ -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(); }
|
||||||
|
});
|
||||||
@@ -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>';
|
||||||
|
|||||||
@@ -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: {} };
|
||||||
|
|||||||
@@ -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(); }
|
||||||
|
});
|
||||||
@@ -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(); }
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user