From 7b4a051a1f8cafa32ff585dacdc1b1bb084c40ef Mon Sep 17 00:00:00 2001 From: commilitia Date: Tue, 2 Jun 2026 01:21:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=A8=99=E9=BB=9E=E5=8D=8A=E5=BD=A2=20?= =?UTF-8?q?margin=20=E5=BE=8C=E5=82=99=E6=A8=A1=E5=BC=8F=EF=BC=8B=E4=B8=8B?= =?UTF-8?q?=E6=B8=B8=E5=9B=9E=E9=A5=8B=E4=BF=AE=E5=BE=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit margin 後備(供缺 OpenType halt 之字型): - jiya.halfWidth: "halt"(預設)|"margin"。margin 以 width:0.5em+字身依墨色 偏側溢出定位,於 CSS 盒模型重現 halt 之半形 advance(真 0.5em;相鄰標點各 0.5em、合佔 1em,無負 margin 之鄰接疊加/單側重疊)。模式由 data-jz-halfwidth 子樹屬性決定(可逐區塊/逐 lang,含 halt-island 反置)。 - 偏側 inkBias 依字身墨色中心給 left/center/right/full;參考表據方正書宋 GBK 實測,consumer 可經 jiya.bias 逐 lang 逐字覆寫。 - _lang.css 字型堆疊僅作後備(全包 :where 零特異度、只命中帶 lang 屬性之元素、 不直接覆寫 jz-inner)→ 消費端 font-family 恆優先,標點隨之以消費端字型渲染。 - 新增 tools/measure-bias.py(量 glyph ink 中心、產偏側建議表)+ README 「字型適配工作流」。demo/fallback.html:方正書宋 GBK 對照 halt 失效 vs margin。 下游回饋修復: - 1a:adjacentLogicalChar 透明穿越 jz-jinze(同 jz-hws/樣式行內)→ pill 緊鄰 CJK 標點時,左緣間隙與作者空格剝除復原(jinze 先於 spacing 之交互)。 - 1b:CJK↔CJK 贅餘作者空格(含跨 inline 標籤)剝除、不留隙,與 CJK↔Latin 之 邊界正規化一致(DEL 哨符,全形空格 U+3000 不動)。 - 問題二:新增 justifyAtoms 選項;false → jz-char/jz-jinze 改 display:inline, 相容分頁器(Paged.js)對齊引擎(halt 半形與禁則 nowrap 保留)。 測試 43→49 全通過;dist 重建;README/ARCHITECTURE 同步更新契約。 --- ARCHITECTURE.md | 42 ++++++++++++- README.md | 139 +++++++++++++++++++++++++++++++++++++++-- demo/fallback.html | 83 ++++++++++++++++++++++++ dist/core/finder.d.ts | 4 ++ dist/core/unicode.d.ts | 11 ++++ dist/juzhen.css | 60 +++++++++++++++--- dist/juzhen.iife.js | 108 +++++++++++++++++++++++++++++--- dist/juzhen.mjs | 108 +++++++++++++++++++++++++++++--- dist/types.d.ts | 26 +++++++- src/core/finder.ts | 17 +++-- src/core/unicode.ts | 80 ++++++++++++++++++++++++ src/css/_jinze.css | 8 +++ src/css/_jiya.css | 80 +++++++++++++++++++++++- src/css/_lang.css | 33 +++++----- src/index.ts | 30 ++++++++- src/types.ts | 26 +++++++- src/typeset/jiya.ts | 20 ++++-- src/typeset/spacing.ts | 23 ++++++- test/juzhen.test.mjs | 99 +++++++++++++++++++++++++++++ tools/measure-bias.py | 97 ++++++++++++++++++++++++++++ 20 files changed, 1028 insertions(+), 66 deletions(-) create mode 100644 demo/fallback.html create mode 100644 tools/measure-bias.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6b00a6f..b4469ff 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -328,6 +328,16 @@ class 約定:`bd`(標點)、`bd-open/close/cop/stop/sep/liga`、`cjk`、`j 語義字元,剪貼板得緊湊原文。 - **切片**:涵蓋 v1 Pass A、Pass B(pill `code/kbd` 邊界亦走同一機制——左字或 pill 元素包入 `jz-hws`;pill 由 avoid 與鄰接判定處理)。 +- **邊界正規化一致性(下游回饋 1a/1b 修)**: + - **1a pill 穿越 jz-jinze**:jinze(order 40)先於 spacing(order 50),會把尾隨 + 標點之 pill 納入 `jz-jinze`。`adjacentLogicalChar` 須**透明穿越 `jz-jinze`** + (與 `jz-hws`、樣式行內同列,見 `finder.isTransparentInline`),否則 pill 左鄰字 + 不可達、左緣間隙與作者空格剝除雙失。 + - **1b CJK↔CJK 贅餘空格**:中文不用詞間空格,故 CJK 表意字之間的單一 ASCII 作者 + 空格(含跨樣式行內標籤,`collectRuns` 已透明合併)一律**剝除、不留隙**(以 + `DEL` 哨符 1:1 替換空格保持對齊,`processBlock` 據此刪空格而不插 `jz-hws`)。 + 與 CJK↔Latin 之剝除一致——凡 inline 邊界之贅餘作者空格皆統一處理。全形空格 + U+3000 不動。 ### 6.1a 行末去隙(gapTrim,justify 修正)— 切片 1 @@ -363,8 +373,25 @@ class 約定:`bd`(標點)、`bd-open/close/cop/stop/sep/liga`、`cjk`、`j 不重疊。`font-feature-settings:"halt"` 各引擎一致(當初疑慮之不一致者為**自動** 的 `text-spacing-trim`/`chws`,非顯式 `halt`);缺 `halt` 之字型優雅降級為全形。 鄰接/行端之「收哪一字」之**決策**仍由 `blankSides` 邏輯定,渲染統一為 `halt`。 +- **`margin` 後備模式(.feedback 增補)**:消費端字型未必含 `halt`(多數系統字型、 + GBK 老字型,實測**方正書宋 GBK 僅含 `vert`、無 `halt`** → halt 模式不擠)。故提供 + 後備:`jiya: { halfWidth: "margin" }`。**機制**(演進後):把 `jz-char` 之 inline-block + 寬收為 `width:0.5em`(真半形 advance——相鄰兩標點各 0.5em、合 1em,無負 margin 之 + 鄰接疊加/單側重疊問題),字身 `jz-inner` 以 `overflow` 溢出、再依墨色偏側左移 + (right −0.5em/center −0.25em/left 0),使墨色落入 0.5em 盒、空白溢向外側——即以 + CSS 盒模型重現 `halt`。偏側由 `core/unicode.ts` 之 `inkBias(ch, langClass, overrides)` + 依**字身墨色中心**給出 `left|center|right|full`(簡體點號偏左、繁體居中;彎引號 + `“”`/角括號 `「」`/尖括號 `〈〉` 墨強偏一側→開 right、閉 left;圓方書名括號 + `()【】《》…` 近中→center),charify 時標為 `jz-ink-{left,center,right}`。消費端可 + 經 `jiya.bias` 逐 lang 逐字覆寫;配字工作流與量測工具 `tools/measure-bias.py` 見 README。 +- **激活與粒度(I3/I10)**:模式由 `data-jz-halfwidth` 屬性**所在子樹**決定(CSS + 後代選擇器)——`render()` 在全域 margin 時自動掛於 root;亦可手寫於任一祖先作逐區塊 + /逐語言控制(含 `margin` 頁中嵌 `halt` 島之 halt-reset,憑來源順序勝出)。bias + class 恆於 charify 標記,halt 模式不讀,故模式切換為純 CSS、無需重 render。實測: + 方正書宋 GBK 下同句行寬 `24.0em(halt 無效)→ 19.7em(margin 生效)`、單標點 + `1.0em → 0.5em`。 - **切片**:涵蓋並超越 v1 Pass D(開明式),加入全角式、lang 切換、行內連續與 - 行端擠壓。 + 行端擠壓、`halt`/`margin` 雙渲染。 ### 6.4 禁則(Jinze,避頭尾)+ justify 綁定 — 切片 1(基礎)/後續(完整) @@ -462,6 +489,14 @@ class 約定:`bd`(標點)、`bd-open/close/cop/stop/sep/liga`、`cjk`、`j > inline-block 影響基線對齊與行內換行,須以 CSS 校正(`vertical-align`、行高), > 並**跨 Chrome/Safari/FF 目視驗證**(§12);此為切片 1 的重點驗證項。 +**分頁器相容(下游回饋問題二)**:常規流式版面 inline-block 原子正常,但**分頁器** +(Paged.js)之對齊引擎會把每個 inline-block 邊界當伸縮點、與 CJK inter-ideograph +疊加,於窄欄混排散架。故提供 `options.justifyAtoms`(預設 true);設 false 時 render +於 root 掛 `data-jz-atoms="inline"`,CSS 令 `jz-char`/`jz-jinze` 改 `display:inline` +(halt 半形與 jinze nowrap 仍保留)。此即 R1 預案之一(解耦對齊原子化),切換僅 CSS、 +不動 JS。注意:`margin` 後備半形依賴 inline-block 之 `width:0.5em`,與 +`justifyAtoms:false` 不可兼得——分頁器場景須用含 `halt` 字型。 + --- ## 9. 跨瀏覽器策略 @@ -470,8 +505,9 @@ class 約定:`bd`(標點)、`bd-open/close/cop/stop/sep/liga`、`cjk`、`j `user-select`、`white-space`、`:where()`、`hyphens`,及字型 OpenType `font-feature-settings:"halt"`(標點半形;各引擎一致,效果僅依字型是否含 halt, Noto CJK 具備,缺則優雅降級為全形)——三大引擎(Blink/WebKit/Gecko)皆穩定 - 支援。**注意**:標點半形不用負 `margin`(對居中字形會重疊),改用 `halt`(見 - §6.3 修訂)。 + 支援。**注意**:標點半形預設不用負 `margin`(對居中字形會重疊),改用 `halt`(見 + §6.3 修訂)。**缺 `halt` 字型之後備**:`jiya.halfWidth:"margin"` 以 `width:0.5em`+ + 字身依偏側(`jz-ink-*`)溢出定位,於 CSS 重現 halt、真半形 advance(見 §6.3)。 - **漸進增強(非必要)**:`text-spacing-trim`(Blink)、`hanging-punctuation` (WebKit)、`text-emphasis`、`text-autospace`——僅在支援時錦上添花,缺席不影響 基本排版(I4)。 diff --git a/README.md b/README.md index 60e9d81..0079f07 100644 --- a/README.md +++ b/README.md @@ -65,9 +65,9 @@ jz.render(document.querySelector("main")); | 功能 | 說明 | |---|---| -| **中西間隙** `spacing` | CJK↔西文邊界插入 copy-clean 之 `jz-hws`(`user-select:none`,剪貼板跳過)。含跨樣式行內邊界與 `code`/`kbd` pill 邊界。 | +| **中西間隙** `spacing` | CJK↔西文邊界插入 copy-clean 之 `jz-hws`(`user-select:none`,剪貼板跳過)。含跨樣式行內邊界與 `code`/`kbd` pill 邊界(pill 緊鄰標點時穿越 `jz-jinze` 仍補間隙)。贅餘作者空格一律正規化: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`)。 | +| **標點擠壓** `jiya` | 標點 charify 為 `jz-char`+`bd-*` class。三機制:①baseline 政策(全角式不擠/開明式擠句內點號+括號引號/半角式全擠);②行內連續擠壓(相鄰標點對向側收);③行端擠壓(`lineedge` layout pass)。半形預設以字型 `halt` 渲染(按字形正確定位,居中字形如 `!?`/繁體 `。,` 不重疊;不依賴 `text-spacing-trim`)。**字型缺 `halt` 時可切 `margin` 後備模式**(見下節)。 | | **禁則** `jinze` | 行首行末避頭尾:閉類/點號綁前一字(不置行首)、開類綁後一字(不置行末),只隔離邊界字故長引文中段仍可斷。亦為 `justify` 綁定載體。 | | **justify 單份間距** | 每個 `jz-char` 為 `inline-block` 原子項,句末標點只貢獻一份對齊伸縮量。 | @@ -90,14 +90,27 @@ createJuzhen({ }, spacing: true, longWord: { minLength: 6, lang: "en" }, - jiya: true, + jiya: true, // 或物件:{ halfWidth: "halt" | "margin", bias } jinze: true, + justifyAtoms: true, // jz-char/jz-jinze 以 inline-block 作對齊原子; + // 分頁器(Paged.js)下設 false 改 inline(見下) blocks: [ // 分塊功能選擇 { selector: ".code-comment", features: [ "spacing" ] }, ], }); ``` +### 分頁器(Paged.js 等)相容:`justifyAtoms` + +預設 `jz-char`/`jz-jinze` 為 `inline-block`,作 `justify` 之單一對齊原子(句末標點 +單份間距)。但**分頁器**(如 Paged.js)之對齊引擎會把每個 inline-block 邊界當伸縮點、 +與 CJK inter-ideograph 疊加,於窄欄混排時字距失控、整段散架。此時設 +`justifyAtoms: false`——`jz-char`/`jz-jinze` 改 `display:inline`(半形 `halt` 與禁則 +`nowrap` 綁定均保留)。常規流式版面無需設定。 + +> 注意:`margin` 後備半形依賴 `inline-block`(`width:0.5em`),故 +> `justifyAtoms:false` 與 `margin` 模式不可兼得——分頁器場景請用含 `halt` 之字型。 + ### 分塊功能選擇(HTML 屬性,就近繼承) ```html @@ -108,6 +121,119 @@ createJuzhen({ --- +## 標點半形:`halt` 預設與 `margin` 後備 + +開明/半角式把標點收成「半形」,有兩種渲染機制: + +| 機制 | 設定 | 原理 | 適用 | +|---|---|---|---| +| **`halt`**(預設) | `jiya: true` | 字型 OpenType `halt`,由字型按字形正確定位、不重疊 | 字型含 `halt`(Noto CJK/思源系列) | +| **`margin`**(後備) | `jiya: { halfWidth: "margin" }` | `width:0.5em`+字身依墨色偏側溢出定位(以 CSS 重現 halt) | 字型**缺** `halt`(多數系統字型、GBK 老字型如方正書宋 GBK、PingFang) | + +> 為何需要後備:`halt` 缺席時開明式擠壓「完全無效」(標點仍佔全形)。實測 +> **方正書宋 GBK 僅含 `vert`、無 `halt`** → halt 模式不擠;切 `margin` 後備後, +> 同句行寬 `24.0em → 19.7em`、單一標點 `1.0em → 0.5em`,半形生效。 + +### 激活與粒度 + +模式由 **`data-jz-halfwidth` 屬性所在子樹**決定(CSS 後代選擇器),故粒度極靈活: + +- **全域**:`jiya: { halfWidth: "margin" }` → `render()` 於處理 root 自動掛屬性。 +- **逐區塊/逐語言**:在 HTML 任一祖先手寫 `data-jz-halfwidth="margin"`(或 + `"halt"`),即令該子樹用對應機制——無需另建實例。常見如「全頁 `halt`,某用 + 無 `halt` 字型之區塊掛 `margin`」,或反之(`margin` 頁中嵌一塊 `halt` 島)。 + +```html + +
+``` + +### 機制與字形偏側表(`margin` 模式專用,可覆寫) + +`margin` 模式把標點 `jz-char` 之 inline-block 寬收為 **0.5em(真半形 advance)**,字身 +(`jz-inner`)以 `overflow` 溢出該盒,再依**墨色偏側**左移、使墨色落於 0.5em 盒內、 +空白溢向外側——即以 CSS 盒模型重現 `halt` 之「收字身寬+按字形定位」。相鄰兩標點各 +0.5em、合佔 1em,無鄰接疊加或單側重疊問題。 + +偏側須知每字之墨色中心。聚珍內建**參考表**(依方正書宋 GBK 實測之 glyph ink 位): +簡體點號 `,、。;:!?` 偏左;繁體點號居中;彎引號 `“”‘’`、角括號 `「」`、尖括號 +`〈〉` 墨色強偏一側(開→`right`、閉→`left`);圓/方/書名等括號 `()【】《》…` 墨色 +近中→`center`。於 charify 標為 `jz-ink-{left,center,right}`。因字型字身分布各異,可逐 +`lang` 逐字覆寫: + +```ts +createJuzhen({ + jiya: { + halfWidth: "margin", + bias: { + "zh-Hans": { ",": "center", "(": "right" }, // 覆寫參考預設 + "zh-Hant": { "。": "center" }, + }, + }, +}); +// 偏側值(決定字身於 0.5em 盒內之左移): +// left 墨偏左 → 不移(字身左對齊) +// center 墨居中 → 左移 0.25em +// right 墨偏右 → 左移 0.5em(右半入盒) +// full 無可壓縮空白 → 不收、維持全形 +``` + +> `halt` 模式不讀偏側表(由字型負責定位)。偏側表僅在 `margin` 後備模式生效。 +> `demo/fallback.html` 用方正書宋 GBK 對照 halt 失效 vs margin 生效。配字工作流見下節。 + +### 字型適配工作流(margin 模式配字) + +換用新字型時,依下列步驟確認半形渲染: + +1. **驗 `halt`**:`python3 tools/measure-bias.py `(需 `pip install fonttools`)。 + 首行報該字型是否含 OpenType `halt`。 + - **有 `halt`** → 直接用預設(`jiya: true`,halt 模式),無需配字,到此為止。 + - **無 `halt`**(多數系統字型、GBK 老字型)→ 用 `margin` 後備,續下。 +2. **取偏側建議**:同一工具同時量各標點 glyph 之墨色中心、印出建議偏側表 + (`left|center|right|full`)與可貼入 `jiya.bias` 之 JSON。 +3. **套用**:`createJuzhen({ jiya: { halfWidth: "margin", bias: <貼入 JSON> } })`; + 或全用內建參考表(多數宋體系 GBK 字型與參考表相符,未必需覆寫)。 +4. **目視微調**:以 `demo/fallback.html`(或自有頁面)對照原文,逐字檢查—— + - 標點與相鄰字**重疊** → 該字墨色比量測更偏該側,偏側往「不移」方向調 + (`right`→`center`→`left`)。 + - 標點與內容**仍有空隙、不夠半形** → 往「多移」方向調(`left`→`center`→`right`)。 + - 某標點根本無可壓空白(字面已滿全形)→ 設 `full`,不收。 +5. **記錄**:把該字型之 `bias` 覆寫存入專案設定,作為該字型之適配結果。 + +> 偏側是「字身於 0.5em 盒內左移多少」的三檔量化(0/0.25/0.5em),量測值近門檻 +> (如 0.6 介於 center/right)時,以目視為準。`halt` 字型走 OpenType 由字型精確 +> 定位,無此配字需求——`margin` 配字僅為缺 `halt` 字型之務實近似。 + +--- + +## CSS 基礎設定(消費端須知) + +JS 只注入 `jz-*` 結構,**所有視覺尺寸在 `juzhen.css`**——故 CSS 不可或缺。消費端 +須備妥下列幾項: + +1. **引入樣式(必須)**:``(或 ESM + `import "cjk-autospace/css"`)。缺此則間隙/擠壓/justify 原子化全部失效。 +2. **設定 `lang`(強烈建議)**:在內容容器標 `lang="zh-Hans"`/`"zh-Hant"`/`"ja"`, + 驅動全角/開明政策與字形 `locl`。無 `lang` 時回退 `options.lang.default`。 +3. **作用域類(用 `scope.include` 時)**:若設 `scope.include: ".juzhen"`,須給內容 + 容器加對應 class(如 `class="juzhen"`);`juzhen.css` 之 normalize/字型堆疊亦 + 收斂於此域,不外溢。 +4. **長英文詞斷字**:`longWord` 包出 `` 後,須由消費端 CSS 提供 + `hyphens: auto`(含 `-webkit-hyphens`)才會原生斷詞顯示連字符。 +5. **`justify`(選用)**:欲兩端對齊則自行設 `text-align: justify`;句末標點單份間距 + 與行末去隙由聚珍處理,無需額外設定。 +6. **字型(影響擠壓)**:開明/半角式半形預設依字型 `halt`。若用含 `halt` 之字型 + (Noto CJK/思源),預設即可;若用缺 `halt` 之字型,改用 `margin` 後備模式(見 + 上節)。`juzhen.css` 內建依 `:lang` 之 Noto Sans/Serif CJK 字型堆疊,但**僅作 + 後備**(全包於 `:where()`、零特異度,且只命中帶 `lang` 屬性之元素、不直接覆寫標點 + 字面容器 `jz-inner`)——**消費端在 `.juzhen` 或任一祖先設 `font-family` 即恆優先**, + 標點亦隨之以消費端字型渲染(故缺 `halt` 字型務必配 `margin` 後備,否則標點不擠)。 +7. **自動處理、無需消費端介入**:`juzhen.css` 已在 `.juzhen` 範圍內以 `@supports` + 關閉 Blink 原生 `text-spacing-trim`/`text-autospace`(避免與託管擠壓重複作用), + 並令注入間隙 `user-select: none`(複製乾淨)。 + +--- + ## v1 向下相容 ```js @@ -121,15 +247,16 @@ IIFE 構建另掛 `globalThis.createCjkAutospace` 別名。**與 v1 之差異** - 間隙標記由 `` 變為 `` (仍帶原 class,既有 `.cjk-autospace{user-select:none}` CSS 仍命中)。 -- 標點擠壓由消費端 `font-feature-settings:"halt"` 改為 `juzhen.css` 之負 `margin` - (需改用 `juzhen.css`;機制不同,同為開明式「句內點號半形」之效果)。 +- 標點擠壓改由 `juzhen.css` 託管(須引入),預設仍以字型 `halt` 渲染半形——與 v1 + 消費端 `font-feature-settings:"halt"` 同機制;缺 `halt` 之字型可切 `margin` 後備。 --- ## 目視驗證 `demo/index.html` 載入 `dist/` 產物,簡繁並列、可切 `justify`、附 copy-clean 測試區。 -版面相關結論(justify 單份間距、擠壓視覺量、行端、`inline-block` 基線)須於 +`demo/fallback.html` 以**方正書宋 GBK**(無 `halt`)對照 `halt` 模式失效 vs `margin` +後備生效。版面相關結論(justify 單份間距、擠壓視覺量、行端、`inline-block` 基線)須於 Chrome/Safari/Firefox 三引擎目視核對——jsdom 僅驗證 DOM 結構正確性。 --- diff --git a/demo/fallback.html b/demo/fallback.html new file mode 100644 index 0000000..b06b0a3 --- /dev/null +++ b/demo/fallback.html @@ -0,0 +1,83 @@ + + + + + +聚珍 — halt 缺失字型之 margin 後備模式(方正書宋 GBK) + + + + +

方正書宋 GBK · margin 後備模式

+

+ 此字型僅含 verthalt,故 halt 模式之開明式擠壓「完全無效」(標點仍佔全形)。 + margin 後備模式以負 margin 依字形偏側收半形——本字型簡體點號(,、。;:!?)墨色皆偏左下,故收右側空白。 + 白底行盒寬度即直接反映效果:B 與 A 同寬(未擠),C 明顯窄一截(已擠)。 +

+ +
+ A · 原文(瀏覽器原生) +

他问:“真的吗?”第一、第二、第三;(含括号)。

+
+ +
+ B · halt 模式
(無 halt → 不擠,等同原文)
+

他问:“真的吗?”第一、第二、第三;(含括号)。

+
+ +
+ C · margin 後備
(半形生效)
+

他问:“真的吗?”第一、第二、第三;(含括号)。

+
+ + + + + diff --git a/dist/core/finder.d.ts b/dist/core/finder.d.ts index 2f006d5..3e67232 100644 --- a/dist/core/finder.d.ts +++ b/dist/core/finder.d.ts @@ -29,6 +29,10 @@ export declare class Finder { constructor(opts: ResolvedOptions); /** 該元素是否匹配 pill 選擇器(行內塊,如 code/kbd)。 */ pillMatches(el: Element): 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)。 */ isAvoided(node: Node): boolean; diff --git a/dist/core/unicode.d.ts b/dist/core/unicode.d.ts index 6873ea7..f8461d8 100644 --- a/dist/core/unicode.d.ts +++ b/dist/core/unicode.d.ts @@ -1,3 +1,4 @@ +import type { LangClass } from "./locale.js"; /** CJK 表意文字/假名/注音等範圍(沿用並擴充 v1 之 PANGU_CJK)。 */ export declare const CJK: string; /** 西文字母與數字。 */ @@ -39,3 +40,13 @@ export declare function blankSides(bdClass: string | null): { left: boolean; right: boolean; }; +/** 字形墨色偏側。 */ +export type Bias = "left" | "center" | "right" | "full"; +/** 逐 lang 逐字之偏側覆寫表(消費端傳入,覆寫參考預設)。 */ +export type BiasTable = Partial>>; +/** + * 解析標點字元在某語言下之墨色偏側(margin 後備模式用)。 + * 優先序:消費端 overrides[langClass][ch] > 括號表 > 內建點號參考表。 + * 非可壓縮標點(間隔/連接號等)回傳 null(margin 模式不收)。 + */ +export declare function inkBias(ch: string, langClass: LangClass, overrides?: BiasTable): Bias | null; diff --git a/dist/juzhen.css b/dist/juzhen.css index c6ff82a..73c7f66 100644 --- a/dist/juzhen.css +++ b/dist/juzhen.css @@ -15,32 +15,32 @@ jz-em { } /* src/css/_lang.css */ -:where(.juzhen):lang(zh-Hans), -:where(.juzhen) :lang(zh-Hans) { +:where(.juzhen:lang(zh-Hans)), +:where(.juzhen [lang]:lang(zh-Hans)) { font-family: "Noto Serif SC", "Noto Serif CJK SC", "Source Han Serif SC", serif; } -:where(.juzhen):lang(zh-Hant), -:where(.juzhen) :lang(zh-Hant) { +:where(.juzhen:lang(zh-Hant)), +:where(.juzhen [lang]:lang(zh-Hant)) { font-family: "Noto Serif TC", "Noto Serif CJK TC", "Source Han Serif TC", serif; } -:where(.juzhen):lang(zh-HK), -:where(.juzhen) :lang(zh-HK) { +:where(.juzhen:lang(zh-HK)), +:where(.juzhen [lang]:lang(zh-HK)) { font-family: "Noto Serif HK", "Noto Serif CJK HK", "Noto Serif TC", serif; } -:where(.juzhen):lang(ja), -:where(.juzhen) :lang(ja) { +:where(.juzhen:lang(ja)), +:where(.juzhen [lang]:lang(ja)) { font-family: "Noto Serif JP", "Noto Serif CJK JP", @@ -63,11 +63,52 @@ jz-char { jz-inner { display: inline; } +[data-jz-atoms=inline] jz-char { + display: inline; +} jz-char.jz-half jz-inner, jz-char.jz-half-le jz-inner { font-feature-settings: "halt" 1; -webkit-font-feature-settings: "halt" 1; } +[data-jz-halfwidth=margin] jz-char.jz-half jz-inner, +[data-jz-halfwidth=margin] jz-char.jz-half-le jz-inner { + font-feature-settings: normal; + -webkit-font-feature-settings: normal; +} +[data-jz-halfwidth=margin] jz-char.jz-half.jz-ink-left, +[data-jz-halfwidth=margin] jz-char.jz-half.jz-ink-center, +[data-jz-halfwidth=margin] jz-char.jz-half.jz-ink-right, +[data-jz-halfwidth=margin] jz-char.jz-half-le.jz-ink-left, +[data-jz-halfwidth=margin] jz-char.jz-half-le.jz-ink-center, +[data-jz-halfwidth=margin] jz-char.jz-half-le.jz-ink-right { + width: 0.5em; + overflow: visible; +} +[data-jz-halfwidth=margin] jz-char.jz-half.jz-ink-left jz-inner, +[data-jz-halfwidth=margin] jz-char.jz-half.jz-ink-center jz-inner, +[data-jz-halfwidth=margin] jz-char.jz-half.jz-ink-right jz-inner, +[data-jz-halfwidth=margin] jz-char.jz-half-le.jz-ink-left jz-inner, +[data-jz-halfwidth=margin] jz-char.jz-half-le.jz-ink-center jz-inner, +[data-jz-halfwidth=margin] jz-char.jz-half-le.jz-ink-right jz-inner { + display: inline-block; +} +[data-jz-halfwidth=margin] jz-char.jz-ink-center jz-inner { + margin-left: -0.25em; +} +[data-jz-halfwidth=margin] jz-char.jz-ink-right jz-inner { + margin-left: -0.5em; +} +[data-jz-halfwidth=halt] jz-char.jz-half jz-inner, +[data-jz-halfwidth=halt] jz-char.jz-half-le jz-inner { + font-feature-settings: "halt" 1; + -webkit-font-feature-settings: "halt" 1; + margin-left: 0; +} +[data-jz-halfwidth=halt] jz-char.jz-half, +[data-jz-halfwidth=halt] jz-char.jz-half-le { + width: auto; +} /* src/css/_jinze.css */ jz-jinze { @@ -75,5 +116,8 @@ jz-jinze { white-space: nowrap; vertical-align: baseline; } +[data-jz-atoms=inline] jz-jinze { + display: inline; +} /* src/css/juzhen.css */ diff --git a/dist/juzhen.iife.js b/dist/juzhen.iife.js index 61d32e2..22438f1 100644 --- a/dist/juzhen.iife.js +++ b/dist/juzhen.iife.js @@ -45,6 +45,12 @@ var Juzhen = (() => { pillMatches(el) { return !!(el.matches && this.pillSelector && el.matches(this.pillSelector)); } + /** 邏輯文本應**透明穿越**之行內元素:樣式行內標籤,以及聚珍自身之 wrap 類 + * jz-*(jz-hws 間隙、jz-jinze 禁則群組)。穿越這些才能正確找到跨包裝之邏輯 + * 鄰字(如 jinze 已把 pill 納入 jz-jinze 後,pill 之左鄰字仍須可達;I7)。 */ + isTransparentInline(name) { + return this.styleSet.has(name) || name === "JZ-HWS" || name === "JZ-JINZE"; + } /** 節點是否處於 avoid 子樹(內建 skip 名單 / SVG / contentEditable / * data-jz-skip / 使用者 avoid 選擇器 / skipAttribute / isSkipped)。 */ isAvoided(node) { @@ -207,7 +213,7 @@ var Juzhen = (() => { if (n2.nodeType === 3 && n2.data.length > 0) { return pickEnd(n2); } - if (n2.nodeType === 1 && (this.styleSet.has(n2.nodeName) || n2.nodeName === "JZ-HWS")) { + if (n2.nodeType === 1 && this.isTransparentInline(n2.nodeName)) { const r = descend(n2); if (r) { return r; @@ -233,7 +239,7 @@ var Juzhen = (() => { if (this.isAvoided(e)) { return null; } - if (this.styleSet.has(e.nodeName) || e.nodeName === "JZ-HWS") { + if (this.isTransparentInline(e.nodeName)) { const r = descend(e); if (r) { return r; @@ -245,7 +251,7 @@ var Juzhen = (() => { } n = direction === -1 ? n.previousSibling : n.nextSibling; } - if (!p || p.nodeType !== 1 || !this.styleSet.has(p.nodeName)) { + if (!p || p.nodeType !== 1 || !this.isTransparentInline(p.nodeName)) { return null; } const pe = p; @@ -546,6 +552,55 @@ var Juzhen = (() => { } } } + var BRACKET_OPEN_RIGHT = "\u300C\u3008\u201C\u2018"; + var BRACKET_CLOSE_LEFT = "\u300D\u3009\u201D\u2019"; + var BIAS_BRACKETS = (() => { + const t = {}; + for (const ch of BD_OPEN) { + t[ch] = BRACKET_OPEN_RIGHT.indexOf(ch) >= 0 ? "right" : "center"; + } + for (const ch of BD_CLOSE) { + t[ch] = BRACKET_CLOSE_LEFT.indexOf(ch) >= 0 ? "left" : "center"; + } + return t; + })(); + var BIAS_HANS = { + "\uFF0C": "left", + "\u3001": "left", + "\u3002": "left", + "\uFF1B": "left", + "\uFF1A": "left", + "\uFF01": "left", + "\uFF1F": "left" + }; + var BIAS_HANT = { + "\uFF0C": "center", + "\u3001": "center", + "\u3002": "center", + "\uFF1B": "center", + "\uFF1A": "center", + "\uFF01": "center", + "\uFF1F": "center" + }; + var BIAS_DEFAULT = { + "zh-Hans": BIAS_HANS, + "zh-Hant": BIAS_HANT, + "ja": BIAS_HANT, + // 日文點號慣例居中,沿用繁體表 + "other": BIAS_HANT + }; + function inkBias(ch, langClass, overrides) { + const ov = overrides ? overrides[langClass] : void 0; + if (ov && ov[ch]) { + return ov[ch]; + } + const bracket = BIAS_BRACKETS[ch]; + if (bracket) { + return bracket; + } + const t = BIAS_DEFAULT[langClass] || BIAS_DEFAULT.other; + return t[ch] || null; + } // src/typeset/jiya.ts var PASS = "jiya"; @@ -584,15 +639,25 @@ var Juzhen = (() => { const data = node.data; const out = []; let style = null; + let langClass = null; + const biasOverrides = ctx.options.jiyaConfig.bias; for (let i = 0; i < data.length; i += 1) { - const bdClass = classifyBiaodian(data.charAt(i)); + const ch = data.charAt(i); + const bdClass = classifyBiaodian(ch); if (!bdClass) { continue; } if (style === null) { style = resolveStyle(node, ctx.options.locale); } + if (langClass === null) { + langClass = classify(effectiveLang(node, ctx.options.locale.default)); + } const classes = baselineHalf(style, bdClass) ? [bdClass, "jz-half"] : [bdClass]; + const bias = inkBias(ch, langClass, biasOverrides); + if (bias) { + classes.push("jz-ink-" + bias); + } out.push({ start: i, end: i + 1, @@ -1017,8 +1082,10 @@ var Juzhen = (() => { var RBR_BASIC = "\\)\\]\\}"; var LBR_EXT = "\\(\\[\\{<>"; var RBR_EXT = "\\)\\]\\}<>"; + var DEL = String.fromCharCode(57347); var STRIP_CJK_SPACE_ANS = new RegExp("([" + CJK + "]) ([" + ANS_AFTER_CJK + "])", "g"); var STRIP_ANS_SPACE_CJK = new RegExp("([" + ANS_BEFORE_CJK + "]) ([" + CJK + "])", "g"); + var STRIP_CJK_SPACE_CJK = new RegExp("([" + CJK + "]) (?=[" + CJK + "])", "g"); var DOTS_CJK = new RegExp("([\\.]{2,}|\u2026)([" + CJK + "])", "g"); var CJK_PUNCTUATION = new RegExp("([" + CJK + "])([!;,\\?:]+)(?=[" + CJK + ALNUM + "])", "g"); var AN_PUNCTUATION_CJK = new RegExp("([" + ALNUM + "])([!;,\\?]+)([" + CJK + "])", "g"); @@ -1056,6 +1123,7 @@ var Juzhen = (() => { let s = text; s = s.replace(STRIP_CJK_SPACE_ANS, "$1$2"); s = s.replace(STRIP_ANS_SPACE_CJK, "$1$2"); + s = s.replace(STRIP_CJK_SPACE_CJK, "$1" + DEL); s = s.replace(DOTS_CJK, "$1" + MARK + "$2"); s = s.replace(CJK_PUNCTUATION, "$1$2" + MARK); s = s.replace(AN_PUNCTUATION_CJK, "$1$2" + MARK + "$3"); @@ -1127,6 +1195,12 @@ var Juzhen = (() => { let spacedIdx = 0; while (spacedIdx < spaced.length) { const sc = spaced.charAt(spacedIdx); + if (sc === DEL) { + actions.push({ before: void 0, space: map[origIdx] }); + origIdx += 1; + spacedIdx += 1; + continue; + } if (sc === MARK) { const oc = origIdx < text.length ? text.charAt(origIdx) : null; if (oc === " ") { @@ -1143,14 +1217,13 @@ var Juzhen = (() => { } for (let i = actions.length - 1; i >= 0; i -= 1) { const act = actions[i]; - if (!act.before) { - continue; - } if (act.space) { const tn = act.space.textNode; tn.data = tn.data.slice(0, act.space.offset) + tn.data.slice(act.space.offset + 1); } - wrapGapLeft(act.before); + if (act.before) { + wrapGapLeft(act.before); + } } } } @@ -1387,6 +1460,15 @@ var Juzhen = (() => { const lang = typeof v.lang === "string" && v.lang ? v.lang : "en"; return { minLength, lang }; } + function resolveJiya(v) { + if (v && typeof v === "object") { + return { + halfWidth: v.halfWidth === "margin" ? "margin" : "halt", + bias: v.bias || {} + }; + } + return { halfWidth: "halt", bias: {} }; + } function normalizeOptions(opts = {}) { const langOpt = opts.lang || {}; const scope = opts.scope || {}; @@ -1397,6 +1479,7 @@ var Juzhen = (() => { policy: langOpt.policy || {} }, autospaceClass: opts.autospaceClass || "", + justifyAtoms: opts.justifyAtoms === false ? false : true, scope: { root: scope.root || null, include: scope.include || null, @@ -1413,6 +1496,7 @@ var Juzhen = (() => { ruby: feature(opts.ruby, false) }, longWord: resolveLongWord(opts.longWord), + jiyaConfig: resolveJiya(opts.jiya), blocks: Array.isArray(opts.blocks) ? opts.blocks.slice() : [], finder: { styleInlines: new Set(opts.styleInlines || DEFAULT_STYLE_INLINES), @@ -1447,6 +1531,12 @@ var Juzhen = (() => { return; } runPasses(PASSES, makeCtx(el)); + if (options.features.jiya && options.jiyaConfig.halfWidth === "margin") { + el.setAttribute("data-jz-halfwidth", "margin"); + } + if (!options.justifyAtoms) { + el.setAttribute("data-jz-atoms", "inline"); + } }, revert(root) { const el = resolveRoot(root, options); @@ -1454,6 +1544,8 @@ var Juzhen = (() => { return; } revertPasses(PASSES, makeCtx(el)); + el.removeAttribute("data-jz-halfwidth"); + el.removeAttribute("data-jz-atoms"); } }; } diff --git a/dist/juzhen.mjs b/dist/juzhen.mjs index 589beb3..34ff370 100644 --- a/dist/juzhen.mjs +++ b/dist/juzhen.mjs @@ -17,6 +17,12 @@ var Finder = class { pillMatches(el) { return !!(el.matches && this.pillSelector && el.matches(this.pillSelector)); } + /** 邏輯文本應**透明穿越**之行內元素:樣式行內標籤,以及聚珍自身之 wrap 類 + * jz-*(jz-hws 間隙、jz-jinze 禁則群組)。穿越這些才能正確找到跨包裝之邏輯 + * 鄰字(如 jinze 已把 pill 納入 jz-jinze 後,pill 之左鄰字仍須可達;I7)。 */ + isTransparentInline(name) { + return this.styleSet.has(name) || name === "JZ-HWS" || name === "JZ-JINZE"; + } /** 節點是否處於 avoid 子樹(內建 skip 名單 / SVG / contentEditable / * data-jz-skip / 使用者 avoid 選擇器 / skipAttribute / isSkipped)。 */ isAvoided(node) { @@ -179,7 +185,7 @@ var Finder = class { if (n2.nodeType === 3 && n2.data.length > 0) { return pickEnd(n2); } - if (n2.nodeType === 1 && (this.styleSet.has(n2.nodeName) || n2.nodeName === "JZ-HWS")) { + if (n2.nodeType === 1 && this.isTransparentInline(n2.nodeName)) { const r = descend(n2); if (r) { return r; @@ -205,7 +211,7 @@ var Finder = class { if (this.isAvoided(e)) { return null; } - if (this.styleSet.has(e.nodeName) || e.nodeName === "JZ-HWS") { + if (this.isTransparentInline(e.nodeName)) { const r = descend(e); if (r) { return r; @@ -217,7 +223,7 @@ var Finder = class { } n = direction === -1 ? n.previousSibling : n.nextSibling; } - if (!p || p.nodeType !== 1 || !this.styleSet.has(p.nodeName)) { + if (!p || p.nodeType !== 1 || !this.isTransparentInline(p.nodeName)) { return null; } const pe = p; @@ -518,6 +524,55 @@ function blankSides(bdClass) { } } } +var BRACKET_OPEN_RIGHT = "\u300C\u3008\u201C\u2018"; +var BRACKET_CLOSE_LEFT = "\u300D\u3009\u201D\u2019"; +var BIAS_BRACKETS = (() => { + const t = {}; + for (const ch of BD_OPEN) { + t[ch] = BRACKET_OPEN_RIGHT.indexOf(ch) >= 0 ? "right" : "center"; + } + for (const ch of BD_CLOSE) { + t[ch] = BRACKET_CLOSE_LEFT.indexOf(ch) >= 0 ? "left" : "center"; + } + return t; +})(); +var BIAS_HANS = { + "\uFF0C": "left", + "\u3001": "left", + "\u3002": "left", + "\uFF1B": "left", + "\uFF1A": "left", + "\uFF01": "left", + "\uFF1F": "left" +}; +var BIAS_HANT = { + "\uFF0C": "center", + "\u3001": "center", + "\u3002": "center", + "\uFF1B": "center", + "\uFF1A": "center", + "\uFF01": "center", + "\uFF1F": "center" +}; +var BIAS_DEFAULT = { + "zh-Hans": BIAS_HANS, + "zh-Hant": BIAS_HANT, + "ja": BIAS_HANT, + // 日文點號慣例居中,沿用繁體表 + "other": BIAS_HANT +}; +function inkBias(ch, langClass, overrides) { + const ov = overrides ? overrides[langClass] : void 0; + if (ov && ov[ch]) { + return ov[ch]; + } + const bracket = BIAS_BRACKETS[ch]; + if (bracket) { + return bracket; + } + const t = BIAS_DEFAULT[langClass] || BIAS_DEFAULT.other; + return t[ch] || null; +} // src/typeset/jiya.ts var PASS = "jiya"; @@ -556,15 +611,25 @@ var jiyaPass = { const data = node.data; const out = []; let style = null; + let langClass = null; + const biasOverrides = ctx.options.jiyaConfig.bias; for (let i = 0; i < data.length; i += 1) { - const bdClass = classifyBiaodian(data.charAt(i)); + const ch = data.charAt(i); + const bdClass = classifyBiaodian(ch); if (!bdClass) { continue; } if (style === null) { style = resolveStyle(node, ctx.options.locale); } + if (langClass === null) { + langClass = classify(effectiveLang(node, ctx.options.locale.default)); + } const classes = baselineHalf(style, bdClass) ? [bdClass, "jz-half"] : [bdClass]; + const bias = inkBias(ch, langClass, biasOverrides); + if (bias) { + classes.push("jz-ink-" + bias); + } out.push({ start: i, end: i + 1, @@ -989,8 +1054,10 @@ var LBR_BASIC = "\\(\\[\\{"; var RBR_BASIC = "\\)\\]\\}"; var LBR_EXT = "\\(\\[\\{<>"; var RBR_EXT = "\\)\\]\\}<>"; +var DEL = String.fromCharCode(57347); var STRIP_CJK_SPACE_ANS = new RegExp("([" + CJK + "]) ([" + ANS_AFTER_CJK + "])", "g"); var STRIP_ANS_SPACE_CJK = new RegExp("([" + ANS_BEFORE_CJK + "]) ([" + CJK + "])", "g"); +var STRIP_CJK_SPACE_CJK = new RegExp("([" + CJK + "]) (?=[" + CJK + "])", "g"); var DOTS_CJK = new RegExp("([\\.]{2,}|\u2026)([" + CJK + "])", "g"); var CJK_PUNCTUATION = new RegExp("([" + CJK + "])([!;,\\?:]+)(?=[" + CJK + ALNUM + "])", "g"); var AN_PUNCTUATION_CJK = new RegExp("([" + ALNUM + "])([!;,\\?]+)([" + CJK + "])", "g"); @@ -1028,6 +1095,7 @@ function panguSpace(text) { let s = text; s = s.replace(STRIP_CJK_SPACE_ANS, "$1$2"); s = s.replace(STRIP_ANS_SPACE_CJK, "$1$2"); + s = s.replace(STRIP_CJK_SPACE_CJK, "$1" + DEL); s = s.replace(DOTS_CJK, "$1" + MARK + "$2"); s = s.replace(CJK_PUNCTUATION, "$1$2" + MARK); s = s.replace(AN_PUNCTUATION_CJK, "$1$2" + MARK + "$3"); @@ -1099,6 +1167,12 @@ function processBlock(block, finder) { let spacedIdx = 0; while (spacedIdx < spaced.length) { const sc = spaced.charAt(spacedIdx); + if (sc === DEL) { + actions.push({ before: void 0, space: map[origIdx] }); + origIdx += 1; + spacedIdx += 1; + continue; + } if (sc === MARK) { const oc = origIdx < text.length ? text.charAt(origIdx) : null; if (oc === " ") { @@ -1115,14 +1189,13 @@ function processBlock(block, finder) { } for (let i = actions.length - 1; i >= 0; i -= 1) { const act = actions[i]; - if (!act.before) { - continue; - } if (act.space) { const tn = act.space.textNode; tn.data = tn.data.slice(0, act.space.offset) + tn.data.slice(act.space.offset + 1); } - wrapGapLeft(act.before); + if (act.before) { + wrapGapLeft(act.before); + } } } } @@ -1359,6 +1432,15 @@ function resolveLongWord(v) { const lang = typeof v.lang === "string" && v.lang ? v.lang : "en"; return { minLength, lang }; } +function resolveJiya(v) { + if (v && typeof v === "object") { + return { + halfWidth: v.halfWidth === "margin" ? "margin" : "halt", + bias: v.bias || {} + }; + } + return { halfWidth: "halt", bias: {} }; +} function normalizeOptions(opts = {}) { const langOpt = opts.lang || {}; const scope = opts.scope || {}; @@ -1369,6 +1451,7 @@ function normalizeOptions(opts = {}) { policy: langOpt.policy || {} }, autospaceClass: opts.autospaceClass || "", + justifyAtoms: opts.justifyAtoms === false ? false : true, scope: { root: scope.root || null, include: scope.include || null, @@ -1385,6 +1468,7 @@ function normalizeOptions(opts = {}) { ruby: feature(opts.ruby, false) }, longWord: resolveLongWord(opts.longWord), + jiyaConfig: resolveJiya(opts.jiya), blocks: Array.isArray(opts.blocks) ? opts.blocks.slice() : [], finder: { styleInlines: new Set(opts.styleInlines || DEFAULT_STYLE_INLINES), @@ -1419,6 +1503,12 @@ function createJuzhen(opts = {}) { return; } runPasses(PASSES, makeCtx(el)); + if (options.features.jiya && options.jiyaConfig.halfWidth === "margin") { + el.setAttribute("data-jz-halfwidth", "margin"); + } + if (!options.justifyAtoms) { + el.setAttribute("data-jz-atoms", "inline"); + } }, revert(root) { const el = resolveRoot(root, options); @@ -1426,6 +1516,8 @@ function createJuzhen(opts = {}) { return; } revertPasses(PASSES, makeCtx(el)); + el.removeAttribute("data-jz-halfwidth"); + el.removeAttribute("data-jz-atoms"); } }; } diff --git a/dist/types.d.ts b/dist/types.d.ts index 68d84ed..16565ce 100644 --- a/dist/types.d.ts +++ b/dist/types.d.ts @@ -1,11 +1,22 @@ import type { Finder } from "./core/finder.js"; import type { LangClass, LocaleConfig, PunctStyle } from "./core/locale.js"; -export type { LangClass, PunctStyle }; +import type { Bias, BiasTable } from "./core/unicode.js"; +export type { LangClass, PunctStyle, Bias, BiasTable }; /** 長英文詞包裝設定。 */ export interface LongWordOptions { minLength?: number; lang?: string; } +/** 標點擠壓設定(架構 §6.3)。布林 true 等同 { halfWidth: "halt" }。 */ +export interface JiyaOptions { + /** 半形渲染機制: + * - "halt"(預設)— 用字型 OpenType `halt`,由字型按字形正確定位,需字型支援; + * - "margin" — 負 margin 後備模式,供缺 halt 之字型,依字形偏側表收半形。 */ + halfWidth?: "halt" | "margin"; + /** margin 後備模式之字形偏側覆寫表(逐 lang 逐字),覆寫內建參考預設。 + * halt 模式不讀此欄。 */ + bias?: BiasTable; +} /** 分塊功能選擇:選擇器 → 該塊內生效之功能集(覆寫全域)。 */ export interface BlockRule { selector: string; @@ -25,6 +36,11 @@ export interface JuzhenOptions { }; /** 間隙標記沿用之 class(v1 相容;預設無額外 class)。 */ autospaceClass?: string; + /** justify 原子化:jz-char/jz-jinze 是否以 inline-block 作對齊單一原子 + * (預設 true)。設 false 改 display:inline——用於分頁器(如 Paged.js)之 + * 對齊引擎會把每個 inline-block 邊界當伸縮點、與 CJK inter-ideograph 疊加 + * 致窄欄散架之情形(halt 半形與 jinze nowrap 仍保留)。 */ + justifyAtoms?: boolean; /** 作用域限定(§3.6)。 */ scope?: { root?: Element | string; @@ -33,7 +49,7 @@ export interface JuzhenOptions { }; spacing?: boolean; longWord?: boolean | LongWordOptions; - jiya?: boolean; + jiya?: boolean | JiyaOptions; jinze?: boolean; hanging?: boolean; biaodian?: boolean; @@ -52,6 +68,7 @@ export interface JuzhenOptions { export interface ResolvedOptions { locale: LocaleConfig; autospaceClass: string; + justifyAtoms: boolean; scope: { root: Element | string | null; include: string | null; @@ -71,6 +88,11 @@ export interface ResolvedOptions { minLength: number; lang: string; }; + /** 擠壓半形渲染設定(halt 預設/margin 後備)。 */ + jiyaConfig: { + halfWidth: "halt" | "margin"; + bias: BiasTable; + }; blocks: BlockRule[]; finder: { styleInlines: Set; diff --git a/src/core/finder.ts b/src/core/finder.ts index 6779b08..983757a 100644 --- a/src/core/finder.ts +++ b/src/core/finder.ts @@ -63,6 +63,14 @@ export class Finder return !!(el.matches && this.pillSelector && el.matches(this.pillSelector)); } + /** 邏輯文本應**透明穿越**之行內元素:樣式行內標籤,以及聚珍自身之 wrap 類 + * jz-*(jz-hws 間隙、jz-jinze 禁則群組)。穿越這些才能正確找到跨包裝之邏輯 + * 鄰字(如 jinze 已把 pill 納入 jz-jinze 後,pill 之左鄰字仍須可達;I7)。 */ + private isTransparentInline(name: string): boolean + { + return this.styleSet.has(name) || name === "JZ-HWS" || name === "JZ-JINZE"; + } + /** 節點是否處於 avoid 子樹(內建 skip 名單 / SVG / contentEditable / * data-jz-skip / 使用者 avoid 選擇器 / skipAttribute / isSkipped)。 */ isAvoided(node: Node): boolean @@ -223,10 +231,7 @@ export class Finder { return pickEnd(n as Text); } - if ( - n.nodeType === 1 - && (this.styleSet.has((n as Element).nodeName) || (n as Element).nodeName === "JZ-HWS") - ) + if (n.nodeType === 1 && this.isTransparentInline((n as Element).nodeName)) { const r = descend(n as Element); if (r) { return r; } @@ -252,7 +257,7 @@ export class Finder { const e = n as Element; if (this.isAvoided(e)) { return null; } - if (this.styleSet.has(e.nodeName) || e.nodeName === "JZ-HWS") + if (this.isTransparentInline(e.nodeName)) { const r = descend(e); if (r) { return r; } @@ -263,7 +268,7 @@ export class Finder } n = direction === -1 ? n.previousSibling : n.nextSibling; } - if (!p || p.nodeType !== 1 || !this.styleSet.has((p as Element).nodeName)) + if (!p || p.nodeType !== 1 || !this.isTransparentInline((p as Element).nodeName)) { return null; } diff --git a/src/core/unicode.ts b/src/core/unicode.ts index a288f59..61726f7 100644 --- a/src/core/unicode.ts +++ b/src/core/unicode.ts @@ -7,6 +7,8 @@ // 這些常數以「regex 字元類片段」形式給出(已就 RegExp 字元類做必要轉義), // 故可直接 `new RegExp("[" + X + "]")`。 +import type { LangClass } from "./locale.js"; + /** CJK 表意文字/假名/注音等範圍(沿用並擴充 v1 之 PANGU_CJK)。 */ export const CJK = ( "⺀-⻿⼀-⿟" // CJK 部首補充 + 康熙部首 @@ -118,3 +120,81 @@ export function blankSides(bdClass: string | null): { left: boolean; right: bool } } } + +// ----- 字形偏側(ink bias)參考表 — margin 後備模式用(架構 §6.3/.feedback)----- +// +// 預設半形渲染用字型 OpenType `halt`:由字型按字形正確定位,無需知道字面偏側。 +// 但消費端字型未必含 halt(部分系統字型、特製字型)。此時改用「margin 後備模式」: +// 以負 margin 收半形空白,**須知道每個標點字形之墨色偏側**才能收對側、不重疊: +// +// left 墨色偏左(空白在右) → 收右:margin-right: -0.5em +// right 墨色偏右(空白在左) → 收左:margin-left: -0.5em +// center 墨色居中(兩側各半空白) → 兩側各收:margin-left/right: -0.25em +// full 無可壓縮空白(字面已佔滿全形) → 不收(維持全形) +// +// 下表為**參考預設**,依簡/繁字形慣例給出(簡體點號偏左下、繁體居中;!?恆 +// 居中;開類偏右、閉類偏左)。因實際字型字形多樣,消費端可經 options.jiya.bias +// 逐 lang 逐字覆寫(見 README「margin 後備模式」一節)。 +// +// 注意:此偏側表只在 margin 後備模式生效;halt 模式(預設)不讀它。 + +/** 字形墨色偏側。 */ +export type Bias = "left" | "center" | "right" | "full"; + +/** 逐 lang 逐字之偏側覆寫表(消費端傳入,覆寫參考預設)。 */ +export type BiasTable = Partial>>; + +// 括號/引號偏側依**字身墨色中心**(margin 後備模式之 width:0.5em 盒內定位用)。 +// 經方正書宋 GBK 實測 glyph ink bbox:彎引號 “”‘’、角括號 「」、尖括號 〈〉 之墨色 +// 強偏一側(開類 inkcenter≈0.64–0.75 偏右、閉類≈0.25–0.37 偏左)→ 開 right、閉 left; +// 圓/方/書名/六角括號 ()【】《》[]{}〔〕〖〗『』 墨色近中(≈0.38–0.62)→ center。 +// 偏側決定字身於 0.5em 半形盒內之左移量(right 移 0.5、center 移 0.25、left 不移), +// 使墨色落於盒內、空白溢向外側而不與鄰字重疊。字型字身分布各異,消費端可經 +// options.jiya.bias 逐字覆寫(見 README「字型適配工作流」)。 +const BRACKET_OPEN_RIGHT = "「〈“‘"; // 開類中墨色強偏右者 +const BRACKET_CLOSE_LEFT = "」〉”’"; // 閉類中墨色強偏左者 +const BIAS_BRACKETS: Record = (() => +{ + const t: Record = {}; + for (const ch of BD_OPEN) { t[ch] = BRACKET_OPEN_RIGHT.indexOf(ch) >= 0 ? "right" : "center"; } + for (const ch of BD_CLOSE) { t[ch] = BRACKET_CLOSE_LEFT.indexOf(ch) >= 0 ? "left" : "center"; } + return t; +})(); + +// 點號偏側因簡繁字形而異。簡體(GBK/宋體系)點號皆沉於左下角、墨色偏左、空白 +// 在右 → 全判 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", +}; +const BIAS_HANT: Record = { + ",": "center", "、": "center", "。": "center", + ";": "center", ":": "center", "!": "center", "?": "center", +}; + +const BIAS_DEFAULT: Record> = { + "zh-Hans": BIAS_HANS, + "zh-Hant": BIAS_HANT, + "ja": BIAS_HANT, // 日文點號慣例居中,沿用繁體表 + "other": BIAS_HANT, +}; + +/** + * 解析標點字元在某語言下之墨色偏側(margin 後備模式用)。 + * 優先序:消費端 overrides[langClass][ch] > 括號表 > 內建點號參考表。 + * 非可壓縮標點(間隔/連接號等)回傳 null(margin 模式不收)。 + */ +export function inkBias( + ch: string, + langClass: LangClass, + overrides?: BiasTable, +): Bias | null +{ + const ov = overrides ? overrides[langClass] : undefined; + if (ov && ov[ch]) { return ov[ch]; } + const bracket = BIAS_BRACKETS[ch]; + if (bracket) { return bracket; } + const t = BIAS_DEFAULT[langClass] || BIAS_DEFAULT.other; + return t[ch] || null; +} diff --git a/src/css/_jinze.css b/src/css/_jinze.css index c60ceb5..232f079 100644 --- a/src/css/_jinze.css +++ b/src/css/_jinze.css @@ -8,3 +8,11 @@ jz-jinze white-space: nowrap; vertical-align: baseline; } + +/* justifyAtoms:false(分頁器相容,如 Paged.js):改 inline,避免 inline-block + 邊界被分頁對齊引擎當伸縮點、與 CJK inter-ideograph 疊加致窄欄散架。 + white-space:nowrap 於 inline 仍綁定群組內不斷行(禁則保留)。 */ +[data-jz-atoms="inline"] jz-jinze +{ + display: inline; +} diff --git a/src/css/_jiya.css b/src/css/_jiya.css index d446089..135d96c 100644 --- a/src/css/_jiya.css +++ b/src/css/_jiya.css @@ -1,5 +1,6 @@ /* 標點擠壓 + justify 原子化(§6.3/§8)。 - 擠壓量以負 margin 託管,跨瀏覽器一致(I4),不依賴 halt/text-spacing-trim。 */ + 半形渲染:預設 halt(字型 OpenType);缺 halt 字型可切 margin 後備模式 + (width:0.5em+字身偏側位移,以 CSS 盒模型重現 halt,見下)。跨瀏覽器一致(I4)。 */ /* §8:每個 jz-char 為 justify 之單一原子項——句末標點只貢獻一份對齊伸縮量 (I5)。inline-block 使標點與其字面留白同屬一項,對齊伸縮只落於項與項之間。 @@ -17,6 +18,14 @@ jz-inner display: inline; } +/* justifyAtoms:false(分頁器相容):jz-char 改 inline。半形仍由 jz-inner 之 halt + 渲染;惟 margin 後備模式之 width:0.5em 依賴 inline-block,故 inline 下半形改回 + 依賴字型 halt(缺 halt 字型 + 分頁器對齊二者不可兼得,見 README)。 */ +[data-jz-atoms="inline"] jz-char +{ + display: inline; +} + /* 半形擠壓:用字型 OpenType `halt`(橫排半形)收字身寬度——由字型按字形正確 定位(簡體點號偏左、繁體居中、括號偏側皆正確),不拉鄰居故不重疊(修正 負 margin 對居中字形如 !?/繁體 。, 之缺陷)。需字型含 halt(Noto CJK @@ -30,3 +39,72 @@ jz-char.jz-half-le jz-inner font-feature-settings: "halt" 1; -webkit-font-feature-settings: "halt" 1; } + +/* margin 後備模式(.feedback):消費端字型缺 halt 時,render() 於 root 設 + data-jz-halfwidth="margin"。**機制**:把 jz-char 之 inline-block 寬收為 0.5em + (真半形 advance——相鄰兩標點各 0.5em、合佔 1em,無負 margin 之鄰接疊加/單側 + 重疊問題),字身(jz-inner)以 overflow 溢出該盒、再依墨色偏側左移使墨色落於 + 0.5em 盒內、空白溢向外側——即以 CSS 盒模型重現 halt 之「收字身寬+按字形定位」。 + left 墨偏左 → 不移(字身左對齊,墨在盒內、右側空白溢出) + center 墨居中 → 左移 0.25em(墨置盒中) + right 墨偏右 → 左移 0.5em(墨之右半落入盒,左側空白溢向前一字外側) + 偏側由 JS 依「ink 中心參考表+消費端 options.jiya.bias 覆寫」於 charify 標為 + jz-ink-{left,center,right}(見 core/unicode.ts inkBias)。先關 halt 免雙重作用。 + 只命中帶 jz-ink-* 者;bias=full(無 ink class)不收、維持全形。 */ +[data-jz-halfwidth="margin"] jz-char.jz-half jz-inner, +[data-jz-halfwidth="margin"] jz-char.jz-half-le jz-inner +{ + font-feature-settings: normal; + -webkit-font-feature-settings: normal; +} + +[data-jz-halfwidth="margin"] jz-char.jz-half.jz-ink-left, +[data-jz-halfwidth="margin"] jz-char.jz-half.jz-ink-center, +[data-jz-halfwidth="margin"] jz-char.jz-half.jz-ink-right, +[data-jz-halfwidth="margin"] jz-char.jz-half-le.jz-ink-left, +[data-jz-halfwidth="margin"] jz-char.jz-half-le.jz-ink-center, +[data-jz-halfwidth="margin"] jz-char.jz-half-le.jz-ink-right +{ + width: 0.5em; + overflow: visible; +} + +[data-jz-halfwidth="margin"] jz-char.jz-half.jz-ink-left jz-inner, +[data-jz-halfwidth="margin"] jz-char.jz-half.jz-ink-center jz-inner, +[data-jz-halfwidth="margin"] jz-char.jz-half.jz-ink-right jz-inner, +[data-jz-halfwidth="margin"] jz-char.jz-half-le.jz-ink-left jz-inner, +[data-jz-halfwidth="margin"] jz-char.jz-half-le.jz-ink-center jz-inner, +[data-jz-halfwidth="margin"] jz-char.jz-half-le.jz-ink-right jz-inner +{ + display: inline-block; +} + +[data-jz-halfwidth="margin"] jz-char.jz-ink-center jz-inner +{ + margin-left: -0.25em; +} + +[data-jz-halfwidth="margin"] jz-char.jz-ink-right jz-inner +{ + margin-left: -0.5em; +} + +/* 粒度控制:模式由 data-jz-halfwidth **屬性所在子樹** 決定(CSS 後代選擇器), + 故可掛於任意祖先——root(JS 全域預設)、某 lang 容器、單一段落皆可,無需重設 + 實例。下列 halt-reset 規則置於 margin 規則之後:當 margin 頁面中**巢狀**一塊 + data-jz-halfwidth="halt"(如該塊改用含 halt 之字型)時,憑來源順序勝出,於該 + 島內復原 halt、撤銷半形盒。常見情境(halt 頁中嵌 margin 塊、或 margin 頁中嵌 + halt 島)皆成立。 */ +[data-jz-halfwidth="halt"] jz-char.jz-half jz-inner, +[data-jz-halfwidth="halt"] jz-char.jz-half-le jz-inner +{ + font-feature-settings: "halt" 1; + -webkit-font-feature-settings: "halt" 1; + margin-left: 0; +} + +[data-jz-halfwidth="halt"] jz-char.jz-half, +[data-jz-halfwidth="halt"] jz-char.jz-half-le +{ + width: auto; +} diff --git a/src/css/_lang.css b/src/css/_lang.css index c48b7e6..5b17595 100644 --- a/src/css/_lang.css +++ b/src/css/_lang.css @@ -1,30 +1,35 @@ -/* 依 lang 之字體堆疊(§6.9)。以 :where() 零特異度,易被消費端覆寫;限於 - .juzhen 容器,非破壞、opt-in。簡繁日各取對應 Noto CJK,使 locl 字形正確。 - 名稱同時涵蓋 webfont(Google Fonts「Noto Serif SC」等)與本機安裝 - (「Noto Serif CJK SC」「Source Han Serif SC」)——擠壓依賴字型之 OpenType - `halt`,Noto/思源具備;務必確保實際命中含 halt 之字型(Safari 對缺 halt - 之 fallback 字型不會擠壓,故 demo 以 webfont 明確載入 Noto 作參考)。 */ +/* 依 lang 之字體堆疊(§6.9)。**僅作預設後備**,消費端自訂字型恆優先。 + 兩項關鍵: + 1. 整個選擇器包進 :where() → 零特異度,消費端任何 `.juzhen { font-family }` + (特異度 ≥ 0,1,0)即可覆寫。 + 2. 只命中**帶 `lang` 屬性之元素**(容器或內層區塊),**不**直接命中 jz-inner/ + jz-char(它們無 lang 屬性,靠繼承取字型)——否則會在標點字面容器上強制 Noto, + 蓋掉消費端字型(曾致 demo 失真:消費端設 FZShuSong 卻仍以 Noto 之 halt 擠壓)。 + 簡繁日各取對應 Noto CJK,使 locl 字形正確。名稱涵蓋 webfont(「Noto Serif SC」) + 與本機(「Noto Serif CJK SC」「Source Han Serif SC」)。擠壓半形依字型 OpenType + `halt`:消費端若用缺 halt 之字型(如方正書宋 GBK),請改用 margin 後備模式 + (見 README「halt/margin 後備」)。 */ -:where(.juzhen):lang(zh-Hans), -:where(.juzhen) :lang(zh-Hans) +:where(.juzhen:lang(zh-Hans)), +:where(.juzhen [lang]:lang(zh-Hans)) { font-family: "Noto Serif SC", "Noto Serif CJK SC", "Source Han Serif SC", serif; } -:where(.juzhen):lang(zh-Hant), -:where(.juzhen) :lang(zh-Hant) +:where(.juzhen:lang(zh-Hant)), +:where(.juzhen [lang]:lang(zh-Hant)) { font-family: "Noto Serif TC", "Noto Serif CJK TC", "Source Han Serif TC", serif; } -:where(.juzhen):lang(zh-HK), -:where(.juzhen) :lang(zh-HK) +:where(.juzhen:lang(zh-HK)), +:where(.juzhen [lang]:lang(zh-HK)) { font-family: "Noto Serif HK", "Noto Serif CJK HK", "Noto Serif TC", serif; } -:where(.juzhen):lang(ja), -:where(.juzhen) :lang(ja) +:where(.juzhen:lang(ja)), +:where(.juzhen [lang]:lang(ja)) { font-family: "Noto Serif JP", "Noto Serif CJK JP", serif; } diff --git a/src/index.ts b/src/index.ts index 5815729..6d0daf3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,7 +14,7 @@ import { jinzePass } from "./typeset/jinze.js"; import { gapTrimPass, lineEdgePass } from "./typeset/lineedge.js"; import { longWordPass } from "./typeset/longword.js"; import { spacingPass } from "./typeset/spacing.js"; -import type { JuzhenOptions, LongWordOptions, Pass, RenderContext, ResolvedOptions } from "./types.js"; +import type { BiasTable, JiyaOptions, JuzhenOptions, LongWordOptions, Pass, RenderContext, ResolvedOptions } from "./types.js"; export type { JuzhenOptions, ResolvedOptions, Pass }; export { createCjkAutospace } from "./compat/v1.js"; @@ -56,6 +56,20 @@ function resolveLongWord(v: boolean | LongWordOptions | undefined): { minLength: return { minLength, lang }; } +function resolveJiya( + v: boolean | JiyaOptions | undefined, +): { halfWidth: "halt" | "margin"; bias: BiasTable } +{ + if (v && typeof v === "object") + { + return { + halfWidth: v.halfWidth === "margin" ? "margin" : "halt", + bias: v.bias || {}, + }; + } + return { halfWidth: "halt", bias: {} }; +} + /** 正規化公開選項為內部 ResolvedOptions。 */ export function normalizeOptions(opts: JuzhenOptions = {}): ResolvedOptions { @@ -68,6 +82,7 @@ export function normalizeOptions(opts: JuzhenOptions = {}): ResolvedOptions policy: langOpt.policy || {}, }, autospaceClass: opts.autospaceClass || "", + justifyAtoms: opts.justifyAtoms === false ? false : true, scope: { root: scope.root || null, include: scope.include || null, @@ -84,6 +99,7 @@ export function normalizeOptions(opts: JuzhenOptions = {}): ResolvedOptions ruby: feature(opts.ruby, false), }, longWord: resolveLongWord(opts.longWord), + jiyaConfig: resolveJiya(opts.jiya), blocks: Array.isArray(opts.blocks) ? opts.blocks.slice() : [], finder: { styleInlines: new Set(opts.styleInlines || DEFAULT_STYLE_INLINES), @@ -134,12 +150,24 @@ export function createJuzhen(opts: JuzhenOptions = {}): Juzhen const el = resolveRoot(root, options); if (!el) { return; } runPasses(PASSES, makeCtx(el)); + // margin 後備模式以 root 屬性作 CSS 開關(見 css/_jiya.css);halt 模式不設。 + if (options.features.jiya && options.jiyaConfig.halfWidth === "margin") + { + el.setAttribute("data-jz-halfwidth", "margin"); + } + // justifyAtoms:false → jz-char/jz-jinze 改 inline(分頁器相容,見 css)。 + if (!options.justifyAtoms) + { + el.setAttribute("data-jz-atoms", "inline"); + } }, revert(root?: Element | string): void { const el = resolveRoot(root, options); if (!el) { return; } revertPasses(PASSES, makeCtx(el)); + el.removeAttribute("data-jz-halfwidth"); + el.removeAttribute("data-jz-atoms"); }, }; } diff --git a/src/types.ts b/src/types.ts index 6e6a854..8a2243b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,8 +2,9 @@ import type { Finder } from "./core/finder.js"; import type { LangClass, LocaleConfig, PunctStyle } from "./core/locale.js"; +import type { Bias, BiasTable } from "./core/unicode.js"; -export type { LangClass, PunctStyle }; +export type { LangClass, PunctStyle, Bias, BiasTable }; /** 長英文詞包裝設定。 */ export interface LongWordOptions @@ -12,6 +13,18 @@ export interface LongWordOptions lang?: string; } +/** 標點擠壓設定(架構 §6.3)。布林 true 等同 { halfWidth: "halt" }。 */ +export interface JiyaOptions +{ + /** 半形渲染機制: + * - "halt"(預設)— 用字型 OpenType `halt`,由字型按字形正確定位,需字型支援; + * - "margin" — 負 margin 後備模式,供缺 halt 之字型,依字形偏側表收半形。 */ + halfWidth?: "halt" | "margin"; + /** margin 後備模式之字形偏側覆寫表(逐 lang 逐字),覆寫內建參考預設。 + * halt 模式不讀此欄。 */ + bias?: BiasTable; +} + /** 分塊功能選擇:選擇器 → 該塊內生效之功能集(覆寫全域)。 */ export interface BlockRule { @@ -36,6 +49,12 @@ export interface JuzhenOptions /** 間隙標記沿用之 class(v1 相容;預設無額外 class)。 */ autospaceClass?: string; + /** justify 原子化:jz-char/jz-jinze 是否以 inline-block 作對齊單一原子 + * (預設 true)。設 false 改 display:inline——用於分頁器(如 Paged.js)之 + * 對齊引擎會把每個 inline-block 邊界當伸縮點、與 CJK inter-ideograph 疊加 + * 致窄欄散架之情形(halt 半形與 jinze nowrap 仍保留)。 */ + justifyAtoms?: boolean; + /** 作用域限定(§3.6)。 */ scope?: { root?: Element | string; @@ -46,7 +65,7 @@ export interface JuzhenOptions // 全域逐功能開關。 spacing?: boolean; longWord?: boolean | LongWordOptions; - jiya?: boolean; + jiya?: boolean | JiyaOptions; jinze?: boolean; hanging?: boolean; biaodian?: boolean; @@ -70,6 +89,7 @@ export interface ResolvedOptions { locale: LocaleConfig; autospaceClass: string; + justifyAtoms: boolean; scope: { root: Element | string | null; include: string | null; @@ -86,6 +106,8 @@ export interface ResolvedOptions ruby: boolean; }; longWord: { minLength: number; lang: string }; + /** 擠壓半形渲染設定(halt 預設/margin 後備)。 */ + jiyaConfig: { halfWidth: "halt" | "margin"; bias: BiasTable }; blocks: BlockRule[]; finder: { styleInlines: Set; diff --git a/src/typeset/jiya.ts b/src/typeset/jiya.ts index f95264a..4ebda07 100644 --- a/src/typeset/jiya.ts +++ b/src/typeset/jiya.ts @@ -14,9 +14,9 @@ // 同時為 §8:所有 jz-char 經 CSS 設 inline-block,成 justify 單一原子項(I5)。 import { bdClassOf, createJz, revertPass } from "../core/dom.js"; -import { resolveStyle } from "../core/locale.js"; -import type { PunctStyle } from "../core/locale.js"; -import { blankSides, classifyBiaodian } from "../core/unicode.js"; +import { classify, effectiveLang, resolveStyle } from "../core/locale.js"; +import type { LangClass, PunctStyle } from "../core/locale.js"; +import { blankSides, classifyBiaodian, inkBias } from "../core/unicode.js"; import type { CharifyPass, RenderContext, StandalonePass, WrapRequest } from "../types.js"; const PASS = "jiya"; @@ -64,13 +64,25 @@ export const jiyaPass: CharifyPass = { const data = node.data; const out: WrapRequest[] = []; let style: PunctStyle | null = null; + let langClass: LangClass | null = null; + const biasOverrides = ctx.options.jiyaConfig.bias; for (let i = 0; i < data.length; i += 1) { - const bdClass = classifyBiaodian(data.charAt(i)); + const ch = data.charAt(i); + const bdClass = classifyBiaodian(ch); if (!bdClass) { continue; } if (style === null) { style = resolveStyle(node, ctx.options.locale); } + if (langClass === null) + { + langClass = classify(effectiveLang(node, ctx.options.locale.default)); + } const classes = baselineHalf(style, bdClass) ? [ bdClass, "jz-half" ] : [ bdClass ]; + // 字形偏側 class(jz-ink-*)— 供 margin 後備模式收對側半形空白;halt 模式 + // 之 CSS 不讀,無害。一律於 charify 標記(偏側為字形固有屬性,與何時擠壓 + // 無關),鄰接/行端 pass 後續加 jz-half 即可沿用。 + const bias = inkBias(ch, langClass, biasOverrides); + if (bias) { classes.push("jz-ink-" + bias); } out.push({ start: i, end: i + 1, diff --git a/src/typeset/spacing.ts b/src/typeset/spacing.ts index 6e5cbba..6fec3a9 100644 --- a/src/typeset/spacing.ts +++ b/src/typeset/spacing.ts @@ -28,8 +28,16 @@ const RBR_BASIC = "\\)\\]\\}"; const LBR_EXT = "\\(\\[\\{<>"; const RBR_EXT = "\\)\\]\\}<>"; +// 純刪除哨符(U+E003):標記「此處原作者空格應刪除、不留隙」(CJK↔CJK,問題 1b)。 +// 與空格 1:1 替換以保持 spaced/text 對齊(processBlock 據此刪空格而不插 jz-hws)。 +const DEL = String.fromCharCode(0xE003); + const STRIP_CJK_SPACE_ANS = new RegExp("([" + CJK + "]) ([" + ANS_AFTER_CJK + "])", "g"); const STRIP_ANS_SPACE_CJK = new RegExp("([" + ANS_BEFORE_CJK + "]) ([" + CJK + "])", "g"); +// CJK↔CJK 之單一作者空格(含跨樣式行內標籤,collectRuns 已透明合併):中文不用 +// 詞間空格,此類空格幾乎必為贅餘 → 以 DEL 標記刪除(1b,邊界正規化一致性)。 +// 用 lookahead 使連續 CJK 空格逐一命中;只匹配 ASCII 空格,全形空格 U+3000 不動。 +const STRIP_CJK_SPACE_CJK = new RegExp("([" + CJK + "]) (?=[" + CJK + "])", "g"); const DOTS_CJK = new RegExp("([\\.]{2,}|…)([" + CJK + "])", "g"); const CJK_PUNCTUATION = new RegExp("([" + CJK + "])([!;,\\?:]+)(?=[" + CJK + ALNUM + "])", "g"); @@ -72,6 +80,7 @@ function panguSpace(text: string): string let s = text; s = s.replace(STRIP_CJK_SPACE_ANS, "$1$2"); s = s.replace(STRIP_ANS_SPACE_CJK, "$1$2"); + s = s.replace(STRIP_CJK_SPACE_CJK, "$1" + DEL); s = s.replace(DOTS_CJK, "$1" + MARK + "$2"); s = s.replace(CJK_PUNCTUATION, "$1$2" + MARK); @@ -164,6 +173,14 @@ function processBlock(block: Element, finder: Finder): void while (spacedIdx < spaced.length) { const sc = spaced.charAt(spacedIdx); + if (sc === DEL) + { + // CJK↔CJK 贅餘空格:純刪除、不留隙(before 留空)。 + actions.push({ before: undefined, space: map[origIdx]! }); + origIdx += 1; + spacedIdx += 1; + continue; + } if (sc === MARK) { const oc = origIdx < text.length ? text.charAt(origIdx) : null; @@ -183,17 +200,17 @@ function processBlock(block: Element, finder: Finder): void spacedIdx += 1; } - // 倒序執行,避免前面動作之 offset 被後面變更影響。 + // 倒序執行,避免前面動作之 offset 被後面變更影響。先刪空格(若有)、再包左字 + // 留隙(若有 before)。刪空格在較高 offset、不擾動更前之 before。 for (let i = actions.length - 1; i >= 0; i -= 1) { const act = actions[i]!; - if (!act.before) { continue; } if (act.space) { const tn = act.space.textNode; tn.data = tn.data.slice(0, act.space.offset) + tn.data.slice(act.space.offset + 1); } - wrapGapLeft(act.before); + if (act.before) { wrapGapLeft(act.before); } } } } diff --git a/test/juzhen.test.mjs b/test/juzhen.test.mjs index db8a5fe..326b062 100644 --- a/test/juzhen.test.mjs +++ b/test/juzhen.test.mjs @@ -197,6 +197,52 @@ test("政策:未設 style/policy 時回退內建預設(繁全角不擠、 assert.ok(!ps[1].querySelector("jz-char.bd-pause").classList.contains("jz-half"), "繁體內建全角"); }); +// ----- 字形偏側(jz-ink-*)+ margin 後備模式(.feedback)----- + +test("偏側:jz-ink-* 恆於 charify 標記(halt 預設模式亦然,root 不設 margin 屬性)", () => +{ + const { document } = setupDom('

好,「走」。

'); + createJuzhen(JIYA_ONLY).render(document.body); + // 簡體點號偏左;角括號「」墨色強偏一側 → 開 right、閉 left(圓方括號才 center)。 + assert.ok(document.querySelector("jz-char.bd-pause").classList.contains("jz-ink-left"), "簡體 ,偏左"); + assert.ok(document.querySelector("jz-char.bd-open").classList.contains("jz-ink-right"), "角括號「墨偏右 → right"); + assert.ok(document.querySelector("jz-char.bd-close").classList.contains("jz-ink-left"), "角括號」墨偏左 → left"); + assert.equal(document.body.getAttribute("data-jz-halfwidth"), null, "halt 預設模式不設 root 屬性"); +}); + +test("偏側:繁體點號居中(jz-ink-center),!?恆居中", () => +{ + const { document } = setupDom('

走,真?

'); + createJuzhen(JIYA_ONLY).render(document.body); + assert.ok(document.querySelector("jz-char.bd-pause").classList.contains("jz-ink-center"), "繁體 ,居中"); + assert.ok(document.querySelector("jz-char.bd-stop").classList.contains("jz-ink-center"), "?居中"); +}); + +test("margin 模式:render 於 root 設 data-jz-halfwidth=margin,revert 清除", () => +{ + const { document } = setupDom('

好,走。

'); + const jz = createJuzhen({ ...JIYA_ONLY, jiya: { halfWidth: "margin" } }); + jz.render(document.body); + assert.equal(document.body.getAttribute("data-jz-halfwidth"), "margin", "margin 模式設 root 屬性"); + // 偏側 class 與 jz-half 並存,CSS 始能定向收縮。 + const comma = document.querySelector("jz-char.bd-pause"); + assert.ok(comma.classList.contains("jz-half") && comma.classList.contains("jz-ink-left"), ",半形+偏左"); + jz.revert(document.body); + assert.equal(document.body.getAttribute("data-jz-halfwidth"), null, "revert 清除 root 屬性"); +}); + +test("margin 模式:消費端 bias 覆寫參考表(如把簡體 ,改判居中)", () => +{ + const { document } = setupDom('

好,走

'); + createJuzhen({ + ...JIYA_ONLY, + jiya: { halfWidth: "margin", bias: { "zh-Hans": { ",": "center" } } }, + }).render(document.body); + const comma = document.querySelector("jz-char.bd-pause"); + assert.ok(comma.classList.contains("jz-ink-center"), "覆寫後 ,判居中"); + assert.ok(!comma.classList.contains("jz-ink-left"), "不再用參考預設之偏左"); +}); + // ----- 長英文詞(longWord)----- test("longWord:[A-Za-z]{6,} 包進 span[lang],textContent 不變", () => @@ -291,6 +337,59 @@ test("分塊:data-juzhen-off 黑名單停用列出功能,其餘繼承", () = assert.equal(countTag(p, "jz-char"), 0, "jiya 被黑名單停用"); }); +// ----- 下游回饋修復(問題一 1a/1b、問題二)----- + +test("1a:pill 後接 CJK 標點時,pill 左緣仍補 jz-hws(穿越 jz-jinze)", () => +{ + const { document } = setupDom("

代码build,后

"); + createJuzhen({ pillSelector: "code, kbd", lang: { default: "zh-Hans", style: "quanjiao" } }) + .render(document.body); + // jinze 已把 code+,納入 jz-jinze;spacing 仍須能找到 code 左鄰字「码」並補間隙。 + const hws = document.querySelector("jz-hws"); + assert.ok(hws && hws.textContent === "码", "pill 左側「码」包入 jz-hws(不因右側標點/jz-jinze 而遺失)"); +}); + +test("1a:pill 後接 CJK 標點且有作者空格 → 空格被剝除、左緣補間隙", () => +{ + const { document } = setupDom("

代码 build,后

"); + createJuzhen({ pillSelector: "code, kbd", lang: { default: "zh-Hans", style: "quanjiao" } }) + .render(document.body); + assert.ok(document.querySelector("jz-hws"), "補了 jz-hws"); + assert.equal(document.querySelector("p").textContent, "代码build,后", "作者空格被剝除(與無空格輸入一致)"); +}); + +test("1b:CJK↔CJK 贅餘作者空格被剝除(含跨樣式行內標籤)", () => +{ + const { document } = setupDom('

事件 元件

'); + createJuzhen({ lang: { default: "zh-Hans" } }).render(document.body); + assert.equal(document.querySelector("p").textContent, "事件元件", "跨 之 CJK↔CJK 空格剝除"); +}); + +test("1b:CJK↔CJK 不插 jz-hws(僅剝空格,無留隙)", () => +{ + const { document } = setupDom("

事件 元件

"); + createJuzhen({ spacing: true, jiya: false, jinze: false, longWord: false }).render(document.body); + assert.equal(countTag(document.body, "jz-hws"), 0, "CJK↔CJK 不需間隙,僅刪空格"); + assert.equal(document.querySelector("p").textContent, "事件元件"); +}); + +test("問題二:justifyAtoms:false → root 設 data-jz-atoms=inline,revert 清除", () => +{ + const { document } = setupDom("

测试,(甲)。

"); + const jz = createJuzhen({ lang: { default: "zh-Hans" }, justifyAtoms: false }); + jz.render(document.body); + assert.equal(document.body.getAttribute("data-jz-atoms"), "inline", "設 inline 原子屬性"); + jz.revert(document.body); + assert.equal(document.body.getAttribute("data-jz-atoms"), null, "revert 清除"); +}); + +test("問題二:justifyAtoms 預設 true → 不設屬性(維持 inline-block 原子)", () => +{ + const { document } = setupDom("

测试,

"); + createJuzhen({ lang: { default: "zh-Hans" } }).render(document.body); + assert.equal(document.body.getAttribute("data-jz-atoms"), null, "預設不設屬性"); +}); + // ----- v1 相容層(§3.5)----- test("compat:createCjkAutospace().apply() 可跑,產生中西間隙(margin 模型,copy-clean)", () => diff --git a/tools/measure-bias.py b/tools/measure-bias.py new file mode 100644 index 0000000..fdde4f6 --- /dev/null +++ b/tools/measure-bias.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""量測字型之標點 glyph 墨色位置,產出聚珍 margin 後備模式之偏側建議表。 + +用途:消費端字型缺 OpenType `halt`(如方正書宋 GBK、多數系統字型)時,margin 後備 +模式需知每個標點之「墨色偏側」以於 0.5em 半形盒內正確定位字身。本工具讀字型輪廓, +量各標點 glyph 之 ink bounding box,依墨色中心建議 left/center/right/full,並印出 +可貼入 `options.jiya.bias` 之 JSON 片段。 + +依賴:fonttools(`pip install fonttools`)。 +用法:python3 tools/measure-bias.py /path/to/font.ttf [zh-Hans|zh-Hant|ja|other] + +輸出之偏側語義(決定字身於 0.5em 盒內之左移;見 README「halt/margin 後備」): + left 墨偏左 → 不移(字身左對齊,墨在盒內、右側空白溢出) + center 墨居中 → 左移 0.25em + right 墨偏右 → 左移 0.5em(右半入盒,左側空白溢出) + full 無 glyph/空白 glyph → 不收、維持全形 +門檻:ink 中心 < 0.375 → left;0.375–0.625 → center;> 0.625 → right。 +判讀後仍建議於瀏覽器目視微調(字身側鬚、字面留白因字型而異)。 +""" + +import json +import sys + +from fontTools.pens.boundsPen import BoundsPen +from fontTools.ttLib import TTFont + +# 聚珍 margin 模式會收半形之標點(句末點號僅半角式收,此處一併量供參考)。 +PUNCT = { + "pause_stop": ",、。;:!?", + "open": "「『(《〈【〖〔[{“‘", + "close": "」』)》〉】〗〕]}”’", +} + + +def has_feature(font, tag): + for t in ("GSUB", "GPOS"): + if t in font and font[t].table.FeatureList: + for fr in font[t].table.FeatureList.FeatureRecord: + if fr.FeatureTag == tag: + return True + return False + + +def bias_of(font, upm, cmap, gs, ch): + cp = ord(ch) + if cp not in cmap: + return "full", None + g = gs[cmap[cp]] + pen = BoundsPen(gs) + g.draw(pen) + if pen.bounds is None: + return "full", None + x_min, _, x_max, _ = pen.bounds + center = ((x_min + x_max) / 2) / upm + if center < 0.375: + b = "left" + elif center > 0.625: + b = "right" + else: + b = "center" + return b, center + + +def main(): + if len(sys.argv) < 2: + print(__doc__) + sys.exit(1) + path = sys.argv[1] + lang = sys.argv[2] if len(sys.argv) > 2 else "zh-Hans" + + font = TTFont(path) + upm = font["head"].unitsPerEm + cmap = font.getBestCmap() + gs = font.getGlyphSet() + + halt = has_feature(font, "halt") + print(f"# 字型:{path}") + print(f"# OpenType halt:{'有(建議直接用 halt 預設模式,無需 margin 後備)' if halt else '無 → 需 margin 後備模式'}") + print(f"# unitsPerEm={upm}") + print() + + table = {} + for group, chars in PUNCT.items(): + print(f"# {group}") + for ch in chars: + b, center = bias_of(font, upm, cmap, gs, ch) + cstr = f"{center:.2f}" if center is not None else " - " + print(f"# {ch} inkcenter={cstr} -> {b}") + table[ch] = b + print() + + print("// 貼入 options.jiya.bias(依瀏覽器目視再微調):") + print(json.dumps({lang: table}, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main()