fix: 行內元素三分類(透明/隔離/整體)——jinze 下潛肢解 KaTeX 修復(下游問題六);v1 相容層移除

下游問題六:行內公式緊鄰禁則標點時,jinze 為綁鄰字透明下潛 .katex(全樹皆 SPAN,
僅憑標籤名判透明),把公式內側末字連同完整殼層逐層克隆切出(splitoff),上標漂移
錯位;pillSelector/skipAttribute/scope.avoid 三層防護全被刺穿。復現另揭兩向量:
相鄰 pill 時 descend 刺穿致 jz-hws 插入公式內部;pill 內部標點被 jiya charify。

確立三分類(§4.1,所有遍歷原語共用同一判定):
  透明(styleInlines+jz-*)— 穿越/run 延續/可克隆切分;
  隔離(isolateSelector,預設 "mark",新增選項)— 邊界斷 run/斷相鄰/不可切分、
    兩端補隙(同 pill),內部照常處理(自成脈絡);
  整體(pillSelector)— 內部對一切 pass 不可見(insidePill 過濾),禁則時作為
    原子鄰字單位整體綁入 jz-jinze。

機制修復:
  finder — transparentInline 改以元素判定(先排除 pill/isolate/avoid 再查標籤集);
    avoidsSelf 自身判定(isAvoided 僅查祖先,自身命中 avoid 曾被穿越);descend 三態
    (邊界字/無內容續找兄弟/blocked 終止——不可越界跳找更遠之字);eachTextNode
    過濾 pill 內部;collectRuns 隔離元素內部自成 run。
  core/split — canSplit 諮詢 finder(pill/isolate/avoid 不可切 → 退化整綁);
    MARK 移出 SPLITTABLE(背景盒切分有可見接縫)。
  jiyaAdjacency — pill/avoid(自身判定)斷相鄰不下探;isolate 斷而內部自成脈絡。
  spacing Pass B — 邊界選擇器擴為 pill ∪ isolate;巢於 pill 內者跳過。
  orphan — avoidsSelf 邊界;avoid 塊不綁(舊漏判:avoid 子樹內裸文本曾被計數包綁)。

v1 相容層移除(消費端已全數遷移 v2 API):src/compat/v1.ts、IIFE 全域別名
createCjkAutospace、死選項 autospaceClass(經查從未被任何 pass 讀取)一併刪;
I8 退役。dist/compat 殘留聲明清除、dist/core/split.d.ts 補入庫(前次遺漏)。

測試 112 全綠(問題六 ×7、isolate ×6、compat 移除 ×1);headless Chrome 實證
公式完整(splitoff=0、內部無 jz-hws/jz-char)、mark 與公式整體入 jz-jinze。
文檔:README 三分類節+選項示例+功能表(hanging 行補「已移除」);ARCHITECTURE
§4.1 三分類/§3.5 相容層移除/§6.1/§6.3/§6.4/I8 退役。
This commit is contained in:
2026-06-12 17:15:00 +08:00
parent a3c94ddee5
commit c4e8c6cfd5
21 changed files with 732 additions and 546 deletions
+43 -28
View File
@@ -50,9 +50,9 @@
移除,DOM 回到 pass 前狀態(至少邏輯等價)。 移除,DOM 回到 pass 前狀態(至少邏輯等價)。
- **I7 邊界感知**:跨樣式行內元素的鄰接(如 `<strong>是</strong>A`)須能識別並 - **I7 邊界感知**:跨樣式行內元素的鄰接(如 `<strong>是</strong>A`)須能識別並
正確處理——由核心 finder 統一提供,各 pass 不自行重複造輪。 正確處理——由核心 finder 統一提供,各 pass 不自行重複造輪。
- **I8 向下相容 v1**:既有 `createCjkAutospace(options).apply(root)` 消費端 - **I8 向下相容 v1(已退役)**:相容層曾保障 v1 消費端(build.sh 單檔串接、Nvim
build.sh 單檔串接、Nvim peek、Claude 模板)**不得 break、無功能倒退**。行為 peek、Claude 模板)過渡期不 break;消費端遷移 v2 API 完成後相容層已移除(見
**不要求與 v1 完全一致**,但須等價可用;以相容層達成(見 §3.5) §3.5),此不變量隨之退役、編號保留不復用
- **I9 遍歷聚合**:邏輯上「一功能一 pass」,但執行上**相容之 pass 共享單次遍歷** - **I9 遍歷聚合**:邏輯上「一功能一 pass」,但執行上**相容之 pass 共享單次遍歷**
(見 §3.4),以減少全樹走訪次數;聚合不得犧牲 I6(冪等可還原)與逐功能開關。 (見 §3.4),以減少全樹走訪次數;聚合不得犧牲 I6(冪等可還原)與逐功能開關。
- **I10 作用域與分塊**:作用範圍可由 `scope.include` 選擇器限定(opt-in);功能集 - **I10 作用域與分塊**:作用範圍可由 `scope.include` 選擇器限定(opt-in);功能集
@@ -175,27 +175,13 @@ for phase in phases:
> 只是 P1 諸 pass 的 `collect` 在同一走訪內完成。若某 pass 不相容(需獨立走訪), > 只是 P1 諸 pass 的 `collect` 在同一走訪內完成。若某 pass 不相容(需獨立走訪),
> 退化為自跑一次,不影響正確性。 > 退化為自跑一次,不影響正確性。
### 3.5 v1 向下相容層I8`src/compat/v1.ts` ### 3.5 v1 向下相容層 — 已移除(I8 退役
導出 `createCjkAutospace(options)`,把 v1 API 映射到 `createJuzhen`**保證不 相容層(`src/compat/v1.ts``createCjkAutospace` 導出、IIFE 之全域別名、
break、無功能倒退**,行為等價但不要求完全一致: `autospaceClass` 選項)曾把 v1 API 映射到 `createJuzhen`,服務 build.sh 單檔串接/
Nvim peekClaude 模板等 v1 消費端之過渡期。**最後一批 v1 消費端遷移 v2 API 完成後
| v1 選項/行為 | 映射 | 已整體移除**`autospaceClass` 經查從未被任何 pass 讀取,屬死選項,一併刪)。v1
|---|---| 原始碼保存於 `v1-autospace` 分支。
| `.apply(root)` | → `.render(root)` |
| `pillSelector` | → pill 鄰接處理 + avoid |
| `styleInlines``blockTags``skipTags` | → finder 設定 |
| `autospaceClass`(預設 `cjk-autospace` | → 套用於 `jz-hws` 之 class,使既有
`.cjk-autospace{…}` CSS 仍命中間隙元素 |
| `skipAttribute``isSkipped` | → avoid 判定 |
| `longWordWrap` | → `longWord` |
| `punctuationSqueeze`v1 halt 句內半角) | → `jiya`(margin 開明/全角;行為近似,
非逐位等同——符合「不要求完全一致」) |
IIFE 構建額外掛 `globalThis.createCjkAutospace` 別名,使
`globalThis.createCjkAutospace` 之消費端(Nvim peek/模板)零改動可跑。相容層之
差異(間隙元素由 `span.cjk-autospace` 變為 `jz-hws[class]`、擠壓由 halt 變 margin
於 README 明列。
### 3.6 作用域與分塊功能選擇(I10) ### 3.6 作用域與分塊功能選擇(I10)
@@ -248,9 +234,31 @@ IIFE 構建額外掛 `globalThis.createCjkAutospace` 別名,使
設計要點:finder 不知道任何具體排版規則;它只提供「在何處、跨何種邊界、對哪些 設計要點:finder 不知道任何具體排版規則;它只提供「在何處、跨何種邊界、對哪些
字元」的遍歷與安全變更原語。規則由各 pass 以 unicode 表 + 回呼注入。 字元」的遍歷與安全變更原語。規則由各 pass 以 unicode 表 + 回呼注入。
**行內元素三分類(下游問題六後確立)**:行內元素對排版有且僅有三種口徑,所有遍歷
原語(`collectRuns``adjacentLogicalChar``eachTextNode`、`jiyaAdjacency` 之相鄰
掃描、`core/split` 之可切判定、orphan 之實義字收集)共用同一判定:
- **透明**`styleInlines` wrap 類 `jz-*`)——穿越、run 延續、禁則可克隆切分。
- **隔離**`isolateSelector`,預設 `mark`)——邊界斷 run/斷相鄰/不可切分、兩端
補隙(同 pill),**內部照常處理**(自成脈絡)。典型:帶背景色之 `<mark>`(切分
背景盒有可見接縫,故亦自 `SPLITTABLE` 白名單移除)。
- **整體**`pillSelector`)——原子單位:內部對一切 pass **不可見**(`eachTextNode`
過濾 `insidePill`,不 charify/不插間隙/不切分/不下潛),禁則時作為原子鄰字
單位**整體綁入** `jz-jinze`。典型:`code``kbd`、行內公式 `.katex`。
判定**以元素為準、非標籤名**`transparentInline(el)`,先排除 pillisolate
avoid 再查標籤集):KaTeX 全樹皆 `<span>`,舊版按標籤名判透明曾令 `canSplit`
`descend` 整棵下潛——禁則把公式內側末字連同完整殼層逐層克隆切出(`splitoff`),
上標漂移錯位(下游問題六);`pillSelector``skipAttribute``scope.avoid` 三層
防護全被刺穿。同根修復:**avoid 以自身判定**(`avoidsSelf(el)`——`isAvoided` 僅查
祖先鏈,元素自身命中 avoid 時曾被穿越);`adjacentLogicalChar` 之 `descend` 改三態
(邊界字/無內容續找兄弟/**blocked** 終止——遇 pillisolateavoid 不可跳過續找
更遠之字,否則重蹈「越過 `jz-char` 取更前字 → 誤隙」之同型錯誤);`core/split` 之
`canSplit` 諮詢 finderpillisolateavoid 一律不可切、退化整綁)。
**「行內透明」原則之一致性(下游 Bug 修復)**:「邏輯文本應透明穿越之行內元素」一語 **「行內透明」原則之一致性(下游 Bug 修復)**:「邏輯文本應透明穿越之行內元素」一語
須在所有遍歷原語中**口徑一致**,否則同一段文本在不同原語下被切成不同邏輯串、生成相 須在所有遍歷原語中**口徑一致**,否則同一段文本在不同原語下被切成不同邏輯串、生成相
互矛盾之結果。`isTransparentInline` 為單一真相源,列入:樣式行內標籤、`jz-hws` 互矛盾之結果。`transparentInline` 為單一真相源,列入:樣式行內標籤、`jz-hws`
`jz-jinze`wrap 群組)、**`jz-char``jz-inner`(標點原子)**。後者必須透明,否則 `jz-jinze`wrap 群組)、**`jz-char``jz-inner`(標點原子)**。後者必須透明,否則
`adjacentLogicalChar` 之 `descend` 遇標點原子既不進入亦不終止、而是**跳過**它續找更前 `adjacentLogicalChar` 之 `descend` 遇標點原子既不進入亦不終止、而是**跳過**它續找更前
之兄弟——`漢字。<code>` 經禁則綁定後,pill 之左鄰字應為「。」(標點,非 pill 鄰字、不 之兄弟——`漢字。<code>` 經禁則綁定後,pill 之左鄰字應為「。」(標點,非 pill 鄰字、不
@@ -360,11 +368,13 @@ class 約定:`bd`(標點)、`bd-open/close/cop/stop/sep/liga`、`cjk`、`j
- **copy-cleanI1,較 Han 更純)**:間隙為純視覺 margin,`textContent` 不增不減 - **copy-cleanI1,較 Han 更純)**:間隙為純視覺 margin,`textContent` 不增不減
語義字元,剪貼板得緊湊原文。 語義字元,剪貼板得緊湊原文。
- **切片**:涵蓋 v1 Pass A、Pass Bpill `code/kbd` 邊界亦走同一機制——左字或 - **切片**:涵蓋 v1 Pass A、Pass Bpill `code/kbd` 邊界亦走同一機制——左字或
pill 元素包入 `jz-hws`pill 由 avoid 與鄰接判定處理)。 pill 元素包入 `jz-hws`pill 由 avoid 與鄰接判定處理)。Pass B 之邊界選擇器為
**pill isolate**(§4.1 三分類:隔離元素兩端補隙口徑與 pill 一致,差異僅在
內部處理;巢於 pill 內者跳過)。
- **邊界正規化一致性(下游回饋 1a/1b 修)**: - **邊界正規化一致性(下游回饋 1a/1b 修)**:
- **1a pill 穿越 jz-jinze**jinzeorder 40)先於 spacingorder 50),會把尾隨 - **1a pill 穿越 jz-jinze**jinzeorder 40)先於 spacingorder 50),會把尾隨
標點之 pill 納入 `jz-jinze`。`adjacentLogicalChar` 須**透明穿越 `jz-jinze`** 標點之 pill 納入 `jz-jinze`。`adjacentLogicalChar` 須**透明穿越 `jz-jinze`**
(與 `jz-hws`、樣式行內同列,見 `finder.isTransparentInline`),否則 pill 左鄰字 (與 `jz-hws`、樣式行內同列,見 `finder.transparentInline`),否則 pill 左鄰字
不可達、左緣間隙與作者空格剝除雙失。 不可達、左緣間隙與作者空格剝除雙失。
- **1b CJK↔CJK 贅餘空格**:中文不用詞間空格,故 CJK 表意字之間的單一 ASCII 作者 - **1b CJK↔CJK 贅餘空格**:中文不用詞間空格,故 CJK 表意字之間的單一 ASCII 作者
空格(含跨樣式行內標籤,`collectRuns` 已透明合併)一律**剝除、不留隙**(以 空格(含跨樣式行內標籤,`collectRuns` 已透明合併)一律**剝除、不留隙**(以
@@ -458,7 +468,8 @@ class 約定:`bd`(標點)、`bd-open/close/cop/stop/sep/liga`、`cjk`、`j
pause 右空白 + open 左空白)兩側皆收 → 全角式與開明式皆合占 1em;`。」`stop 右空白; pause 右空白 + open 左空白)兩側皆收 → 全角式與開明式皆合占 1em;`。」`stop 右空白;
」左為墨)只收 ``→ 全角式 1.5em/開明式 1em(」baseline 已半);`·—`(皆無指向對方之 」左為墨)只收 ``→ 全角式 1.5em/開明式 1em(」baseline 已半);`·—`(皆無指向對方之
空白)皆不收 → 兩式皆 2em(不宜挤之例外)。多標點鏈逐對處理。`jiyaAdjacencyPass` 以**從 root 之透明遍歷**(穿越 strong 空白)皆不收 → 兩式皆 2em(不宜挤之例外)。多標點鏈逐對處理。`jiyaAdjacencyPass` 以**從 root 之透明遍歷**(穿越 strong
em… 等樣式行內;block 邊界與 avoid 子樹斷相鄰;jz-char 為原子不下探)取代原「同 parent em… 等樣式行內;block/pill/avoid(自身判定)斷相鄰且不下探,isolate 斷相鄰但內部
自成脈絡照常配對——§4.1 三分類;jz-char 為原子不下探)取代原「同 parent
直接兄弟」掃描,使**跨 inline 邊界**之連續標點亦正確配對(原漏致跨 strong/em 之標點對未挤、 直接兄弟」掃描,使**跨 inline 邊界**之連續標點亦正確配對(原漏致跨 strong/em 之標點對未挤、
達 2em)。 達 2em)。
- **`margin` 後備模式(.feedback 增補)**:消費端字型未必含 `halt`(多數系統字型、 - **`margin` 後備模式(.feedback 增補)**:消費端字型未必含 `halt`(多數系統字型、
@@ -497,7 +508,11 @@ class 約定:`bd`(標點)、`bd-open/close/cop/stop/sep/liga`、`cjk`、`j
把邊界詞元剝入一個淺克隆(保留標籤/class/style/lang,去 id),只綁該克隆(最小綁定), 把邊界詞元剝入一個淺克隆(保留標籤/class/style/lang,去 id),只綁該克隆(最小綁定),
原元素其餘部分留原位、渲染視覺無縫。克隆標 `data-jz-kind="splitoff"``revertPass` 之**延後 原元素其餘部分留原位、渲染視覺無縫。克隆標 `data-jz-kind="splitoff"``revertPass` 之**延後
階段**(先解包 wrap、後併 splitoff,確保前兄弟為原元素而非 wrap 殼)併回原元素、還原一致 階段**(先解包 wrap、後併 splitoff,確保前兄弟為原元素而非 wrap 殼)併回原元素、還原一致
I6)。僅純樣式標籤白名單可切`a``abbr``ruby`/帶 id 者退化為整元素綁定(安全)。 I6)。僅純樣式標籤白名單可切,且 `canSplit` **諮詢 finder 三分類**(§4.1):`a``abbr`
`ruby`/帶 id 者,以及 **pill(整體)/isolate(隔離)/avoid 自身命中者**,一律退化為
整元素綁定(安全)——pill 即「作為原子鄰字單位整體綁入 `jz-jinze`」之期望行為(下游問題六:
KaTeX 全樹皆 SPAN,舊版僅憑標籤白名單曾整棵下潛肢解公式);isolate(如背景色 `mark`)切分
有可見接縫故整綁;`MARK` 並自白名單移除。
- **切片**:切片 1 完成行首行末避頭尾(閉類/點號避頭、開類避尾);行首縮排聯動、 - **切片**:切片 1 完成行首行末避頭尾(閉類/點號避頭、開類避尾);行首縮排聯動、
更細之禁則對象列後續,沿用同一 `jz-jinze` 結構。 更細之禁則對象列後續,沿用同一 `jz-jinze` 結構。
- **已知交互(待瀏覽器精修)**:`lineedge` 之行端偵測需跨 `jz-jinze` 群組邊界向上 - **已知交互(待瀏覽器精修)**:`lineedge` 之行端偵測需跨 `jz-jinze` 群組邊界向上
+35 -20
View File
@@ -8,8 +8,8 @@
倉庫名仍為 `cjk-autospace`;公開 API 為 `createJuzhen` / 全域 `Juzhen`;注入之 倉庫名仍為 `cjk-autospace`;公開 API 為 `createJuzhen` / 全域 `Juzhen`;注入之
自訂元素前綴 `jz-`。完整設計見 [`ARCHITECTURE.md`](./ARCHITECTURE.md)。 自訂元素前綴 `jz-`。完整設計見 [`ARCHITECTURE.md`](./ARCHITECTURE.md)。
> 本版(2.x)為從零重寫。v1 之 `createCjkAutospace` API 由相容層保留(見下), > 本版(2.x)為從零重寫。v1 之 `createCjkAutospace` 相容層**已移除**(最後一批
> 既有消費端不需改碼即可運行v1 原始碼保存於 `v1-autospace` 分支。 > v1 消費端已遷移 v2 APIv1 原始碼保存於 `v1-autospace` 分支。
--- ---
@@ -65,12 +65,12 @@ jz.render(document.querySelector("main"));
| 功能 | 說明 | | 功能 | 說明 |
|---|---| |---|---|
| **中西間隙** `spacing` | CJK↔西文邊界插入 copy-clean 之 `jz-hws``user-select:none`,剪貼板跳過)。「西文」涵蓋拉丁(含擴充 A/B,拼音聲調 `ā ǎ ē`)、希臘、西里爾、數字、貨幣(`€ ₩ ₿`)、數學運算符等;CJK 標點(`。,「」()……` 等)不誤判為西文、不生隙。含跨樣式行內邊界與 `code``kbd` pill 邊界(pill 緊鄰標點時穿越 `jz-jinze` 仍補間隙;pill 跨行右隙、間隙落行末之自我應驗皆已修)。**pill 之邏輯鄰字為標點時(如 `漢字。<code>`)不誤加間隙**(標點非 pill 鄰字;`jz-char``jz-inner` 透明穿越,與 `collectRuns` 一致)。贅餘作者空格一律正規化:CJK↔西文吸收為受管間隙、CJK↔CJK(含跨 inline 標籤)剝除不留隙。 | | **中西間隙** `spacing` | CJK↔西文邊界插入 copy-clean 之 `jz-hws``user-select:none`,剪貼板跳過)。「西文」涵蓋拉丁(含擴充 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;透明穿越 `strong``em` 等行內邊界);③行端擠壓(`lineedge` layout pass)。半形預設以字型 `halt` 渲染(按字形正確定位,居中字形如 `!?`/繁體 `。,` 不重疊;不依賴 `text-spacing-trim`)。**字型缺 `halt` 時可切 `margin` 後備模式**(見下節)。 | | **標點擠壓** `jiya` | 標點 charify 為 `jz-char``bd-*` class。三機制:①baseline 政策(全角式不擠/開明式擠句內點號+括號引號/半角式全擠);②行內連續擠壓(相鄰標點**雙側獨立**收——各標點若其有空白之側緊鄰另一標點即收半,故 `,「` 全角式與開明式皆 1em、`。」` 全角式 1.5em/開明式 1em;透明穿越 `strong``em` 等行內邊界);③行端擠壓(`lineedge` layout pass)。半形預設以字型 `halt` 渲染(按字形正確定位,居中字形如 `!?`/繁體 `。,` 不重疊;不依賴 `text-spacing-trim`)。**字型缺 `halt` 時可切 `margin` 後備模式**(見下節)。 |
| **禁則** `jinze` | 行首行末避頭尾:閉類/點號綁前一字(不置行首)、開類綁後一字(不置行末),只隔離邊界字故長引文中段仍可斷。亦為 `justify` 綁定載體。邊界字落於行內樣式元素(`strong``em``span` 等)內時**克隆切分**只綁邊界詞元(避免整個元素入 `nowrap` 致行末整體掉行、上行留巨大空隙),切出之克隆標 `data-jz-kind="splitoff"``revert` 時自動併回原元素;不可切元素(`a``abbr``ruby`/帶 `id`)退化為整元素綁定。 | | **禁則** `jinze` | 行首行末避頭尾:閉類/點號綁前一字(不置行首)、開類綁後一字(不置行末),只隔離邊界字故長引文中段仍可斷。亦為 `justify` 綁定載體。邊界字落於**透明**行內樣式元素(`strong``em` 等)內時**克隆切分**只綁邊界詞元(避免整個元素入 `nowrap` 致行末整體掉行、上行留巨大空隙),切出之克隆標 `data-jz-kind="splitoff"``revert` 時自動併回原元素;不可切元素(`a``abbr``ruby`/帶 `id`,以及 **pill/隔離/avoid**——見「行內元素三分類」)退化為**整元素綁定**——pill(如行內公式 `.katex`)即作為原子鄰字單位整體綁入 `jz-jinze`,絕不下潛肢解其內部。 |
| **垂懸字避免** `orphan` | 段末行 CJK 實義字 ≥ N(預設 2;標點/符號不計)。方案 C(斷行層、無測量):把段末「第 N 個實義字起至段末」包入 `jz-orphan``white-space:nowrap`),整體不可斷 → 末行恒含之 ⇒ 末行實義字 ≥ N,任意寬度/字型成立。單行段無害。邊界字落於行內元素內時同樣**克隆切分**(只綁末 N 字、避免整個長元素入 `nowrap` 溢出容器;機制同禁則)。**預設開**(`orphan:false` 關,`{chars:n}` 調)。段落級功能。 | | **垂懸字避免** `orphan` | 段末行 CJK 實義字 ≥ N(預設 2;標點/符號不計)。方案 C(斷行層、無測量):把段末「第 N 個實義字起至段末」包入 `jz-orphan``white-space:nowrap`),整體不可斷 → 末行恒含之 ⇒ 末行實義字 ≥ N,任意寬度/字型成立。單行段無害。邊界字落於行內元素內時同樣**克隆切分**(只綁末 N 字、避免整個長元素入 `nowrap` 溢出容器;機制同禁則)。**預設開**(`orphan:false` 關,`{chars:n}` 調)。段落級功能。 |
| ~~**標點懸掛** `hanging`~~ | **已評估停用**(§6.5.6):純負 margin 機制不可行(墨色不出版心/滿凸拉升後字重疊/resize 重排錯亂),原生 `hanging-punctuation` 為正解但 Chrome 不支援`hanging` 選項仍被接受但**無任何效果**;程式碼休眠待原生支援成熟。 | | ~~**標點懸掛** `hanging`~~ | **已移除**(§6.5.6):純負 margin 機制為機制級死路(寬度守恆矛盾,CSS 接觸不到斷行器),原生 `hanging-punctuation`唯一正解但僅 Safari 支援、且與本庫 inline-block 原子互斥`hanging` 選項仍被接受API 相容)但**無任何效果**相關程式碼/CSS 已全數刪除,原生路徑屬遠期「原生管線」新功能。 |
| **功能分級** `level` | 功能分文本級(中西間隙、標點擠壓——任意文本片段適用)與段落級(禁則、垂懸字、懸掛、長詞——需行/段布局)。塊級元素預設全功能;`data-jz-level="text"` 子樹或選項 `level:{text:"選擇器"}`(如標題/按鈕)僅跑文本級。 | | **功能分級** `level` | 功能分文本級(中西間隙、標點擠壓——任意文本片段適用)與段落級(禁則、垂懸字、懸掛、長詞——需行/段布局)。塊級元素預設全功能;`data-jz-level="text"` 子樹或選項 `level:{text:"選擇器"}`(如標題/按鈕)僅跑文本級。 |
| **justify 單份間距** | 每個 `jz-char``inline-block` 原子項,句末標點只貢獻一份對齊伸縮量。 | | **justify 單份間距** | 每個 `jz-char``inline-block` 原子項,句末標點只貢獻一份對齊伸縮量。 |
@@ -96,8 +96,10 @@ createJuzhen({
jiya: true, // 或物件:{ halfWidth: "halt" | "margin", bias } jiya: true, // 或物件:{ halfWidth: "halt" | "margin", bias }
jinze: true, jinze: true,
orphan: true, // 垂懸字避免(預設開);或 { chars: 2 }false 關 orphan: true, // 垂懸字避免(預設開);或 { chars: 2 }false 關
// hanging:已停用、無效果(見「標點懸掛」一節) // hanging:已移除、無效果(見「標點懸掛」一節)
level: { text: "h1,h2,h3,button,.ui" }, // 此選擇器子樹僅跑文本級功能(見下) level: { text: "h1,h2,h3,button,.ui" }, // 此選擇器子樹僅跑文本級功能(見下)
pillSelector: "code, kbd, .katex", // 整體(pill)元素(見「行內元素三分類」)
isolateSelector: "mark", // 內外隔離元素(預設 mark"" 停用)
justifyAtoms: true, // jz-char/jz-jinze 以 inline-block 作對齊原子; justifyAtoms: true, // jz-char/jz-jinze 以 inline-block 作對齊原子;
// 分頁器(Paged.js)下設 false 改 inline(見下) // 分頁器(Paged.js)下設 false 改 inline(見下)
blocks: [ // 分塊功能選擇 blocks: [ // 分塊功能選擇
@@ -106,6 +108,28 @@ createJuzhen({
}); });
``` ```
### 行內元素三分類:透明/隔離/整體
行內元素對排版有且僅有三種口徑,**所有遍歷原語共用同一判定**(中西間隙、擠壓相鄰、
禁則綁定、垂懸字、克隆切分皆一致):
| 分類 | 選擇器 | 邊界 | 內部 | 典型 |
|---|---|---|---|---|
| **透明** | `styleInlines`(預設常見樣式標籤) | 穿越——間隙/相鄰/run 皆延續 | 照常處理;禁則可**克隆切分**(最小綁定) | `strong``em``b``i``a``span` |
| **隔離** | `isolateSelector`(預設 `"mark"`) | 斷 run/斷相鄰/不可切分;**兩端**與 CJK/西文相鄰時補 `jz-hws` 間隙 | **照常處理**(自成脈絡:內部標點照擠、內部中西照補隙) | 帶背景色之 `<mark>`、badge 類盒樣式行內 |
| **整體** | `pillSelector`(預設 `"code, kbd"`) | 同隔離(邊界斷、兩端補隙) | **完全不可見**——不 charify、不插間隙、不切分、不下潛 | `code``kbd`、行內公式 `.katex`、引用標 `.cite-ref` |
要點:
- **判定以元素為準、非標籤名**:KaTeX 全樹皆 `<span>`,但 `.katex` 命中 `pillSelector`
即為整體邊界——舊版按標籤名判透明曾整棵下潛、把公式內側末字連同殼層克隆切出(肢解)。
- **禁則之整體綁定**:pill 緊鄰禁則標點時(`…$公式$`),整個 pill 作為原子鄰字單位
綁入 `jz-jinze`(公式本不可斷,無額外代價);隔離元素同樣整綁(切分背景盒有可見接縫)。
- **`scope.avoid``skipAttribute``data-jz-skip` 以自身判定**:命中元素自身即邊界、
內外皆不處理(舊版僅查祖先,元素自身命中時仍被穿越)。
- **行內公式/夾注引用宜列為 pill**:`pillSelector: "code, kbd, .katex, .cite-ref"`——
獨立行內單位以 pill 邊界邏輯統一處理(兩側補隙、吸收作者空格、整體綁定)。
### 分頁器(Paged.js 等)相容:`justifyAtoms` ### 分頁器(Paged.js 等)相容:`justifyAtoms`
預設 `jz-char``jz-jinze``inline-block`,作 `justify` 之單一對齊原子(句末標點 預設 `jz-char``jz-jinze``inline-block`,作 `justify` 之單一對齊原子(句末標點
@@ -351,21 +375,12 @@ createJuzhen({
--- ---
## v1 向下相容 ## v1 向下相容 — 已移除
```js v1 相容層(`createCjkAutospace` 導出、IIFE 之 `globalThis.createCjkAutospace`
// 既有消費端零改動: 別名、`autospaceClass` 選項)**已隨最後一批 v1 消費端遷移完成而移除**。既有消費端
const cjk = createCjkAutospace({ pillSelector: "code, kbd" }); 請改用 `createJuzhen(options).render(root)` v2 APIv1 原始碼保存於 `v1-autospace`
cjk.apply(document.body); 分支。
```
IIFE 構建另掛 `globalThis.createCjkAutospace` 別名。**與 v1 之差異**(行為等價、
不要求逐位一致):
- 間隙標記由 `<span class="cjk-autospace">` 變為 `<jz-hws class="cjk-autospace">`
(仍帶原 class,既有 `.cjk-autospace{user-select:none}` CSS 仍命中)。
- 標點擠壓改由 `juzhen.css` 託管(須引入),預設仍以字型 `halt` 渲染半形——與 v1
消費端 `font-feature-settings:"halt"` 同機制;缺 `halt` 之字型可切 `margin` 後備。
--- ---
+2 -5
View File
@@ -2,8 +2,7 @@
// 另行產出(見 package.json scripts)。 // 另行產出(見 package.json scripts)。
// //
// dist/juzhen.mjs ESM,供打包器/TypeScriptjsdom 測試。 // dist/juzhen.mjs ESM,供打包器/TypeScriptjsdom 測試。
// dist/juzhen.iife.js IIFE,全域 `Juzhen`,原生 <script> 直跑;額外掛 // dist/juzhen.iife.js IIFE,全域 `Juzhen`,原生 <script> 直跑
// `globalThis.createCjkAutospace` 別名(v1 相容消費端)。
// dist/juzhen.css 合併之樣式(css/juzhen.css 之 @import 展開)。 // dist/juzhen.css 合併之樣式(css/juzhen.css 之 @import 展開)。
import { build } from "esbuild"; import { build } from "esbuild";
@@ -18,12 +17,10 @@ const SHARED = (
} }
); );
// IIFE:把 Juzhen 掛上 globalThis,並補 v1 全域別名。 // IIFE:把 Juzhen 掛上 globalThisv1 全域別名已隨相容層移除)
const IIFE_FOOTER = ( const IIFE_FOOTER = (
"if(typeof globalThis!=='undefined'){" "if(typeof globalThis!=='undefined'){"
+ "globalThis.Juzhen=Juzhen;" + "globalThis.Juzhen=Juzhen;"
+ "if(Juzhen&&Juzhen.createCjkAutospace&&!globalThis.createCjkAutospace)"
+ "{globalThis.createCjkAutospace=Juzhen.createCjkAutospace;}"
+ "}" + "}"
); );
-29
View File
@@ -1,29 +0,0 @@
interface V1LongWord {
minLength?: number;
lang?: string;
}
interface V1Squeeze {
squeezeClass?: string;
marks?: string[];
langWhitelist?: string[];
}
export interface V1Options {
styleInlines?: string[];
blockTags?: string[];
skipTags?: string[];
pillSelector?: string;
autospaceClass?: string;
skipAttribute?: string;
isSkipped?: (node: Node) => boolean;
longWordWrap?: boolean | V1LongWord;
punctuationSqueeze?: boolean | V1Squeeze;
}
export interface V1Instance {
apply(root?: Element): void;
revert(root?: Element): void;
}
/**
* v1 相容工廠。回傳 { apply, revert }apply→render、revert→revert。
*/
export declare function createCjkAutospace(options?: V1Options): V1Instance;
export {};
+4
View File
@@ -16,6 +16,10 @@ export declare function createMarker(tag: JzTag, pass: string): HTMLElement;
/** /**
* 還原某 pass 之全部產物(I6)。以 data-jz 定址,逆文件序處理(先內層後 * 還原某 pass 之全部產物(I6)。以 data-jz 定址,逆文件序處理(先內層後
* 外層),marker 移除、wrap 解包,最後 normalize 受影響父節點合併文本。 * 外層),marker 移除、wrap 解包,最後 normalize 受影響父節點合併文本。
*
* splitoff(克隆切分之邊界詞元,見 core/split.ts)須在 wrap 全部解包**之後**才併回:
* 否則其前兄弟可能仍是 wrap 殼(如 jz-jinze)而非原元素。故主迴圈跳過 splitoff,於
* 解包後第二階段以文件序逐一併回前兄弟(同標籤元素),還原為單一元素(I6 一致)。
*/ */
export declare function revertPass(pass: string, root: ParentNode): void; export declare function revertPass(pass: string, root: ParentNode): void;
/** 讀取 jz-char 之 bd-* 子類(無則 null)。 */ /** 讀取 jz-char 之 bd-* 子類(無則 null)。 */
+32 -7
View File
@@ -21,6 +21,7 @@ export declare class Finder {
private readonly blockSet; private readonly blockSet;
private readonly skipSet; private readonly skipSet;
private readonly pillSelector; private readonly pillSelector;
private readonly isolateSelector;
private readonly avoidSelector; private readonly avoidSelector;
private readonly includeSelector; private readonly includeSelector;
private readonly skipAttr; private readonly skipAttr;
@@ -29,18 +30,42 @@ export declare class Finder {
private readonly featureCache; private readonly featureCache;
private readonly levelCache; private readonly levelCache;
constructor(opts: ResolvedOptions); constructor(opts: ResolvedOptions);
/** 該元素是否匹配 pill 選擇器(行內塊,如 code/kbd)。 */ /** 該元素是否匹配 pill 選擇器(三分類之「整體」:codekbd/行內公式等)。 */
pillMatches(el: Element): boolean; pillMatches(el: Element): boolean;
/** 該元素是否匹配 isolate 選擇器(三分類之「隔離」:帶盒樣式之行內,如背景色
* <mark>)。邊界口徑同 pill(斷 run/斷相鄰/不可切分/兩端補隙),內部照常處理。 */
isolateMatches(el: Element): boolean;
/** 節點是否位於 pill **內部**(僅查祖先,不含自身)。pill 為整體元素:內部對
* 一切 pass 不可見——不 charify、不收 run、不切分、不於其內插間隙(下游問題六:
* .katex 內部曾被 jiyaspacing 直接變更)。 */
insidePill(node: Node): boolean;
/** 邏輯文本應**透明穿越**之行內元素:樣式行內標籤,以及聚珍自身之 wrap 類 /** 邏輯文本應**透明穿越**之行內元素:樣式行內標籤,以及聚珍自身之 wrap 類
* jz-*jz-hws 間隙、jz-jinze 禁則群組)。穿越這些才能正確找到跨包裝之邏輯 * jz-*jz-hws 間隙、jz-jinze 禁則群組、jz-charjz-inner 標點原子)。穿越這些
* 鄰字(如 jinze 已把 pill 納入 jz-jinze 後,pill 之左鄰字仍須可達;I7)。 */ * 才能正確找到跨包裝之邏輯鄰字(如 jinze 已把 pill 納入 jz-jinze 後,pill 之左
private isTransparentInline; * 鄰字仍須可達;I7)。
/** 節點是否處於 avoid 子樹(內建 skip 名單 / SVG / contentEditable / * ⚠ 以**元素**而非標籤名判定:pillisolate/avoid 縱使標籤名在樣式集亦為邊界、
* data-jz-skip / 使用者 avoid 選擇器 / skipAttribute / isSkipped)。 */ * 不可穿越(下游問題六:KaTeX 全樹皆 SPAN,名字判定令整棵公式被當透明下潛而
* 肢解;scope.avoidskipAttribute 命中自身者同理)。
* ⚠ jz-charjz-inner 必須透明:否則 adjacentLogicalChar 之 descend 遇標點原子既
* 不進入亦不終止、而是**跳過**它續找更前之兄弟(下游 Bug`汉字。<code>` 經 jinze
* 綁定為 `汉<jz-jinze>字<jz-char>。</jz-char></jz-jinze><code>`pill 之左鄰字應
* 為「。」、不補間隙;舊邏輯卻越過 jz-char 取到「字」→ 誤包 jz-hws 於「字。」之間)。
* jz-char 僅含標點(jiya 只 charify biaodian),標點非 pillNeighbor,故透明後 descend
* 回傳標點 → 不補隙,與 collectRuns 之既有透明遞迴一致。jz-inner 亦須列入,否則
* descend 進入 jz-char 後因 jz-inner 不透明而跳過其內標點。 */
private transparentInline;
/** 該元素**自身**是否為 avoid 邊界(內建 skip 名單 / SVG / contentEditable /
* data-jz-skip / skipAttribute / avoid 選擇器 / isSkipped)。isAvoided 沿祖先
* 鏈逐層呼叫本判定;遍歷原語另須對「正要進入」之元素直接判自身——舊版僅查
* 祖先,元素自身命中 avoid 時被當透明穿越(下游問題六:scope.avoid 對 .katex
* 形同虛設)。 */
avoidsSelf(el: Element): boolean;
/** 節點是否處於 avoid 子樹(祖先鏈上任一元素 avoidsSelf;自身不計——元素
* 自身之邊界判定由遍歷原語以 avoidsSelf 執行)。 */
isAvoided(node: Node): boolean; isAvoided(node: Node): boolean;
/** scope.include 啟用判定:未設則恆 true;設則須有命中祖先(opt-in)。 */ /** scope.include 啟用判定:未設則恆 true;設則須有命中祖先(opt-in)。 */
inScope(node: Node): boolean; inScope(node: Node): boolean;
/** 走訪 root 下所有「可處理」文本節點(已過濾 avoid / 非作用域)。 */ /** 走訪 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)。 */
eachBlock(root: Element, cb: (block: Element) => void): void; eachBlock(root: Element, cb: (block: Element) => void): void;
+14
View File
@@ -0,0 +1,14 @@
import type { Finder } from "./finder.js";
/**
* 隔離 el 之邊界詞元供綁定(最小綁定)。side="tail" 取末詞元、="head" 取首詞元。
* 回傳**應綁定之節點**
* · 可切且確實切分 → tail 回克隆(末詞元)、head 回原 el(已只剩首詞元,其餘入克隆);
* · 不可切/無法定位切點/整元素即該詞元 → 回 el 本身(綁整個元素,退化但安全)。
* 切出之克隆標 splitoff,由 revertPass 併回;故 render→revert 還原一致(I6)。
*/
export declare function isolateBoundaryToken(el: Element, side: "head" | "tail", anyCjk: RegExp, pass: string, finder: Finder): Element;
/**
* 把 el 之**指定切點**(精確字位)起至末尾剝入克隆並回傳(供 orphan:自實義字綁至塊末)。
* 不可切或切點在最起點 → 回 null(呼叫端綁整個直接子)。
*/
export declare function splitElementAt(el: Element, textNode: Text, offset: number, pass: string, finder: Finder): Element | null;
-1
View File
@@ -1,7 +1,6 @@
import type { JuzhenOptions, Pass, ResolvedOptions } from "./types.js"; import type { JuzhenOptions, Pass, ResolvedOptions } from "./types.js";
export type { JuzhenOptions, ResolvedOptions, Pass }; export type { JuzhenOptions, ResolvedOptions, Pass };
export type { CharClassOverride, BdSubclass, Ruleset, Bias, BiasTable } from "./types.js"; export type { CharClassOverride, BdSubclass, Ruleset, Bias, BiasTable } from "./types.js";
export { createCjkAutospace } from "./compat/v1.js";
/** 正規化公開選項為內部 ResolvedOptions。 */ /** 正規化公開選項為內部 ResolvedOptions。 */
export declare function normalizeOptions(opts?: JuzhenOptions): ResolvedOptions; export declare function normalizeOptions(opts?: JuzhenOptions): ResolvedOptions;
export interface Juzhen { export interface Juzhen {
+128 -124
View File
@@ -21,7 +21,6 @@ var Juzhen = (() => {
// src/index.ts // src/index.ts
var index_exports = {}; var index_exports = {};
__export(index_exports, { __export(index_exports, {
createCjkAutospace: () => createCjkAutospace,
createJuzhen: () => createJuzhen, createJuzhen: () => createJuzhen,
normalizeOptions: () => normalizeOptions normalizeOptions: () => normalizeOptions
}); });
@@ -35,6 +34,7 @@ var Juzhen = (() => {
this.blockSet = opts.finder.blockTags; this.blockSet = opts.finder.blockTags;
this.skipSet = opts.finder.skipTags; this.skipSet = opts.finder.skipTags;
this.pillSelector = opts.finder.pillSelector; this.pillSelector = opts.finder.pillSelector;
this.isolateSelector = opts.finder.isolateSelector;
this.skipAttr = opts.finder.skipAttribute; this.skipAttr = opts.finder.skipAttribute;
this.userIsSkipped = opts.finder.isSkipped; this.userIsSkipped = opts.finder.isSkipped;
this.levelTextSelector = opts.levelText; this.levelTextSelector = opts.levelText;
@@ -43,14 +43,35 @@ var Juzhen = (() => {
this.featureCache = /* @__PURE__ */ new WeakMap(); this.featureCache = /* @__PURE__ */ new WeakMap();
this.levelCache = /* @__PURE__ */ new WeakMap(); this.levelCache = /* @__PURE__ */ new WeakMap();
} }
/** 該元素是否匹配 pill 選擇器(行內塊,如 code/kbd)。 */ /** 該元素是否匹配 pill 選擇器(三分類之「整體」:codekbd/行內公式等)。 */
pillMatches(el) { pillMatches(el) {
return !!(el.matches && this.pillSelector && el.matches(this.pillSelector)); return !!(el.matches && this.pillSelector && el.matches(this.pillSelector));
} }
/** 該元素是否匹配 isolate 選擇器(三分類之「隔離」:帶盒樣式之行內,如背景色
* <mark>)。邊界口徑同 pill(斷 run/斷相鄰/不可切分/兩端補隙),內部照常處理。 */
isolateMatches(el) {
return !!(el.matches && this.isolateSelector && el.matches(this.isolateSelector));
}
/** 節點是否位於 pill **內部**(僅查祖先,不含自身)。pill 為整體元素:內部對
* 一切 pass 不可見——不 charify、不收 run、不切分、不於其內插間隙(下游問題六:
* .katex 內部曾被 jiyaspacing 直接變更)。 */
insidePill(node) {
let p = node.parentNode;
while (p && p.nodeType === 1) {
if (this.pillMatches(p)) {
return true;
}
p = p.parentNode;
}
return false;
}
/** 邏輯文本應**透明穿越**之行內元素:樣式行內標籤,以及聚珍自身之 wrap 類 /** 邏輯文本應**透明穿越**之行內元素:樣式行內標籤,以及聚珍自身之 wrap 類
* jz-*jz-hws 間隙、jz-jinze 禁則群組、jz-charjz-inner 標點原子)。穿越這些 * jz-*jz-hws 間隙、jz-jinze 禁則群組、jz-charjz-inner 標點原子)。穿越這些
* 才能正確找到跨包裝之邏輯鄰字(如 jinze 已把 pill 納入 jz-jinze 後,pill 之左 * 才能正確找到跨包裝之邏輯鄰字(如 jinze 已把 pill 納入 jz-jinze 後,pill 之左
* 鄰字仍須可達;I7)。 * 鄰字仍須可達;I7)。
* ⚠ 以**元素**而非標籤名判定:pillisolate/avoid 縱使標籤名在樣式集亦為邊界、
* 不可穿越(下游問題六:KaTeX 全樹皆 SPAN,名字判定令整棵公式被當透明下潛而
* 肢解;scope.avoidskipAttribute 命中自身者同理)。
* ⚠ jz-charjz-inner 必須透明:否則 adjacentLogicalChar 之 descend 遇標點原子既 * ⚠ jz-charjz-inner 必須透明:否則 adjacentLogicalChar 之 descend 遇標點原子既
* 不進入亦不終止、而是**跳過**它續找更前之兄弟(下游 Bug`汉字。<code>` 經 jinze * 不進入亦不終止、而是**跳過**它續找更前之兄弟(下游 Bug`汉字。<code>` 經 jinze
* 綁定為 `汉<jz-jinze>字<jz-char>。</jz-char></jz-jinze><code>`pill 之左鄰字應 * 綁定為 `汉<jz-jinze>字<jz-char>。</jz-char></jz-jinze><code>`pill 之左鄰字應
@@ -58,34 +79,51 @@ var Juzhen = (() => {
* jz-char 僅含標點(jiya 只 charify biaodian),標點非 pillNeighbor,故透明後 descend * jz-char 僅含標點(jiya 只 charify biaodian),標點非 pillNeighbor,故透明後 descend
* 回傳標點 → 不補隙,與 collectRuns 之既有透明遞迴一致。jz-inner 亦須列入,否則 * 回傳標點 → 不補隙,與 collectRuns 之既有透明遞迴一致。jz-inner 亦須列入,否則
* descend 進入 jz-char 後因 jz-inner 不透明而跳過其內標點。 */ * descend 進入 jz-char 後因 jz-inner 不透明而跳過其內標點。 */
isTransparentInline(name) { transparentInline(el) {
if (this.pillMatches(el) || this.isolateMatches(el) || this.avoidsSelf(el)) {
return false;
}
const name = el.nodeName;
return this.styleSet.has(name) || name === "JZ-HWS" || name === "JZ-JINZE" || name === "JZ-CHAR" || name === "JZ-INNER"; return this.styleSet.has(name) || name === "JZ-HWS" || name === "JZ-JINZE" || name === "JZ-CHAR" || name === "JZ-INNER";
} }
/** 節點是否處於 avoid 子樹(內建 skip 名單 / SVG / contentEditable / /** 該元素**自身**是否為 avoid 邊界(內建 skip 名單 / SVG / contentEditable /
* data-jz-skip / 使用者 avoid 選擇器 / skipAttribute / isSkipped)。 */ * data-jz-skip / skipAttribute / avoid 選擇器 / isSkipped)。isAvoided 沿祖先
* 鏈逐層呼叫本判定;遍歷原語另須對「正要進入」之元素直接判自身——舊版僅查
* 祖先,元素自身命中 avoid 時被當透明穿越(下游問題六:scope.avoid 對 .katex
* 形同虛設)。 */
avoidsSelf(el) {
if (this.userIsSkipped && this.userIsSkipped(el)) {
return true;
}
if (this.skipSet.has(el.nodeName)) {
return true;
}
if (el.namespaceURI === SVG_NS) {
return true;
}
if (el.isContentEditable) {
return true;
}
if (el.hasAttribute("data-jz-skip")) {
return true;
}
if (this.skipAttr && el.hasAttribute(this.skipAttr)) {
return true;
}
if (this.avoidSelector && el.matches && el.matches(this.avoidSelector)) {
return true;
}
return false;
}
/** 節點是否處於 avoid 子樹(祖先鏈上任一元素 avoidsSelf;自身不計——元素
* 自身之邊界判定由遍歷原語以 avoidsSelf 執行)。 */
isAvoided(node) { isAvoided(node) {
if (this.userIsSkipped && this.userIsSkipped(node)) { if (this.userIsSkipped && this.userIsSkipped(node)) {
return true; return true;
} }
let p = node.parentNode; let p = node.parentNode;
while (p && p.nodeType === 1) { while (p && p.nodeType === 1) {
const el = p; if (this.avoidsSelf(p)) {
if (this.skipSet.has(el.nodeName)) {
return true;
}
if (el.namespaceURI === SVG_NS) {
return true;
}
if (el.isContentEditable) {
return true;
}
if (el.hasAttribute("data-jz-skip")) {
return true;
}
if (this.skipAttr && el.hasAttribute(this.skipAttr)) {
return true;
}
if (this.avoidSelector && el.matches && el.matches(this.avoidSelector)) {
return true; return true;
} }
p = p.parentNode; p = p.parentNode;
@@ -103,7 +141,7 @@ var Juzhen = (() => {
} }
return !!(el.closest && el.closest(this.includeSelector)); return !!(el.closest && el.closest(this.includeSelector));
} }
/** 走訪 root 下所有「可處理」文本節點(已過濾 avoid / 非作用域)。 */ /** 走訪 root 下所有「可處理」文本節點(已過濾 avoid / pill 內部 / 非作用域)。 */
eachTextNode(root, cb) { eachTextNode(root, cb) {
const ownerDoc = root.ownerDocument || (typeof document !== "undefined" ? document : null); const ownerDoc = root.ownerDocument || (typeof document !== "undefined" ? document : null);
if (!ownerDoc) { if (!ownerDoc) {
@@ -114,7 +152,7 @@ var Juzhen = (() => {
let n = walker.nextNode(); let n = walker.nextNode();
while (n) { while (n) {
const t = n; const t = n;
if (!this.isAvoided(t) && this.inScope(t)) { if (!this.isAvoided(t) && !this.insidePill(t) && this.inScope(t)) {
nodes.push(t); nodes.push(t);
} }
n = walker.nextNode(); n = walker.nextNode();
@@ -176,11 +214,7 @@ var Juzhen = (() => {
return; return;
} }
const el = node; const el = node;
if (this.isAvoided(el)) { if (this.avoidsSelf(el) || this.blockSet.has(el.nodeName)) {
flush();
return;
}
if (this.skipSet.has(el.nodeName) || this.blockSet.has(el.nodeName)) {
flush(); flush();
return; return;
} }
@@ -188,6 +222,16 @@ var Juzhen = (() => {
flush(); flush();
return; return;
} }
if (this.isolateMatches(el)) {
flush();
let ic = el.firstChild;
while (ic) {
visit(ic);
ic = ic.nextSibling;
}
flush();
return;
}
let c2 = el.firstChild; let c2 = el.firstChild;
while (c2) { while (c2) {
visit(c2); visit(c2);
@@ -223,7 +267,10 @@ var Juzhen = (() => {
if (n2.nodeType === 3 && n2.data.length > 0) { if (n2.nodeType === 3 && n2.data.length > 0) {
return pickEnd(n2); return pickEnd(n2);
} }
if (n2.nodeType === 1 && this.isTransparentInline(n2.nodeName)) { if (n2.nodeType === 1) {
if (!this.transparentInline(n2)) {
return "blocked";
}
const r = descend(n2); const r = descend(n2);
if (r) { if (r) {
return r; return r;
@@ -246,22 +293,22 @@ var Juzhen = (() => {
} }
if (n.nodeType === 1) { if (n.nodeType === 1) {
const e = n; const e = n;
if (this.isAvoided(e)) { if (!this.transparentInline(e)) {
return null; return null;
} }
if (this.isTransparentInline(e.nodeName)) { const r = descend(e);
const r = descend(e); if (r === "blocked") {
if (r) { return null;
return r;
}
n = direction === -1 ? e.previousSibling : e.nextSibling;
continue;
} }
return null; if (r) {
return r;
}
n = direction === -1 ? e.previousSibling : e.nextSibling;
continue;
} }
n = direction === -1 ? n.previousSibling : n.nextSibling; n = direction === -1 ? n.previousSibling : n.nextSibling;
} }
if (!p || p.nodeType !== 1 || !this.isTransparentInline(p.nodeName)) { if (!p || p.nodeType !== 1 || !this.transparentInline(p)) {
return null; return null;
} }
const pe = p; const pe = p;
@@ -840,7 +887,11 @@ var Juzhen = (() => {
prev = el; prev = el;
} else if (finder.pillMatches(el)) { } else if (finder.pillMatches(el)) {
prev = null; prev = null;
} else if (finder.isAvoided(el)) { } else if (finder.avoidsSelf(el)) {
prev = null;
} else if (finder.isolateMatches(el)) {
prev = null;
visit(el);
prev = null; prev = null;
} else if (blockSet.has(nm)) { } else if (blockSet.has(nm)) {
prev = null; prev = null;
@@ -868,26 +919,25 @@ var Juzhen = (() => {
"U", "U",
"S", "S",
"SPAN", "SPAN",
"MARK",
"SMALL", "SMALL",
"INS", "INS",
"DEL", "DEL",
"SUB", "SUB",
"SUP" "SUP"
]); ]);
function canSplit(el) { function canSplit(el, finder) {
return SPLITTABLE.has(el.nodeName) && !el.hasAttribute("id"); return SPLITTABLE.has(el.nodeName) && !el.hasAttribute("id") && !finder.pillMatches(el) && !finder.isolateMatches(el) && !finder.avoidsSelf(el);
} }
function firstText(el) { function firstText(el, finder) {
for (let c = el.firstChild; c; c = c.nextSibling) { for (let c = el.firstChild; c; c = c.nextSibling) {
if (c.nodeType === 3 && c.data.length > 0) { if (c.nodeType === 3 && c.data.length > 0) {
return c; return c;
} }
if (c.nodeType === 1) { if (c.nodeType === 1) {
if (!canSplit(c)) { if (!canSplit(c, finder)) {
return null; return null;
} }
const r = firstText(c); const r = firstText(c, finder);
if (r) { if (r) {
return r; return r;
} }
@@ -895,16 +945,16 @@ var Juzhen = (() => {
} }
return null; return null;
} }
function lastText(el) { function lastText(el, finder) {
for (let c = el.lastChild; c; c = c.previousSibling) { for (let c = el.lastChild; c; c = c.previousSibling) {
if (c.nodeType === 3 && c.data.length > 0) { if (c.nodeType === 3 && c.data.length > 0) {
return c; return c;
} }
if (c.nodeType === 1) { if (c.nodeType === 1) {
if (!canSplit(c)) { if (!canSplit(c, finder)) {
return null; return null;
} }
const r = lastText(c); const r = lastText(c, finder);
if (r) { if (r) {
return r; return r;
} }
@@ -936,8 +986,8 @@ var Juzhen = (() => {
} }
return s; return s;
} }
function splitTreeAfter(top, textNode, offset, pass) { function splitTreeAfter(top, textNode, offset, pass, finder) {
if (offset <= 0 && firstText(top) === textNode) { if (offset <= 0 && firstText(top, finder) === textNode) {
return null; return null;
} }
let rightAtLevel; let rightAtLevel;
@@ -987,26 +1037,26 @@ var Juzhen = (() => {
} }
return topClone; return topClone;
} }
function isolateBoundaryToken(el, side, anyCjk, pass) { function isolateBoundaryToken(el, side, anyCjk, pass, finder) {
if (!canSplit(el)) { if (!canSplit(el, finder)) {
return el; return el;
} }
const text = side === "tail" ? lastText(el) : firstText(el); const text = side === "tail" ? lastText(el, finder) : firstText(el, finder);
if (!text) { if (!text) {
return el; return el;
} }
const point = side === "tail" ? { textNode: text, offset: trailingTokenStart(text.data, anyCjk) } : { textNode: text, offset: leadingTokenEnd(text.data, anyCjk) }; const point = side === "tail" ? { textNode: text, offset: trailingTokenStart(text.data, anyCjk) } : { textNode: text, offset: leadingTokenEnd(text.data, anyCjk) };
const clone = splitTreeAfter(el, point.textNode, point.offset, pass); const clone = splitTreeAfter(el, point.textNode, point.offset, pass, finder);
if (!clone) { if (!clone) {
return el; return el;
} }
return side === "tail" ? clone : el; return side === "tail" ? clone : el;
} }
function splitElementAt(el, textNode, offset, pass) { function splitElementAt(el, textNode, offset, pass, finder) {
if (!canSplit(el)) { if (!canSplit(el, finder)) {
return null; return null;
} }
return splitTreeAfter(el, textNode, offset, pass); return splitTreeAfter(el, textNode, offset, pass, finder);
} }
// src/typeset/jinze.ts // src/typeset/jinze.ts
@@ -1104,10 +1154,10 @@ var Juzhen = (() => {
} else if (u.nodeType === 1) { } else if (u.nodeType === 1) {
const el = u; const el = u;
if (needLast) { if (needLast) {
isolateBoundaryToken(el, "tail", anyCjk, PASS2); isolateBoundaryToken(el, "tail", anyCjk, PASS2, finder);
} }
if (needFirst) { if (needFirst) {
isolateBoundaryToken(el, "head", anyCjk, PASS2); isolateBoundaryToken(el, "head", anyCjk, PASS2, finder);
} }
} }
} }
@@ -1449,7 +1499,7 @@ var Juzhen = (() => {
return; return;
} }
const el = node; const el = node;
if (finder.isAvoided(el)) { if (finder.avoidsSelf(el)) {
return; return;
} }
if (blockSet.has(el.nodeName) || finder.pillMatches(el)) { if (blockSet.has(el.nodeName) || finder.pillMatches(el)) {
@@ -1483,7 +1533,8 @@ var Juzhen = (() => {
startChild, startChild,
boundary.textNode, boundary.textNode,
boundary.offset, boundary.offset,
PASS4 PASS4,
finder
); );
if (clone) { if (clone) {
startChild = clone; startChild = clone;
@@ -1517,6 +1568,9 @@ var Juzhen = (() => {
if (!finder.featureEnabledFor(block, PASS4)) { if (!finder.featureEnabledFor(block, PASS4)) {
return; return;
} }
if (finder.avoidsSelf(block) || finder.isAvoided(block)) {
return;
}
const sel = Array.from(blockSet).map((t) => t.toLowerCase()).join(","); const sel = Array.from(blockSet).map((t) => t.toLowerCase()).join(",");
if (sel && block.querySelector(sel)) { if (sel && block.querySelector(sel)) {
return; return;
@@ -1693,9 +1747,9 @@ var Juzhen = (() => {
} }
} }
} }
function processPills(root, finder, pillSelector, pillNeighbor) { function processPills(root, finder, boundarySelector, pillNeighbor) {
root.querySelectorAll(pillSelector).forEach((pill) => { root.querySelectorAll(boundarySelector).forEach((pill) => {
if (finder.isAvoided(pill) || !finder.inScope(pill)) { if (finder.isAvoided(pill) || finder.insidePill(pill) || !finder.inScope(pill)) {
return; return;
} }
if (!finder.featureEnabledFor(pill, PASS5)) { if (!finder.featureEnabledFor(pill, PASS5)) {
@@ -1760,68 +1814,16 @@ var Juzhen = (() => {
if (!options.finder.blockTags.has(ctx.root.nodeName) && finder.featureEnabledFor(ctx.root, PASS5) && finder.inScope(ctx.root) && !finder.isAvoided(ctx.root)) { if (!options.finder.blockTags.has(ctx.root.nodeName) && finder.featureEnabledFor(ctx.root, PASS5) && finder.inScope(ctx.root) && !finder.isAvoided(ctx.root)) {
processBlock2(ctx.root, finder, R); processBlock2(ctx.root, finder, R);
} }
processPills(ctx.root, finder, options.finder.pillSelector, R.pillNeighbor); const boundarySelector = [options.finder.pillSelector, options.finder.isolateSelector].filter(Boolean).join(", ");
if (boundarySelector) {
processPills(ctx.root, finder, boundarySelector, R.pillNeighbor);
}
}, },
revert(ctx) { revert(ctx) {
revertPass(PASS5, ctx.root); revertPass(PASS5, ctx.root);
} }
}; };
// src/compat/v1.ts
function mapLongWord(v) {
if (v === false) {
return false;
}
if (v === void 0 || v === true || v === null) {
return true;
}
const out = {};
if (typeof v.minLength === "number") {
out.minLength = v.minLength;
}
if (typeof v.lang === "string") {
out.lang = v.lang;
}
return out;
}
function createCjkAutospace(options = {}) {
const o = options || {};
const mapped = {
// finder 設定直通。
...o.styleInlines ? { styleInlines: o.styleInlines } : {},
...o.blockTags ? { blockTags: o.blockTags } : {},
...o.skipTags ? { skipTags: o.skipTags } : {},
pillSelector: o.pillSelector || "code, kbd",
autospaceClass: o.autospaceClass || "cjk-autospace",
skipAttribute: o.skipAttribute || "data-md-key",
...o.isSkipped ? { isSkipped: o.isSkipped } : {},
spacing: true,
longWord: mapLongWord(o.longWordWrap),
// v1 Pass D 不分 lang 地擠句內點號(開明式);compat 強制 kaiming
// 政策以避免功能倒退(預設 zh-Hant→quanjiao 不擠壓)。
jiya: o.punctuationSqueeze === false ? false : true,
// v1 無連續標點綁定,保持 DOM 與 v1 一致:關閉 jinze。
jinze: false,
lang: {
policy: {
"zh-Hant": "kaiming",
"zh-Hans": "kaiming",
"ja": "kaiming",
"other": "kaiming"
}
}
};
const jz = createJuzhen(mapped);
return {
apply(root) {
jz.render(root);
},
revert(root) {
jz.revert(root);
}
};
}
// src/index.ts // src/index.ts
var DEFAULT_STYLE_INLINES = [ var DEFAULT_STYLE_INLINES = [
"STRONG", "STRONG",
@@ -1960,7 +1962,6 @@ var Juzhen = (() => {
policy: langOpt.policy || {} policy: langOpt.policy || {}
}, },
ruleset: resolveRuleset(opts.charClass), ruleset: resolveRuleset(opts.charClass),
autospaceClass: opts.autospaceClass || "",
justifyAtoms: opts.justifyAtoms === false ? false : true, justifyAtoms: opts.justifyAtoms === false ? false : true,
levelText: opts.level && opts.level.text || null, levelText: opts.level && opts.level.text || null,
scope: { scope: {
@@ -1990,6 +1991,9 @@ var Juzhen = (() => {
blockTags: new Set(opts.blockTags || DEFAULT_BLOCK_TAGS), blockTags: new Set(opts.blockTags || DEFAULT_BLOCK_TAGS),
skipTags: new Set(opts.skipTags || DEFAULT_SKIP_TAGS), skipTags: new Set(opts.skipTags || DEFAULT_SKIP_TAGS),
pillSelector: opts.pillSelector || "code, kbd", pillSelector: opts.pillSelector || "code, kbd",
// 內外隔離(§4.1 三分類):預設 <mark>(瀏覽器預設帶背景,切分有可見
// 接縫、邊界需留隙)。顯式判 undefined,使 "" 可停用。
isolateSelector: opts.isolateSelector !== void 0 ? opts.isolateSelector : "mark",
skipAttribute: opts.skipAttribute || null, skipAttribute: opts.skipAttribute || null,
isSkipped: typeof opts.isSkipped === "function" ? opts.isSkipped : null isSkipped: typeof opts.isSkipped === "function" ? opts.isSkipped : null
} }
@@ -2038,4 +2042,4 @@ var Juzhen = (() => {
} }
return __toCommonJS(index_exports); return __toCommonJS(index_exports);
})(); })();
if(typeof globalThis!=='undefined'){globalThis.Juzhen=Juzhen;if(Juzhen&&Juzhen.createCjkAutospace&&!globalThis.createCjkAutospace){globalThis.createCjkAutospace=Juzhen.createCjkAutospace;}} if(typeof globalThis!=='undefined'){globalThis.Juzhen=Juzhen;}
+127 -123
View File
@@ -7,6 +7,7 @@ var Finder = class {
this.blockSet = opts.finder.blockTags; this.blockSet = opts.finder.blockTags;
this.skipSet = opts.finder.skipTags; this.skipSet = opts.finder.skipTags;
this.pillSelector = opts.finder.pillSelector; this.pillSelector = opts.finder.pillSelector;
this.isolateSelector = opts.finder.isolateSelector;
this.skipAttr = opts.finder.skipAttribute; this.skipAttr = opts.finder.skipAttribute;
this.userIsSkipped = opts.finder.isSkipped; this.userIsSkipped = opts.finder.isSkipped;
this.levelTextSelector = opts.levelText; this.levelTextSelector = opts.levelText;
@@ -15,14 +16,35 @@ var Finder = class {
this.featureCache = /* @__PURE__ */ new WeakMap(); this.featureCache = /* @__PURE__ */ new WeakMap();
this.levelCache = /* @__PURE__ */ new WeakMap(); this.levelCache = /* @__PURE__ */ new WeakMap();
} }
/** 該元素是否匹配 pill 選擇器(行內塊,如 code/kbd)。 */ /** 該元素是否匹配 pill 選擇器(三分類之「整體」:codekbd/行內公式等)。 */
pillMatches(el) { pillMatches(el) {
return !!(el.matches && this.pillSelector && el.matches(this.pillSelector)); return !!(el.matches && this.pillSelector && el.matches(this.pillSelector));
} }
/** 該元素是否匹配 isolate 選擇器(三分類之「隔離」:帶盒樣式之行內,如背景色
* <mark>)。邊界口徑同 pill(斷 run/斷相鄰/不可切分/兩端補隙),內部照常處理。 */
isolateMatches(el) {
return !!(el.matches && this.isolateSelector && el.matches(this.isolateSelector));
}
/** 節點是否位於 pill **內部**(僅查祖先,不含自身)。pill 為整體元素:內部對
* 一切 pass 不可見——不 charify、不收 run、不切分、不於其內插間隙(下游問題六:
* .katex 內部曾被 jiyaspacing 直接變更)。 */
insidePill(node) {
let p = node.parentNode;
while (p && p.nodeType === 1) {
if (this.pillMatches(p)) {
return true;
}
p = p.parentNode;
}
return false;
}
/** 邏輯文本應**透明穿越**之行內元素:樣式行內標籤,以及聚珍自身之 wrap 類 /** 邏輯文本應**透明穿越**之行內元素:樣式行內標籤,以及聚珍自身之 wrap 類
* jz-*jz-hws 間隙、jz-jinze 禁則群組、jz-charjz-inner 標點原子)。穿越這些 * jz-*jz-hws 間隙、jz-jinze 禁則群組、jz-charjz-inner 標點原子)。穿越這些
* 才能正確找到跨包裝之邏輯鄰字(如 jinze 已把 pill 納入 jz-jinze 後,pill 之左 * 才能正確找到跨包裝之邏輯鄰字(如 jinze 已把 pill 納入 jz-jinze 後,pill 之左
* 鄰字仍須可達;I7)。 * 鄰字仍須可達;I7)。
* ⚠ 以**元素**而非標籤名判定:pillisolate/avoid 縱使標籤名在樣式集亦為邊界、
* 不可穿越(下游問題六:KaTeX 全樹皆 SPAN,名字判定令整棵公式被當透明下潛而
* 肢解;scope.avoidskipAttribute 命中自身者同理)。
* ⚠ jz-charjz-inner 必須透明:否則 adjacentLogicalChar 之 descend 遇標點原子既 * ⚠ jz-charjz-inner 必須透明:否則 adjacentLogicalChar 之 descend 遇標點原子既
* 不進入亦不終止、而是**跳過**它續找更前之兄弟(下游 Bug`汉字。<code>` 經 jinze * 不進入亦不終止、而是**跳過**它續找更前之兄弟(下游 Bug`汉字。<code>` 經 jinze
* 綁定為 `汉<jz-jinze>字<jz-char>。</jz-char></jz-jinze><code>`pill 之左鄰字應 * 綁定為 `汉<jz-jinze>字<jz-char>。</jz-char></jz-jinze><code>`pill 之左鄰字應
@@ -30,34 +52,51 @@ var Finder = class {
* jz-char 僅含標點(jiya 只 charify biaodian),標點非 pillNeighbor,故透明後 descend * jz-char 僅含標點(jiya 只 charify biaodian),標點非 pillNeighbor,故透明後 descend
* 回傳標點 → 不補隙,與 collectRuns 之既有透明遞迴一致。jz-inner 亦須列入,否則 * 回傳標點 → 不補隙,與 collectRuns 之既有透明遞迴一致。jz-inner 亦須列入,否則
* descend 進入 jz-char 後因 jz-inner 不透明而跳過其內標點。 */ * descend 進入 jz-char 後因 jz-inner 不透明而跳過其內標點。 */
isTransparentInline(name) { transparentInline(el) {
if (this.pillMatches(el) || this.isolateMatches(el) || this.avoidsSelf(el)) {
return false;
}
const name = el.nodeName;
return this.styleSet.has(name) || name === "JZ-HWS" || name === "JZ-JINZE" || name === "JZ-CHAR" || name === "JZ-INNER"; return this.styleSet.has(name) || name === "JZ-HWS" || name === "JZ-JINZE" || name === "JZ-CHAR" || name === "JZ-INNER";
} }
/** 節點是否處於 avoid 子樹(內建 skip 名單 / SVG / contentEditable / /** 該元素**自身**是否為 avoid 邊界(內建 skip 名單 / SVG / contentEditable /
* data-jz-skip / 使用者 avoid 選擇器 / skipAttribute / isSkipped)。 */ * data-jz-skip / skipAttribute / avoid 選擇器 / isSkipped)。isAvoided 沿祖先
* 鏈逐層呼叫本判定;遍歷原語另須對「正要進入」之元素直接判自身——舊版僅查
* 祖先,元素自身命中 avoid 時被當透明穿越(下游問題六:scope.avoid 對 .katex
* 形同虛設)。 */
avoidsSelf(el) {
if (this.userIsSkipped && this.userIsSkipped(el)) {
return true;
}
if (this.skipSet.has(el.nodeName)) {
return true;
}
if (el.namespaceURI === SVG_NS) {
return true;
}
if (el.isContentEditable) {
return true;
}
if (el.hasAttribute("data-jz-skip")) {
return true;
}
if (this.skipAttr && el.hasAttribute(this.skipAttr)) {
return true;
}
if (this.avoidSelector && el.matches && el.matches(this.avoidSelector)) {
return true;
}
return false;
}
/** 節點是否處於 avoid 子樹(祖先鏈上任一元素 avoidsSelf;自身不計——元素
* 自身之邊界判定由遍歷原語以 avoidsSelf 執行)。 */
isAvoided(node) { isAvoided(node) {
if (this.userIsSkipped && this.userIsSkipped(node)) { if (this.userIsSkipped && this.userIsSkipped(node)) {
return true; return true;
} }
let p = node.parentNode; let p = node.parentNode;
while (p && p.nodeType === 1) { while (p && p.nodeType === 1) {
const el = p; if (this.avoidsSelf(p)) {
if (this.skipSet.has(el.nodeName)) {
return true;
}
if (el.namespaceURI === SVG_NS) {
return true;
}
if (el.isContentEditable) {
return true;
}
if (el.hasAttribute("data-jz-skip")) {
return true;
}
if (this.skipAttr && el.hasAttribute(this.skipAttr)) {
return true;
}
if (this.avoidSelector && el.matches && el.matches(this.avoidSelector)) {
return true; return true;
} }
p = p.parentNode; p = p.parentNode;
@@ -75,7 +114,7 @@ var Finder = class {
} }
return !!(el.closest && el.closest(this.includeSelector)); return !!(el.closest && el.closest(this.includeSelector));
} }
/** 走訪 root 下所有「可處理」文本節點(已過濾 avoid / 非作用域)。 */ /** 走訪 root 下所有「可處理」文本節點(已過濾 avoid / pill 內部 / 非作用域)。 */
eachTextNode(root, cb) { eachTextNode(root, cb) {
const ownerDoc = root.ownerDocument || (typeof document !== "undefined" ? document : null); const ownerDoc = root.ownerDocument || (typeof document !== "undefined" ? document : null);
if (!ownerDoc) { if (!ownerDoc) {
@@ -86,7 +125,7 @@ var Finder = class {
let n = walker.nextNode(); let n = walker.nextNode();
while (n) { while (n) {
const t = n; const t = n;
if (!this.isAvoided(t) && this.inScope(t)) { if (!this.isAvoided(t) && !this.insidePill(t) && this.inScope(t)) {
nodes.push(t); nodes.push(t);
} }
n = walker.nextNode(); n = walker.nextNode();
@@ -148,11 +187,7 @@ var Finder = class {
return; return;
} }
const el = node; const el = node;
if (this.isAvoided(el)) { if (this.avoidsSelf(el) || this.blockSet.has(el.nodeName)) {
flush();
return;
}
if (this.skipSet.has(el.nodeName) || this.blockSet.has(el.nodeName)) {
flush(); flush();
return; return;
} }
@@ -160,6 +195,16 @@ var Finder = class {
flush(); flush();
return; return;
} }
if (this.isolateMatches(el)) {
flush();
let ic = el.firstChild;
while (ic) {
visit(ic);
ic = ic.nextSibling;
}
flush();
return;
}
let c2 = el.firstChild; let c2 = el.firstChild;
while (c2) { while (c2) {
visit(c2); visit(c2);
@@ -195,7 +240,10 @@ var Finder = class {
if (n2.nodeType === 3 && n2.data.length > 0) { if (n2.nodeType === 3 && n2.data.length > 0) {
return pickEnd(n2); return pickEnd(n2);
} }
if (n2.nodeType === 1 && this.isTransparentInline(n2.nodeName)) { if (n2.nodeType === 1) {
if (!this.transparentInline(n2)) {
return "blocked";
}
const r = descend(n2); const r = descend(n2);
if (r) { if (r) {
return r; return r;
@@ -218,22 +266,22 @@ var Finder = class {
} }
if (n.nodeType === 1) { if (n.nodeType === 1) {
const e = n; const e = n;
if (this.isAvoided(e)) { if (!this.transparentInline(e)) {
return null; return null;
} }
if (this.isTransparentInline(e.nodeName)) { const r = descend(e);
const r = descend(e); if (r === "blocked") {
if (r) { return null;
return r;
}
n = direction === -1 ? e.previousSibling : e.nextSibling;
continue;
} }
return null; if (r) {
return r;
}
n = direction === -1 ? e.previousSibling : e.nextSibling;
continue;
} }
n = direction === -1 ? n.previousSibling : n.nextSibling; n = direction === -1 ? n.previousSibling : n.nextSibling;
} }
if (!p || p.nodeType !== 1 || !this.isTransparentInline(p.nodeName)) { if (!p || p.nodeType !== 1 || !this.transparentInline(p)) {
return null; return null;
} }
const pe = p; const pe = p;
@@ -812,7 +860,11 @@ var jiyaAdjacencyPass = {
prev = el; prev = el;
} else if (finder.pillMatches(el)) { } else if (finder.pillMatches(el)) {
prev = null; prev = null;
} else if (finder.isAvoided(el)) { } else if (finder.avoidsSelf(el)) {
prev = null;
} else if (finder.isolateMatches(el)) {
prev = null;
visit(el);
prev = null; prev = null;
} else if (blockSet.has(nm)) { } else if (blockSet.has(nm)) {
prev = null; prev = null;
@@ -840,26 +892,25 @@ var SPLITTABLE = /* @__PURE__ */ new Set([
"U", "U",
"S", "S",
"SPAN", "SPAN",
"MARK",
"SMALL", "SMALL",
"INS", "INS",
"DEL", "DEL",
"SUB", "SUB",
"SUP" "SUP"
]); ]);
function canSplit(el) { function canSplit(el, finder) {
return SPLITTABLE.has(el.nodeName) && !el.hasAttribute("id"); return SPLITTABLE.has(el.nodeName) && !el.hasAttribute("id") && !finder.pillMatches(el) && !finder.isolateMatches(el) && !finder.avoidsSelf(el);
} }
function firstText(el) { function firstText(el, finder) {
for (let c = el.firstChild; c; c = c.nextSibling) { for (let c = el.firstChild; c; c = c.nextSibling) {
if (c.nodeType === 3 && c.data.length > 0) { if (c.nodeType === 3 && c.data.length > 0) {
return c; return c;
} }
if (c.nodeType === 1) { if (c.nodeType === 1) {
if (!canSplit(c)) { if (!canSplit(c, finder)) {
return null; return null;
} }
const r = firstText(c); const r = firstText(c, finder);
if (r) { if (r) {
return r; return r;
} }
@@ -867,16 +918,16 @@ function firstText(el) {
} }
return null; return null;
} }
function lastText(el) { function lastText(el, finder) {
for (let c = el.lastChild; c; c = c.previousSibling) { for (let c = el.lastChild; c; c = c.previousSibling) {
if (c.nodeType === 3 && c.data.length > 0) { if (c.nodeType === 3 && c.data.length > 0) {
return c; return c;
} }
if (c.nodeType === 1) { if (c.nodeType === 1) {
if (!canSplit(c)) { if (!canSplit(c, finder)) {
return null; return null;
} }
const r = lastText(c); const r = lastText(c, finder);
if (r) { if (r) {
return r; return r;
} }
@@ -908,8 +959,8 @@ function trailingTokenStart(d, anyCjk) {
} }
return s; return s;
} }
function splitTreeAfter(top, textNode, offset, pass) { function splitTreeAfter(top, textNode, offset, pass, finder) {
if (offset <= 0 && firstText(top) === textNode) { if (offset <= 0 && firstText(top, finder) === textNode) {
return null; return null;
} }
let rightAtLevel; let rightAtLevel;
@@ -959,26 +1010,26 @@ function splitTreeAfter(top, textNode, offset, pass) {
} }
return topClone; return topClone;
} }
function isolateBoundaryToken(el, side, anyCjk, pass) { function isolateBoundaryToken(el, side, anyCjk, pass, finder) {
if (!canSplit(el)) { if (!canSplit(el, finder)) {
return el; return el;
} }
const text = side === "tail" ? lastText(el) : firstText(el); const text = side === "tail" ? lastText(el, finder) : firstText(el, finder);
if (!text) { if (!text) {
return el; return el;
} }
const point = side === "tail" ? { textNode: text, offset: trailingTokenStart(text.data, anyCjk) } : { textNode: text, offset: leadingTokenEnd(text.data, anyCjk) }; const point = side === "tail" ? { textNode: text, offset: trailingTokenStart(text.data, anyCjk) } : { textNode: text, offset: leadingTokenEnd(text.data, anyCjk) };
const clone = splitTreeAfter(el, point.textNode, point.offset, pass); const clone = splitTreeAfter(el, point.textNode, point.offset, pass, finder);
if (!clone) { if (!clone) {
return el; return el;
} }
return side === "tail" ? clone : el; return side === "tail" ? clone : el;
} }
function splitElementAt(el, textNode, offset, pass) { function splitElementAt(el, textNode, offset, pass, finder) {
if (!canSplit(el)) { if (!canSplit(el, finder)) {
return null; return null;
} }
return splitTreeAfter(el, textNode, offset, pass); return splitTreeAfter(el, textNode, offset, pass, finder);
} }
// src/typeset/jinze.ts // src/typeset/jinze.ts
@@ -1076,10 +1127,10 @@ function processParent(parent, finder, anyCjk) {
} else if (u.nodeType === 1) { } else if (u.nodeType === 1) {
const el = u; const el = u;
if (needLast) { if (needLast) {
isolateBoundaryToken(el, "tail", anyCjk, PASS2); isolateBoundaryToken(el, "tail", anyCjk, PASS2, finder);
} }
if (needFirst) { if (needFirst) {
isolateBoundaryToken(el, "head", anyCjk, PASS2); isolateBoundaryToken(el, "head", anyCjk, PASS2, finder);
} }
} }
} }
@@ -1421,7 +1472,7 @@ function collectSubst(block, finder, blockSet, anyCjk, biaodian) {
return; return;
} }
const el = node; const el = node;
if (finder.isAvoided(el)) { if (finder.avoidsSelf(el)) {
return; return;
} }
if (blockSet.has(el.nodeName) || finder.pillMatches(el)) { if (blockSet.has(el.nodeName) || finder.pillMatches(el)) {
@@ -1455,7 +1506,8 @@ function processBlock(block, finder, blockSet, anyCjk, biaodian, chars) {
startChild, startChild,
boundary.textNode, boundary.textNode,
boundary.offset, boundary.offset,
PASS4 PASS4,
finder
); );
if (clone) { if (clone) {
startChild = clone; startChild = clone;
@@ -1489,6 +1541,9 @@ var orphanPass = {
if (!finder.featureEnabledFor(block, PASS4)) { if (!finder.featureEnabledFor(block, PASS4)) {
return; return;
} }
if (finder.avoidsSelf(block) || finder.isAvoided(block)) {
return;
}
const sel = Array.from(blockSet).map((t) => t.toLowerCase()).join(","); const sel = Array.from(blockSet).map((t) => t.toLowerCase()).join(",");
if (sel && block.querySelector(sel)) { if (sel && block.querySelector(sel)) {
return; return;
@@ -1665,9 +1720,9 @@ function processBlock2(block, finder, R) {
} }
} }
} }
function processPills(root, finder, pillSelector, pillNeighbor) { function processPills(root, finder, boundarySelector, pillNeighbor) {
root.querySelectorAll(pillSelector).forEach((pill) => { root.querySelectorAll(boundarySelector).forEach((pill) => {
if (finder.isAvoided(pill) || !finder.inScope(pill)) { if (finder.isAvoided(pill) || finder.insidePill(pill) || !finder.inScope(pill)) {
return; return;
} }
if (!finder.featureEnabledFor(pill, PASS5)) { if (!finder.featureEnabledFor(pill, PASS5)) {
@@ -1732,68 +1787,16 @@ var spacingPass = {
if (!options.finder.blockTags.has(ctx.root.nodeName) && finder.featureEnabledFor(ctx.root, PASS5) && finder.inScope(ctx.root) && !finder.isAvoided(ctx.root)) { if (!options.finder.blockTags.has(ctx.root.nodeName) && finder.featureEnabledFor(ctx.root, PASS5) && finder.inScope(ctx.root) && !finder.isAvoided(ctx.root)) {
processBlock2(ctx.root, finder, R); processBlock2(ctx.root, finder, R);
} }
processPills(ctx.root, finder, options.finder.pillSelector, R.pillNeighbor); const boundarySelector = [options.finder.pillSelector, options.finder.isolateSelector].filter(Boolean).join(", ");
if (boundarySelector) {
processPills(ctx.root, finder, boundarySelector, R.pillNeighbor);
}
}, },
revert(ctx) { revert(ctx) {
revertPass(PASS5, ctx.root); revertPass(PASS5, ctx.root);
} }
}; };
// src/compat/v1.ts
function mapLongWord(v) {
if (v === false) {
return false;
}
if (v === void 0 || v === true || v === null) {
return true;
}
const out = {};
if (typeof v.minLength === "number") {
out.minLength = v.minLength;
}
if (typeof v.lang === "string") {
out.lang = v.lang;
}
return out;
}
function createCjkAutospace(options = {}) {
const o = options || {};
const mapped = {
// finder 設定直通。
...o.styleInlines ? { styleInlines: o.styleInlines } : {},
...o.blockTags ? { blockTags: o.blockTags } : {},
...o.skipTags ? { skipTags: o.skipTags } : {},
pillSelector: o.pillSelector || "code, kbd",
autospaceClass: o.autospaceClass || "cjk-autospace",
skipAttribute: o.skipAttribute || "data-md-key",
...o.isSkipped ? { isSkipped: o.isSkipped } : {},
spacing: true,
longWord: mapLongWord(o.longWordWrap),
// v1 Pass D 不分 lang 地擠句內點號(開明式);compat 強制 kaiming
// 政策以避免功能倒退(預設 zh-Hant→quanjiao 不擠壓)。
jiya: o.punctuationSqueeze === false ? false : true,
// v1 無連續標點綁定,保持 DOM 與 v1 一致:關閉 jinze。
jinze: false,
lang: {
policy: {
"zh-Hant": "kaiming",
"zh-Hans": "kaiming",
"ja": "kaiming",
"other": "kaiming"
}
}
};
const jz = createJuzhen(mapped);
return {
apply(root) {
jz.render(root);
},
revert(root) {
jz.revert(root);
}
};
}
// src/index.ts // src/index.ts
var DEFAULT_STYLE_INLINES = [ var DEFAULT_STYLE_INLINES = [
"STRONG", "STRONG",
@@ -1932,7 +1935,6 @@ function normalizeOptions(opts = {}) {
policy: langOpt.policy || {} policy: langOpt.policy || {}
}, },
ruleset: resolveRuleset(opts.charClass), ruleset: resolveRuleset(opts.charClass),
autospaceClass: opts.autospaceClass || "",
justifyAtoms: opts.justifyAtoms === false ? false : true, justifyAtoms: opts.justifyAtoms === false ? false : true,
levelText: opts.level && opts.level.text || null, levelText: opts.level && opts.level.text || null,
scope: { scope: {
@@ -1962,6 +1964,9 @@ function normalizeOptions(opts = {}) {
blockTags: new Set(opts.blockTags || DEFAULT_BLOCK_TAGS), blockTags: new Set(opts.blockTags || DEFAULT_BLOCK_TAGS),
skipTags: new Set(opts.skipTags || DEFAULT_SKIP_TAGS), skipTags: new Set(opts.skipTags || DEFAULT_SKIP_TAGS),
pillSelector: opts.pillSelector || "code, kbd", pillSelector: opts.pillSelector || "code, kbd",
// 內外隔離(§4.1 三分類):預設 <mark>(瀏覽器預設帶背景,切分有可見
// 接縫、邊界需留隙)。顯式判 undefined,使 "" 可停用。
isolateSelector: opts.isolateSelector !== void 0 ? opts.isolateSelector : "mark",
skipAttribute: opts.skipAttribute || null, skipAttribute: opts.skipAttribute || null,
isSkipped: typeof opts.isSkipped === "function" ? opts.isSkipped : null isSkipped: typeof opts.isSkipped === "function" ? opts.isSkipped : null
} }
@@ -2009,7 +2014,6 @@ function createJuzhen(opts = {}) {
}; };
} }
export { export {
createCjkAutospace,
createJuzhen, createJuzhen,
normalizeOptions normalizeOptions
}; };
+7 -12
View File
@@ -12,9 +12,6 @@ export interface OrphanOptions {
/** 段末行至少保留之 CJK 實義字數(標點/符號不計;預設 2)。 */ /** 段末行至少保留之 CJK 實義字數(標點/符號不計;預設 2)。 */
chars?: number; chars?: number;
} }
/** §6.5.2squeezehang
* nonesqueeze hang */
export type LineEndMode = "squeeze" | "hang" | "none";
/** 標點擠壓設定(架構 §6.3)。布林 true 等同 { halfWidth: "halt" }。 */ /** 標點擠壓設定(架構 §6.3)。布林 true 等同 { halfWidth: "halt" }。 */
export interface JiyaOptions { export interface JiyaOptions {
/** /**
@@ -53,8 +50,6 @@ export interface JuzhenOptions {
level?: { level?: {
text?: string; text?: string;
}; };
/** 間隙標記沿用之 class(v1 相容;預設無額外 class)。 */
autospaceClass?: string;
/** justify jz-charjz-jinze inline-block /** justify jz-charjz-jinze inline-block
* true false display:inline Paged.js * true false display:inline Paged.js
* inline-block CJK inter-ideograph * inline-block CJK inter-ideograph
@@ -71,9 +66,9 @@ export interface JuzhenOptions {
jiya?: boolean | JiyaOptions; jiya?: boolean | JiyaOptions;
jinze?: boolean; jinze?: boolean;
orphan?: boolean | OrphanOptions; orphan?: boolean | OrphanOptions;
/** @deprecated ****§6.5.6 margin /** @deprecated ****§6.5.6 margin
* 滿resize hanging-punctuation Chrome * CSS hanging-punctuation Safari
* API **** squeeze */ * inline-block API **** */
hanging?: boolean; hanging?: boolean;
biaodian?: boolean; biaodian?: boolean;
emphasis?: boolean; emphasis?: boolean;
@@ -83,7 +78,10 @@ export interface JuzhenOptions {
styleInlines?: string[]; styleInlines?: string[];
blockTags?: string[]; blockTags?: string[];
skipTags?: string[]; skipTags?: string[];
/** 整體(pill)元素選擇器(預設 "code, kbd")。 */
pillSelector?: string; pillSelector?: string;
/** 內外隔離元素選擇器(預設 "mark")。傳 "" 停用(mark 回歸透明)。 */
isolateSelector?: string;
skipAttribute?: string; skipAttribute?: string;
isSkipped?: (node: Node) => boolean; isSkipped?: (node: Node) => boolean;
} }
@@ -92,7 +90,6 @@ export interface ResolvedOptions {
locale: LocaleConfig; locale: LocaleConfig;
/** 最終字符分類規則集(內置默認 + charClass 覆寫)。 */ /** 最終字符分類規則集(內置默認 + charClass 覆寫)。 */
ruleset: Ruleset; ruleset: Ruleset;
autospaceClass: string;
justifyAtoms: boolean; justifyAtoms: boolean;
/** 功能分級之 text-level 選擇器(§6.5.0a);null 表示未設。 */ /** 功能分級之 text-level 選擇器(§6.5.0a);null 表示未設。 */
levelText: string | null; levelText: string | null;
@@ -107,7 +104,6 @@ export interface ResolvedOptions {
jiya: boolean; jiya: boolean;
jinze: boolean; jinze: boolean;
orphan: boolean; orphan: boolean;
hanging: boolean;
biaodian: boolean; biaodian: boolean;
emphasis: boolean; emphasis: boolean;
ruby: boolean; ruby: boolean;
@@ -120,8 +116,6 @@ export interface ResolvedOptions {
orphan: { orphan: {
chars: number; chars: number;
}; };
/** 行端標點模式(解析後;§6.5.2)。 */
lineEnd: LineEndMode;
/** 擠壓半形渲染設定(halt 預設/margin 後備)。 */ /** 擠壓半形渲染設定(halt 預設/margin 後備)。 */
jiyaConfig: { jiyaConfig: {
halfWidth: "halt" | "margin"; halfWidth: "halt" | "margin";
@@ -133,6 +127,7 @@ export interface ResolvedOptions {
blockTags: Set<string>; blockTags: Set<string>;
skipTags: Set<string>; skipTags: Set<string>;
pillSelector: string; pillSelector: string;
isolateSelector: string;
skipAttribute: string | null; skipAttribute: string | null;
isSkipped: ((node: Node) => boolean) | null; isSkipped: ((node: Node) => boolean) | null;
}; };
-96
View File
@@ -1,96 +0,0 @@
// v1 向下相容層(架構 §3.5,I8)。
//
// 導出 createCjkAutospace(options),把 v1 API 映射到 createJuzhen,保證既有
// 消費端(build.sh 單檔串接 / Nvim peek / Claude 模板)不 break、無功能倒退。
// 行為等價但不要求逐位一致——主要差異:
// - 間隙標記由 <span class="cjk-autospace"> 變為 <jz-hws class="cjk-autospace">
// (仍帶原 class,故既有 `.cjk-autospace{user-select:none}` CSS 仍命中)。
// - 擠壓由消費端 `font-feature-settings:"halt"` 改為 juzhen.css 之負 margin
// (消費端須改用 juzhen.css;機制不同但同為「句內點號半形」之開明式效果)。
// 上述差異於 README 明列。
import { createJuzhen } from "../index.js";
import type { JuzhenOptions, LongWordOptions } from "../types.js";
interface V1LongWord
{
minLength?: number;
lang?: string;
}
interface V1Squeeze
{
squeezeClass?: string;
marks?: string[];
langWhitelist?: string[];
}
export interface V1Options
{
styleInlines?: string[];
blockTags?: string[];
skipTags?: string[];
pillSelector?: string;
autospaceClass?: string;
skipAttribute?: string;
isSkipped?: (node: Node) => boolean;
longWordWrap?: boolean | V1LongWord;
punctuationSqueeze?: boolean | V1Squeeze;
}
export interface V1Instance
{
apply(root?: Element): void;
revert(root?: Element): void;
}
function mapLongWord(v: boolean | V1LongWord | undefined): boolean | LongWordOptions
{
if (v === false) { return false; }
if (v === undefined || v === true || v === null) { return true; }
const out: LongWordOptions = {};
if (typeof v.minLength === "number") { out.minLength = v.minLength; }
if (typeof v.lang === "string") { out.lang = v.lang; }
return out;
}
/**
* v1 { apply, revert }applyrenderrevertrevert
*/
export function createCjkAutospace(options: V1Options = {}): V1Instance
{
const o = options || {};
const mapped: JuzhenOptions = {
// finder 設定直通。
...(o.styleInlines ? { styleInlines: o.styleInlines } : {}),
...(o.blockTags ? { blockTags: o.blockTags } : {}),
...(o.skipTags ? { skipTags: o.skipTags } : {}),
pillSelector: o.pillSelector || "code, kbd",
autospaceClass: o.autospaceClass || "cjk-autospace",
skipAttribute: o.skipAttribute || "data-md-key",
...(o.isSkipped ? { isSkipped: o.isSkipped } : {}),
spacing: true,
longWord: mapLongWord(o.longWordWrap),
// v1 Pass D 不分 lang 地擠句內點號(開明式);compat 強制 kaiming
// 政策以避免功能倒退(預設 zh-Hant→quanjiao 不擠壓)。
jiya: o.punctuationSqueeze === false ? false : true,
// v1 無連續標點綁定,保持 DOM 與 v1 一致:關閉 jinze。
jinze: false,
lang: {
policy: {
"zh-Hant": "kaiming",
"zh-Hans": "kaiming",
"ja": "kaiming",
"other": "kaiming",
},
},
};
const jz = createJuzhen(mapped);
return {
apply(root?: Element): void { jz.render(root); },
revert(root?: Element): void { jz.revert(root); },
};
}
+97 -33
View File
@@ -1,8 +1,15 @@
// 文本遍歷引擎(架構 §4.1,取代 v1 之內聯 DFS / Fibre.js 角色)。 // 文本遍歷引擎(架構 §4.1,取代 v1 之內聯 DFS / Fibre.js 角色)。
// //
// 所有 pass 之共同地基:avoid 名單、scope.include、邏輯文本 run 收集(跨樣式 // 所有 pass 之共同地基:avoid 名單、scope.include、邏輯文本 run 收集、pill 鄰字
// 行內元素透明、pillblockavoid 中斷)、pill 鄰字查找、per-node 功能閘 // 查找、per-node 功能閘featureEnabledFor)。finder 不知具體排版規則,只提供
// featureEnabledFor)。finder 不知具體排版規則,只提供遍歷與安全變更原語。 // 遍歷與安全變更原語。
//
// 行內元素對排版有且僅有**三種口徑**(§4.1,所有遍歷原語共用同一判定):
// 透明(styleInlines jz-* wrap)— 穿越、run 延續、可克隆切分;
// 隔離(isolateSelector,預設 mark)— 邊界斷 run/斷相鄰/不可切分、兩端補隙,
// 內部照常處理(自成脈絡);
// 整體(pillSelector)— 原子單位:內部對一切 pass 不可見,邊界同隔離。
// avoid(skip 名單/屬性/選擇器)疊加其上:自身即邊界、內外皆不處理。
import type { FeatureLevel, ResolvedOptions } from "../types.js"; import type { FeatureLevel, ResolvedOptions } from "../types.js";
@@ -37,6 +44,7 @@ export class Finder
private readonly blockSet: Set<string>; private readonly blockSet: Set<string>;
private readonly skipSet: Set<string>; private readonly skipSet: Set<string>;
private readonly pillSelector: string; private readonly pillSelector: string;
private readonly isolateSelector: string;
private readonly avoidSelector: string | null; private readonly avoidSelector: string | null;
private readonly includeSelector: string | null; private readonly includeSelector: string | null;
private readonly skipAttr: string | null; private readonly skipAttr: string | null;
@@ -52,6 +60,7 @@ export class Finder
this.blockSet = opts.finder.blockTags; this.blockSet = opts.finder.blockTags;
this.skipSet = opts.finder.skipTags; this.skipSet = opts.finder.skipTags;
this.pillSelector = opts.finder.pillSelector; this.pillSelector = opts.finder.pillSelector;
this.isolateSelector = opts.finder.isolateSelector;
this.skipAttr = opts.finder.skipAttribute; this.skipAttr = opts.finder.skipAttribute;
this.userIsSkipped = opts.finder.isSkipped; this.userIsSkipped = opts.finder.isSkipped;
this.levelTextSelector = opts.levelText; this.levelTextSelector = opts.levelText;
@@ -61,16 +70,40 @@ export class Finder
this.levelCache = new WeakMap(); this.levelCache = new WeakMap();
} }
/** 該元素是否匹配 pill 選擇器(行內塊,如 code/kbd)。 */ /** 該元素是否匹配 pill 選擇器(三分類之「整體」:codekbd/行內公式等)。 */
pillMatches(el: Element): boolean pillMatches(el: Element): boolean
{ {
return !!(el.matches && this.pillSelector && el.matches(this.pillSelector)); return !!(el.matches && this.pillSelector && el.matches(this.pillSelector));
} }
/** isolate
* <mark> pill run */
isolateMatches(el: Element): boolean
{
return !!(el.matches && this.isolateSelector && el.matches(this.isolateSelector));
}
/** pill ****pill
* pass charify run
* .katex jiyaspacing */
insidePill(node: Node): boolean
{
let p: Node | null = node.parentNode;
while (p && p.nodeType === 1)
{
if (this.pillMatches(p as Element)) { return true; }
p = p.parentNode;
}
return false;
}
/** **穿** wrap /** **穿** wrap
* jz-*jz-hws jz-jinze jz-charjz-inner 穿 * jz-*jz-hws jz-jinze jz-charjz-inner 穿
* jinze pill jz-jinze pill * jinze pill jz-jinze pill
* I7 * I7
* ****pillisolateavoid 使
* 穿KaTeX SPAN
* scope.avoidskipAttribute
* jz-charjz-inner adjacentLogicalChar descend * jz-charjz-inner adjacentLogicalChar descend
* **** Bug`汉字。<code>` jinze * **** Bug`汉字。<code>` jinze
* `汉<jz-jinze>字<jz-char>。</jz-char></jz-jinze><code>`pill * `汉<jz-jinze>字<jz-char>。</jz-char></jz-jinze><code>`pill
@@ -78,31 +111,47 @@ export class Finder
* jz-char jiya charify biaodian pillNeighbor descend * jz-char jiya charify biaodian pillNeighbor descend
* collectRuns jz-inner * collectRuns jz-inner
* descend jz-char jz-inner */ * descend jz-char jz-inner */
private isTransparentInline(name: string): boolean private transparentInline(el: Element): boolean
{ {
if (this.pillMatches(el) || this.isolateMatches(el) || this.avoidsSelf(el))
{
return false;
}
const name = el.nodeName;
return this.styleSet.has(name) return this.styleSet.has(name)
|| name === "JZ-HWS" || name === "JZ-JINZE" || name === "JZ-HWS" || name === "JZ-JINZE"
|| name === "JZ-CHAR" || name === "JZ-INNER"; || name === "JZ-CHAR" || name === "JZ-INNER";
} }
/** 節點是否處於 avoid 子樹(內建 skip 名單 / SVG / contentEditable / /** 該元素**自身**是否為 avoid 邊界(內建 skip 名單 / SVG / contentEditable /
* data-jz-skip / 使 avoid / skipAttribute / isSkipped */ * data-jz-skip / skipAttribute / avoid / isSkippedisAvoided 沿
*
* avoid 穿scope.avoid .katex
* */
avoidsSelf(el: Element): boolean
{
if (this.userIsSkipped && this.userIsSkipped(el)) { return true; }
if (this.skipSet.has(el.nodeName)) { return true; }
if ((el as { namespaceURI?: string }).namespaceURI === SVG_NS) { return true; }
if ((el as HTMLElement).isContentEditable) { return true; }
if (el.hasAttribute("data-jz-skip")) { return true; }
if (this.skipAttr && el.hasAttribute(this.skipAttr)) { return true; }
if (this.avoidSelector && el.matches && el.matches(this.avoidSelector))
{
return true;
}
return false;
}
/** avoid avoidsSelf
* avoidsSelf */
isAvoided(node: Node): boolean isAvoided(node: Node): boolean
{ {
if (this.userIsSkipped && this.userIsSkipped(node)) { return true; } if (this.userIsSkipped && this.userIsSkipped(node)) { return true; }
let p: Node | null = node.parentNode; let p: Node | null = node.parentNode;
while (p && p.nodeType === 1) while (p && p.nodeType === 1)
{ {
const el = p as Element; if (this.avoidsSelf(p as Element)) { return true; }
if (this.skipSet.has(el.nodeName)) { return true; }
if ((el as { namespaceURI?: string }).namespaceURI === SVG_NS) { return true; }
if ((el as HTMLElement).isContentEditable) { return true; }
if (el.hasAttribute("data-jz-skip")) { return true; }
if (this.skipAttr && el.hasAttribute(this.skipAttr)) { return true; }
if (this.avoidSelector && el.matches && el.matches(this.avoidSelector))
{
return true;
}
p = p.parentNode; p = p.parentNode;
} }
return false; return false;
@@ -119,7 +168,7 @@ export class Finder
return !!(el.closest && el.closest(this.includeSelector)); return !!(el.closest && el.closest(this.includeSelector));
} }
/** 走訪 root 下所有「可處理」文本節點(已過濾 avoid / 非作用域)。 */ /** 走訪 root 下所有「可處理」文本節點(已過濾 avoid / pill 內部 / 非作用域)。 */
eachTextNode(root: Element, cb: (node: Text) => void): void eachTextNode(root: Element, cb: (node: Text) => void): void
{ {
const ownerDoc = root.ownerDocument || (typeof document !== "undefined" ? document : null); const ownerDoc = root.ownerDocument || (typeof document !== "undefined" ? document : null);
@@ -130,7 +179,10 @@ export class Finder
while (n) while (n)
{ {
const t = n as Text; const t = n as Text;
if (!this.isAvoided(t) && this.inScope(t)) { nodes.push(t); } if (!this.isAvoided(t) && !this.insidePill(t) && this.inScope(t))
{
nodes.push(t);
}
n = walker.nextNode(); n = walker.nextNode();
} }
// 先收集後回呼:容許 cb 變更 DOM 而不擾動走訪。 // 先收集後回呼:容許 cb 變更 DOM 而不擾動走訪。
@@ -199,13 +251,21 @@ export class Finder
const el = node as Element; const el = node as Element;
// jz-hws 現包裹左側邊界字(以 margin-right 留隙)→ 透明遞迴,使其內 // jz-hws 現包裹左側邊界字(以 margin-right 留隙)→ 透明遞迴,使其內
// 之字仍計入邏輯 run;冪等改由「該字已在 jz-hws 內則不重複包裝」保證。 // 之字仍計入邏輯 run;冪等改由「該字已在 jz-hws 內則不重複包裝」保證。
if (this.isAvoided(el)) { flush(); return; } if (this.avoidsSelf(el) || this.blockSet.has(el.nodeName))
if (this.skipSet.has(el.nodeName) || this.blockSet.has(el.nodeName))
{ {
flush(); flush();
return; return;
} }
if (this.pillMatches(el)) { flush(); return; } if (this.pillMatches(el)) { flush(); return; } // 整體:邊界斷 run、內部不入
if (this.isolateMatches(el))
{
// 隔離:邊界斷 run,內部自成獨立 run(照常處理、不與外部相鄰)。
flush();
let ic = el.firstChild;
while (ic) { visit(ic); ic = ic.nextSibling; }
flush();
return;
}
// 樣式行內 / 未知行內 / wrap 類 jz-* / span[lang]:透明遞迴。 // 樣式行內 / 未知行內 / wrap 類 jz-* / span[lang]:透明遞迴。
let c = el.firstChild; let c = el.firstChild;
while (c) { visit(c); c = c.nextSibling; } while (c) { visit(c); c = c.nextSibling; }
@@ -236,7 +296,10 @@ export class Finder
return { textNode: tn, offset: 0, ch: tn.data.charAt(0) }; return { textNode: tn, offset: 0, ch: tn.data.charAt(0) };
}; };
const descend = (into: Element): AdjacentChar | null => // descend 三態:邊界字/null(子樹無內容,續找兄弟)/"blocked"(遇 pill
// isolateavoidblock 邊界——邏輯鄰居非「字」,終止整個查找)。不可把邊界
// 當無內容跳過續找更遠之字:那是「越過 jz-char 取更前字 → 誤隙」之同型錯誤。
const descend = (into: Element): AdjacentChar | "blocked" | null =>
{ {
let n: Node | null = direction === -1 ? into.lastChild : into.firstChild; let n: Node | null = direction === -1 ? into.lastChild : into.firstChild;
while (n) while (n)
@@ -245,10 +308,11 @@ export class Finder
{ {
return pickEnd(n as Text); return pickEnd(n as Text);
} }
if (n.nodeType === 1 && this.isTransparentInline((n as Element).nodeName)) if (n.nodeType === 1)
{ {
if (!this.transparentInline(n as Element)) { return "blocked"; }
const r = descend(n as Element); const r = descend(n as Element);
if (r) { return r; } if (r) { return r; } // 字或 blocked 皆上拋
} }
n = direction === -1 ? n.previousSibling : n.nextSibling; n = direction === -1 ? n.previousSibling : n.nextSibling;
} }
@@ -270,19 +334,19 @@ export class Finder
if (n.nodeType === 1) if (n.nodeType === 1)
{ {
const e = n as Element; const e = n as Element;
if (this.isAvoided(e)) { return null; } if (!this.transparentInline(e))
if (this.isTransparentInline(e.nodeName))
{ {
const r = descend(e); return null; // pillisolateavoidblock — 邊界,止。
if (r) { return r; }
n = direction === -1 ? e.previousSibling : e.nextSibling;
continue;
} }
return null; // pill 或 block — 止。 const r = descend(e);
if (r === "blocked") { return null; }
if (r) { return r; }
n = direction === -1 ? e.previousSibling : e.nextSibling;
continue;
} }
n = direction === -1 ? n.previousSibling : n.nextSibling; n = direction === -1 ? n.previousSibling : n.nextSibling;
} }
if (!p || p.nodeType !== 1 || !this.isTransparentInline((p as Element).nodeName)) if (!p || p.nodeType !== 1 || !this.transparentInline(p as Element))
{ {
return null; return null;
} }
+37 -19
View File
@@ -13,16 +13,22 @@
// 包 wrap、後併 splitoff,確保前兄弟為原元素而非 wrap 殼;I6 還原一致)。 // 包 wrap、後併 splitoff,確保前兄弟為原元素而非 wrap 殼;I6 還原一致)。
// //
// 僅純樣式行內標籤可切(白名單);帶語義/引用者(A 連結會克隆出兩個錨點、ABBR 之 // 僅純樣式行內標籤可切(白名單);帶語義/引用者(A 連結會克隆出兩個錨點、ABBR 之
// title、RUBY 結構、帶 id 者)一律回退整元素綁定——退化但安全。 // title、RUBY 結構、帶 id 者)一律回退整元素綁定——退化但安全。可切性另須諮詢
// finder 三分類(§4.1):pill(整體,如 .katex——KaTeX 全樹皆 SPAN,僅憑標籤名會
// 整棵下潛肢解;下游問題六)、isolate(隔離,帶盒樣式切分有可見接縫)、avoid 自身
// 命中者,皆不可切——回退整元素綁定。
import type { Finder } from "./finder.js";
const PASS_ATTR = "data-jz"; const PASS_ATTR = "data-jz";
const KIND_ATTR = "data-jz-kind"; const KIND_ATTR = "data-jz-kind";
// 可安全克隆切分之純樣式行內標籤(切分不改變語義、相鄰兩份視覺無縫)。 // 可安全克隆切分之純樣式行內標籤(切分不改變語義、相鄰兩份視覺無縫)。
// 刻意排除:A(克隆出兩個連結)、ABBR/Q(title/引號語義重複)、RUBY 系(結構性)、 // 刻意排除:A(克隆出兩個連結)、ABBR/Q(title/引號語義重複)、RUBY 系(結構性)、
// 帶 id 者(id 重複)——皆回退整元素綁定。 // MARK(盒樣式:背景色切分有可見接縫;預設亦列為 isolate)、帶 id 者(id 重複)——
// 皆回退整元素綁定。
const SPLITTABLE = new Set([ const SPLITTABLE = new Set([
"STRONG", "EM", "B", "I", "U", "S", "SPAN", "MARK", "STRONG", "EM", "B", "I", "U", "S", "SPAN",
"SMALL", "INS", "DEL", "SUB", "SUP", "SMALL", "INS", "DEL", "SUB", "SUP",
]); ]);
@@ -32,22 +38,26 @@ interface Point
offset: number; offset: number;
} }
/** 元素是否可克隆切分(純樣式行內標籤、無 id)。 */ /** 元素是否可克隆切分(純樣式行內標籤、無 id、非 pillisolateavoid 邊界)。 */
function canSplit(el: Element): boolean function canSplit(el: Element, finder: Finder): boolean
{ {
return SPLITTABLE.has(el.nodeName) && !el.hasAttribute("id"); return SPLITTABLE.has(el.nodeName)
&& !el.hasAttribute("id")
&& !finder.pillMatches(el)
&& !finder.isolateMatches(el)
&& !finder.avoidsSelf(el);
} }
/** el 內**首**個非空文本節點(僅下探可切標籤;遇非可切元素回 null=放棄切分)。 */ /** el 內**首**個非空文本節點(僅下探可切標籤;遇非可切元素回 null=放棄切分)。 */
function firstText(el: Element): Text | null function firstText(el: Element, finder: Finder): Text | 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) { return c as Text; } if (c.nodeType === 3 && (c as Text).data.length > 0) { return c as Text; }
if (c.nodeType === 1) if (c.nodeType === 1)
{ {
if (!canSplit(c as Element)) { return null; } if (!canSplit(c as Element, finder)) { return null; }
const r = firstText(c as Element); const r = firstText(c as Element, finder);
if (r) { return r; } if (r) { return r; }
} }
} }
@@ -55,15 +65,15 @@ function firstText(el: Element): Text | null
} }
/** el 內**末**個非空文本節點(僅下探可切標籤;遇非可切元素回 null=放棄切分)。 */ /** el 內**末**個非空文本節點(僅下探可切標籤;遇非可切元素回 null=放棄切分)。 */
function lastText(el: Element): Text | null function lastText(el: Element, finder: Finder): Text | 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) { return c as Text; } if (c.nodeType === 3 && (c as Text).data.length > 0) { return c as Text; }
if (c.nodeType === 1) if (c.nodeType === 1)
{ {
if (!canSplit(c as Element)) { return null; } if (!canSplit(c as Element, finder)) { return null; }
const r = lastText(c as Element); const r = lastText(c as Element, finder);
if (r) { return r; } if (r) { return r; }
} }
} }
@@ -102,10 +112,16 @@ function trailingTokenStart(d: string, anyCjk: RegExp): number
* 沿 * 沿
* revert * revert
*/ */
function splitTreeAfter(top: Element, textNode: Text, offset: number, pass: string): Element | null function splitTreeAfter(
top: Element,
textNode: Text,
offset: number,
pass: string,
finder: Finder,
): Element | null
{ {
// 切點位於 top 最起點(首文本節點之 offset 0)→ 左側無內容,不切。 // 切點位於 top 最起點(首文本節點之 offset 0)→ 左側無內容,不切。
if (offset <= 0 && firstText(top) === textNode) { return null; } if (offset <= 0 && firstText(top, finder) === textNode) { return null; }
// 該文本層之右側首節點。 // 該文本層之右側首節點。
let rightAtLevel: Node | null; let rightAtLevel: Node | null;
@@ -175,17 +191,18 @@ export function isolateBoundaryToken(
side: "head" | "tail", side: "head" | "tail",
anyCjk: RegExp, anyCjk: RegExp,
pass: string, pass: string,
finder: Finder,
): Element ): Element
{ {
if (!canSplit(el)) { return el; } if (!canSplit(el, finder)) { return el; }
const text = side === "tail" ? lastText(el) : firstText(el); const text = side === "tail" ? lastText(el, finder) : firstText(el, finder);
if (!text) { return el; } if (!text) { return el; }
const point: Point = side === "tail" const point: Point = side === "tail"
? { textNode: text, offset: trailingTokenStart(text.data, anyCjk) } ? { textNode: text, offset: trailingTokenStart(text.data, anyCjk) }
: { textNode: text, offset: leadingTokenEnd(text.data, anyCjk) }; : { textNode: text, offset: leadingTokenEnd(text.data, anyCjk) };
const clone = splitTreeAfter(el, point.textNode, point.offset, pass); const clone = splitTreeAfter(el, point.textNode, point.offset, pass, finder);
if (!clone) { return el; } // 整元素即邊界詞元 → 綁整個 el。 if (!clone) { return el; } // 整元素即邊界詞元 → 綁整個 el。
return side === "tail" ? clone : el; return side === "tail" ? clone : el;
} }
@@ -199,8 +216,9 @@ export function splitElementAt(
textNode: Text, textNode: Text,
offset: number, offset: number,
pass: string, pass: string,
finder: Finder,
): Element | null ): Element | null
{ {
if (!canSplit(el)) { return null; } if (!canSplit(el, finder)) { return null; }
return splitTreeAfter(el, textNode, offset, pass); return splitTreeAfter(el, textNode, offset, pass, finder);
} }
+5 -4
View File
@@ -4,8 +4,8 @@
// jz.render(root?); // 套用已啟用之 pass // jz.render(root?); // 套用已啟用之 pass
// jz.revert(root?); // 還原 // jz.revert(root?); // 還原
// //
// 全域(IIFE):window.Juzhen.createJuzhen(...);並掛 v1 相容別名 // 全域(IIFE):window.Juzhen.createJuzhen(...)
// globalThis.createCjkAutospace(見 build.mjs footer 與 compat/v1.ts // v1 相容層(createCjkAutospace)已隨最後一批 v1 消費端遷移完成而移除
import { Finder } from "./core/finder.js"; import { Finder } from "./core/finder.js";
import { runPasses, revertPasses } from "./core/pass.js"; import { runPasses, revertPasses } from "./core/pass.js";
@@ -20,7 +20,6 @@ import type { BiasTable, JiyaOptions, JuzhenOptions, LongWordOptions, OrphanOpti
export type { JuzhenOptions, ResolvedOptions, Pass }; export type { JuzhenOptions, ResolvedOptions, Pass };
export type { CharClassOverride, BdSubclass, Ruleset, Bias, BiasTable } from "./types.js"; export type { CharClassOverride, BdSubclass, Ruleset, Bias, BiasTable } from "./types.js";
export { createCjkAutospace } from "./compat/v1.js";
const DEFAULT_STYLE_INLINES = [ const DEFAULT_STYLE_INLINES = [
"STRONG", "EM", "A", "B", "I", "U", "S", "MARK", "INS", "DEL", "STRONG", "EM", "A", "B", "I", "U", "S", "MARK", "INS", "DEL",
@@ -95,7 +94,6 @@ export function normalizeOptions(opts: JuzhenOptions = {}): ResolvedOptions
policy: langOpt.policy || {}, policy: langOpt.policy || {},
}, },
ruleset: resolveRuleset(opts.charClass), ruleset: resolveRuleset(opts.charClass),
autospaceClass: opts.autospaceClass || "",
justifyAtoms: opts.justifyAtoms === false ? false : true, justifyAtoms: opts.justifyAtoms === false ? false : true,
levelText: (opts.level && opts.level.text) || null, levelText: (opts.level && opts.level.text) || null,
scope: { scope: {
@@ -125,6 +123,9 @@ export function normalizeOptions(opts: JuzhenOptions = {}): ResolvedOptions
blockTags: new Set(opts.blockTags || DEFAULT_BLOCK_TAGS), blockTags: new Set(opts.blockTags || DEFAULT_BLOCK_TAGS),
skipTags: new Set(opts.skipTags || DEFAULT_SKIP_TAGS), skipTags: new Set(opts.skipTags || DEFAULT_SKIP_TAGS),
pillSelector: opts.pillSelector || "code, kbd", pillSelector: opts.pillSelector || "code, kbd",
// 內外隔離(§4.1 三分類):預設 <mark>(瀏覽器預設帶背景,切分有可見
// 接縫、邊界需留隙)。顯式判 undefined,使 "" 可停用。
isolateSelector: opts.isolateSelector !== undefined ? opts.isolateSelector : "mark",
skipAttribute: opts.skipAttribute || null, skipAttribute: opts.skipAttribute || null,
isSkipped: typeof opts.isSkipped === "function" ? opts.isSkipped : null, isSkipped: typeof opts.isSkipped === "function" ? opts.isSkipped : null,
}, },
+12 -5
View File
@@ -66,9 +66,6 @@ export interface JuzhenOptions
text?: string; text?: string;
}; };
/** 間隙標記沿用之 class(v1 相容;預設無額外 class)。 */
autospaceClass?: string;
/** justify jz-charjz-jinze inline-block /** justify jz-charjz-jinze inline-block
* true false display:inline Paged.js * true false display:inline Paged.js
* inline-block CJK inter-ideograph * inline-block CJK inter-ideograph
@@ -99,11 +96,21 @@ export interface JuzhenOptions
/** 分塊功能選擇(§3.6)。 */ /** 分塊功能選擇(§3.6)。 */
blocks?: BlockRule[]; blocks?: BlockRule[];
// ----- v1 相容用之 finder 設定 ----- // ----- finder 設定(行內元素三分類,§4.1-----
//
// 行內元素對排版有且僅有三種口徑:
// 透明(styleInlines — 遍歷穿越、run 延續、可克隆切分(<strong><em> 等純樣式);
// 隔離(isolateSelector)— 兩端如 pill 補間隙、斷 run/斷相鄰、不可切分,
// **內部照常處理**(帶盒樣式者,如背景色 <mark>);
// 整體(pillSelector — 原子單位:內部對一切 pass 不可見,禁則整體綁定,
// 兩端補間隙(code/kbd/行內公式 .katex 等)。
styleInlines?: string[]; styleInlines?: string[];
blockTags?: string[]; blockTags?: string[];
skipTags?: string[]; skipTags?: string[];
/** 整體(pill)元素選擇器(預設 "code, kbd")。 */
pillSelector?: string; pillSelector?: string;
/** 內外隔離元素選擇器(預設 "mark")。傳 "" 停用(mark 回歸透明)。 */
isolateSelector?: string;
skipAttribute?: string; skipAttribute?: string;
isSkipped?: (node: Node) => boolean; isSkipped?: (node: Node) => boolean;
} }
@@ -114,7 +121,6 @@ export interface ResolvedOptions
locale: LocaleConfig; locale: LocaleConfig;
/** 最終字符分類規則集(內置默認 + charClass 覆寫)。 */ /** 最終字符分類規則集(內置默認 + charClass 覆寫)。 */
ruleset: Ruleset; ruleset: Ruleset;
autospaceClass: string;
justifyAtoms: boolean; justifyAtoms: boolean;
/** 功能分級之 text-level 選擇器(§6.5.0a);null 表示未設。 */ /** 功能分級之 text-level 選擇器(§6.5.0a);null 表示未設。 */
levelText: string | null; levelText: string | null;
@@ -144,6 +150,7 @@ export interface ResolvedOptions
blockTags: Set<string>; blockTags: Set<string>;
skipTags: Set<string>; skipTags: Set<string>;
pillSelector: string; pillSelector: string;
isolateSelector: string;
skipAttribute: string | null; skipAttribute: string | null;
isSkipped: ((node: Node) => boolean) | null; isSkipped: ((node: Node) => boolean) | null;
}; };
+5 -3
View File
@@ -122,10 +122,12 @@ function processParent(parent: Node, finder: Finder, anyCjk: RegExp): void
{ {
// 元素單元(樣式行內如 <strong>)最小綁定(下游 Bug:整元素入 nowrap inline-block // 元素單元(樣式行內如 <strong>)最小綁定(下游 Bug:整元素入 nowrap inline-block
// 致行末整體掉行):只隔離與 jz-char 相鄰側之邊界詞元,餘下留原位。雙側禁斷時先 // 致行末整體掉行):只隔離與 jz-char 相鄰側之邊界詞元,餘下留原位。雙側禁斷時先
// tail 後 head——head 作用於切剩之左半(仍為原元素 u)。不可切之元素退化整綁。 // tail 後 head——head 作用於切剩之左半(仍為原元素 u)。不可切之元素(含 pill
// isolateavoid,由 canSplit 諮詢 finder 判定)退化整綁——pill 即「作為原子
// 鄰字單位整體綁入 jz-jinze」之期望行為(下游問題六)。
const el = u as Element; const el = u as Element;
if (needLast) { isolateBoundaryToken(el, "tail", anyCjk, PASS); } if (needLast) { isolateBoundaryToken(el, "tail", anyCjk, PASS, finder); }
if (needFirst) { isolateBoundaryToken(el, "head", anyCjk, PASS); } if (needFirst) { isolateBoundaryToken(el, "head", anyCjk, PASS, finder); }
} }
} }
+6 -2
View File
@@ -158,8 +158,12 @@ export const jiyaAdjacencyPass: StandalonePass = {
if (prev) { squeeze(prev, el); } if (prev) { squeeze(prev, el); }
prev = el; // jz-char 為原子,不下探(內含 jz-inner) prev = el; // jz-char 為原子,不下探(內含 jz-inner)
} }
else if (finder.pillMatches(el)) { prev = null; } // pillcode/kbd):斷相鄰、不下探(內容對排版不可見,不應被當透明行內穿越 else if (finder.pillMatches(el)) { prev = null; } // pill整體):斷相鄰、不下探(內容對排版不可見)
else if (finder.isAvoided(el)) { prev = null; } // avoid/skip 子樹:斷、不下探 else if (finder.avoidsSelf(el)) { prev = null; } // avoid 邊界(自身判定):斷、不下探
else if (finder.isolateMatches(el))
{
prev = null; visit(el); prev = null; // 隔離:前後皆斷相鄰,內部自成脈絡(同 block)
}
else if (blockSet.has(nm)) else if (blockSet.has(nm))
{ {
prev = null; visit(el); prev = null; // block 邊界:前後皆斷相鄰,內部另計 prev = null; visit(el); prev = null; // block 邊界:前後皆斷相鄰,內部另計
+7 -3
View File
@@ -70,7 +70,9 @@ function collectSubst(
} }
if (node.nodeType !== 1) { return; } if (node.nodeType !== 1) { return; }
const el = node as Element; const el = node as Element;
if (finder.isAvoided(el)) { return; } // avoid 以自身判定(舊版僅查祖先,自身命中 avoid 之元素被下潛);pill 整體
// 不計不下探;isolate 內部為正常文本,照常下探計數(綁定時不可切、整綁)。
if (finder.avoidsSelf(el)) { return; }
if (blockSet.has(el.nodeName) || finder.pillMatches(el)) { return; } // 邊界:不計、不下探 if (blockSet.has(el.nodeName) || finder.pillMatches(el)) { return; } // 邊界:不計、不下探
for (let c = el.firstChild; c; c = c.nextSibling) { visit(c, directChild); } for (let c = el.firstChild; c; c = c.nextSibling) { visit(c, directChild); }
}; };
@@ -111,7 +113,7 @@ function processBlock(
else if (boundary.textNode && startChild.nodeType === 1) else if (boundary.textNode && startChild.nodeType === 1)
{ {
const clone = splitElementAt( const clone = splitElementAt(
startChild as Element, boundary.textNode, boundary.offset, PASS, startChild as Element, boundary.textNode, boundary.offset, PASS, finder,
); );
if (clone) { startChild = clone; } if (clone) { startChild = clone; }
} }
@@ -144,9 +146,11 @@ export const orphanPass: StandalonePass = {
finder.eachBlock(ctx.root, (block) => finder.eachBlock(ctx.root, (block) =>
{ {
// 段落級閘 per-node 功能閘 // 段落級閘 per-node 功能閘 + avoid(自身或祖先命中皆不綁——舊版
// 漏判,avoid 子樹內塊之裸文本直接子曾被計數並包入 jz-orphan)。
if (!finder.levelAllows(block, "paragraph")) { return; } if (!finder.levelAllows(block, "paragraph")) { return; }
if (!finder.featureEnabledFor(block, PASS)) { return; } if (!finder.featureEnabledFor(block, PASS)) { return; }
if (finder.avoidsSelf(block) || finder.isAvoided(block)) { return; }
// 僅處理葉段落(無塊級後代):含巢狀塊之容器,其實義字屬內層段落,另行處理。 // 僅處理葉段落(無塊級後代):含巢狀塊之容器,其實義字屬內層段落,另行處理。
const sel = Array.from(blockSet).map((t) => t.toLowerCase()).join(","); const sel = Array.from(blockSet).map((t) => t.toLowerCase()).join(",");
if (sel && block.querySelector(sel)) { return; } if (sel && block.querySelector(sel)) { return; }
+16 -5
View File
@@ -246,11 +246,16 @@ function processBlock(block: Element, finder: Finder, R: Rules): void
} }
} }
function processPills(root: Element, finder: Finder, pillSelector: string, pillNeighbor: RegExp): void // boundarySelector pill(整體)∪ isolate(隔離):兩端補隙之口徑一致(§4.1
// 三分類);差異僅在內部(pill 不可見、isolate 照常處理),不在此 pass。
function processPills(root: Element, finder: Finder, boundarySelector: string, pillNeighbor: RegExp): void
{ {
root.querySelectorAll(pillSelector).forEach((pill) => root.querySelectorAll(boundarySelector).forEach((pill) =>
{ {
if (finder.isAvoided(pill) || !finder.inScope(pill)) { return; } if (finder.isAvoided(pill) || finder.insidePill(pill) || !finder.inScope(pill))
{
return;
}
// per-node 功能閘(T6):data-juzhen-off="spacing" 等區域內之 pill 不補間隙 // per-node 功能閘(T6):data-juzhen-off="spacing" 等區域內之 pill 不補間隙
// (與 Pass A 逐 block 之 featureEnabledFor 一致;舊版漏判)。 // (與 Pass A 逐 block 之 featureEnabledFor 一致;舊版漏判)。
if (!finder.featureEnabledFor(pill, PASS)) { return; } if (!finder.featureEnabledFor(pill, PASS)) { return; }
@@ -331,8 +336,14 @@ export const spacingPass: StandalonePass = {
{ {
processBlock(ctx.root, finder, R); processBlock(ctx.root, finder, R);
} }
// Pass Bpill 邊界。 // Pass Bpillisolate 邊界。
processPills(ctx.root, finder, options.finder.pillSelector, R.pillNeighbor); const boundarySelector = [ options.finder.pillSelector, options.finder.isolateSelector ]
.filter(Boolean)
.join(", ");
if (boundarySelector)
{
processPills(ctx.root, finder, boundarySelector, R.pillNeighbor);
}
}, },
revert(ctx: RenderContext): void revert(ctx: RenderContext): void
{ {
+155 -27
View File
@@ -7,7 +7,7 @@ import { fileURLToPath } from "node:url";
import { test } from "node:test"; import { test } from "node:test";
import { setupDom, countTag } from "./helpers.mjs"; import { setupDom, countTag } from "./helpers.mjs";
import { createJuzhen, createCjkAutospace } from "../dist/juzhen.mjs"; import { createJuzhen } from "../dist/juzhen.mjs";
// ----- 中西間隙(spacing----- // ----- 中西間隙(spacing-----
@@ -598,32 +598,6 @@ test("問題二:justifyAtoms 預設 true → 不設屬性(維持 inline-bloc
assert.equal(document.body.getAttribute("data-jz-atoms"), null, "預設不設屬性"); assert.equal(document.body.getAttribute("data-jz-atoms"), null, "預設不設屬性");
}); });
// ----- v1 相容層(§3.5-----
test("compatcreateCjkAutospace().apply() 可跑,產生中西間隙(margin 模型,copy-clean", () =>
{
const { document } = setupDom("<p>我用Neovim</p>");
createCjkAutospace().apply(document.body);
const p = document.querySelector("p");
assert.ok(p.querySelector("jz-hws"), "間隙仍產生(jz-hws 包左側字 + margin");
assert.equal(p.textContent, "我用Neovim", "textContent 不變(無注入空白)");
});
test("compatpunctuationSqueeze:false 關閉 jiya", () =>
{
const { document } = setupDom('<p lang="zh-Hant">好,走</p>');
createCjkAutospace({ punctuationSqueeze: false }).apply(document.body);
assert.equal(countTag(document.body, "jz-char"), 0);
});
test("compat:預設強制 kaiming,句內點號半形(jz-half)(不因預設 zh-Hant 而退化)", () =>
{
const { document } = setupDom('<p lang="zh-Hant">好,走</p>');
createCjkAutospace().apply(document.body);
const comma = document.querySelector("jz-char.bd-pause");
assert.ok(comma && comma.classList.contains("jz-half"), "compat 不退化:仍擠句內");
});
// ----- text-indent 漏溢防護(下游回報)----- // ----- text-indent 漏溢防護(下游回報)-----
test("CSS:jz-* 原子歸零「區塊容器首/末行級」繼承屬性,免 inline-block 重套(透明原則)", () => test("CSS:jz-* 原子歸零「區塊容器首/末行級」繼承屬性,免 inline-block 重套(透明原則)", () =>
@@ -972,3 +946,157 @@ test("行端半形:lineedge 仍正常擠壓(squeeze 保留)——jsdom 無
jz.revert(document.body); jz.revert(document.body);
assert.equal(countTag(document.body, "jz-char"), 0, "revert 清除"); assert.equal(countTag(document.body, "jz-char"), 0, "revert 清除");
}); });
// ----- 行內元素三分類(§4.1):透明/隔離(isolate)/整體(pill)(下游問題六)-----
// KaTeX 式巢狀 span 結構(全樹皆 SPAN——僅憑標籤名判透明會整棵下潛肢解)。
const KATEX = '<span class="katex"><span class="katex-html">'
+ '<span class="base"><span class="mord">4.4</span><span class="mbin">x</span>'
+ '<span class="mord">10</span><span class="msupsub"><span class="vlist">'
+ '<span class="mord">-17</span></span></span></span></span></span>';
const KATEX_OPTS = { pillSelector: "code, kbd, .katex", lang: { default: "zh-Hans", style: "kaiming" } };
test("問題六:pill(.katex)後接句內點號——整體綁入 jz-jinze,絕不肢解(無 splitoff", () =>
{
const { document } = setupDom("<p>結果為 " + KATEX + ",在浮點中</p>");
createJuzhen(KATEX_OPTS).render(document.body);
assert.equal(document.querySelectorAll(".katex").length, 1, "公式仍為單一元素(未裂)");
assert.equal(countTag(document.body, '[data-jz-kind="splitoff"]'), 0, "無克隆切分殼層");
const jinze = document.querySelector("jz-jinze");
assert.ok(jinze && jinze.querySelector(".katex"), "整個 .katex 作為原子鄰字單位入 jz-jinze");
assert.ok(jinze.querySelector("jz-char.bd-pause"), "逗號與公式同組(避頭綁定)");
});
test("問題六:開類+pill+閉類(雙側禁斷)——公式仍完整、不裂為多塊", () =>
{
const { document } = setupDom("<p>" + KATEX + ")在浮點中</p>");
createJuzhen(KATEX_OPTS).render(document.body);
assert.equal(document.querySelectorAll(".katex").length, 1, "雙側綁定亦不肢解");
assert.equal(countTag(document.body, '[data-jz-kind="splitoff"]'), 0);
});
test("問題六:scope.avoid 自身命中(.katex)——avoid 邊界以自身判定,不被透明穿越", () =>
{
const { document } = setupDom("<p>結果為 " + KATEX + ",在浮點中</p>");
createJuzhen({ scope: { avoid: ".katex" }, lang: { default: "zh-Hans", style: "kaiming" } })
.render(document.body);
assert.equal(document.querySelectorAll(".katex").length, 1, "avoid 元素未被切分");
assert.equal(countTag(document.body, '[data-jz-kind="splitoff"]'), 0);
assert.equal(countTag(document.body, ".katex jz-char"), 0, "avoid 內部不 charify");
});
test("問題六:skipAttribute 自身命中——同 avoid,不切分不下潛", () =>
{
const html = KATEX.replace('class="katex"', 'class="katex" data-md-key="x"');
const { document } = setupDom("<p>結果為 " + html + ",在浮點中</p>");
createJuzhen({ skipAttribute: "data-md-key", lang: { default: "zh-Hans", style: "kaiming" } })
.render(document.body);
assert.equal(document.querySelectorAll(".katex").length, 1);
assert.equal(countTag(document.body, '[data-jz-kind="splitoff"]'), 0);
});
test("問題六:相鄰兩 pill——descend 不刺穿,jz-hws 絕不插入公式內部", () =>
{
const { document } = setupDom("<p>公式" + KATEX + KATEX + "文字</p>");
createJuzhen(KATEX_OPTS).render(document.body);
assert.equal(countTag(document.body, ".katex jz-hws"), 0, "公式內部無間隙標記");
assert.equal(countTag(document.body, '[data-jz-kind="splitoff"]'), 0);
});
test("pill 內部對一切 pass 不可見:內部標點不 charify、內部不插間隙", () =>
{
const { document } = setupDom(
'<p>公式<span class="katex"><span class="mord">內文,標點Word混排</span></span>文字</p>',
);
createJuzhen(KATEX_OPTS).render(document.body);
assert.equal(countTag(document.body, ".katex jz-char"), 0, "pill 內部標點不 charify");
assert.equal(countTag(document.body, ".katex jz-hws"), 0, "pill 內部不插中西間隙");
assert.equal(countTag(document.body, ".katex span[lang]"), 0, "pill 內部不做長詞包裝");
});
test("問題六 revertpill 整綁後還原——DOM 結構回原樣(公式逐字節一致)", () =>
{
// 無作者空格之夾具:spacing 對 pill 左緣作者空格之吸收(正規化為受管間隙)
// 屬既有設計、revert 不復活,故 I6 斷言聚焦結構還原。
const { document } = setupDom("<p>結果為" + KATEX + ",在浮點中</p>");
const before = document.querySelector("p").innerHTML;
const jz = createJuzhen(KATEX_OPTS);
jz.render(document.body);
jz.revert(document.body);
assert.equal(document.querySelector("p").innerHTML, before, "render→revert 還原一致(I6");
});
// ----- 隔離(isolate,預設 <mark>):邊界如 pill、內部照常 -----
test("isolatemark 兩端與 CJK 相鄰補間隙(兩端需要空白)", () =>
{
const { document } = setupDom("<p>高亮<mark>重點</mark>文字</p>");
createJuzhen({ jiya: false, jinze: false, longWord: false }).render(document.body);
const p = document.querySelector("p");
// 左緣:包「亮」之 jz-hws;右緣:包整個 mark 之 jz-hws。
assert.equal(countTag(p, "jz-hws"), 2, "mark 兩端各一處間隙");
assert.ok(p.querySelector("jz-hws > mark"), "右緣間隙以包裹 mark 自身承載");
});
test("isolatemark 內部照常處理(內部標點 charify、內部中西補隙)", () =>
{
const { document } = setupDom(
'<p lang="zh-Hans"><mark>內文,混排Word之後</mark></p>',
);
createJuzhen({ jinze: false, longWord: false }).render(document.body);
assert.ok(document.querySelector("mark jz-char.bd-pause"), "內部標點照常 charify");
assert.ok(document.querySelector("mark jz-hws"), "內部中西邊界照常補隙");
});
test("isolatemark 不被克隆切分——mark+逗號整綁入 jz-jinze(背景切分有可見接縫)", () =>
{
const { document } = setupDom('<p lang="zh-Hans">前文<mark>高亮一段話</mark>,後文</p>');
createJuzhen().render(document.body);
assert.equal(document.querySelectorAll("mark").length, 1, "mark 未被切分");
assert.equal(countTag(document.body, '[data-jz-kind="splitoff"]'), 0);
const jinze = document.querySelector("jz-jinze");
assert.ok(jinze && jinze.querySelector("mark"), "整個 mark 入 jz-jinze(退化整綁)");
});
test("isolate:跨 mark 邊界之相鄰標點不互擠(斷相鄰,內部自成脈絡)", () =>
{
const { document } = setupDom('<p lang="zh-Hans">說:<mark>「重點」</mark>之外。</p>');
createJuzhen({ jinze: false }).render(document.body);
// :與「之間隔 mark 邊界——不得配對互擠(:右空白不因「而收)。
const colon = document.querySelector("jz-char.bd-pause");
assert.ok(colon, ":已 charify");
// 開明式 baseline 本就半形;驗證點在「不因鄰接而額外互動」——此處以
// 全角式重跑驗證邊界斷相鄰。
const dom2 = setupDom('<p lang="zh-Hant">說:<mark>「重點」</mark>之外。</p>');
createJuzhen({ jinze: false, lang: { style: "quanjiao" } }).render(dom2.document.body);
dom2.document.querySelectorAll("jz-char").forEach((c) =>
{
assert.ok(!c.classList.contains("jz-half"), "全角式+邊界斷相鄰:無任何互擠半形");
});
});
test("isolateisolateSelector 傳 \"\" 停用——mark 回歸透明(無邊界間隙、可切分)", () =>
{
const { document } = setupDom("<p>高亮<mark>重點</mark>文字</p>");
createJuzhen({ isolateSelector: "", jiya: false, jinze: false, longWord: false })
.render(document.body);
assert.equal(countTag(document.body, "jz-hws"), 0, "純 CJK 透明穿越,無間隙");
});
test("isolate revertmark 整綁+邊界間隙還原——DOM 回原樣", () =>
{
const { document } = setupDom('<p lang="zh-Hans">前文<mark>高亮</mark>,混排Word後</p>');
const before = document.querySelector("p").innerHTML;
const jz = createJuzhen();
jz.render(document.body);
jz.revert(document.body);
assert.equal(document.querySelector("p").innerHTML, before, "render→revert 還原一致(I6");
});
// ----- v1 相容層移除 -----
test("v1 相容層已移除:createCjkAutospace 不再導出(消費端已全數遷移 v2 API)", async () =>
{
const mod = await import("../dist/juzhen.mjs");
assert.equal(mod.createCjkAutospace, undefined, "compat 導出已刪");
});