From 3a4fa1c932f97776f8518e705e75084fbb614a90 Mon Sep 17 00:00:00 2001 From: commilitia Date: Sat, 3 Oct 2026 21:15:54 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=A1=8C=E7=BC=98?= =?UTF-8?q?=E3=80=81=E5=88=87=E5=88=86=E8=BF=98=E5=8E=9F=E4=B8=8E=E8=AF=AD?= =?UTF-8?q?=E8=A8=80=E8=A7=A3=E6=9E=90=E5=B9=B6=E5=90=8C=E6=AD=A5=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留逐字 margin/jiya.bias 字体后备方案;补齐 demo 重排、量测边界说明及共享规则 1.0.1 反例向量。 验证:完整构建通过,Node 376 项、WKWebView 64 项及原生 demo 9 项通过;Firefox 仍暂缓。 --- ARCHITECTURE.md | 36 +++--- README.md | 38 ++++--- UPDATES.md | 48 ++++++++ demo/e2e.html | 91 +++++++++++++++ demo/fallback.html | 11 +- demo/index.html | 12 +- demo/resize-check.html | 11 +- demo/showcase.html | 2 +- dist/core/dom.d.ts | 2 + dist/core/finder.d.ts | 4 +- dist/core/locale.d.ts | 5 +- dist/core/unicode.d.ts | 8 +- dist/juzhen.iife.js | 100 ++++++++++++++--- dist/juzhen.mjs | 100 ++++++++++++++--- dist/types.d.ts | 9 +- rules/README.md | 9 ++ rules/golden-vectors.json | 44 +++++++- rules/juzhen-rules.json | 2 +- src/core/dom.ts | 82 ++++++++++++-- src/core/finder.ts | 6 +- src/core/locale.ts | 23 ++-- src/core/split.ts | 4 +- src/core/unicode.ts | 8 +- src/css/_normalize.css | 3 +- src/index.ts | 2 +- src/types.ts | 9 +- src/typeset/lineedge.ts | 44 +++++--- test/demo-controls.test.mjs | 92 ++++++++++++++++ test/measure-bias.test.mjs | 95 ++++++++++++++++ test/observer-recovery.test.mjs | 10 +- test/scan-regressions.test.mjs | 189 ++++++++++++++++++++++++++++++++ test/shared-rules.test.mjs | 14 ++- tools/measure-bias.py | 10 +- 33 files changed, 983 insertions(+), 140 deletions(-) create mode 100644 test/demo-controls.test.mjs create mode 100644 test/measure-bias.test.mjs create mode 100644 test/scan-regressions.test.mjs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d42b1a8..0684931 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -67,6 +67,7 @@ CSS box、observer、瀏覽器量測,以及 TeX node/glue/penalty/macro 其他實例內容。沒有外部修改時,DOM 回到處理前的**邏輯等價**態;`spacing` 正規化的 贅餘作者空格不還原(見 I1),此差異不隨反覆 render/revert 累積。 外部克隆、替換、刪除後,按產物及來源完整性檢查安全回退,不復活過時文字; + splitoff 無法安全併回時保留作者當前樣式及文字位置,僅撤銷本庫標記及憑證; 不保證恢復歷史快照或清空所有 `[data-jz]`。DOM 標記本身不授予所有權。 - **I7 邊界感知**:跨樣式行內元素的鄰接(如 `是A`)須能識別並 正確處理——由核心 finder 統一提供,各 pass 不自行重複造輪。 @@ -367,8 +368,9 @@ avoid」用組合入口 `isAvoided`(=自身或祖先,階段二歸一,免 - **結構克隆切分安全**(`core/split` 之 `canSplit`)——更窄白名單(`STRONG/EM/…`,排除 ``/`ABBR`/`RUBY`/帶 `id` 等——切分會重複語義/結構),故「可透明穿越」⊋「可克隆切分」。 -- **物理版面**(`lineedge` 之 `edgeRect`)——依 `getClientRects` 之物理相鄰判行首/行尾,攀爬集 - 由 `styleInlines` 派生(免遺漏 `ins`/`del`/`sub` 致行端誤判 T4),且**須跨 isolate**(``) +- **物理版面**(`lineedge` 之 `edgeRect`)——依 `getClientRects` 之物理相鄰判行首/行尾,以 computed `display` 判斷物理行內流(inline 系列/ruby/contents 可攀出;block 停止), + 可見 `br` 是強制行緣,隱藏 `br` 跳過。無計算樣式時才用 `styleInlines`+jz 包裹標籤後備, + 包含 `jz-orphan`。**須跨物理行內的 isolate/opaque**(如 ``/自訂標籤) 找真實相鄰內容:isolate 斷的是**邏輯** run/相鄰,非**物理**位置(`文字「引」` 之 `「` 物理上接在「文字」後、非行首,若停在 mark 邊界會誤判行首而錯收左半)。 @@ -388,7 +390,8 @@ avoid」用組合入口 `isAvoided`(=自身或祖先,階段二歸一,免 - **規則正則**:中西間隙邊界、擠壓相鄰組合、禁則對象等,皆以此表組裝。 **規則集抽象(`Ruleset`/`charClass`,可配置)**:字符分類不再各 pass 硬編碼,而統一為 -一個**規則集**——`{ cjk, ansAfter, ansBefore, biaodian{6 子類} }`,各為 regex 字元類片段。 +一個**規則集**——`{ cjk, ansAfter, ansBefore, biaodian{6 子類} }`,中文/西文為 regex 字元類片段,標點各子類為逐碼點字串; +後者不解讀範圍(追加 `①②③`,不能以 `①-③` 表示三個圈碼)。regex 特殊字元須轉義。 `DEFAULT_RULESET` 即內置默認(上述 CJK/西文/BD_* 值)。消費端經 `options.charClass` (`{ cjk?, western?, biaodian? }`)**擴充**,`resolveRuleset(override)` 以**追加**語義合併 為最終規則集,置於 `ResolvedOptions.ruleset`。各 pass 由此取分類: @@ -410,9 +413,13 @@ avoid」用組合入口 `isAvoided`(=自身或祖先,階段二歸一,免 ### 4.3 `locale.ts`——語言與全角/開明政策 -- `effectiveLang(node)`:最近祖先 `lang`(小寫),無則回退 `options.lang.default`。 +- `effectiveLang(node)`:自身及祖先最近的 `lang`(小寫),僅缺失時回退 `options.lang.default`; + 顯式空值為未知語言,不繼承祖先。空 default 亦表示未知。 - `classify(lang)`:歸為 `zh-Hans` / `zh-Hant`(含 `zh-TW`/`zh-HK`)/ `ja` / 其他; - BCP-47 前綴邊界比對(`zh` 命中 `zh-Hant`,不命中 `zhx`)。 + BCP-47 前綴邊界比對(`zh` 命中 `zh-Hant`,不命中 `zhx`)。只讀 language/ + extlang 後的 script/region 位置,不把 extension/private-use 當腳本或地區。 + 本函數不提供完整 BCP-47 有效性驗證。依據 [RFC 5646 §2.1](https://www.rfc-editor.org/rfc/rfc5646.html#section-2.1); + HTML 空 lang 依 [HTML 語言契約](https://html.spec.whatwg.org/multipage/dom.html#the-lang-and-xml:lang-attributes)。 - `resolveStyle(node)`:回傳節點之有效標點寬度政策——`quanjiao`(全角式)|`kaiming` (開明式)|`banjiao`(半角式)。**優先序**:**逐元素覆寫**(`resolveStyleOverride`:自身及 祖先鏈掃 `data-jz-style` 屬性/`stylePolicy` 選擇器命中,近祖先勝、同層屬性優先於選擇器) @@ -433,8 +440,11 @@ avoid」用組合入口 `isAvoided`(=自身或祖先,階段二歸一,免 `revertPass(pass, root, ownedElements)` 先驗證本實例所有權與合法 pass/kind/tag 組合。 目前接受 `wrap`(包裝原內容)與 **`splitoff`**(行內元素克隆切分之邊界詞元);不提供 -泛用 `marker` 還原。第一階段逆序解包 wrap,第二階段把 splitoff 併回同標籤前兄弟, -最後 normalize 受影響父節點。順序必要,否則 splitoff 前兄弟可能仍是 wrap 殼。 +泛用 `marker` 還原。第一階段逆序解包 wrap,第二階段依私有來源弱映射,僅在前兄弟仍為切分來源或經私有切分/合併鏈證明同源的片段、 +同標籤且作者屬性一致時併回, +最後 normalize 受影響父節點。外部插入、替換來源或改樣式導致不安全合併時,保留片段 +元素、樣式及文字位置,還原其原有 marker 屬性並撤銷本庫憑證,不跨新內容搬字。 +順序必要,否則 splitoff 前兄弟可能仍是 wrap 殼。 `recordUnwrappedParent`/`resolveUnwrappedParent` 記錄及解析合法產物的受控解包鏈, 供跨節點來源回退使用;重用空殼會使舊位置失效,重定向環不作為文字恢復依據。 @@ -662,8 +672,8 @@ class 約定以現有 CSS/pass 為準:標點採 `bd-open`/`bd-close`/`bd 改以 `isolateBoundaryToken(el, side)` **克隆切分**:沿「文本節點→邊界元素」祖先鏈逐層 把邊界詞元剝入一個淺克隆(保留標籤/class/style/lang,去 id),只綁該克隆(最小綁定), 原元素其餘部分留原位、渲染視覺無縫。克隆標 `data-jz-kind="splitoff"`,`revertPass` 之**延後 - 階段**(先解包 wrap、後併 splitoff,確保前兄弟為原元素而非 wrap 殼)併回原元素、還原一致 - (I6)。僅純樣式標籤白名單可切,且 `canSplit` **諮詢 finder 三分類**(§4.1):`a`/`abbr`/ + 階段**(先解包 wrap、後併 splitoff)驗私有來源身份、鄰接與屬性一致後併回; + 未受外部干預時還原一致(I6),無法安全合併時保留片段,詳見 §4.4。僅純樣式標籤白名單可切,且 `canSplit` **諮詢 finder 三分類**(§4.1):`a`/`abbr`/ `ruby`/帶 id 者,以及 **pill(整體)/isolate(隔離)**,可退化為 整元素綁定(安全)——pill 即「作為原子鄰字單位整體綁入 `jz-jinze`」之期望行為(下游問題六: KaTeX 全樹皆 SPAN,舊版僅憑標籤白名單曾整棵下潛肢解公式);isolate(如背景色 `mark`)切分 @@ -701,8 +711,8 @@ class 約定以現有 CSS/pass 為準:標點採 `bd-open`/`bd-close`/`bd > **標點懸掛移除之根因**(純 CSS 之寬度守恆矛盾,headless + 真實瀏覽器實測):行端標點盒若 > 保持非零寬,墨色出去多少、版心內就空出多少(行末實義字無法齊版心右緣);若收為零寬,釋放 > 一整字寬、斷行器把下一字拉上本行與標點重疊(且 justifyAtoms 預設之 inline-block 下負 margin -> 被 justify 吸收)。**CSS hack 接觸不到斷行器,此恒等式無解**。唯一正解為原生 -> `hanging-punctuation`(僅 Safari 支援、且 jz-char 為 inline-block 原子非文本,原生懸掛不作用), +> 被 justify 吸收)。**CSS hack 接觸不到斷行器,此恒等式無解**。未來可評估原生 +> `hanging-punctuation`(須驗證引擎能力,且 jz-char 為 inline-block 原子非文本), > 屬遠期「原生管線」新功能、與已移除程式碼無共享。詳見 6.5.6。 #### 6.5.0a 功能分級:文本級 vs 段落級 + 元素標記(前置基礎設施) @@ -860,8 +870,8 @@ order `90 jiyaLineEdge`,`revert` 清 `jz-half-le`。純版面、jsdom 跳過 `ResolvedOptions.lineEnd`/`features.hanging`;`index.ts` 之 `lineEnd` 鎖。行端僅餘半形擠壓 (`jz-half-le`)。`hanging` 公開選項保留為 **@deprecated no-op**(API 相容;測試覆蓋 option/data-juzhen/blocks 各路徑皆不產生 jz-hang-* 且輸出與不傳一致)。 -- **未來路徑(與已移除碼無共享)**:原生 CSS `hanging-punctuation` 為唯一正解,惟 Chrome/ - Firefox 至今不支援(僅 Safari);且本庫 `jz-char` 為 inline-block 原子、非文本,原生懸掛 +- **未來路徑(與已移除碼無共享)**:原生 CSS `hanging-punctuation` 為可評估路徑,須另行核對 + 各引擎實際能力及字型行為;且本庫 `jz-char` 為 inline-block 原子、非文本,原生懸掛 不作用之。未來若做,須是**繞開 jz-char 的「原生管線」新功能**——`text-spacing-trim` 接管 擠壓 + `hanging-punctuation` 接管懸掛,按 `CSS.supports` 能力門控,整體退出 jz-char 機制。 與已移除之負 margin 程式碼**沒有一行可複用**,故不保留休眠碼。 diff --git a/README.md b/README.md index 21dd3d0..1312af9 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ > 現代簡/繁中文網頁排版套件。Han.css 之現代化重寫,沿其功能版圖,並針對 > 數種情況作調整:**複製乾淨**(中西間隙不入剪貼板)、**依 `lang` 全角/開明 -> 自動切換**、**`justify` 下句末標點只佔一份間距**、**跨瀏覽器一致** -> (Chrome/Edge/Safari/WebKit/Firefox)。 +> 自動切換**、**`justify` 下句末標點只佔一份間距**、**跨瀏覽器一致的設計目標** +> (Chrome/Edge/Safari/WebKit/Firefox;實際驗收範圍見 UPDATES,Firefox 暫緩)。 倉庫名仍為 `cjk-autospace`;公開 API 為 `createJuzhen` / 全域 `Juzhen`;注入之 自訂元素前綴 `jz-`。消費端契約見本文的[用法](#用法)與[標記說明](#行內元素三分類透明隔離整體), @@ -11,7 +11,9 @@ 2026-10-02 的 Web/CSS 邊界與生命週期修復已推送至 `main`; [修復提交 `b1a16c4`](https://git.commilitia.net/admin/cjk-autospace/commit/b1a16c49698dc418aa733e41a04bdbc9922927be) -包含配套構建產物。套件版本仍為 `2.0.0`、共享規則仍為 `1.0.0`,未進行 npm 發佈; +包含配套構建產物。2026-10-03 的全專案掃描修復亦已提交並推送至 `main`, +共享規則 `1.0.1` 補充既有語言契約的反例向量,詳見 [UPDATES](./UPDATES.md)。 +套件版本仍為 `2.0.0`,未進行 npm 發佈。 使用倉庫產物時須一併更新 JS 與 CSS,具體升級影響見上述消費端更新記錄。 > 本版(2.x)為從零重寫。v1 之 `createCjkAutospace` 相容層**已移除**(最後一批 @@ -44,7 +46,7 @@ npm run test:wkwebview # macOS 原生 WKWebView 版面/observer E2E(需 Xco ## 共享規則契約 -`rules/` 是 Web/CSS 與並行 XeLaTeX 實作之唯一規則權威源;初版規則版本為 `1.0.0`。 +`rules/` 是 Web/CSS 與並行 XeLaTeX 實作之唯一規則權威源;初版規則版本為 `1.0.0`,目前為 `1.0.1`(契約澄清與新增反例向量)。 規則以 Unicode code-point 區間、語言政策、標點寬度、間隙、禁斷與段末約束描述「應發生 什麼」,不規定 DOM/CSS、TeX node/glue/penalty 或其他 adapter 的實作機制。 @@ -166,7 +168,7 @@ jz.observe(document.body); // 之後 DOM 變動即自動增量恢復 | **中西間隙** `spacing` | CJK↔西文邊界以 copy-clean 之 `jz-hws` 與 `margin-right` 留隙;**不插入空白字元**,故剪貼板不多出空格。唯跨節點 IVS 產物以 `::after` 產生的 `U+200B` 斷行機會設 `user-select:none`;它不進入 DOM/`textContent`。「西文」涵蓋拉丁(含擴充 A/B,拼音聲調 `ā ǎ ē`)、希臘、西里爾、數字、貨幣(`€ ₩ ₿`)、數學運算符等;CJK 標點(`。,「」()……` 等)不誤判為西文、不生隙。含跨樣式行內邊界與 pill/隔離元素邊界(`code`/`kbd`/`.katex` 與 `mark`,兩端補隙口徑一致;pill 緊鄰標點時穿越 `jz-jinze` 仍補間隙;pill 跨行右隙、間隙落行末之自我應驗皆已修)。**pill 之邏輯鄰字為標點時(如 `漢字。`)不誤加間隙**(標點非 pill 鄰字;`jz-char`/`jz-inner` 透明穿越,與 `collectRuns` 一致)。贅餘作者空格一律正規化:CJK↔西文吸收為受管間隙、CJK↔CJK(含跨 inline 標籤)剝除不留隙。 | | **長英文詞** `longWord` | `[A-Za-z]{6,}` 包入 ``,配合 CSS `hyphens:auto` 原生斷詞(DOM 無連字符 → 複製不污染)。 | | **標點擠壓** `jiya` | 標點 charify 為 `jz-char`+`bd-*` class。三機制:①baseline 政策(全角式不擠/開明式擠句內點號+括號引號/半角式全擠);②行內連續擠壓(相鄰標點**雙側獨立**收——各標點若其有空白之側緊鄰另一標點即收半,故 `,「` 全角式與開明式皆 1em、`。」` 全角式 1.5em/開明式 1em;**唯句末點號接開類為句界**——`。「`/`?「` 保留句末點號右空白、僅收開類,全角式與開明式皆 1.5em;透明穿越 `strong`/`em` 等行內邊界);③行端擠壓(`lineedge` layout pass)。半形預設以字型 `halt` 渲染(按字形正確定位,居中字形如 `!?`/繁體 `。,` 不重疊;不依賴 `text-spacing-trim`)。**字型缺 `halt` 時可切 `margin` 後備模式**(見下節)。 | -| **禁則** `jinze` | 行首行末避頭尾:閉類/點號綁前一字(不置行首)、開類綁後一字(不置行末),只隔離邊界字故長引文中段仍可斷。亦為 `justify` 綁定載體。邊界字落於**透明**行內樣式元素(`strong`/`em` 等)內時**克隆切分**只綁邊界詞元(避免整個元素入 `nowrap` 致行末整體掉行、上行留巨大空隙),切出之克隆標 `data-jz-kind="splitoff"`、`revert` 時自動併回原元素;不可切元素(`a`/`abbr`/`ruby`/帶 `id`,以及 **pill/隔離**——見「行內元素三分類」)退化為**整元素綁定**——pill(如行內公式 `.katex`)即作為原子鄰字單位整體綁入 `jz-jinze`,絕不下潛肢解其內部。兩側須符合作用域、paragraph 級別與 `jinze` 功能閘;不可切時也不把 avoid/text/off 後代整綁帶入。 | +| **禁則** `jinze` | 行首行末避頭尾:閉類/點號綁前一字(不置行首)、開類綁後一字(不置行末),只隔離邊界字故長引文中段仍可斷。亦為 `justify` 綁定載體。邊界字落於**透明**行內樣式元素(`strong`/`em` 等)內時**克隆切分**只綁邊界詞元(避免整個元素入 `nowrap` 致行末整體掉行、上行留巨大空隙),切出之克隆標 `data-jz-kind="splitoff"`、`revert` 時驗來源身份及屬性後併回原元素;若外部插入兄弟、替換來源或改樣式而無法安全合併,保留片段元素與文字位置並撤銷聚珍標記;不可切元素(`a`/`abbr`/`ruby`/帶 `id`,以及 **pill/隔離**——見「行內元素三分類」)退化為**整元素綁定**——pill(如行內公式 `.katex`)即作為原子鄰字單位整體綁入 `jz-jinze`,絕不下潛肢解其內部。兩側須符合作用域、paragraph 級別與 `jinze` 功能閘;不可切時也不把 avoid/text/off 後代整綁帶入。 | | **垂懸字避免** `orphan` | 段末可處理的連續區域 CJK 實義字 ≥ N(預設 2;標點/符號不計,完整碼點及 base+VS 算一字)。方案 C(斷行層、無測量):把段末「第 N 個實義字起至段末」包入 `jz-orphan`(`white-space:nowrap`),整體不可斷 → 末行恒含之 ⇒ 末行實義字 ≥ N,任意寬度/字型成立。單行段無害。邊界字落於行內元素內時同樣**克隆切分**(只綁末 N 字、避免整個長元素入 `nowrap` 溢出容器;機制同禁則)。**預設開**(`orphan:false` 關,`{chars:n}` 調)。段落級功能。avoid/text/off/`isSkipped` 區域中斷計數,只綁其後的合法尾部;不足 N 字或不可切容器含排除內容時不綁。 | | **功能分級** `level` | 功能分文本級(中西間隙、標點擠壓——任意文本片段適用)與段落級(禁則、垂懸字、長詞——需行/段布局)。塊級元素預設全功能;`data-jz-level="text"` 子樹或選項 `level:{text:"選擇器"}`(如標題/按鈕)僅跑文本級。 | | **justify 單份間距** | 每個 `jz-char` 為 `inline-block` 原子項,句末標點只貢獻一份對齊伸縮量。 | @@ -184,7 +186,7 @@ createJuzhen({ lang: { default: "zh-Hant", // 無 lang 祖先時之預設 style: "kaiming", // 全域政策一鍵覆寫(quanjiao|kaiming|banjiao) - policy: { "zh-Hant": "quanjiao", "zh-Hans": "kaiming" }, // 逐 lang(最高優先) + policy: { "zh-Hant": "quanjiao", "zh-Hans": "kaiming" }, // 逐 lang(低於逐元素覆寫) }, scope: { root: document.body, // 處理根;render(root) 可逐次覆寫 @@ -217,7 +219,7 @@ createJuzhen({ | 選項 | 型別 | 預設 | 作用 | |---|---|---|---| -| `lang.default` | string | `"zh-Hant"` | 無 `lang` 祖先時之預設語言標籤 | +| `lang.default` | string | `"zh-Hant"` | 自身及祖先皆無 `lang` 時之預設;空字串表示未知 | | `lang.style` | `quanjiao|kaiming|banjiao` | 逐語言內建(繁→全角、簡→開明) | **全域政策一鍵覆寫** | | `lang.policy` | `Partial>` | `{}` | 按語言分類覆寫:鍵僅為 `zh-Hans`、`zh-Hant`、`ja`、`other`;如 `zh-TW` 先歸入 `zh-Hant`,不直接作為鍵 | | `lang.stylePolicy` | `{selector,style}[]` | `[]` | **逐元素樣式覆寫**(選擇器命中之子樹用指定樣式;如 `[{selector:"h1,button,.ui",style:"banjiao"}]`);配合 `data-jz-style` 屬性,優先於 lang | @@ -481,7 +483,7 @@ CSS 皆刪)。`hanging` 選項仍被接受(API 相容)但**無任何效果 **墨色出去多少版心內就空多少;想不空就得讓斷行器重排這塊寬度,而 CSS hack 接觸不到斷行器**。 補償墊片、兩階段試錯都改變不了此恒等式。 -唯一正解為原生 CSS `hanging-punctuation`,惟 **Chrome/Firefox 至今不支援**(僅 Safari),且 +原生 CSS `hanging-punctuation` 是未來可評估路徑,須另行核對各引擎能力及字型行為,且 本庫之 `jz-char` 為 inline-block 原子、非文本,原生懸掛不作用——未來若做,須是繞開 `jz-char` 的「原生管線」新功能(`text-spacing-trim` + `hanging-punctuation`,按能力門控),與已移除之 程式碼無共享。詳見 `ARCHITECTURE.md §6.5.6`。 @@ -566,11 +568,14 @@ createJuzhen({ 換用新字型時,依下列步驟確認半形渲染: 1. **驗 `halt`**:`python3 tools/measure-bias.py `(需 `pip install fonttools`)。 - 首行報該字型是否含 OpenType `halt`。 - - **有 `halt`** → 直接用預設(`jiya: true`,halt 模式),無需配字,到此為止。 - - **無 `halt`**(多數系統字型、GBK 老字型)→ 用 `margin` 後備,續下。 + 首行報該字型是否含 OpenType `halt`;特性存在不保證所有標點/語言字形均覆蓋。 + - **有 `halt`** → 先在目標瀏覽器、字型及 `lang` 下逐標點驗證預設 halt 模式; + 覆蓋及定位符合要求者可免配字,未覆蓋者改用局部 `margin`。 + - **無 `halt`** → 用 `margin` 後備,續下。 2. **取偏側建議**:同一工具同時量各標點 glyph 之墨色中心、印出建議偏側表 - (`left|center|right|full`)與可貼入 `jiya.bias` 之 JSON。 + (`left|center|right|full`)與可貼入 `jiya.bias` 之 JSON。工具只量預設 cmap glyph, + 不做 shaping/`locl`;第二個語言參數僅選 JSON 分類鍵,不會改變所量字形。泛 CJK 地域字形須 + 以瀏覽器實際輸出微調,不能把各語言同值建議當成逐語言字形驗收。 3. **套用**:`createJuzhen({ jiya: { halfWidth: "margin", bias: <貼入 JSON> } })`; 或全用內建參考表(多數宋體系 GBK 字型與參考表相符,未必需覆寫)。 4. **目視微調**:以 `demo/fallback.html`(或自有頁面)對照原文,逐字檢查—— @@ -582,7 +587,7 @@ createJuzhen({ > 偏側是「字身於 0.5em 盒內左移多少」的三檔量化(0/0.25/0.5em),量測值近門檻 > (如 0.6 介於 center/right)時,以目視為準。`halt` 字型走 OpenType 由字型精確 -> 定位,無此配字需求——`margin` 配字僅為缺 `halt` 字型之務實近似。 +> 定位;是否完整覆蓋仍須核對——`margin` 配字是缺 `halt` 或覆蓋不足字型之務實近似。 --- @@ -594,7 +599,9 @@ 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`。 + 驅動全角/開明政策與字形 `locl`。自身及祖先均無 `lang` 時回退 `options.lang.default`;顯式 `lang=""` 為未知語言 + (`other`),不繼承外層語言。簡繁分類只讀 BCP 47 的 script/region 位置,忽略 + extension/private-use 內的同名字串(如 `zh-Hans-x-hant` 仍為簡體)。 3. **作用域類**:受管內容容器須加 `class="juzhen"`,以啟用內建原生間距禁用樣式。 `scope.include` 只控制 JS 處理範圍,不會改寫 CSS 選擇器;若使用 `.prose` 等自訂 選擇器,請同時加類別(如 `class="prose juzhen"`)。 @@ -645,6 +652,9 @@ JS 只注入 `jz-*` 結構,**所有視覺尺寸在 `juzhen.css`**——故 CSS ## 自定義字符分類(`charClass`) +`cjk`/`western` 是 regex 字元類片段,特殊字元須轉義;`biaodian` 各子類則是 +逐碼點字串,不解析範圍。追加三個圈碼應寫 `"①②③"`;`"①-③"` 會追加 `①`、`-`、`③`。 + 每個字符的分類(中文/西文、全寬標點各子類)抽象為**規則集**:內置默認規則集即聚珍 的分類表,消費端可經 `options.charClass` **擴充**,以覆寫 `spacing`/`jiya`/`jinze` 等規則對特定字符之判定。語義為**追加**——把當前未分類之字符、或新符號歸入對應類。 diff --git a/UPDATES.md b/UPDATES.md index 0c0a075..b20d666 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -2,6 +2,54 @@ 目前用法與標記契約以 [README](./README.md#用法) 為準;本檔只記錄升級影響與所需動作。 +## 2026-10-03:全專案掃描後的行緣、回退與語言修復 + +- **基線/範圍**:以 `024ad5d`(套件 2.0.0、ruleset 1.0.0)為掃描基線,修復 + Web adapter、demo、量測工具及文件。套件版本仍為 2.0.0。 +- **交付狀態**:本次修復已提交並推送至 `main`,倉庫含配套 JS/CSS 與型別宣告。 + 未進行 npm 發佈、下游更新或部署;既有安裝及網站不會自動更新。 + +### 可見行為與升級影響 + +1. **物理行緣**:`jz-orphan` 包裹不再把同行開括號誤當行首;自訂 isolate/opaque + 的實際 inline/inline-block/contents 盒可攀出,block 仍是邊界。可見 `br` 作為 + 強制行緣,隱藏 `br` 可穿越。修正影響行端半形及同行/行末間隙判斷,邏輯隔離政策不變。 +2. **切分回退**:splitoff 不再只憑前兄弟標籤合併。私有切分/合併鏈證明同源、仍相鄰 + 且作者屬性一致時才併回,支援多實例再次切分;外部插入、替換來源或改樣式時保留 + 片段元素、樣式及字序並撤銷本庫標記/憑證。受外部干預後不承諾恢復歷史 DOM 快照。 +3. **語言**:自身及祖先最近的 `lang=""` 表示未知(`other`),僅屬性缺失才繼承/回退; + `lang.default: ""` 亦表示未知。script/region 只按 BCP 47 的位置判讀,私用或擴展 + 內容不再冒充腳本,如 `zh-Hans-x-hant` 為簡體、`zh-TW-x-hans` 為繁體。四個分類鍵、 + 裸 `zh` 預設繁體及逐元素樣式優先序不變。依賴舊錯誤行為者須改成明確語言或樣式。 +4. **demo 控制**:主 demo 對齊/寬度切換顯式重排,revert 後控制保持停用;拖曳頁 + 八個框各自作為 render 根,寬度觀察及對齊刷新不再只依賴 body 尺寸變化。 +5. **字型後備**:`margin`+逐語言分類/逐字 `jiya.bias`(left/center/right/full) + 完整保留。工具補量 `.`(U+FF0E),並明示只量預設 cmap、未做 shaping/`locl`; + 語言參數僅選 JSON 分類鍵,`halt` 特性存在不保證逐字覆蓋。目標字型與語言下仍須目視微調。 + +共享規則升為 **1.0.1 patch**:澄清既有 explicit-script/region 契約並新增七個反例向量, +原有 55 個向量結果、字符集合、政策、schema 及 adapter 義務均不變。Web 修正違反既有 +契約的解析錯誤,不另立 Web 專用分類政策;其他 adapter 須核對全部 62 個向量。 +本次未驗收 XeLaTeX 或其他 adapter。版本依據見 [共享規則](./rules/README.md#101-語言契約澄清)。 + +取用本次倉庫修改時,一起更新配套 JS/CSS/型別及共享規則;公開入口與選項名稱不變。 +文件另修正 Finder 快取生命週期、no-op 懸掛說明、功能級別預設、樣式優先序及 +`charClass` 輸入格式:cjk/western 是 regex 字元類片段,biaodian 是逐碼點字串, +`"①-③"` 不代表三個圈碼,應寫 `"①②③"`。 + +### 本地驗證 + +- `npm run build`:JS/CSS 與 TypeScript 型別宣告完整構建通過。 +- `npm test`:**376/376 通過,0 失敗、0 跳過**;含 strict schema/常數對照、 + 全部 62 個語義向量、外部插入/替換/改樣式、observer 回退及多實例切分 36 個組合。 +- `npm run test:wkwebview`:最終構建 **64/64 通過**,failures 為空;新增行緣、 + 語言、外部同標籤兄弟及多實例切分用例均在真實 WKWebView 執行。 +- 最終產物另以原生宿主載入兩個 demo 副本:主頁 4 項、拖曳頁 5 項通過,核對 + 對齊後與手動重排一致、revert 停用,以及框寬變化實際改變行端決策。 + +以上是本地 DOM/原生幾何及控制驗證,不代表三引擎目視、所有字型地域字形或真實下游 +頁面驗收。本次未重新驗收 Chrome/Edge;**Firefox 依用戶要求暫緩**,不安裝或作相容性結論。 + ## 2026-10-02:Web 模組邊界、半形模式與局部生命週期修復 - **基線/範圍**:以 `1c2fb0e`(套件 2.0.0、ruleset 1.0.0)為修復前基線;本次為 diff --git a/demo/e2e.html b/demo/e2e.html index 7d987f4..d2aec40 100644 --- a/demo/e2e.html +++ b/demo/e2e.html @@ -352,6 +352,97 @@ }); layoutJz.revert(layoutFixture); layoutJz.disconnect(); + var auditRoot = document.createElement("section"); + auditRoot.lang = "zh-Hant"; + auditRoot.style.cssText = "font-size:20px;width:600px;text-align:left"; + document.body.appendChild(auditRoot); + [ false, true ].forEach(function (orphan) + { + auditRoot.innerHTML = '

前甲「乙丙」

'; + var edgeOwner = Juzhen.createJuzhen({ orphan: orphan, spacing: false, longWord: false }); + edgeOwner.render(auditRoot); + record("同行 orphan 不製造行首,orphan=" + orphan, + !auditRoot.querySelector(".bd-open").classList.contains("jz-half-le")); + edgeOwner.revert(auditRoot); + edgeOwner.disconnect(); + }); + [ "inline", "inline-block", "contents", "block" ].forEach(function (display) + { + auditRoot.innerHTML = '

前甲「乙」丙

'; + var edgeOwner = Juzhen.createJuzhen({ isolateSelector: "x-box", orphan: false, spacing: false, longWord: false }); + edgeOwner.render(auditRoot); + record("自訂隔離元素按物理行內流判斷,display=" + display, + Array.from(auditRoot.querySelectorAll("jz-char")).every(function (char) + { + return char.classList.contains("jz-half-le") === (display === "block"); + })); + edgeOwner.revert(auditRoot); + edgeOwner.disconnect(); + }); + [ false, true ].forEach(function (hidden) + { + auditRoot.innerHTML = '

甲」「乙

'; + var edgeOwner = Juzhen.createJuzhen({ orphan: false, spacing: false, longWord: false }); + edgeOwner.render(auditRoot); + record("BR 是強制行緣,hidden=" + hidden, + Array.from(auditRoot.querySelectorAll("jz-char")).every(function (char) + { + return char.classList.contains("jz-half-le") === !hidden; + })); + edgeOwner.revert(auditRoot); + edgeOwner.disconnect(); + }); + auditRoot.innerHTML = '

甲乙丙,丁

'; + var splitOwner = Juzhen.createJuzhen({ orphan: false, spacing: false, longWord: false }); + splitOwner.render(auditRoot); + var splitFragment = auditRoot.querySelector('[data-jz-kind="splitoff"]'); + var inserted = document.createElement("strong"); + inserted.className = "external"; + inserted.textContent = "新"; + splitFragment.before(inserted); + splitOwner.revert(auditRoot); + record("splitoff 不將邊界字移入外部同標籤兄弟", + inserted.textContent === "新" && splitFragment.textContent === "丙" + && splitFragment.className === "original" && !splitFragment.hasAttribute("data-jz") + && auditRoot.textContent === "甲乙新丙,丁"); + splitOwner.disconnect(); + auditRoot.innerHTML = '

甲,乙

甲,乙

甲,乙

'; + var localeOwner = Juzhen.createJuzhen({ orphan: false, jinze: false }); + localeOwner.render(auditRoot); + var localeChars = auditRoot.querySelectorAll("jz-char"); + record("語言私用部分不覆寫 script/region,空值為未知", + localeChars[0].classList.contains("jz-half") + && !localeChars[1].classList.contains("jz-half") + && !localeChars[2].classList.contains("jz-half")); + localeOwner.revert(auditRoot); + localeOwner.disconnect(); + var splitMatrixPass = true; + var splitOrders = [ [0,1,2], [0,2,1], [1,0,2], [1,2,0], [2,0,1], [2,1,0] ]; + [ '甲乙丙丁,戊己', '「甲乙丙丁」', + '甲乙丙丁,戊己', + '甲乙丙丁,戊己', + '甲乙𠀀\uDB40\uDD00丙丁,戊己', + '甲乙丙丁,戊己' + ].forEach(function (html) + { + splitOrders.forEach(function (order) + { + auditRoot.innerHTML = '

' + html + '

'; + var p = auditRoot.firstElementChild; + var original = p.innerHTML; + var owners = [ + Juzhen.createJuzhen({ jiya: false, spacing: false, orphan: false, longWord: false }), + Juzhen.createJuzhen({ jiya: false, spacing: false, jinze: false, longWord: false }), + Juzhen.createJuzhen({ spacing: false, orphan: false, longWord: false }) + ]; + owners.forEach(function (owner) { owner.render(p); }); + order.forEach(function (index) { owners[index].revert(p); }); + if (p.innerHTML !== original) { splitMatrixPass = false; } + owners.forEach(function (owner) { owner.disconnect(); }); + }); + }); + record("私有切分來源鏈支援多實例六種回退順序,36 組合", splitMatrixPass); + auditRoot.remove(); var moduleFixture = document.getElementById("module-fixture"); var splitSurrogate = document.getElementById("surrogate-spacing"); [ "\uD840", "\uDC00", "\uDB40", "\uDD00" ].forEach(function (part, index) diff --git a/demo/fallback.html b/demo/fallback.html index 6a027de..fe06669 100644 --- a/demo/fallback.html +++ b/demo/fallback.html @@ -16,8 +16,8 @@ font-family: system-ui, sans-serif; } /* 字型類獨立於 .juzhen:A 用裸 .cn(瀏覽器原生排版),B/C 才掛 .juzhen - (含 normalize 之 text-spacing-trim:space-all)。否則 A 之原生標點被 space-all - 撐開、顯得比 B 鬆,造成「B 似乎有效」之假象(實則 B 全形未擠、與真原文等寬)。 */ + (禁用原生 text-autospace)。聚珍不設定 text-spacing-trim;A 保留瀏覽器原生 + 排版,B/C 使用相同受管基線以比較 halt 與 margin。 */ /* 字型設於容器即可——聚珍不管字型(無 _lang.css)、亦不強制 jz-inner(`font: inherit`),故消費端字型經繼承正確套到標點字面(標點亦以 FZShuSong 渲染,halt 確實缺席)。此即「字型由消費端負責」之示例。 */ @@ -47,8 +47,9 @@

方正書宋 GBK · margin 後備模式

- 此字型僅含 vert、無 halt,故 halt 模式之開明式擠壓「完全無效」(標點仍佔全形)。 - margin 後備模式以負 margin 依字形偏側收半形——本字型簡體點號(,、。;:!?)墨色皆偏左下,故收右側空白。 + 本頁需本機安裝指定版本的方正書宋 GBK;若發生字型替代,下列比較不能作該字型驗收。 + 已測版本僅含 vert、無 halt,故 halt 模式之開明式擠壓「完全無效」(標點仍佔全形)。 + margin 後備模式以 0.5em 外盒收半形、內盒依墨色偏側定位——本字型簡體點號(,、。;:!?)墨色皆偏左下,故收右側空白。 白底行盒寬度即直接反映效果:B 與 A 同寬(未擠),C 明顯窄一截(已擠)。

@@ -78,7 +79,7 @@ .render(document.getElementById("margin")); // A 面板留原文(不 render)。 // 註:margin 模式以 width:0.5em+字身溢出定位,標點 advance 真為 0.5em;整句 - // 行寬 halt 24.0em → margin 19.5em(10 標點各收 0.5em),可於瀏覽器 devtools 量。 + // 整句行寬差取決於實際壓縮字符及目標字型,可於瀏覽器 devtools 量;不以固定 em 值驗收。 diff --git a/demo/index.html b/demo/index.html index 56397f0..81e8f82 100644 --- a/demo/index.html +++ b/demo/index.html @@ -108,24 +108,32 @@ }); } var jz = makeJz(); + var rendered = false; var statusEl = document.getElementById("status"); function render() { jz.revert(document.body); // 先還原(重套政策時清除上次產物;首次為 no-op) jz = makeJz(); jz.render(document.body); + rendered = true; statusEl.textContent = "已 render(" + document.querySelectorAll("jz-hws").length + " 间隙、" + document.querySelectorAll("jz-char").length + " 标点原子;繁体政策:" + (forceKaiming ? "开明式" : "全角式") + ")"; } function revert() { jz.revert(document.body); + rendered = false; statusEl.textContent = "已 revert(DOM 还原)"; } + function refreshLayout() + { + // 對齊/寬度改變不一定改變 body 尺寸,須顯式重算;revert 後保持停用。 + if (rendered) { jz.rerender(document.body); } + } document.getElementById("force-kaiming").addEventListener("change", function (e) { forceKaiming = e.target.checked; - render(); + if (rendered) { render(); } }); // 量測每個 jz-char 之實際渲染寬度(em)。輸出純 ASCII(碼位+class+數值), // 並按 lang/codepoint/class 去重,便於回報診斷 halt 是否生效、收哪側。 @@ -167,6 +175,7 @@ document.getElementById("justify").addEventListener("change", function (e) { document.body.classList.toggle("justify", e.target.checked); + refreshLayout(); }); document.getElementById("wide").addEventListener("change", function (e) { @@ -174,6 +183,7 @@ { el.style.maxWidth = e.target.checked ? "none" : ""; }); + refreshLayout(); }); render(); diff --git a/demo/resize-check.html b/demo/resize-check.html index af2b266..d98164a 100644 --- a/demo/resize-check.html +++ b/demo/resize-check.html @@ -76,14 +76,17 @@