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