From fdf071c5bba9ac85ea88b8f7da6a3030037a3664 Mon Sep 17 00:00:00 2001 From: commilitia Date: Tue, 9 Jun 2026 15:19:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=85=A8=E8=A7=92=E5=8F=A5=E9=BB=9E=20?= =?UTF-8?q?=EF=BC=8E=20=E7=B4=8D=E5=85=A5=E5=8F=A5=E6=9C=AB=E6=8C=A4?= =?UTF-8?q?=E5=8E=8B=20=EF=BC=8B=20=E9=80=A3=E7=BA=8C=E6=A8=99=E9=BB=9E?= =?UTF-8?q?=E6=8C=A4=E5=8E=8B=E8=A6=8F=E5=89=87=EF=BC=88=E9=9B=99=E5=81=B4?= =?UTF-8?q?=E7=8D=A8=E7=AB=8B=EF=BC=8B=E9=80=8F=E6=98=8E=E7=A9=BF=E8=B6=8A?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本會話標點挤压一致性兩項(改內置默認;皆以 Noto CJK + 真實 Chromium 量測驗證)。 一、全角句點 .(U+FF0E)納入句末標點 - BD_STOP 由 。!? 擴為 。.!?——納入全角句點 .(異於表意句號 。U+3002)。實測 Noto CJK SC/TC 之 halt(GPOS SinglePos)皆覆蓋 .、挤压收半形 advance 0.5em,與 。 一致。inkBias 同步補 .(簡 left/繁 center),margin 後備亦可挤。 - 政策行為與 。!? 同:全角式與開明式句末保持全角 1em,banjiao(或鄰接被收)挤半。 - 設計原則:halt 即字型之「挤压意願」——字型不提供某標點 halt(字形占滿、不宜挤,如某些 字型之全角 ?、〈〉)則信任字型、保持全角不強挤;margin 後備僅用於整體無 halt 之字型。 二、連續標點挤压規則:雙側獨立 + 透明穿越(修正既有不一致) - 規則(用戶確定):相鄰兩標點,**對每一標點**獨立判斷——若其「有空白之側」緊鄰另一標點, 即挤掉該側空白(收半形)。原 jiyaAdjacencyPass 為「二選一只收一側」,致: · ,「(pause 右空白 + open 左空白)原只收 ,→ 全角式 1.5em;應雙側皆收 → 1em。 改 else-if 為雙獨立 if。修後:,「 全角式與開明式皆 1em;。」 全角式 1.5em/開明式 1em (」左為墨不收);·— 兩式皆 2em(不宜挤之例外)。 - 透明穿越:原以「同 parent 直接兄弟」掃描,漏掉跨 strong/em 等行內邊界之連續標點(致 跨邊界標點對未挤、達 2em)。改為從 root 之透明遍歷(穿越樣式行內;block 邊界與 avoid 子樹斷相鄰;jz-char 為原子不下探),跨 inline 邊界之連續標點亦正確配對。 - src/core/unicode.ts:BD_STOP +.;inkBias 表 +.。 - src/typeset/jiya.ts:jiyaAdjacencyPass 重寫(雙側收 + root 透明遍歷)。 - test:. 納入+連續挤压(雙側/句末單側/跨 inline/多標點鏈)回歸,70 測試通過。 - README/ARCHITECTURE:. 與連續挤压規則、halt 挤压意願原則;**涉長度處標明全角式/ 開明式**(。」 全角 1.5em/開明 1em 等)。dist 重建(僅 JS,juzhen.css 不變)。 --- ARCHITECTURE.md | 24 ++++++++++++-- README.md | 11 +++++-- dist/juzhen.iife.js | 69 +++++++++++++++++++++++++-------------- dist/juzhen.mjs | 69 +++++++++++++++++++++++++-------------- src/core/unicode.ts | 10 +++--- src/typeset/jiya.ts | 77 +++++++++++++++++++++++++------------------- test/juzhen.test.mjs | 73 ++++++++++++++++++++++++++++++++++------- 7 files changed, 231 insertions(+), 102 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1ddb113..b7beab7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -419,7 +419,7 @@ class 約定:`bd`(標點)、`bd-open/close/cop/stop/sep/liga`、`cjk`、`j class 觸發(I3;改用 class 而非 CSS `:lang()`,以避免 `:lang(zh-Hans)` 不匹配 `zh-CN` 等地區標籤之陷阱)。三機制並集落地:①**baseline 政策**(全角不擠/ 開明擠句內點號+括號引號/半角全擠);②**行內連續擠壓**(`jiyaAdjacencyPass`, - 相鄰標點對向側收一字,lang 無關);③**行端擠壓**(`lineedge.ts` layout pass)。 + lang 無關,見下「連續標點挤壓規則」);③**行端擠壓**(`lineedge.ts` layout pass)。 - **跨瀏覽器(I4)— 修訂**:半形改以字型 OpenType **`halt`**(`font-feature- settings:"halt"`)渲染,**非**負 `margin`。原因:負 margin 假設可壓縮空白全在 單側,對**居中字形**(`!?`、繁體 `locl` 下之 `。,`)會把字面拉入鄰字而**重疊**; @@ -427,6 +427,24 @@ class 約定:`bd`(標點)、`bd-open/close/cop/stop/sep/liga`、`cjk`、`j 不重疊。`font-feature-settings:"halt"` 各引擎一致(當初疑慮之不一致者為**自動** 的 `text-spacing-trim`/`chws`,非顯式 `halt`);缺 `halt` 之字型優雅降級為全形。 鄰接/行端之「收哪一字」之**決策**仍由 `blankSides` 邏輯定,渲染統一為 `halt`。 +- **句末標點集含全角句點 `.`(下游回饋)**:`BD_STOP` 為 `。.!?`——除表意句號 `。` + (U+3002)外,納入**全角句點 `.`**(U+FF0E)。實測 Noto CJK SC/TC 之 `halt`(GPOS + SinglePos 調 advance)皆覆蓋 `.`、挤壓收 0.5em,與 `。` 一致;`inkBias` 同步補 `.` + (簡 left/繁 center),margin 後備亦可挤。政策行為與 `。!?` 同:quanjiao/kaiming 句末 + 保持全角、banjiao 挤半。 +- **halt 即字型之「挤壓意願」(設計原則)**:字型對某標點提供 `halt` =該字形適合挤半; + **不提供**=該字形不宜挤(占滿字框,如 Noto TC 之全角 `?` 與 SC 不同、`〈〉` 無 halt)。 + 故聚珍**信任字型 halt 覆蓋**:有 halt 字型中個別無 halt 之字符,**保持全角、不強挤**(優雅 + 降級),不為其特設規則。`margin` 後備僅用於**整體無 halt** 之字型(GBK 老字型等),以 + `inkBias` 逐字型調教近似 halt——非用來強挤「有 halt 字型刻意不挤」之字符。 +- **連續標點挤壓規則(雙側獨立 + 透明穿越;下游回饋)**:相鄰兩標點,**對每一標點**獨立 + 判斷——若其「有空白之側」緊鄰另一標點,則挤掉該側空白(收半形 `jz-half`)。故 `,「` + (pause 右空白 + open 左空白)兩側皆收 → 全角式與開明式皆合占 1em;`。」`(stop 右空白; + 」左為墨)只收 `。`→ 全角式 1.5em/開明式 1em(」baseline 已半);`·—`(皆無指向對方之 + 空白)皆不收 → 兩式皆 2em(不宜挤之例外)。多標點鏈逐對處理。`jiyaAdjacencyPass` 以**從 root 之透明遍歷**(穿越 strong/ + em… 等樣式行內;block 邊界與 avoid 子樹斷相鄰;jz-char 為原子不下探)取代原「同 parent + 直接兄弟」掃描,使**跨 inline 邊界**之連續標點亦正確配對(原漏致跨 strong/em 之標點對未挤、 + 達 2em)。 - **`margin` 後備模式(.feedback 增補)**:消費端字型未必含 `halt`(多數系統字型、 GBK 老字型,實測**方正書宋 GBK 僅含 `vert`、無 `halt`** → halt 模式不擠)。故提供 後備:`jiya: { halfWidth: "margin" }`。**機制**(演進後):把 `jz-char` 之 inline-block @@ -442,8 +460,8 @@ class 約定:`bd`(標點)、`bd-open/close/cop/stop/sep/liga`、`cjk`、`j 後代選擇器)——`render()` 在全域 margin 時自動掛於 root;亦可手寫於任一祖先作逐區塊 /逐語言控制(含 `margin` 頁中嵌 `halt` 島之 halt-reset,憑來源順序勝出)。bias class 恆於 charify 標記,halt 模式不讀,故模式切換為純 CSS、無需重 render。實測: - 方正書宋 GBK 下同句行寬 `24.0em(halt 無效)→ 19.7em(margin 生效)`、單標點 - `1.0em → 0.5em`。 + 方正書宋 GBK(開明式 zh-Hans)下同句行寬 `24.0em(halt 無效)→ 19.7em(margin 生效)`、 + 單一句內點號 `1.0em → 0.5em`。 - **切片**:涵蓋並超越 v1 Pass D(開明式),加入全角式、lang 切換、行內連續與 行端擠壓、`halt`/`margin` 雙渲染。 diff --git a/README.md b/README.md index 78ca751..dc79dbe 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ jz.render(document.querySelector("main")); |---|---| | **中西間隙** `spacing` | CJK↔西文邊界插入 copy-clean 之 `jz-hws`(`user-select:none`,剪貼板跳過)。「西文」涵蓋拉丁(含擴充 A/B,拼音聲調 `ā ǎ ē`)、希臘、西里爾、數字、貨幣(`€ ₩ ₿`)、數學運算符等;CJK 標點(`。,「」()……` 等)不誤判為西文、不生隙。含跨樣式行內邊界與 `code`/`kbd` pill 邊界(pill 緊鄰標點時穿越 `jz-jinze` 仍補間隙;pill 跨行右隙、間隙落行末之自我應驗皆已修)。贅餘作者空格一律正規化:CJK↔西文吸收為受管間隙、CJK↔CJK(含跨 inline 標籤)剝除不留隙。 | | **長英文詞** `longWord` | `[A-Za-z]{6,}` 包入 ``,配合 CSS `hyphens:auto` 原生斷詞(DOM 無連字符 → 複製不污染)。 | -| **標點擠壓** `jiya` | 標點 charify 為 `jz-char`+`bd-*` class。三機制:①baseline 政策(全角式不擠/開明式擠句內點號+括號引號/半角式全擠);②行內連續擠壓(相鄰標點對向側收);③行端擠壓(`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` 綁定載體。 | | **justify 單份間距** | 每個 `jz-char` 為 `inline-block` 原子項,句末標點只貢獻一份對齊伸縮量。 | @@ -132,7 +132,14 @@ createJuzhen({ > 為何需要後備:`halt` 缺席時開明式擠壓「完全無效」(標點仍佔全形)。實測 > **方正書宋 GBK 僅含 `vert`、無 `halt`** → halt 模式不擠;切 `margin` 後備後, -> 同句行寬 `24.0em → 19.7em`、單一標點 `1.0em → 0.5em`,半形生效。 +> (開明式 zh-Hans)同句行寬 `24.0em → 19.7em`、單一句內點號 `1.0em → 0.5em`,半形生效。 + +> **句末標點含全角句點 `.`**:句末點號為 `。.!?`——除表意句號 `。`(U+3002)外納入 +> 全角句點 `.`(U+FF0E,實測 Noto CJK 有 `halt`)。政策行為與 `。!?` 同:全角式與開明式 +> 句末**保持全角 1em**;唯 banjiao(或與鄰標點連續被收)時 `halt` 收為半形 advance 0.5em。 +> **`halt` 即字型之「挤壓意願」**:字型不提供某標點之 `halt`(其字形占滿字框、不宜挤半, +> 如某些字型之全角 `?`、`〈〉`)時,聚珍**信任字型、保持全角不強挤**(優雅降級); +> `margin` 後備僅用於**整體無 `halt`** 之字型,逐字型以 `jiya.bias` 調教。 ### 激活與粒度 diff --git a/dist/juzhen.iife.js b/dist/juzhen.iife.js index ea6002d..8efb7d5 100644 --- a/dist/juzhen.iife.js +++ b/dist/juzhen.iife.js @@ -515,7 +515,7 @@ var Juzhen = (() => { var WEST_SHARED = "0-9\\$%\\^&\\*\\-\\+\\\\=\\u0370-\\u03ff\\u0100-\\u024f\\u0400-\\u04ff\\u00a1-\\u00b6\\u00b8-\\u00ff\\u20a0-\\u20bf\\u2150-\\u21ff\\u2200-\\u22ff\\u2700-\\u27bf"; var ANS_AFTER_CJK = ALPHA + "@" + WEST_SHARED; var ANS_BEFORE_CJK = ALPHA + WEST_SHARED; - var BD_STOP = "\u3002\uFF01\uFF1F"; + var BD_STOP = "\u3002\uFF0E\uFF01\uFF1F"; var BD_PAUSE = "\uFF0C\u3001\uFF1B\uFF1A"; var BD_OPEN = "\u300C\u300E\uFF08\u300A\u3008\u3010\u3016\u3014\uFF3B\uFF5B\u201C\u2018"; var BD_CLOSE = "\u300D\u300F\uFF09\u300B\u3009\u3011\u3017\u3015\uFF3D\uFF5D\u201D\u2019"; @@ -603,6 +603,7 @@ var Juzhen = (() => { "\uFF0C": "left", "\u3001": "left", "\u3002": "left", + "\uFF0E": "left", "\uFF1B": "left", "\uFF1A": "left", "\uFF01": "left", @@ -612,6 +613,7 @@ var Juzhen = (() => { "\uFF0C": "center", "\u3001": "center", "\u3002": "center", + "\uFF0E": "center", "\uFF1B": "center", "\uFF1A": "center", "\uFF01": "center", @@ -718,33 +720,52 @@ var Juzhen = (() => { order: 25, enabled: (o) => o.features.jiya, render(ctx) { - const { finder } = ctx; - const parents = /* @__PURE__ */ new Set(); - ctx.root.querySelectorAll("jz-char").forEach((c) => { - if (c.parentNode) { - parents.add(c.parentNode); + const { finder, options } = ctx; + const blockSet = options.finder.blockTags; + let prev = null; + const squeeze = (L, R) => { + if (!finder.featureEnabledFor(L, PASS)) { + return; } - }); - for (const parent of parents) { - let child = parent.firstChild; - while (child) { - const next = child.nextSibling; - if (child.nodeType === 1 && child.nodeName === "JZ-CHAR" && next && next.nodeType === 1 && next.nodeName === "JZ-CHAR") { - const L = child; - const R = next; - if (finder.featureEnabledFor(L, PASS)) { - const lSides = blankSides(bdClassOf(L)); - const rSides = blankSides(bdClassOf(R)); - if (lSides.right) { - L.classList.add("jz-half"); - } else if (rSides.left) { - R.classList.add("jz-half"); - } + const lSides = blankSides(bdClassOf(L)); + const rSides = blankSides(bdClassOf(R)); + if (lSides.right) { + L.classList.add("jz-half"); + } + if (rSides.left) { + R.classList.add("jz-half"); + } + }; + const visit = (node) => { + for (let c = node.firstChild; c; c = c.nextSibling) { + if (c.nodeType === 3) { + if (c.data.length > 0) { + prev = null; } + continue; + } + if (c.nodeType !== 1) { + continue; + } + const el = c; + const nm = el.nodeName; + if (nm === "JZ-CHAR") { + if (prev) { + squeeze(prev, el); + } + prev = el; + } else if (finder.isAvoided(el)) { + prev = null; + } else if (blockSet.has(nm)) { + prev = null; + visit(el); + prev = null; + } else { + visit(el); } - child = next; } - } + }; + visit(ctx.root); }, revert() { } diff --git a/dist/juzhen.mjs b/dist/juzhen.mjs index 7361bfb..44785cf 100644 --- a/dist/juzhen.mjs +++ b/dist/juzhen.mjs @@ -487,7 +487,7 @@ var ANY_CJK = new RegExp("[" + CJK + "]"); var WEST_SHARED = "0-9\\$%\\^&\\*\\-\\+\\\\=\\u0370-\\u03ff\\u0100-\\u024f\\u0400-\\u04ff\\u00a1-\\u00b6\\u00b8-\\u00ff\\u20a0-\\u20bf\\u2150-\\u21ff\\u2200-\\u22ff\\u2700-\\u27bf"; var ANS_AFTER_CJK = ALPHA + "@" + WEST_SHARED; var ANS_BEFORE_CJK = ALPHA + WEST_SHARED; -var BD_STOP = "\u3002\uFF01\uFF1F"; +var BD_STOP = "\u3002\uFF0E\uFF01\uFF1F"; var BD_PAUSE = "\uFF0C\u3001\uFF1B\uFF1A"; var BD_OPEN = "\u300C\u300E\uFF08\u300A\u3008\u3010\u3016\u3014\uFF3B\uFF5B\u201C\u2018"; var BD_CLOSE = "\u300D\u300F\uFF09\u300B\u3009\u3011\u3017\u3015\uFF3D\uFF5D\u201D\u2019"; @@ -575,6 +575,7 @@ var BIAS_HANS = { "\uFF0C": "left", "\u3001": "left", "\u3002": "left", + "\uFF0E": "left", "\uFF1B": "left", "\uFF1A": "left", "\uFF01": "left", @@ -584,6 +585,7 @@ var BIAS_HANT = { "\uFF0C": "center", "\u3001": "center", "\u3002": "center", + "\uFF0E": "center", "\uFF1B": "center", "\uFF1A": "center", "\uFF01": "center", @@ -690,33 +692,52 @@ var jiyaAdjacencyPass = { order: 25, enabled: (o) => o.features.jiya, render(ctx) { - const { finder } = ctx; - const parents = /* @__PURE__ */ new Set(); - ctx.root.querySelectorAll("jz-char").forEach((c) => { - if (c.parentNode) { - parents.add(c.parentNode); + const { finder, options } = ctx; + const blockSet = options.finder.blockTags; + let prev = null; + const squeeze = (L, R) => { + if (!finder.featureEnabledFor(L, PASS)) { + return; } - }); - for (const parent of parents) { - let child = parent.firstChild; - while (child) { - const next = child.nextSibling; - if (child.nodeType === 1 && child.nodeName === "JZ-CHAR" && next && next.nodeType === 1 && next.nodeName === "JZ-CHAR") { - const L = child; - const R = next; - if (finder.featureEnabledFor(L, PASS)) { - const lSides = blankSides(bdClassOf(L)); - const rSides = blankSides(bdClassOf(R)); - if (lSides.right) { - L.classList.add("jz-half"); - } else if (rSides.left) { - R.classList.add("jz-half"); - } + const lSides = blankSides(bdClassOf(L)); + const rSides = blankSides(bdClassOf(R)); + if (lSides.right) { + L.classList.add("jz-half"); + } + if (rSides.left) { + R.classList.add("jz-half"); + } + }; + const visit = (node) => { + for (let c = node.firstChild; c; c = c.nextSibling) { + if (c.nodeType === 3) { + if (c.data.length > 0) { + prev = null; } + continue; + } + if (c.nodeType !== 1) { + continue; + } + const el = c; + const nm = el.nodeName; + if (nm === "JZ-CHAR") { + if (prev) { + squeeze(prev, el); + } + prev = el; + } else if (finder.isAvoided(el)) { + prev = null; + } else if (blockSet.has(nm)) { + prev = null; + visit(el); + prev = null; + } else { + visit(el); } - child = next; } - } + }; + visit(ctx.root); }, revert() { } diff --git a/src/core/unicode.ts b/src/core/unicode.ts index 194168a..b250ef4 100644 --- a/src/core/unicode.ts +++ b/src/core/unicode.ts @@ -60,8 +60,10 @@ export const ANS_BEFORE_CJK = ALPHA + WEST_SHARED; // 注意:分類依「字形角色」,而開明式/全角式之別依「句法角色」。slice 1 之 // 擠壓集(句內點號)由 BD_PAUSE 表達——此即 v1 Pass D 所擠之 ,、;:。 -/** 句末點號:。!?(開明式與全角式皆保持全形,不擠壓)。 */ -export const BD_STOP = "。!?"; +/** 句末點號:。.!?(開明式與全角式皆保持全形,不擠壓;banjiao 擠半)。 + * 含全角句點 .(U+FF0E,異於表意句號 。U+3002)——實測 Noto CJK SC/TC 皆有 halt、 + * 挤壓收 0.5em,故與 。!? 同列為句末點號(下游回饋)。 */ +export const BD_STOP = "。.!?"; /** 句內點號:,、;:(開明式擠成半形;全角式保持全形)。 */ export const BD_PAUSE = ",、;:"; @@ -245,11 +247,11 @@ const BIAS_BRACKETS: Record = (() => // 在右 → 全判 left(經方正書宋 GBK 實測:,、。;:!? 墨色 inkL≈0.1–0.18、 // inkR≈0.58–0.68,一致偏左)。繁體(Noto/教育部字形)點號置中 → center。 const BIAS_HANS: Record = { - ",": "left", "、": "left", "。": "left", + ",": "left", "、": "left", "。": "left", ".": "left", ";": "left", ":": "left", "!": "left", "?": "left", }; const BIAS_HANT: Record = { - ",": "center", "、": "center", "。": "center", + ",": "center", "、": "center", "。": "center", ".": "center", ";": "center", ":": "center", "!": "center", "?": "center", }; diff --git a/src/typeset/jiya.ts b/src/typeset/jiya.ts index b04c181..e1e9f31 100644 --- a/src/typeset/jiya.ts +++ b/src/typeset/jiya.ts @@ -107,14 +107,17 @@ export const jiyaPass: CharifyPass = { // ----- 行內連續擠壓(機制 A)----- // -// 對 DOM 直接相鄰(nextSibling 即下一 jz-char、其間無文本)之標點對 (L, R): -// 收掉「指向彼此」之那一側——L 若有右空白則收 L(半形),否則 R 若有左空白 -// 則收 R。每相鄰對只收一字 → 該對約 1.5em。鏈狀連續標點逐對處理。加 jz-half -// 冪等(並集),與 baseline 疊加(已半形則 no-op)。class 掛於 charify 之 -// jz-char,隨其解包清除,本 pass 無獨立產物。 -// -// 半形以字型 halt 渲染(居中字形如 ?!亦正確、不重疊);例:?!→ 只收 ?, -// !保持全形 1em。 +// 對「邏輯相鄰」之標點對 (L, R)——其間只隔**透明行內邊界**(樣式行內 strong/em…), +// 無文本、無非透明元素、不跨 block——挤掉兩者「指向彼此」之空白:**兩側獨立判斷** +// (非二選一)——L 右側有空白則收 L(半形),R 左側有空白則收 R。故: +// ・,「(pause 右空白 + open 左空白):兩側皆收 → 合占 1em。 +// ・。」(stop 右空白 + close 右空白,」左為墨):只收 。→ 全角式 1.5em/開明式 +// 1em(」baseline 已半)。 +// ・·—(中點/連接,皆無指向對方之空白):皆不收 → 2em(不宜挤之例外)。 +// 機制依 clreq「相鄰空白重疊即挤」。**透明穿越**:以從 root 之遍歷取代原「同 parent +// 直接兄弟」掃描,使跨 strong/em 邊界之連續標點亦正確配對(下游回饋;block 邊界與 +// avoid 子樹斷相鄰)。半形以 halt 渲染、冪等並集,與 baseline 疊加。class 掛於 +// charify 之 jz-char,隨其解包清除,本 pass 無獨立產物。 export const jiyaAdjacencyPass: StandalonePass = { name: "jiyaAdjacency", @@ -123,37 +126,45 @@ export const jiyaAdjacencyPass: StandalonePass = { enabled: (o) => o.features.jiya, render(ctx: RenderContext): void { - const { finder } = ctx; - const parents = new Set(); - ctx.root.querySelectorAll("jz-char").forEach((c) => - { - if (c.parentNode) { parents.add(c.parentNode); } - }); + const { finder, options } = ctx; + const blockSet = options.finder.blockTags; + let prev: Element | null = null; - for (const parent of parents) + const squeeze = (L: Element, R: Element): void => { - let child: Node | null = parent.firstChild; - while (child) + if (!finder.featureEnabledFor(L, PASS)) { return; } + const lSides = blankSides(bdClassOf(L)); + const rSides = blankSides(bdClassOf(R)); + if (lSides.right) { L.classList.add("jz-half"); } + if (rSides.left) { R.classList.add("jz-half"); } + }; + + const visit = (node: Node): void => + { + for (let c: Node | null = node.firstChild; c; c = c.nextSibling) { - const next = child.nextSibling; - if ( - child.nodeType === 1 && (child as Element).nodeName === "JZ-CHAR" - && next && next.nodeType === 1 && (next as Element).nodeName === "JZ-CHAR" - ) + if (c.nodeType === 3) { - const L = child as Element; - const R = next as Element; - if (finder.featureEnabledFor(L, PASS)) - { - const lSides = blankSides(bdClassOf(L)); - const rSides = blankSides(bdClassOf(R)); - if (lSides.right) { L.classList.add("jz-half"); } - else if (rSides.left) { R.classList.add("jz-half"); } - } + if ((c as Text).data.length > 0) { prev = null; } // 文本斷相鄰 + continue; } - child = next; + if (c.nodeType !== 1) { continue; } // 註解等:略過、不斷 + const el = c as Element; + const nm = el.nodeName; + if (nm === "JZ-CHAR") + { + if (prev) { squeeze(prev, el); } + prev = el; // jz-char 為原子,不下探(內含 jz-inner) + } + else if (finder.isAvoided(el)) { prev = null; } // avoid/skip 子樹:斷、不下探 + else if (blockSet.has(nm)) + { + prev = null; visit(el); prev = null; // block 邊界:前後皆斷相鄰,內部另計 + } + else { visit(el); } // 行內(樣式行內 / pill 等):透明穿越、相鄰性延續 } - } + }; + visit(ctx.root); }, revert(): void { diff --git a/test/juzhen.test.mjs b/test/juzhen.test.mjs index b4fb8f7..7b46d6f 100644 --- a/test/juzhen.test.mjs +++ b/test/juzhen.test.mjs @@ -199,6 +199,49 @@ test("jiya 機制 A:連續開類,後者半形(「「→ 第二個 jz-half assert.ok(opens[1].classList.contains("jz-half"), "第二個開類內側 → 半形"); }); +test("jiya 機制 A:,「→ 兩側皆收半形(雙側獨立判斷,合占 1em)", () => +{ + // ,右側有空白且右鄰標點「→ 收,;「左側有空白且左鄰標點,→ 收「。兩者皆半 = 1em。 + const { document } = setupDom('

甲,「乙

'); + createJuzhen({ spacing: false, longWord: false, jinze: false }).render(document.body); + const comma = document.querySelector("jz-char.bd-pause"); // , + const open = document.querySelector("jz-char.bd-open"); // 「 + assert.ok(comma.classList.contains("jz-half"), ",右空白鄰標點 → 半形"); + assert.ok(open.classList.contains("jz-half"), "「左空白鄰標點 → 半形(雙側收,非二選一)"); +}); + +test("jiya 機制 A:。」→ 僅 。半形(」左為墨、無空白可挤,全角式合占 1.5em)", () => +{ + const { document } = setupDom('

甲。」乙

'); + createJuzhen({ spacing: false, longWord: false, jinze: false }).render(document.body); + const stop = document.querySelector("jz-char.bd-stop"); // 。 + const close = document.querySelector("jz-char.bd-close"); // 」 + assert.ok(stop.classList.contains("jz-half"), "。右空白鄰標點 → 半形"); + assert.ok(!close.classList.contains("jz-half"), "」左為墨、無空白 → 不收(全角式 1.5em)"); +}); + +test("jiya 機制 A:跨透明 inline 邊界之連續標點亦正確擠(,「 分置 strong/em,雙側皆收)", () => +{ + // 下游回饋:原「同 parent 直接兄弟」掃描漏掉跨 strong/em 邊界之連續標點。 + const { document } = setupDom('

'); + createJuzhen({ spacing: false, longWord: false, jinze: false }).render(document.body); + assert.ok(document.querySelector("jz-char.bd-pause").classList.contains("jz-half"), "跨邊界 ,半形"); + assert.ok(document.querySelector("jz-char.bd-open").classList.contains("jz-half"), "跨邊界 「半形(透明穿越)"); +}); + +test("jiya 機制 A:多標點鏈 ,」。逐對判斷(,半/」半/。全)", () => +{ + const { document } = setupDom('

甲,」。乙

'); + createJuzhen({ spacing: false, longWord: false, jinze: false }).render(document.body); + const p = document.querySelector("p"); + const comma = p.querySelector("jz-char.bd-pause"); // ,右空白鄰」→ 半 + const close = p.querySelector("jz-char.bd-close"); // 」右空白鄰。→ 半 + const stop = p.querySelector("jz-char.bd-stop"); // 。左為墨、右鄰漢字 → 全 + assert.ok(comma.classList.contains("jz-half"), ",→ 半"); + assert.ok(close.classList.contains("jz-half"), "」右空白鄰標點。→ 半"); + assert.ok(!stop.classList.contains("jz-half"), "。左為墨、右側無標點鄰 → 全"); +}); + test("jiya:revert 還原標點(無 jz-char,textContent 不變)", () => { const { document } = setupDom('

好,走。

'); @@ -511,21 +554,27 @@ test("CSS:聚珍不內建 font-family(字型交消費端),半形顯式 // ----- charClass:自定義字符分類規則集(向下兼容+擴充)----- -test("charClass:預設不傳時,英文全角句號 . 未分類(不 charify、不挤、不補隙)", () => +test("jiya:英文全角句號 . 已內建為句末標點 bd-stop(本次納入;Noto CJK 有 halt)", () => { - const { document } = setupDom('

中.文

'); + // . U+FF0E(異於表意句號 。U+3002)已納入內置 BD_STOP,預設即 charify 為 bd-stop。 + const { document } = setupDom('

中.文。

'); createJuzhen({ lang: { style: "kaiming" } }).render(document.body); const p = document.querySelector("p"); - assert.equal(countTag(p, "jz-char"), 0, ". 預設未分類為標點,不 charify"); - assert.equal(countTag(p, "jz-hws"), 0, ". 預設非西文,無中西間隙"); + const dot = Array.from(p.querySelectorAll("jz-char")).find((c) => c.textContent === "."); + assert.ok(dot && dot.classList.contains("bd-stop"), ". 預設 charify 為 bd-stop"); + // 開明式句末不挤(與 。一致);banjiao 才挤——此處僅確認分類與政策一致。 + assert.equal(dot.classList.contains("jz-half"), false, "開明式句末標點不挤半(保持全角,同 。)"); }); -test("charClass.biaodian.stop:把 . 歸為全寬句末標點 → 被 jiya charify 為 bd-stop", () => +test("charClass.biaodian.stop:把仍未分類之 ‥ 歸為全寬句末標點 → charify 為 bd-stop", () => { - const { document } = setupDom('

中.文.

'); - createJuzhen({ lang: { style: "kaiming" }, charClass: { biaodian: { stop: "." } } }).render(document.body); - const p = document.querySelector("p"); - assert.ok(countTag(p, "jz-char.bd-stop") >= 1, ". 應 charify 為 bd-stop"); + // ‥ U+2025 預設未分類;以 charClass 歸句末標點以示「追加」機制。 + const base = setupDom('

中‥文

'); + createJuzhen({ lang: { style: "kaiming" } }).render(base.document.body); + assert.equal(countTag(base.document.querySelector("p"), "jz-char"), 0, "‥ 預設未分類、不 charify"); + const { document } = setupDom('

中‥文

'); + createJuzhen({ lang: { style: "kaiming" }, charClass: { biaodian: { stop: "‥" } } }).render(document.body); + assert.ok(countTag(document.querySelector("p"), "jz-char.bd-stop") >= 1, "‥ 經 charClass 歸 bd-stop"); }); test("charClass.western:把未分類符號 ★ 歸為西文 → 與 CJK 邊界補 jz-hws", () => @@ -586,10 +635,10 @@ test("charClass.western:追加不破壞內置——€(內置)與 ★( test("charClass.biaodian:多子類同傳,各自生效(stop/pause)", () => { - const { document } = setupDom('

甲.乙•丙

'); - createJuzhen({ lang: { style: "kaiming" }, charClass: { biaodian: { stop: ".", pause: "•" } } }).render(document.body); + const { document } = setupDom('

甲‥乙•丙

'); + createJuzhen({ lang: { style: "kaiming" }, charClass: { biaodian: { stop: "‥", pause: "•" } } }).render(document.body); const p = document.querySelector("p"); - assert.ok(countTag(p, "jz-char.bd-stop") >= 1, ". → bd-stop"); + assert.ok(countTag(p, "jz-char.bd-stop") >= 1, "‥ → bd-stop"); const pause = p.querySelector("jz-char.bd-pause"); assert.ok(pause && pause.classList.contains("jz-half"), "• → bd-pause 且開明式挤半形"); });