fix: 修复行缘、切分还原与语言解析并同步文档
保留逐字 margin/jiya.bias 字体后备方案;补齐 demo 重排、量测边界说明及共享规则 1.0.1 反例向量。 验证:完整构建通过,Node 376 项、WKWebView 64 项及原生 demo 9 项通过;Firefox 仍暂缓。
This commit is contained in:
+23
-13
@@ -67,6 +67,7 @@ CSS box、observer、瀏覽器量測,以及 TeX node/glue/penalty/macro
|
||||
其他實例內容。沒有外部修改時,DOM 回到處理前的**邏輯等價**態;`spacing` 正規化的
|
||||
贅餘作者空格不還原(見 I1),此差異不隨反覆 render/revert 累積。
|
||||
外部克隆、替換、刪除後,按產物及來源完整性檢查安全回退,不復活過時文字;
|
||||
splitoff 無法安全併回時保留作者當前樣式及文字位置,僅撤銷本庫標記及憑證;
|
||||
不保證恢復歷史快照或清空所有 `[data-jz]`。DOM 標記本身不授予所有權。
|
||||
- **I7 邊界感知**:跨樣式行內元素的鄰接(如 `<strong>是</strong>A`)須能識別並
|
||||
正確處理——由核心 finder 統一提供,各 pass 不自行重複造輪。
|
||||
@@ -367,8 +368,9 @@ avoid」用組合入口 `isAvoided`(=自身或祖先,階段二歸一,免
|
||||
|
||||
- **結構克隆切分安全**(`core/split` 之 `canSplit`)——更窄白名單(`STRONG/EM/…`,排除
|
||||
`<a>`/`ABBR`/`RUBY`/帶 `id` 等——切分會重複語義/結構),故「可透明穿越」⊋「可克隆切分」。
|
||||
- **物理版面**(`lineedge` 之 `edgeRect`)——依 `getClientRects` 之物理相鄰判行首/行尾,攀爬集
|
||||
由 `styleInlines` 派生(免遺漏 `ins`/`del`/`sub` 致行端誤判 T4),且**須跨 isolate**(`<mark>`)
|
||||
- **物理版面**(`lineedge` 之 `edgeRect`)——依 `getClientRects` 之物理相鄰判行首/行尾,以 computed `display` 判斷物理行內流(inline 系列/ruby/contents 可攀出;block 停止),
|
||||
可見 `br` 是強制行緣,隱藏 `br` 跳過。無計算樣式時才用 `styleInlines`+jz 包裹標籤後備,
|
||||
包含 `jz-orphan`。**須跨物理行內的 isolate/opaque**(如 `<mark>`/自訂標籤)
|
||||
找真實相鄰內容:isolate 斷的是**邏輯** run/相鄰,非**物理**位置(`文字<mark>「引」</mark>` 之
|
||||
`「` 物理上接在「文字」後、非行首,若停在 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 程式碼**沒有一行可複用**,故不保留休眠碼。
|
||||
|
||||
@@ -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 之邏輯鄰字為標點時(如 `漢字。<code>`)不誤加間隙**(標點非 pill 鄰字;`jz-char`/`jz-inner` 透明穿越,與 `collectRuns` 一致)。贅餘作者空格一律正規化:CJK↔西文吸收為受管間隙、CJK↔CJK(含跨 inline 標籤)剝除不留隙。 |
|
||||
| **長英文詞** `longWord` | `[A-Za-z]{6,}` 包入 `<span lang="en">`,配合 CSS `hyphens:auto` 原生斷詞(DOM 無連字符 → 複製不污染)。 |
|
||||
| **標點擠壓** `jiya` | 標點 charify 為 `jz-char`+`bd-*` class。三機制:①baseline 政策(全角式不擠/開明式擠句內點號+括號引號/半角式全擠);②行內連續擠壓(相鄰標點**雙側獨立**收——各標點若其有空白之側緊鄰另一標點即收半,故 `,「` 全角式與開明式皆 1em、`。」` 全角式 1.5em/開明式 1em;**唯句末點號接開類為句界**——`。「`/`?「` 保留句末點號右空白、僅收開類,全角式與開明式皆 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<Record<LangClass, PunctStyle>>` | `{}` | 按語言分類覆寫:鍵僅為 `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 <font.ttf>`(需 `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. **引入樣式(必須)**:`<link rel="stylesheet" href="dist/juzhen.css">`(或 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`
|
||||
等規則對特定字符之判定。語義為**追加**——把當前未分類之字符、或新符號歸入對應類。
|
||||
|
||||
+48
@@ -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)為修復前基線;本次為
|
||||
|
||||
@@ -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 = '<p>前甲「乙丙」</p>';
|
||||
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 = '<p>前甲<x-box style="display:' + display + '">「乙」</x-box>丙</p>';
|
||||
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 = '<p>甲」<br' + (hidden ? ' style="display:none"' : '') + '>「乙</p>';
|
||||
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 = '<p><strong class="original">甲乙丙</strong>,丁</p>';
|
||||
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 = '<p lang="zh-Hans-x-hant">甲,乙</p><p lang="zh-TW-x-hans">甲,乙</p><p lang="">甲,乙</p>';
|
||||
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] ];
|
||||
[ '<strong>甲乙丙丁</strong>,戊己', '「<strong>甲乙丙丁</strong>」',
|
||||
'<strong><em>甲乙丙丁</em></strong>,戊己',
|
||||
'<strong class="author" style="color:red">甲乙丙丁</strong>,戊己',
|
||||
'<strong>甲乙𠀀\uDB40\uDD00丙丁</strong>,戊己',
|
||||
'<strong data-jz="author" data-jz-kind="author">甲乙丙丁</strong>,戊己'
|
||||
].forEach(function (html)
|
||||
{
|
||||
splitOrders.forEach(function (order)
|
||||
{
|
||||
auditRoot.innerHTML = '<p>' + html + '</p>';
|
||||
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)
|
||||
|
||||
+6
-5
@@ -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 @@
|
||||
<body>
|
||||
<h1>方正書宋 GBK · margin 後備模式</h1>
|
||||
<p class="meta">
|
||||
此字型僅含 <code>vert</code>、<b>無 <code>halt</code></b>,故 halt 模式之開明式擠壓「完全無效」(標點仍佔全形)。
|
||||
margin 後備模式以負 margin 依字形偏側收半形——本字型簡體點號(,、。;:!?)墨色皆偏左下,故收右側空白。
|
||||
本頁需本機安裝指定版本的方正書宋 GBK;若發生字型替代,下列比較不能作該字型驗收。
|
||||
已測版本僅含 <code>vert</code>、<b>無 <code>halt</code></b>,故 halt 模式之開明式擠壓「完全無效」(標點仍佔全形)。
|
||||
margin 後備模式以 0.5em 外盒收半形、內盒依墨色偏側定位——本字型簡體點號(,、。;:!?)墨色皆偏左下,故收右側空白。
|
||||
白底行盒寬度即直接反映效果:B 與 A 同寬(未擠),C 明顯窄一截(已擠)。
|
||||
</p>
|
||||
|
||||
@@ -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 值驗收。
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+11
-1
@@ -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();
|
||||
</script>
|
||||
|
||||
@@ -76,14 +76,17 @@
|
||||
|
||||
<script src="../dist/juzhen.iife.js"></script>
|
||||
<script>
|
||||
// 全頁一次渲染(quanjiao 全角式,與下游回饋之政策一致);各框由其 lang 屬性
|
||||
// 驅動簡/繁字形。(標點懸掛已移除,見 ARCHITECTURE §6.5.6。)
|
||||
Juzhen.createJuzhen({ scope: { include: ".juzhen" }, lang: { style: "quanjiao" } })
|
||||
.render(document.body);
|
||||
// 各獨立拖曳框作為 render 根,讓 ResizeObserver 直接觀察其寬度。
|
||||
// quanjiao 全角式;各框由 lang 驅動簡/繁字形。
|
||||
var jz = Juzhen.createJuzhen({ scope: { include: ".juzhen" }, lang: { style: "quanjiao" } });
|
||||
var boxes = Array.from(document.querySelectorAll(".box.juzhen"));
|
||||
boxes.forEach(function (box) { jz.render(box); });
|
||||
|
||||
document.getElementById("t-justify").addEventListener("change", function (e)
|
||||
{
|
||||
document.body.classList.toggle("justify", e.target.checked);
|
||||
// 對齊改變未必產生 resize,不能只等 observer。
|
||||
boxes.forEach(function (box) { jz.rerender(box); });
|
||||
});
|
||||
document.getElementById("t-gaps").addEventListener("change", function (e)
|
||||
{
|
||||
|
||||
+1
-1
@@ -107,7 +107,7 @@
|
||||
<p class="look">末行=「<strong>願意」。</strong>」——保 <strong>2 個實義字</strong>(願意);尾隨 <code>」。</code> 隨尾段綁入但不佔 N(開紫框見綁定範圍含標點,且整個 <code>「我願意」</code> 禁則群組跨 <code>jz-jinze</code> 不被切割)。</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">邊界:段落實義字 < N(或單行段)→ 不綁、無副作用。極窄容器(<N 字寬)→ 該尾段可能溢出(罕見)。</p>
|
||||
<p class="hint">邊界:段落實義字 < N → 不綁。單行段仍可能建立尾段綁定,但不需拉字換行、效果無害。極窄容器(<N 字寬)→ 該尾段可能溢出(罕見)。</p>
|
||||
</div>
|
||||
|
||||
<!-- ===================== 三、標點懸掛(已移除) ===================== -->
|
||||
|
||||
Vendored
+2
@@ -7,6 +7,8 @@ export interface CreateOptions {
|
||||
/** 文本內容。 */
|
||||
text?: string;
|
||||
}
|
||||
/** 切分時記錄真實來源身份;須於 clone 的排版標記覆寫前呼叫。 */
|
||||
export declare function registerSplitoffSource(clone: Element, source: Element, pass: string, ownedElements: WeakSet<Element>): void;
|
||||
/** 完成受控解包後登記;只接受真實、已移除且空的受管產物。 */
|
||||
export declare function recordUnwrappedParent(el: Element, parent: Node, ownedElements: WeakSet<Element>): void;
|
||||
/** 其他 pass/實例合法撤回來源包裹後,搬移記錄沿受控解包鏈查詢現父。 */
|
||||
|
||||
Vendored
+2
-2
@@ -82,8 +82,8 @@ export declare class Finder {
|
||||
* collectRuns/jiyaAdjacency/jinze/orphan/split 各 pass 逐元素重算——以 categoryCache
|
||||
* 記憶化砍除跨 pass 重複。安全性:單次 render 內一元素之分類**恆定**(class/skip 屬性/
|
||||
* nodeName 皆不因 render 變更;jz-* 包裹為新建元素、按 nodeName 分類亦恆定),且 Finder
|
||||
* 隨 makeCtx 每次 render 重建 → 快取僅存活單次 render,無跨 render 失效問題(持久化屬 C1,
|
||||
* 另議)。
|
||||
* 隨實例持久化,跨 render/revert 復用;屬性或位置變化由 invalidateSubtree 清除受影響
|
||||
* 子樹,不能沿用舊祖先鏈結果。
|
||||
*/
|
||||
category(el: Element): InlineCategory;
|
||||
private computeCategory;
|
||||
|
||||
Vendored
+3
-2
@@ -20,12 +20,13 @@ export interface LocaleConfig {
|
||||
stylePolicy?: StyleRule[];
|
||||
}
|
||||
/**
|
||||
* 由節點向上找最近帶 lang 屬性之祖先,回傳小寫值;無則回傳 fallback。
|
||||
* 由節點(含元素自身)向上找最近 lang,回傳小寫值;空值表示語言未知,僅缺失時回退。
|
||||
*/
|
||||
export declare function effectiveLang(node: Node, fallback: string): string;
|
||||
/**
|
||||
* 將 BCP-47 語言標籤歸類。前綴以子標籤邊界比對('zh-tw' 命中 zh-Hant,
|
||||
* 'zhx' 不命中 zh)。香港/台灣繁體歸 zh-Hant;含 Hant/Hans 子標籤者優先。
|
||||
* 'zhx' 不命中 zh)。只讀 BCP-47 的 script/region 位置,不把 extension/private-use
|
||||
* 內容當成腳本或地區;本函數不是完整標籤驗證器。
|
||||
*/
|
||||
export declare function classify(lang: string): LangClass;
|
||||
/**
|
||||
|
||||
Vendored
+4
-4
@@ -43,7 +43,7 @@ export declare const BD_LIGA = "\u2026\u2014";
|
||||
/** 全寬標點之六子類鍵。 */
|
||||
export type BdSubclass = "stop" | "pause" | "open" | "close" | "middle" | "liga";
|
||||
/**
|
||||
* 字符分類規則集(§4.2):把「中文/西文/全寬標點各子類」各表為 regex 字元類片段。
|
||||
* 字符分類規則集(§4.2):中文/西文表為 regex 字元類片段,標點各子類為逐碼點字串(不解析範圍)。
|
||||
* 各 pass(spacing 之 CJK/ANS 正則、jiya 之 classifyBiaodian、jinze 之 anyCjk)皆由此
|
||||
* 取分類,不再各自硬編碼。內置默認見 DEFAULT_RULESET;消費端經 options.charClass 擴充
|
||||
* (resolveRuleset)。
|
||||
@@ -60,11 +60,11 @@ export interface Ruleset {
|
||||
}
|
||||
/** 消費端字符分類覆寫(追加語義;見 README「自定義字符分類」)。 */
|
||||
export interface CharClassOverride {
|
||||
/** 追加為中文(CJK)。 */
|
||||
/** 追加為中文(CJK):regex 字元類片段,特殊字元須轉義。 */
|
||||
cjk?: string;
|
||||
/** 追加為西文(半寬標點亦歸此,按西文處理間隙);併入 ansAfter/ansBefore。 */
|
||||
/** 追加為西文:regex 字元類片段,特殊字元須轉義;併入 ansAfter/ansBefore。 */
|
||||
western?: string;
|
||||
/** 追加為全寬標點,按子類。 */
|
||||
/** 追加為全寬標點,按子類列出逐碼點字串;例如 "①②③",不解析 "①-③" 為範圍。 */
|
||||
biaodian?: Partial<Record<BdSubclass, string>>;
|
||||
}
|
||||
/** 內置默認規則集(即現狀硬編碼值)。 */
|
||||
|
||||
Vendored
+84
-16
@@ -328,8 +328,8 @@ var Juzhen = (() => {
|
||||
* collectRuns/jiyaAdjacency/jinze/orphan/split 各 pass 逐元素重算——以 categoryCache
|
||||
* 記憶化砍除跨 pass 重複。安全性:單次 render 內一元素之分類**恆定**(class/skip 屬性/
|
||||
* nodeName 皆不因 render 變更;jz-* 包裹為新建元素、按 nodeName 分類亦恆定),且 Finder
|
||||
* 隨 makeCtx 每次 render 重建 → 快取僅存活單次 render,無跨 render 失效問題(持久化屬 C1,
|
||||
* 另議)。
|
||||
* 隨實例持久化,跨 render/revert 復用;屬性或位置變化由 invalidateSubtree 清除受影響
|
||||
* 子樹,不能沿用舊祖先鏈結果。
|
||||
*/
|
||||
category(el) {
|
||||
const cached = this.categoryCache.get(el);
|
||||
@@ -837,6 +837,45 @@ var Juzhen = (() => {
|
||||
var ATTRIBUTE_WRITERS = /* @__PURE__ */ new WeakMap();
|
||||
var OWNER_ATTRIBUTES = /* @__PURE__ */ new WeakMap();
|
||||
var UNWRAPPED_PARENTS = /* @__PURE__ */ new WeakMap();
|
||||
var SPLITOFF_SOURCES = /* @__PURE__ */ new WeakMap();
|
||||
function registerSplitoffSource(clone, source, pass, ownedElements) {
|
||||
if (!ownedElements.has(clone) || !SPLITOFF_TAGS.has(clone.nodeName) || clone.nodeName !== source.nodeName) {
|
||||
return;
|
||||
}
|
||||
const earlier = SPLITOFF_SOURCES.get(source);
|
||||
const inherited = earlier && source.getAttribute(PASS_ATTR) === earlier.pass && source.getAttribute(KIND_ATTR) === "splitoff" ? earlier : null;
|
||||
SPLITOFF_SOURCES.set(clone, {
|
||||
source,
|
||||
pass,
|
||||
originalPass: inherited ? inherited.originalPass : clone.getAttribute(PASS_ATTR),
|
||||
originalKind: inherited ? inherited.originalKind : clone.getAttribute(KIND_ATTR)
|
||||
});
|
||||
}
|
||||
function sameSplitAttributes(a, b) {
|
||||
const attrs = (el) => el.getAttributeNames().filter((name) => name !== PASS_ATTR && name !== KIND_ATTR);
|
||||
const names = attrs(a);
|
||||
return names.length === attrs(b).length && names.every((name) => a.getAttribute(name) === b.getAttribute(name));
|
||||
}
|
||||
function splitOrigin(node) {
|
||||
let current = resolveUnwrappedParent(node);
|
||||
const visited = /* @__PURE__ */ new Set();
|
||||
while (current) {
|
||||
if (visited.has(current)) {
|
||||
return null;
|
||||
}
|
||||
visited.add(current);
|
||||
if (current.nodeType !== 1) {
|
||||
return current;
|
||||
}
|
||||
const el = current;
|
||||
const record = SPLITOFF_SOURCES.get(el);
|
||||
if (!record || el.getAttribute(PASS_ATTR) !== record.pass || el.getAttribute(KIND_ATTR) !== "splitoff") {
|
||||
return current;
|
||||
}
|
||||
current = resolveUnwrappedParent(record.source);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function recordUnwrappedParent(el, parent, ownedElements) {
|
||||
const pass = el.getAttribute(PASS_ATTR);
|
||||
if (ownedElements.has(el) && pass && knownProduct(pass, el.getAttribute(KIND_ATTR), el) && !el.parentNode && !el.hasChildNodes()) {
|
||||
@@ -1022,20 +1061,32 @@ var Juzhen = (() => {
|
||||
continue;
|
||||
}
|
||||
touched.add(parent);
|
||||
const record = SPLITOFF_SOURCES.get(so);
|
||||
if (!record || record.pass !== pass) {
|
||||
continue;
|
||||
}
|
||||
const source = resolveUnwrappedParent(record.source);
|
||||
const prev = so.previousSibling;
|
||||
if (prev && prev.nodeType === 1 && prev.nodeName === so.nodeName) {
|
||||
const origin = source ? splitOrigin(source) : null;
|
||||
if (origin && prev?.nodeType === 1 && splitOrigin(prev) === origin && prev.nodeName === so.nodeName && sameSplitAttributes(so, prev)) {
|
||||
while (so.firstChild) {
|
||||
prev.appendChild(so.firstChild);
|
||||
}
|
||||
parent.removeChild(so);
|
||||
recordUnwrappedParent(so, prev, ownedElements);
|
||||
} else {
|
||||
while (so.firstChild) {
|
||||
parent.insertBefore(so.firstChild, so);
|
||||
for (const [name, value] of [
|
||||
[PASS_ATTR, record.originalPass],
|
||||
[KIND_ATTR, record.originalKind]
|
||||
]) {
|
||||
if (value === null) {
|
||||
so.removeAttribute(name);
|
||||
} else {
|
||||
so.setAttribute(name, value);
|
||||
}
|
||||
parent.removeChild(so);
|
||||
recordUnwrappedParent(so, parent, ownedElements);
|
||||
}
|
||||
}
|
||||
SPLITOFF_SOURCES.delete(so);
|
||||
ownedElements.delete(so);
|
||||
}
|
||||
for (const p of touched) {
|
||||
@@ -1355,11 +1406,11 @@ var Juzhen = (() => {
|
||||
"other": "quanjiao"
|
||||
};
|
||||
function effectiveLang(node, fallback) {
|
||||
let p = node.parentNode;
|
||||
let p = node.nodeType === 1 ? node : node.parentNode;
|
||||
while (p && p.nodeType === 1) {
|
||||
const el = p;
|
||||
const v = el.getAttribute("lang");
|
||||
if (v) {
|
||||
if (v !== null) {
|
||||
return v.toLowerCase();
|
||||
}
|
||||
p = p.parentNode;
|
||||
@@ -1372,13 +1423,19 @@ var Juzhen = (() => {
|
||||
return "ja";
|
||||
}
|
||||
if (l === "zh" || l.indexOf("zh-") === 0) {
|
||||
if (/(?:^|-)hant(?:-|$)/.test(l)) {
|
||||
const subtags = l.split("-");
|
||||
let at = 1;
|
||||
for (let count = 0; count < 3 && /^[a-z]{3}$/.test(subtags[at] || ""); count++) {
|
||||
at++;
|
||||
}
|
||||
const script = /^[a-z]{4}$/.test(subtags[at] || "") ? subtags[at++] : void 0;
|
||||
if (script === "hant") {
|
||||
return "zh-Hant";
|
||||
}
|
||||
if (/(?:^|-)hans(?:-|$)/.test(l)) {
|
||||
if (script === "hans") {
|
||||
return "zh-Hans";
|
||||
}
|
||||
if (/-(tw|hk|mo)\b/.test(l)) {
|
||||
if (["tw", "hk", "mo"].includes(subtags[at] || "")) {
|
||||
return "zh-Hant";
|
||||
}
|
||||
if (l === "zh") {
|
||||
@@ -1753,9 +1810,10 @@ var Juzhen = (() => {
|
||||
if (rightAtLevel) {
|
||||
clone = anc.cloneNode(false);
|
||||
clone.removeAttribute("id");
|
||||
ownedElements.add(clone);
|
||||
registerSplitoffSource(clone, anc, pass, ownedElements);
|
||||
clone.setAttribute(PASS_ATTR2, pass);
|
||||
clone.setAttribute(KIND_ATTR2, "splitoff");
|
||||
ownedElements.add(clone);
|
||||
let n = rightAtLevel;
|
||||
while (n) {
|
||||
const next = n.nextSibling;
|
||||
@@ -2240,7 +2298,7 @@ var Juzhen = (() => {
|
||||
owners.delete(owner);
|
||||
}
|
||||
}
|
||||
var JZ_WRAP_TAGS = ["JZ-JINZE", "JZ-CHAR", "JZ-INNER", "JZ-HWS"];
|
||||
var JZ_WRAP_TAGS = ["JZ-JINZE", "JZ-CHAR", "JZ-INNER", "JZ-HWS", "JZ-ORPHAN"];
|
||||
function buildWrapSet(options) {
|
||||
const s = new Set(JZ_WRAP_TAGS);
|
||||
for (const name of options.finder.styleInlines) {
|
||||
@@ -2248,6 +2306,13 @@ var Juzhen = (() => {
|
||||
}
|
||||
return s;
|
||||
}
|
||||
function inlineFlow(el, wrapSet) {
|
||||
const display = el.ownerDocument.defaultView?.getComputedStyle(el).display;
|
||||
if (display) {
|
||||
return display === "contents" || display.startsWith("inline") || display.startsWith("ruby");
|
||||
}
|
||||
return wrapSet.has(el.nodeName);
|
||||
}
|
||||
function rectOf(n, side) {
|
||||
if (n.nodeType === 3 && n.data.length > 0) {
|
||||
const t = n;
|
||||
@@ -2277,6 +2342,9 @@ var Juzhen = (() => {
|
||||
let parentRef = from.parentNode;
|
||||
for (; ; ) {
|
||||
while (n) {
|
||||
if (n.nodeType === 1 && n.nodeName === "BR" && n.ownerDocument.defaultView?.getComputedStyle(n).display !== "none") {
|
||||
return null;
|
||||
}
|
||||
const r = rectOf(n, side);
|
||||
if (r) {
|
||||
return r;
|
||||
@@ -2286,7 +2354,7 @@ var Juzhen = (() => {
|
||||
if (!parentRef || parentRef.nodeType !== 1) {
|
||||
return null;
|
||||
}
|
||||
if (!wrapSet.has(parentRef.nodeName)) {
|
||||
if (!inlineFlow(parentRef, wrapSet)) {
|
||||
return null;
|
||||
}
|
||||
n = side === "prev" ? parentRef.previousSibling : parentRef.nextSibling;
|
||||
@@ -3402,7 +3470,7 @@ var Juzhen = (() => {
|
||||
const scope = opts.scope || {};
|
||||
return {
|
||||
locale: {
|
||||
default: langOpt.default || "zh-Hant",
|
||||
default: langOpt.default === void 0 ? "zh-Hant" : langOpt.default,
|
||||
...langOpt.style ? { style: langOpt.style } : {},
|
||||
policy: langOpt.policy || {},
|
||||
stylePolicy: Array.isArray(langOpt.stylePolicy) ? langOpt.stylePolicy.slice() : []
|
||||
|
||||
Vendored
+84
-16
@@ -301,8 +301,8 @@ var Finder = class {
|
||||
* collectRuns/jiyaAdjacency/jinze/orphan/split 各 pass 逐元素重算——以 categoryCache
|
||||
* 記憶化砍除跨 pass 重複。安全性:單次 render 內一元素之分類**恆定**(class/skip 屬性/
|
||||
* nodeName 皆不因 render 變更;jz-* 包裹為新建元素、按 nodeName 分類亦恆定),且 Finder
|
||||
* 隨 makeCtx 每次 render 重建 → 快取僅存活單次 render,無跨 render 失效問題(持久化屬 C1,
|
||||
* 另議)。
|
||||
* 隨實例持久化,跨 render/revert 復用;屬性或位置變化由 invalidateSubtree 清除受影響
|
||||
* 子樹,不能沿用舊祖先鏈結果。
|
||||
*/
|
||||
category(el) {
|
||||
const cached = this.categoryCache.get(el);
|
||||
@@ -810,6 +810,45 @@ var SPLITOFF_TAGS = /* @__PURE__ */ new Set([
|
||||
var ATTRIBUTE_WRITERS = /* @__PURE__ */ new WeakMap();
|
||||
var OWNER_ATTRIBUTES = /* @__PURE__ */ new WeakMap();
|
||||
var UNWRAPPED_PARENTS = /* @__PURE__ */ new WeakMap();
|
||||
var SPLITOFF_SOURCES = /* @__PURE__ */ new WeakMap();
|
||||
function registerSplitoffSource(clone, source, pass, ownedElements) {
|
||||
if (!ownedElements.has(clone) || !SPLITOFF_TAGS.has(clone.nodeName) || clone.nodeName !== source.nodeName) {
|
||||
return;
|
||||
}
|
||||
const earlier = SPLITOFF_SOURCES.get(source);
|
||||
const inherited = earlier && source.getAttribute(PASS_ATTR) === earlier.pass && source.getAttribute(KIND_ATTR) === "splitoff" ? earlier : null;
|
||||
SPLITOFF_SOURCES.set(clone, {
|
||||
source,
|
||||
pass,
|
||||
originalPass: inherited ? inherited.originalPass : clone.getAttribute(PASS_ATTR),
|
||||
originalKind: inherited ? inherited.originalKind : clone.getAttribute(KIND_ATTR)
|
||||
});
|
||||
}
|
||||
function sameSplitAttributes(a, b) {
|
||||
const attrs = (el) => el.getAttributeNames().filter((name) => name !== PASS_ATTR && name !== KIND_ATTR);
|
||||
const names = attrs(a);
|
||||
return names.length === attrs(b).length && names.every((name) => a.getAttribute(name) === b.getAttribute(name));
|
||||
}
|
||||
function splitOrigin(node) {
|
||||
let current = resolveUnwrappedParent(node);
|
||||
const visited = /* @__PURE__ */ new Set();
|
||||
while (current) {
|
||||
if (visited.has(current)) {
|
||||
return null;
|
||||
}
|
||||
visited.add(current);
|
||||
if (current.nodeType !== 1) {
|
||||
return current;
|
||||
}
|
||||
const el = current;
|
||||
const record = SPLITOFF_SOURCES.get(el);
|
||||
if (!record || el.getAttribute(PASS_ATTR) !== record.pass || el.getAttribute(KIND_ATTR) !== "splitoff") {
|
||||
return current;
|
||||
}
|
||||
current = resolveUnwrappedParent(record.source);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function recordUnwrappedParent(el, parent, ownedElements) {
|
||||
const pass = el.getAttribute(PASS_ATTR);
|
||||
if (ownedElements.has(el) && pass && knownProduct(pass, el.getAttribute(KIND_ATTR), el) && !el.parentNode && !el.hasChildNodes()) {
|
||||
@@ -995,20 +1034,32 @@ function revertPass(pass, root, ownedElements) {
|
||||
continue;
|
||||
}
|
||||
touched.add(parent);
|
||||
const record = SPLITOFF_SOURCES.get(so);
|
||||
if (!record || record.pass !== pass) {
|
||||
continue;
|
||||
}
|
||||
const source = resolveUnwrappedParent(record.source);
|
||||
const prev = so.previousSibling;
|
||||
if (prev && prev.nodeType === 1 && prev.nodeName === so.nodeName) {
|
||||
const origin = source ? splitOrigin(source) : null;
|
||||
if (origin && prev?.nodeType === 1 && splitOrigin(prev) === origin && prev.nodeName === so.nodeName && sameSplitAttributes(so, prev)) {
|
||||
while (so.firstChild) {
|
||||
prev.appendChild(so.firstChild);
|
||||
}
|
||||
parent.removeChild(so);
|
||||
recordUnwrappedParent(so, prev, ownedElements);
|
||||
} else {
|
||||
while (so.firstChild) {
|
||||
parent.insertBefore(so.firstChild, so);
|
||||
for (const [name, value] of [
|
||||
[PASS_ATTR, record.originalPass],
|
||||
[KIND_ATTR, record.originalKind]
|
||||
]) {
|
||||
if (value === null) {
|
||||
so.removeAttribute(name);
|
||||
} else {
|
||||
so.setAttribute(name, value);
|
||||
}
|
||||
parent.removeChild(so);
|
||||
recordUnwrappedParent(so, parent, ownedElements);
|
||||
}
|
||||
}
|
||||
SPLITOFF_SOURCES.delete(so);
|
||||
ownedElements.delete(so);
|
||||
}
|
||||
for (const p of touched) {
|
||||
@@ -1328,11 +1379,11 @@ var DEFAULT_POLICY = {
|
||||
"other": "quanjiao"
|
||||
};
|
||||
function effectiveLang(node, fallback) {
|
||||
let p = node.parentNode;
|
||||
let p = node.nodeType === 1 ? node : node.parentNode;
|
||||
while (p && p.nodeType === 1) {
|
||||
const el = p;
|
||||
const v = el.getAttribute("lang");
|
||||
if (v) {
|
||||
if (v !== null) {
|
||||
return v.toLowerCase();
|
||||
}
|
||||
p = p.parentNode;
|
||||
@@ -1345,13 +1396,19 @@ function classify(lang) {
|
||||
return "ja";
|
||||
}
|
||||
if (l === "zh" || l.indexOf("zh-") === 0) {
|
||||
if (/(?:^|-)hant(?:-|$)/.test(l)) {
|
||||
const subtags = l.split("-");
|
||||
let at = 1;
|
||||
for (let count = 0; count < 3 && /^[a-z]{3}$/.test(subtags[at] || ""); count++) {
|
||||
at++;
|
||||
}
|
||||
const script = /^[a-z]{4}$/.test(subtags[at] || "") ? subtags[at++] : void 0;
|
||||
if (script === "hant") {
|
||||
return "zh-Hant";
|
||||
}
|
||||
if (/(?:^|-)hans(?:-|$)/.test(l)) {
|
||||
if (script === "hans") {
|
||||
return "zh-Hans";
|
||||
}
|
||||
if (/-(tw|hk|mo)\b/.test(l)) {
|
||||
if (["tw", "hk", "mo"].includes(subtags[at] || "")) {
|
||||
return "zh-Hant";
|
||||
}
|
||||
if (l === "zh") {
|
||||
@@ -1726,9 +1783,10 @@ function splitTreeAfter(top, textNode, offset, pass, finder, ownedElements) {
|
||||
if (rightAtLevel) {
|
||||
clone = anc.cloneNode(false);
|
||||
clone.removeAttribute("id");
|
||||
ownedElements.add(clone);
|
||||
registerSplitoffSource(clone, anc, pass, ownedElements);
|
||||
clone.setAttribute(PASS_ATTR2, pass);
|
||||
clone.setAttribute(KIND_ATTR2, "splitoff");
|
||||
ownedElements.add(clone);
|
||||
let n = rightAtLevel;
|
||||
while (n) {
|
||||
const next = n.nextSibling;
|
||||
@@ -2213,7 +2271,7 @@ function uninstallLayout(root, owner, key) {
|
||||
owners.delete(owner);
|
||||
}
|
||||
}
|
||||
var JZ_WRAP_TAGS = ["JZ-JINZE", "JZ-CHAR", "JZ-INNER", "JZ-HWS"];
|
||||
var JZ_WRAP_TAGS = ["JZ-JINZE", "JZ-CHAR", "JZ-INNER", "JZ-HWS", "JZ-ORPHAN"];
|
||||
function buildWrapSet(options) {
|
||||
const s = new Set(JZ_WRAP_TAGS);
|
||||
for (const name of options.finder.styleInlines) {
|
||||
@@ -2221,6 +2279,13 @@ function buildWrapSet(options) {
|
||||
}
|
||||
return s;
|
||||
}
|
||||
function inlineFlow(el, wrapSet) {
|
||||
const display = el.ownerDocument.defaultView?.getComputedStyle(el).display;
|
||||
if (display) {
|
||||
return display === "contents" || display.startsWith("inline") || display.startsWith("ruby");
|
||||
}
|
||||
return wrapSet.has(el.nodeName);
|
||||
}
|
||||
function rectOf(n, side) {
|
||||
if (n.nodeType === 3 && n.data.length > 0) {
|
||||
const t = n;
|
||||
@@ -2250,6 +2315,9 @@ function edgeRect(start, side, from, wrapSet) {
|
||||
let parentRef = from.parentNode;
|
||||
for (; ; ) {
|
||||
while (n) {
|
||||
if (n.nodeType === 1 && n.nodeName === "BR" && n.ownerDocument.defaultView?.getComputedStyle(n).display !== "none") {
|
||||
return null;
|
||||
}
|
||||
const r = rectOf(n, side);
|
||||
if (r) {
|
||||
return r;
|
||||
@@ -2259,7 +2327,7 @@ function edgeRect(start, side, from, wrapSet) {
|
||||
if (!parentRef || parentRef.nodeType !== 1) {
|
||||
return null;
|
||||
}
|
||||
if (!wrapSet.has(parentRef.nodeName)) {
|
||||
if (!inlineFlow(parentRef, wrapSet)) {
|
||||
return null;
|
||||
}
|
||||
n = side === "prev" ? parentRef.previousSibling : parentRef.nextSibling;
|
||||
@@ -3375,7 +3443,7 @@ function normalizeOptions(opts = {}) {
|
||||
const scope = opts.scope || {};
|
||||
return {
|
||||
locale: {
|
||||
default: langOpt.default || "zh-Hant",
|
||||
default: langOpt.default === void 0 ? "zh-Hant" : langOpt.default,
|
||||
...langOpt.style ? { style: langOpt.style } : {},
|
||||
policy: langOpt.policy || {},
|
||||
stylePolicy: Array.isArray(langOpt.stylePolicy) ? langOpt.stylePolicy.slice() : []
|
||||
|
||||
Vendored
+4
-5
@@ -33,7 +33,7 @@ export interface BlockRule {
|
||||
export interface JuzhenOptions {
|
||||
/** 語言與全角/開明政策。 */
|
||||
lang?: {
|
||||
/** 無 lang 祖先時之預設語言標籤(預設 "zh-Hant")。 */
|
||||
/** 無 lang 屬性(自身及祖先)時之預設語言標籤(預設 "zh-Hant");空字串表示未知。 */
|
||||
default?: string;
|
||||
/** 全域標點擠壓預設:quanjiao(全角式)|kaiming(開明式)|
|
||||
* banjiao(半角式)。覆寫內建逐語言預設,可被 policy 逐 lang/stylePolicy 逐元素覆寫。 */
|
||||
@@ -48,11 +48,11 @@ export interface JuzhenOptions {
|
||||
/** 自定義字符分類(擴充內置規則集)。把未分類字符或新符號歸入中文/西文/
|
||||
* 全寬標點各子類,以覆寫 spacing/jiya 等規則之分類判定(追加語義)。 */
|
||||
charClass?: CharClassOverride;
|
||||
/** 功能分級標記(§6.5.0a)。段落級功能(禁則/行端/懸掛/垂懸字/長詞斷字)
|
||||
/** 功能分級標記(§6.5.0a)。段落級功能(禁則/行端/垂懸字/長詞斷字)
|
||||
* 僅作用於段落級元素;文本級功能(中西間隙/標點擠壓)任意文本皆適用。
|
||||
* - `text`:選擇器,命中之子樹**僅文本級**(標題/按鈕/UI 文字典型);亦可
|
||||
* 經元素屬性 `data-jz-level="text"`/`"paragraph"` 標記(屬性優先、最近祖先勝)。
|
||||
* 預設:塊級元素(blockTags)→ 段落級(全功能)。 */
|
||||
* 自身向上找最近有效訊號,皆無才預設 paragraph;不按 HTML 標籤或 display 推斷。 */
|
||||
level?: {
|
||||
text?: string;
|
||||
};
|
||||
@@ -73,8 +73,7 @@ export interface JuzhenOptions {
|
||||
jinze?: boolean;
|
||||
orphan?: boolean | OrphanOptions;
|
||||
/** @deprecated 標點懸掛**已移除**(§6.5.6):純負 margin 機制經實測為機制級死路(寬度
|
||||
* 守恆矛盾,CSS 接觸不到斷行器),原生 hanging-punctuation 為唯一正解但僅 Safari 支援、
|
||||
* 且與本庫 inline-block 原子互斥。此選項仍被接受以保 API 相容,但**無任何效果**。 */
|
||||
* 守恆矛盾,CSS 接觸不到斷行器),未來原生 hanging-punctuation 路徑須另行設計及驗證。此選項仍被接受以保 API 相容,但**無任何效果**。 */
|
||||
hanging?: boolean;
|
||||
/** @reserved 標點修正(簡/繁正規化)——架構已預留(切片 #7),**當前無任何效果**。
|
||||
* 注意:此為功能開關,與 `charClass.biaodian`(標點子類擴充表,語義完全不同)同名,勿混。 */
|
||||
|
||||
@@ -40,6 +40,15 @@ Unicode profile 另行記錄。更新 Unicode 範圍時,即使 JSON 結構不
|
||||
字符覆寫固定為追加語義:不得經 override 移除或重分類內置字符。若未來需要破壞此
|
||||
規則,屬 major 變更,須先作產品政策決策,不能由單一 adapter 私自實作。
|
||||
|
||||
## 1.0.1 語言契約澄清
|
||||
|
||||
`zh-explicit-script` 及 `zh-traditional-region` 指 [BCP 47 的 script/region 位置](https://www.rfc-editor.org/rfc/rfc5646.html#section-2.1),
|
||||
不能從 extension/private-use 內容搜尋同名字串。Web 曾把 `zh-Hans-x-hant` 誤判成
|
||||
繁體,現修正為簡體;空語言標籤歸 `other`。新增七個反例向量,既有 55 個向量結果、
|
||||
字符集合、預設政策、schema 與 adapter 義務均未改,故依上述 patch 規則升為 1.0.1。
|
||||
HTML 如何繼承 `lang` 是 Web adapter 契約,不寫入共享規則。未來 adapter 仍須通過
|
||||
全部 62 個向量;本次未驗收其他 adapter。
|
||||
|
||||
## Adapter 邊界
|
||||
|
||||
字符集合一律以大寫十六進位 Unicode code point 的閉區間/單點表示;陣列須按起點
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "./golden-vectors.schema.json",
|
||||
"schemaVersion": 1,
|
||||
"rulesetVersion": "1.0.0",
|
||||
"rulesetVersion": "1.0.1",
|
||||
"vectors": [
|
||||
{
|
||||
"id": "locale-ja",
|
||||
@@ -63,6 +63,48 @@
|
||||
"input": { "lang": "en" },
|
||||
"expected": { "langClass": "other" }
|
||||
},
|
||||
{
|
||||
"id": "locale-hans-private-hant",
|
||||
"category": "language-classification",
|
||||
"input": { "lang": "zh-Hans-x-hant" },
|
||||
"expected": { "langClass": "zh-Hans" }
|
||||
},
|
||||
{
|
||||
"id": "locale-tw-private-hans",
|
||||
"category": "language-classification",
|
||||
"input": { "lang": "zh-TW-x-hans" },
|
||||
"expected": { "langClass": "zh-Hant" }
|
||||
},
|
||||
{
|
||||
"id": "locale-latn-private-tw",
|
||||
"category": "language-classification",
|
||||
"input": { "lang": "zh-Latn-x-tw" },
|
||||
"expected": { "langClass": "zh-Hans" }
|
||||
},
|
||||
{
|
||||
"id": "locale-extension-hant",
|
||||
"category": "language-classification",
|
||||
"input": { "lang": "zh-CN-u-co-hant" },
|
||||
"expected": { "langClass": "zh-Hans" }
|
||||
},
|
||||
{
|
||||
"id": "locale-extlang-hans",
|
||||
"category": "language-classification",
|
||||
"input": { "lang": "zh-cmn-Hans-TW" },
|
||||
"expected": { "langClass": "zh-Hans" }
|
||||
},
|
||||
{
|
||||
"id": "locale-hant-cn",
|
||||
"category": "language-classification",
|
||||
"input": { "lang": "zh-Hant-CN" },
|
||||
"expected": { "langClass": "zh-Hant" }
|
||||
},
|
||||
{
|
||||
"id": "locale-empty",
|
||||
"category": "language-classification",
|
||||
"input": { "lang": "" },
|
||||
"expected": { "langClass": "other" }
|
||||
},
|
||||
{
|
||||
"id": "punct-stop",
|
||||
"category": "punctuation-classification",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "./juzhen-rules.schema.json",
|
||||
"schemaVersion": 1,
|
||||
"rulesetVersion": "1.0.0",
|
||||
"rulesetVersion": "1.0.1",
|
||||
"unicodeProfile": {
|
||||
"version": "17.0.0",
|
||||
"coverage": "CJK Unified Ideographs through Extension J, compatibility ideographs, kana, bopomofo, radicals, and enclosed CJK characters"
|
||||
|
||||
+74
-8
@@ -1,8 +1,7 @@
|
||||
// jz-* 元素工廠與還原(架構 §4.4/§5)。
|
||||
//
|
||||
// 所有注入結構皆帶 data-jz="<pass>" 標記(冪等 + 可定址還原,I6),並以
|
||||
// data-jz-kind 區分「marker(純注入,還原時移除)」與「wrap(包裝原內容,
|
||||
// 還原時解包)」。視覺尺寸一律在 CSS(I2);本模組只造結構。
|
||||
// data-jz-kind 區分 wrap 與 splitoff。視覺尺寸一律在 CSS(I2);本模組只造結構。
|
||||
|
||||
/** jz-* 自訂元素名(皆含連字號,合法 custom element,預設 inline)。 */
|
||||
export type JzTag =
|
||||
@@ -18,7 +17,7 @@ export type JzTag =
|
||||
| "jz-rt";
|
||||
|
||||
// createJz 目前僅有 wrap(包裝原內容,還原時解包)生產者。
|
||||
// data-jz-kind 另有第三種值 "splitoff"(克隆切分之邊界詞元),由 core/split.ts 直接以
|
||||
// data-jz-kind 另有 "splitoff"(克隆切分之邊界詞元),由 core/split.ts 直接以
|
||||
// setAttribute 設定、不經 createJz;revertPass 於 wrap 全解包後之第二階段併回(見下)。
|
||||
type JzKind = "wrap";
|
||||
|
||||
@@ -61,6 +60,61 @@ const OWNER_ATTRIBUTES = new WeakMap<object, Map<string, Set<Element>>>();
|
||||
// 僅本模組已驗證所有權的解包/合併可留下來源父重定向;作者外改不登記。
|
||||
const UNWRAPPED_PARENTS = new WeakMap<Node, Node>();
|
||||
|
||||
interface SplitoffSource
|
||||
{
|
||||
source: Element;
|
||||
pass: string;
|
||||
originalPass: string | null;
|
||||
originalKind: string | null;
|
||||
}
|
||||
|
||||
const SPLITOFF_SOURCES = new WeakMap<Element, SplitoffSource>();
|
||||
|
||||
/** 切分時記錄真實來源身份;須於 clone 的排版標記覆寫前呼叫。 */
|
||||
export function registerSplitoffSource(
|
||||
clone: Element, source: Element, pass: string, ownedElements: WeakSet<Element>,
|
||||
): void
|
||||
{
|
||||
if (!ownedElements.has(clone) || !SPLITOFF_TAGS.has(clone.nodeName)
|
||||
|| clone.nodeName !== source.nodeName) { return; }
|
||||
const earlier = SPLITOFF_SOURCES.get(source);
|
||||
const inherited = earlier && source.getAttribute(PASS_ATTR) === earlier.pass
|
||||
&& source.getAttribute(KIND_ATTR) === "splitoff" ? earlier : null;
|
||||
SPLITOFF_SOURCES.set(clone, {
|
||||
source, pass,
|
||||
originalPass: inherited ? inherited.originalPass : clone.getAttribute(PASS_ATTR),
|
||||
originalKind: inherited ? inherited.originalKind : clone.getAttribute(KIND_ATTR),
|
||||
});
|
||||
}
|
||||
|
||||
function sameSplitAttributes(a: Element, b: Element): boolean
|
||||
{
|
||||
const attrs = (el: Element): string[] => el.getAttributeNames()
|
||||
.filter((name) => name !== PASS_ATTR && name !== KIND_ATTR);
|
||||
const names = attrs(a);
|
||||
return names.length === attrs(b).length
|
||||
&& names.every((name) => a.getAttribute(name) === b.getAttribute(name));
|
||||
}
|
||||
|
||||
/** 多實例可再次切分同一來源;只沿仍有效的私有切分/合併鏈找共同原元素。 */
|
||||
function splitOrigin(node: Node): Node | null
|
||||
{
|
||||
let current = resolveUnwrappedParent(node);
|
||||
const visited = new Set<Node>();
|
||||
while (current)
|
||||
{
|
||||
if (visited.has(current)) { return null; }
|
||||
visited.add(current);
|
||||
if (current.nodeType !== 1) { return current; }
|
||||
const el = current as Element;
|
||||
const record = SPLITOFF_SOURCES.get(el);
|
||||
if (!record || el.getAttribute(PASS_ATTR) !== record.pass
|
||||
|| el.getAttribute(KIND_ATTR) !== "splitoff") { return current; }
|
||||
current = resolveUnwrappedParent(record.source);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 完成受控解包後登記;只接受真實、已移除且空的受管產物。 */
|
||||
export function recordUnwrappedParent(el: Element, parent: Node, ownedElements: WeakSet<Element>): void
|
||||
{
|
||||
@@ -276,8 +330,14 @@ export function revertPass(
|
||||
const parent = so.parentNode;
|
||||
if (!parent) { continue; }
|
||||
touched.add(parent);
|
||||
const record = SPLITOFF_SOURCES.get(so);
|
||||
if (!record || record.pass !== pass) { continue; }
|
||||
const source = resolveUnwrappedParent(record.source);
|
||||
const prev = so.previousSibling;
|
||||
if (prev && prev.nodeType === 1 && (prev as Element).nodeName === so.nodeName)
|
||||
const origin = source ? splitOrigin(source) : null;
|
||||
if (origin && prev?.nodeType === 1 && splitOrigin(prev) === origin
|
||||
&& (prev as Element).nodeName === so.nodeName
|
||||
&& sameSplitAttributes(so, prev as Element))
|
||||
{
|
||||
// 併入前兄弟(同標籤原元素)尾部。
|
||||
while (so.firstChild) { prev.appendChild(so.firstChild); }
|
||||
@@ -286,11 +346,17 @@ export function revertPass(
|
||||
}
|
||||
else
|
||||
{
|
||||
// 前兄弟非預期同標籤元素(罕見,結構曾被外力改動):原地解包,仍移除標記。
|
||||
while (so.firstChild) { parent.insertBefore(so.firstChild, so); }
|
||||
parent.removeChild(so);
|
||||
recordUnwrappedParent(so, parent, ownedElements);
|
||||
// 外部插入/替換/改樣式後,不併入任意同標籤元素,也不丟失片段當前樣式。
|
||||
// 保留原地元素及內容,僅撤回本次切分標記;不能跨新內容重排或復活舊來源。
|
||||
for (const [ name, value ] of [
|
||||
[ PASS_ATTR, record.originalPass ], [ KIND_ATTR, record.originalKind ],
|
||||
] as const)
|
||||
{
|
||||
if (value === null) { so.removeAttribute(name); }
|
||||
else { so.setAttribute(name, value); }
|
||||
}
|
||||
}
|
||||
SPLITOFF_SOURCES.delete(so);
|
||||
ownedElements.delete(so);
|
||||
}
|
||||
for (const p of touched)
|
||||
|
||||
+3
-3
@@ -111,7 +111,7 @@ export class Finder
|
||||
private readonly categoryCache: WeakMap<Element, InlineCategory>;
|
||||
// 祖先游走記憶化(S3 延伸/S4):ancestorOrSelfPill/…Avoided 之逐元素結果,使 insidePill
|
||||
// /isAvoided 之「逐文本節點沿祖先鏈」由 O(文本節點×depth) 收斂為 O(元素數)——兄弟節點共用
|
||||
// 祖先鏈快取。同 categoryCache:Finder 隨 render 重建 → 僅存活單次 render、無跨 render 失效。
|
||||
// 祖先鏈快取。Finder 隨實例持久化;render/revert 與 observer 更新須失效受影響子樹。
|
||||
private readonly pillAncestorCache: WeakMap<Element, boolean>;
|
||||
private readonly avoidAncestorCache: WeakMap<Element, boolean>;
|
||||
|
||||
@@ -212,8 +212,8 @@ export class Finder
|
||||
* collectRuns/jiyaAdjacency/jinze/orphan/split 各 pass 逐元素重算——以 categoryCache
|
||||
* 記憶化砍除跨 pass 重複。安全性:單次 render 內一元素之分類**恆定**(class/skip 屬性/
|
||||
* nodeName 皆不因 render 變更;jz-* 包裹為新建元素、按 nodeName 分類亦恆定),且 Finder
|
||||
* 隨 makeCtx 每次 render 重建 → 快取僅存活單次 render,無跨 render 失效問題(持久化屬 C1,
|
||||
* 另議)。
|
||||
* 隨實例持久化,跨 render/revert 復用;屬性或位置變化由 invalidateSubtree 清除受影響
|
||||
* 子樹,不能沿用舊祖先鏈結果。
|
||||
*/
|
||||
category(el: Element): InlineCategory
|
||||
{
|
||||
|
||||
+14
-9
@@ -1,7 +1,7 @@
|
||||
// 語言與全角/開明政策(架構 §4.3)。
|
||||
//
|
||||
// 解析節點之有效 lang,歸類,並依政策決定標點寬度(全角式/開明式/半角式)。
|
||||
// CSS 端以 :lang() 落實同一政策(I2/I3);本模組供 JS pass 在需要時查詢。
|
||||
// JS 判定政策後寫入半形 class,CSS 按 class 渲染(I2/I3)。
|
||||
|
||||
/** 標點寬度政策。 */
|
||||
export type PunctStyle = "quanjiao" | "kaiming" | "banjiao";
|
||||
@@ -38,16 +38,16 @@ const DEFAULT_POLICY: Record<LangClass, PunctStyle> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* 由節點向上找最近帶 lang 屬性之祖先,回傳小寫值;無則回傳 fallback。
|
||||
* 由節點(含元素自身)向上找最近 lang,回傳小寫值;空值表示語言未知,僅缺失時回退。
|
||||
*/
|
||||
export function effectiveLang(node: Node, fallback: string): string
|
||||
{
|
||||
let p: Node | null = node.parentNode;
|
||||
let p: Node | null = node.nodeType === 1 ? node : node.parentNode;
|
||||
while (p && p.nodeType === 1)
|
||||
{
|
||||
const el = p as Element;
|
||||
const v = el.getAttribute("lang");
|
||||
if (v) { return v.toLowerCase(); }
|
||||
if (v !== null) { return v.toLowerCase(); }
|
||||
p = p.parentNode;
|
||||
}
|
||||
return (fallback || "").toLowerCase();
|
||||
@@ -55,7 +55,8 @@ export function effectiveLang(node: Node, fallback: string): string
|
||||
|
||||
/**
|
||||
* 將 BCP-47 語言標籤歸類。前綴以子標籤邊界比對('zh-tw' 命中 zh-Hant,
|
||||
* 'zhx' 不命中 zh)。香港/台灣繁體歸 zh-Hant;含 Hant/Hans 子標籤者優先。
|
||||
* 'zhx' 不命中 zh)。只讀 BCP-47 的 script/region 位置,不把 extension/private-use
|
||||
* 內容當成腳本或地區;本函數不是完整標籤驗證器。
|
||||
*/
|
||||
export function classify(lang: string): LangClass
|
||||
{
|
||||
@@ -63,11 +64,15 @@ export function classify(lang: string): LangClass
|
||||
if (l === "ja" || l.indexOf("ja-") === 0) { return "ja"; }
|
||||
if (l === "zh" || l.indexOf("zh-") === 0)
|
||||
{
|
||||
// 顯式子標籤優先。
|
||||
if (/(?:^|-)hant(?:-|$)/.test(l)) { return "zh-Hant"; }
|
||||
if (/(?:^|-)hans(?:-|$)/.test(l)) { return "zh-Hans"; }
|
||||
const subtags = l.split("-");
|
||||
let at = 1;
|
||||
// language 後可有至多三個三字母 extlang,然後才是 script/region。
|
||||
for (let count = 0; count < 3 && /^[a-z]{3}$/.test(subtags[at] || ""); count++) { at++; }
|
||||
const script = /^[a-z]{4}$/.test(subtags[at] || "") ? subtags[at++] : undefined;
|
||||
if (script === "hant") { return "zh-Hant"; }
|
||||
if (script === "hans") { return "zh-Hans"; }
|
||||
// 地區推斷:台灣/香港/澳門 → 繁體;其餘(含 cn/sg)→ 簡體。
|
||||
if (/-(tw|hk|mo)\b/.test(l)) { return "zh-Hant"; }
|
||||
if ([ "tw", "hk", "mo" ].includes(subtags[at] || "")) { return "zh-Hant"; }
|
||||
if (l === "zh") { return "zh-Hant"; }
|
||||
return "zh-Hans";
|
||||
}
|
||||
|
||||
+3
-1
@@ -24,6 +24,7 @@
|
||||
// <button>)會令中間 opaque 祖先被逐層克隆肢解——下游 A7 之 .selection-bar-message 病例。
|
||||
|
||||
import type { Finder } from "./finder.js";
|
||||
import { registerSplitoffSource } from "./dom.js";
|
||||
import {
|
||||
previousVariationSequenceStart, variationSequenceAtUtf16,
|
||||
} from "./unicode.js";
|
||||
@@ -180,9 +181,10 @@ function splitTreeAfter(
|
||||
{
|
||||
clone = anc.cloneNode(false) as Element;
|
||||
clone.removeAttribute("id");
|
||||
ownedElements.add(clone);
|
||||
registerSplitoffSource(clone, anc, pass, ownedElements);
|
||||
clone.setAttribute(PASS_ATTR, pass);
|
||||
clone.setAttribute(KIND_ATTR, "splitoff");
|
||||
ownedElements.add(clone);
|
||||
let n: Node | null = rightAtLevel;
|
||||
while (n)
|
||||
{
|
||||
|
||||
+4
-4
@@ -163,7 +163,7 @@ const BD_ORDER: ReadonlyArray<BdSubclass> = [
|
||||
];
|
||||
|
||||
/**
|
||||
* 字符分類規則集(§4.2):把「中文/西文/全寬標點各子類」各表為 regex 字元類片段。
|
||||
* 字符分類規則集(§4.2):中文/西文表為 regex 字元類片段,標點各子類為逐碼點字串(不解析範圍)。
|
||||
* 各 pass(spacing 之 CJK/ANS 正則、jiya 之 classifyBiaodian、jinze 之 anyCjk)皆由此
|
||||
* 取分類,不再各自硬編碼。內置默認見 DEFAULT_RULESET;消費端經 options.charClass 擴充
|
||||
* (resolveRuleset)。
|
||||
@@ -183,11 +183,11 @@ export interface Ruleset
|
||||
/** 消費端字符分類覆寫(追加語義;見 README「自定義字符分類」)。 */
|
||||
export interface CharClassOverride
|
||||
{
|
||||
/** 追加為中文(CJK)。 */
|
||||
/** 追加為中文(CJK):regex 字元類片段,特殊字元須轉義。 */
|
||||
cjk?: string;
|
||||
/** 追加為西文(半寬標點亦歸此,按西文處理間隙);併入 ansAfter/ansBefore。 */
|
||||
/** 追加為西文:regex 字元類片段,特殊字元須轉義;併入 ansAfter/ansBefore。 */
|
||||
western?: string;
|
||||
/** 追加為全寬標點,按子類。 */
|
||||
/** 追加為全寬標點,按子類列出逐碼點字串;例如 "①②③",不解析 "①-③" 為範圍。 */
|
||||
biaodian?: Partial<Record<BdSubclass, string>>;
|
||||
}
|
||||
|
||||
|
||||
@@ -36,8 +36,7 @@ jz-em
|
||||
寬膨脹為 縮排量+字形,標點附近冒出巨大間距(下游回報)。歸零後實測:段落首行
|
||||
縮排保持,原子寬回到 0.5em/1em。
|
||||
· hanging-punctuation:同屬區塊容器首/末行機制,每個原子各自懸掛自身標點。
|
||||
Chrome 未實作(測不出),Safari 會觸發;且與本庫規劃之「標點懸掛」相衝,故一併
|
||||
歸零(不支援處為 no-op,支援處既擋原子重套、亦不礙段落級懸掛)。
|
||||
歸零是為阻止原子內重套;本庫目前不提供懸掛功能,不能由此推定段落懸掛的相容性。
|
||||
不歸零者(經 headless Chrome 實測不洩漏,或本應逐字放行):letter-spacing/
|
||||
word-spacing(原子邊界處理一致、無翻倍);text-align/text-align-last/
|
||||
text-justify(halt 為 shrink-to-fit、margin 為內容溢出,二者皆無對齊餘量,
|
||||
|
||||
+1
-1
@@ -102,7 +102,7 @@ export function normalizeOptions(opts: JuzhenOptions = {}): ResolvedOptions
|
||||
const scope = opts.scope || {};
|
||||
return {
|
||||
locale: {
|
||||
default: langOpt.default || "zh-Hant",
|
||||
default: langOpt.default === undefined ? "zh-Hant" : langOpt.default,
|
||||
...(langOpt.style ? { style: langOpt.style } : {}),
|
||||
policy: langOpt.policy || {},
|
||||
stylePolicy: Array.isArray(langOpt.stylePolicy) ? langOpt.stylePolicy.slice() : [],
|
||||
|
||||
+4
-5
@@ -46,7 +46,7 @@ export interface JuzhenOptions
|
||||
{
|
||||
/** 語言與全角/開明政策。 */
|
||||
lang?: {
|
||||
/** 無 lang 祖先時之預設語言標籤(預設 "zh-Hant")。 */
|
||||
/** 無 lang 屬性(自身及祖先)時之預設語言標籤(預設 "zh-Hant");空字串表示未知。 */
|
||||
default?: string;
|
||||
/** 全域標點擠壓預設:quanjiao(全角式)|kaiming(開明式)|
|
||||
* banjiao(半角式)。覆寫內建逐語言預設,可被 policy 逐 lang/stylePolicy 逐元素覆寫。 */
|
||||
@@ -63,11 +63,11 @@ export interface JuzhenOptions
|
||||
* 全寬標點各子類,以覆寫 spacing/jiya 等規則之分類判定(追加語義)。 */
|
||||
charClass?: CharClassOverride;
|
||||
|
||||
/** 功能分級標記(§6.5.0a)。段落級功能(禁則/行端/懸掛/垂懸字/長詞斷字)
|
||||
/** 功能分級標記(§6.5.0a)。段落級功能(禁則/行端/垂懸字/長詞斷字)
|
||||
* 僅作用於段落級元素;文本級功能(中西間隙/標點擠壓)任意文本皆適用。
|
||||
* - `text`:選擇器,命中之子樹**僅文本級**(標題/按鈕/UI 文字典型);亦可
|
||||
* 經元素屬性 `data-jz-level="text"`/`"paragraph"` 標記(屬性優先、最近祖先勝)。
|
||||
* 預設:塊級元素(blockTags)→ 段落級(全功能)。 */
|
||||
* 自身向上找最近有效訊號,皆無才預設 paragraph;不按 HTML 標籤或 display 推斷。 */
|
||||
level?: {
|
||||
text?: string;
|
||||
};
|
||||
@@ -92,8 +92,7 @@ export interface JuzhenOptions
|
||||
jinze?: boolean;
|
||||
orphan?: boolean | OrphanOptions;
|
||||
/** @deprecated 標點懸掛**已移除**(§6.5.6):純負 margin 機制經實測為機制級死路(寬度
|
||||
* 守恆矛盾,CSS 接觸不到斷行器),原生 hanging-punctuation 為唯一正解但僅 Safari 支援、
|
||||
* 且與本庫 inline-block 原子互斥。此選項仍被接受以保 API 相容,但**無任何效果**。 */
|
||||
* 守恆矛盾,CSS 接觸不到斷行器),未來原生 hanging-punctuation 路徑須另行設計及驗證。此選項仍被接受以保 API 相容,但**無任何效果**。 */
|
||||
hanging?: boolean;
|
||||
/** @reserved 標點修正(簡/繁正規化)——架構已預留(切片 #7),**當前無任何效果**。
|
||||
* 注意:此為功能開關,與 `charClass.biaodian`(標點子類擴充表,語義完全不同)同名,勿混。 */
|
||||
|
||||
+26
-18
@@ -5,8 +5,8 @@
|
||||
//
|
||||
// 註:原計劃之**標點懸掛**(行端標點負 margin 凸出版心)經實測為機制級死路(寬度守恆
|
||||
// 矛盾:行端盒非零寬則墨色出去多少版心內就空多少、零寬則釋放一字寬致下一字拉上重疊;
|
||||
// 純 CSS 接觸不到斷行器),已於 §6.5.6 移除。唯一正解為原生 hanging-punctuation(僅
|
||||
// Safari 支援、且與本庫 inline-block 原子互斥),屬遠期「原生管線」新功能、與此處無共享。
|
||||
// 純 CSS 接觸不到斷行器),已於 §6.5.6 移除。未來可評估原生 hanging-punctuation
|
||||
// (引擎能力及與本庫原子之互動須另行驗證),屬遠期「原生管線」新功能、與此處無共享。
|
||||
//
|
||||
// ⚠ 純版面、無法於 jsdom 驗證(getClientRects 回傳 0);故無 ResizeObserver
|
||||
// 之環境(jsdom)直接跳過,僅於真實瀏覽器生效(架構 §12/R1/R2)。須跨
|
||||
@@ -83,13 +83,9 @@ function uninstallLayout(root: Element, owner: object, key: string): void
|
||||
if (m.size === 0) { owners!.delete(owner); }
|
||||
}
|
||||
|
||||
// 行內包裹集:edgeRect 攀爬時須跨越「不中斷行內流」之元素找相鄰內容(jz-char 可能被
|
||||
// jz-jinze 包住,其流鄰居在群組外)。遇 block 邊界即止(回 null,視為行端)。
|
||||
// **由 options.finder.styleInlines 派生**+聚珍自身之 jz 包裹標籤——舊版硬編碼僅含
|
||||
// SPAN/STRONG/EM/A/B/I/U/MARK 子集,遇未列之樣式行內(S/INS/DEL/SUB/SUP/SMALL/
|
||||
// ABBR/CITE/DFN/Q/VAR/SAMP/TIME/BDO/BDI/RUBY 等)會提前止步、誤判行端而錯加行端
|
||||
// 半形(透明性不一致之同根隱患 T4)。
|
||||
const JZ_WRAP_TAGS = [ "JZ-JINZE", "JZ-CHAR", "JZ-INNER", "JZ-HWS" ];
|
||||
// 無計算樣式時之已知行內標籤後備。真實版面以 computed display 判斷物理行內流,
|
||||
// 可跨 jz-jinze/jz-orphan、自訂 isolate/opaque,於 block 或可見 BR 停止。
|
||||
const JZ_WRAP_TAGS = [ "JZ-JINZE", "JZ-CHAR", "JZ-INNER", "JZ-HWS", "JZ-ORPHAN" ];
|
||||
|
||||
function buildWrapSet(options: ResolvedOptions): Set<string>
|
||||
{
|
||||
@@ -98,6 +94,18 @@ function buildWrapSet(options: ResolvedOptions): Set<string>
|
||||
return s;
|
||||
}
|
||||
|
||||
/** 邏輯 isolate/opaque 不代表物理斷行;實際行內盒與 display:contents 可攀出。 */
|
||||
function inlineFlow(el: Element, wrapSet: Set<string>): boolean
|
||||
{
|
||||
const display = el.ownerDocument.defaultView?.getComputedStyle(el).display;
|
||||
if (display)
|
||||
{
|
||||
return display === "contents" || display.startsWith("inline") || display.startsWith("ruby");
|
||||
}
|
||||
// 無計算樣式的 DOM 測試仍可按已知行內標籤建立幾何模型。
|
||||
return wrapSet.has(el.nodeName);
|
||||
}
|
||||
|
||||
function rectOf(n: Node, side: "prev" | "next"): DOMRect | null
|
||||
{
|
||||
if (n.nodeType === 3 && (n as Text).data.length > 0)
|
||||
@@ -121,14 +129,8 @@ function rectOf(n: Node, side: "prev" | "next"): DOMRect | null
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 取某節點側邊界字元之矩形(橫排:判定相鄰內容是否同行);跨我們的行內包裹
|
||||
* 向上攀爬,遇 block 邊界回 null(視為行端)。 */
|
||||
// 註:edgeRect 為**版面**測量(getClientRects 之物理相鄰),非邏輯文本流。故攀爬邊界
|
||||
// 僅依 wrapSet(行內包裹之標籤集),**刻意不**排除 isolate(<mark>)——isolate 斷的是
|
||||
// 邏輯 run/相鄰,其內部字元之物理行首行尾判定仍須跨 mark 找真實相鄰內容(如
|
||||
// 「文字<mark>「引」</mark>」之 「 物理上接在「文字」之後、非行首,須攀出 mark 取「文字」
|
||||
// 之 rect,否則誤判行首而錯收左半)。pill 之內部無 jz-char(eachTextNode 已濾),且 pill
|
||||
// 標籤(code/kbd)不在 styleInlines、自然不在 wrapSet,攀爬遇之即止,無需另判。
|
||||
/** 取側邊界內容之矩形;跨物理行內盒攀爬,於 block/可見 BR 視為行端。
|
||||
* 邏輯 isolate/opaque 不等於物理換行,pill 內部不由本庫生成標點原子。 */
|
||||
function edgeRect(
|
||||
start: Node | null,
|
||||
side: "prev" | "next",
|
||||
@@ -142,13 +144,19 @@ function edgeRect(
|
||||
{
|
||||
while (n)
|
||||
{
|
||||
// BR 的矩形可能仍屬前一行,不能當成同一行的後續內容。隱藏 BR 不斷行。
|
||||
if (n.nodeType === 1 && (n as Element).nodeName === "BR"
|
||||
&& (n as Element).ownerDocument.defaultView?.getComputedStyle(n as Element).display !== "none")
|
||||
{
|
||||
return null;
|
||||
}
|
||||
const r = rectOf(n, side);
|
||||
if (r) { return r; }
|
||||
n = side === "prev" ? n.previousSibling : n.nextSibling;
|
||||
}
|
||||
// 本層無內容:若父為行內包裹,攀出續找其鄰居。
|
||||
if (!parentRef || parentRef.nodeType !== 1) { return null; }
|
||||
if (!wrapSet.has((parentRef as Element).nodeName)) { return null; }
|
||||
if (!inlineFlow(parentRef as Element, wrapSet)) { return null; }
|
||||
n = side === "prev" ? parentRef.previousSibling : parentRef.nextSibling;
|
||||
parentRef = parentRef.parentNode;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
function openDemo(name)
|
||||
{
|
||||
const html = readFileSync(new URL("../demo/" + name, import.meta.url), "utf8");
|
||||
const dom = new JSDOM(html, { runScripts: "outside-only" });
|
||||
const { window } = dom;
|
||||
const observers = [];
|
||||
window.ResizeObserver = class
|
||||
{
|
||||
constructor(callback) { this.callback = callback; this.disconnected = false; observers.push(this); }
|
||||
observe(root) { this.root = root; }
|
||||
disconnect() { this.disconnected = true; }
|
||||
};
|
||||
// 此處驗證事件刷新與觀察目標;矩形模型不作真實 layout 驗收。
|
||||
const rect = { top: 0, bottom: 20, left: 0, right: 20, width: 20, height: 20 };
|
||||
window.Element.prototype.getClientRects = () => [ rect ];
|
||||
window.Range.prototype.getClientRects = () => [ rect ];
|
||||
window.eval(readFileSync(new URL("../dist/juzhen.iife.js", import.meta.url), "utf8"));
|
||||
for (const script of window.document.querySelectorAll("script:not([src])"))
|
||||
{
|
||||
window.eval(script.textContent);
|
||||
}
|
||||
return { dom, window, document: window.document, observers };
|
||||
}
|
||||
|
||||
function change(window, id, checked)
|
||||
{
|
||||
const input = window.document.getElementById(id);
|
||||
input.checked = checked;
|
||||
input.dispatchEvent(new window.Event("change"));
|
||||
}
|
||||
|
||||
test("index demo:對齊與寬度明確重排,revert 後開關不重新啟用", () =>
|
||||
{
|
||||
const { dom, window, document, observers } = openDemo("index.html");
|
||||
try
|
||||
{
|
||||
for (const [ id, checked ] of [ [ "justify", true ], [ "wide", true ] ])
|
||||
{
|
||||
const oldGap = document.querySelector("jz-hws");
|
||||
const oldObservers = observers.filter((observer) => !observer.disconnected);
|
||||
assert.ok(oldGap);
|
||||
change(window, id, checked);
|
||||
assert.equal(oldGap.isConnected, false, id + " 撤回舊產物");
|
||||
assert.ok(document.querySelector("jz-hws"), id + " 重新建立產物");
|
||||
assert.ok(oldObservers.every((observer) => observer.disconnected), id + " 更換 layout 安裝");
|
||||
}
|
||||
assert.ok(document.body.classList.contains("justify"));
|
||||
assert.equal(document.querySelector(".narrow").style.maxWidth, "none");
|
||||
document.getElementById("revert").click();
|
||||
const count = observers.length;
|
||||
assert.equal(document.querySelector("[data-jz]"), null);
|
||||
change(window, "justify", false);
|
||||
change(window, "wide", false);
|
||||
change(window, "force-kaiming", true);
|
||||
assert.equal(document.querySelector("[data-jz]"), null, "revert 狀態仍保持");
|
||||
assert.equal(observers.length, count, "停用時不新增 layout 安裝");
|
||||
document.getElementById("render").click();
|
||||
assert.ok(document.querySelector("jz-hws"), "手動 render 可重新啟用");
|
||||
assert.ok(document.getElementById("status").textContent.includes("开明式"));
|
||||
}
|
||||
finally { dom.window.close(); }
|
||||
});
|
||||
|
||||
test("resize demo:各拖曳框各自被觀察,切換對齊刷新所有框", () =>
|
||||
{
|
||||
const { dom, window, document, observers } = openDemo("resize-check.html");
|
||||
try
|
||||
{
|
||||
const boxes = Array.from(document.querySelectorAll(".box.juzhen"));
|
||||
const targets = new Set(observers.map((observer) => observer.root));
|
||||
assert.equal(targets.size, boxes.length);
|
||||
assert.ok(boxes.every((box) => targets.has(box)));
|
||||
assert.equal(targets.has(document.body), false);
|
||||
const oldGaps = boxes.flatMap((box) => Array.from(box.querySelectorAll("jz-hws")));
|
||||
const oldObservers = observers.slice();
|
||||
assert.ok(oldGaps.length > 0);
|
||||
change(window, "t-justify", true);
|
||||
assert.ok(oldGaps.every((gap) => !gap.isConnected), "所有框撤回舊間隙");
|
||||
assert.ok(oldObservers.every((observer) => observer.disconnected));
|
||||
const activeTargets = new Set(observers.filter((observer) => !observer.disconnected)
|
||||
.map((observer) => observer.root));
|
||||
assert.equal(activeTargets.size, boxes.length);
|
||||
assert.ok(boxes.every((box) => activeTargets.has(box)));
|
||||
assert.ok(document.querySelector("jz-hws"), "新產物仍存在");
|
||||
}
|
||||
finally { dom.window.close(); }
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const python = "python3";
|
||||
const available = spawnSync(python, [ "-c", "import fontTools" ]).status === 0;
|
||||
const tool = fileURLToPath(new URL("../tools/measure-bias.py", import.meta.url));
|
||||
const fixture = `
|
||||
from fontTools.fontBuilder import FontBuilder
|
||||
from fontTools.pens.ttGlyphPen import TTGlyphPen
|
||||
from fontTools.feaLib.builder import addOpenTypeFeaturesFromString
|
||||
import sys
|
||||
fb = FontBuilder(1000, isTTF=True)
|
||||
names = ['.notdef', 'comma', 'commaHant', 'fullstop']
|
||||
fb.setupGlyphOrder(names)
|
||||
fb.setupCharacterMap({0xff0c: 'comma', 0xff0e: 'fullstop'})
|
||||
glyphs = {}
|
||||
for name, left, right in [('.notdef', 0, 0), ('comma', 50, 250),
|
||||
('commaHant', 450, 550), ('fullstop', 650, 850)]:
|
||||
p = TTGlyphPen(None)
|
||||
if right > left:
|
||||
p.moveTo((left, 0)); p.lineTo((right, 0)); p.lineTo((right, 150))
|
||||
p.lineTo((left, 150)); p.closePath()
|
||||
glyphs[name] = p.glyph()
|
||||
fb.setupGlyf(glyphs)
|
||||
fb.setupHorizontalMetrics({'.notdef': (1000, 0), 'comma': (1000, 50),
|
||||
'commaHant': (1000, 450), 'fullstop': (1000, 650)})
|
||||
fb.setupHorizontalHeader(ascent=800, descent=-200)
|
||||
fb.setupNameTable({'familyName': 'BiasFixture', 'styleName': 'Regular',
|
||||
'uniqueFontIdentifier': 'BiasFixture', 'fullName': 'BiasFixture',
|
||||
'psName': 'BiasFixture'})
|
||||
fb.setupOS2(sTypoAscender=800, sTypoDescender=-200, usWinAscent=800, usWinDescent=200)
|
||||
fb.setupPost(); fb.setupMaxp()
|
||||
features = 'languagesystem hani ZHT; feature locl { script hani; language ZHT; sub comma by commaHant; } locl;'
|
||||
if sys.argv[2] == 'halt':
|
||||
features += ' feature halt { sub fullstop by comma; } halt;'
|
||||
addOpenTypeFeaturesFromString(fb.font, features)
|
||||
fb.save(sys.argv[1])
|
||||
`;
|
||||
|
||||
function run(args)
|
||||
{
|
||||
const result = spawnSync(python, args, { encoding: "utf8" });
|
||||
assert.equal(result.status, 0, result.stderr || String(result.error));
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
function withFont(feature, check)
|
||||
{
|
||||
const dir = mkdtempSync(join(tmpdir(), "juzhen-bias-test-"));
|
||||
try
|
||||
{
|
||||
const font = join(dir, "fixture.ttf");
|
||||
run([ "-c", fixture, font, feature ]);
|
||||
check(font);
|
||||
}
|
||||
finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
function table(output)
|
||||
{
|
||||
return JSON.parse(output.slice(output.indexOf("\n{") + 1));
|
||||
}
|
||||
|
||||
test("measure-bias:全角句點入表,lang 僅選鍵且輸出說明 cmap/locl 限制",
|
||||
{ skip: available ? false : "需工具本身的 Python fonttools 依賴" }, () =>
|
||||
{
|
||||
withFont("none", (font) =>
|
||||
{
|
||||
for (const lang of [ "zh-Hans", "zh-Hant" ])
|
||||
{
|
||||
const output = run([ tool, font, lang ]);
|
||||
assert.equal(table(output)[lang]["."], "right");
|
||||
assert.equal(table(output)[lang][","], "left", "仍量預設 cmap,未套 ZHT locl 替代字形");
|
||||
assert.match(output, /未做 shaping.*未應用 locl/);
|
||||
assert.match(output, /語言參數僅選 JSON 分類鍵/);
|
||||
assert.match(output, /瀏覽器實際字形逐字微調/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("measure-bias:halt feature 存在只報存在,不承諾逐字覆蓋",
|
||||
{ skip: available ? false : "需工具本身的 Python fonttools 依賴" }, () =>
|
||||
{
|
||||
withFont("halt", (font) =>
|
||||
{
|
||||
const output = run([ tool, font ]);
|
||||
assert.match(output, /halt:有.*不保證涵蓋所有標點/);
|
||||
assert.equal(table(output)["zh-Hans"]["."], "right", "feature 偵測不改預設輪廓分類");
|
||||
});
|
||||
});
|
||||
@@ -447,7 +447,15 @@ test("observer provenance: external writes on owned products are not consumed",
|
||||
splitoff.classList.add("consumer-splitoff");
|
||||
await tick();
|
||||
assert.equal(counting.instances[0].callbackCount, 3, "owned splitoff 外部 class 觸發");
|
||||
assert.equal(splitoff.isConnected, false);
|
||||
// 外部樣式使片段無法安全併回;rerender 須保留作者元素與字序,不能丟掉 class。
|
||||
assert.equal(splitoff.isConnected, true);
|
||||
assert.ok(splitoff.classList.contains("consumer-splitoff"));
|
||||
assert.equal(splitoff.hasAttribute("data-jz-kind"), false, "舊 splitoff 憑證已撤銷");
|
||||
assert.equal(root.textContent, "前文粗体文字,后续");
|
||||
jz.revert(root);
|
||||
assert.equal(splitoff.isConnected, true);
|
||||
assert.ok(splitoff.classList.contains("consumer-splitoff"));
|
||||
assert.equal(root.querySelectorAll("[data-jz]").length, 0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { setupDom } from "./helpers.mjs";
|
||||
import { createJuzhen } from "../dist/juzhen.mjs";
|
||||
|
||||
function sameLine(document)
|
||||
{
|
||||
const view = document.defaultView;
|
||||
const previous = globalThis.ResizeObserver;
|
||||
const elementRects = view.Element.prototype.getClientRects;
|
||||
const rangeRects = view.Range.prototype.getClientRects;
|
||||
const rect = { top: 0, bottom: 20, left: 0, right: 20, width: 20, height: 20 };
|
||||
globalThis.ResizeObserver = class { observe() {} disconnect() {} };
|
||||
view.Element.prototype.getClientRects = () => [ rect ];
|
||||
view.Range.prototype.getClientRects = () => [ rect ];
|
||||
return () =>
|
||||
{
|
||||
view.Element.prototype.getClientRects = elementRects;
|
||||
view.Range.prototype.getClientRects = rangeRects;
|
||||
if (previous === undefined) { delete globalThis.ResizeObserver; }
|
||||
else { globalThis.ResizeObserver = previous; }
|
||||
};
|
||||
}
|
||||
|
||||
for (const orphan of [ false, true ])
|
||||
{
|
||||
test("line edge:同行 orphan 不製造行首 orphan=" + orphan, () =>
|
||||
{
|
||||
const { dom, document } = setupDom('<main lang="zh-Hant"><p>前甲「乙丙」</p></main>');
|
||||
const restore = sameLine(document);
|
||||
const jz = createJuzhen({ orphan, spacing: false, longWord: false });
|
||||
try
|
||||
{
|
||||
jz.render(document.querySelector("main"));
|
||||
assert.equal(document.querySelector(".bd-open").classList.contains("jz-half-le"), false);
|
||||
}
|
||||
finally { jz.revert(); restore(); dom.window.close(); }
|
||||
});
|
||||
}
|
||||
|
||||
for (const display of [ "inline", "inline-block", "contents", "block" ])
|
||||
{
|
||||
test("line edge:自訂隔離元素按實際物理盒攀爬 display=" + display, () =>
|
||||
{
|
||||
const { dom, document } = setupDom('<p lang="zh-Hant">前甲<x-box style="display:'
|
||||
+ display + '">「乙」</x-box>丙</p>');
|
||||
const restore = sameLine(document);
|
||||
const jz = createJuzhen({ isolateSelector: "x-box", orphan: false, spacing: false, longWord: false });
|
||||
try
|
||||
{
|
||||
jz.render();
|
||||
for (const char of document.querySelectorAll("jz-char"))
|
||||
{
|
||||
assert.equal(char.classList.contains("jz-half-le"), display === "block");
|
||||
}
|
||||
}
|
||||
finally { jz.revert(); restore(); dom.window.close(); }
|
||||
});
|
||||
}
|
||||
|
||||
for (const hidden of [ false, true ])
|
||||
{
|
||||
test("line edge:BR 為強制行緣,隱藏時可穿越 hidden=" + hidden, () =>
|
||||
{
|
||||
const { dom, document } = setupDom('<p lang="zh-Hant">甲」<br'
|
||||
+ (hidden ? ' style="display:none"' : '') + '>「乙</p>');
|
||||
const restore = sameLine(document);
|
||||
const jz = createJuzhen({ orphan: false, spacing: false, longWord: false });
|
||||
try
|
||||
{
|
||||
jz.render();
|
||||
for (const char of document.querySelectorAll("jz-char"))
|
||||
{
|
||||
assert.equal(char.classList.contains("jz-half-le"), !hidden);
|
||||
}
|
||||
}
|
||||
finally { jz.revert(); restore(); dom.window.close(); }
|
||||
});
|
||||
}
|
||||
|
||||
for (const action of [ "insert", "replace-source", "edit-fragment", "edit-source" ])
|
||||
{
|
||||
test("splitoff:回退須驗來源身份並保留外部樣式 action=" + action, () =>
|
||||
{
|
||||
const { dom, document } = setupDom('<p><strong class="original">甲乙丙</strong>,丁</p>');
|
||||
const p = document.querySelector("p");
|
||||
const source = p.querySelector("strong");
|
||||
const jz = createJuzhen({ orphan: false, spacing: false, longWord: false });
|
||||
try
|
||||
{
|
||||
jz.render(p);
|
||||
const fragment = p.querySelector('[data-jz-kind="splitoff"]');
|
||||
assert.ok(fragment);
|
||||
if (action === "insert")
|
||||
{
|
||||
const added = document.createElement("strong");
|
||||
added.className = "external";
|
||||
added.textContent = "新";
|
||||
fragment.before(added);
|
||||
}
|
||||
else if (action === "replace-source")
|
||||
{
|
||||
const replacement = source.cloneNode(false);
|
||||
replacement.textContent = "替";
|
||||
source.replaceWith(replacement);
|
||||
}
|
||||
else if (action === "edit-fragment") { fragment.className = "edited"; }
|
||||
else { source.className = "edited"; }
|
||||
jz.revert(p);
|
||||
assert.equal(fragment.parentNode, p, "來源失效時保留片段當前父與樣式");
|
||||
assert.equal(fragment.textContent, "丙");
|
||||
assert.equal(fragment.className, action === "edit-fragment" ? "edited" : "original");
|
||||
assert.equal(p.querySelector("[data-jz]"), null);
|
||||
if (action === "insert")
|
||||
{
|
||||
assert.equal(p.querySelector(".external").textContent, "新");
|
||||
assert.equal(p.textContent, "甲乙新丙,丁");
|
||||
}
|
||||
if (action === "replace-source") { assert.equal(p.textContent, "替丙,丁"); }
|
||||
jz.revert(p);
|
||||
assert.equal(fragment.textContent, "丙", "撤回所有權後再次 revert 不碰片段");
|
||||
}
|
||||
finally { jz.revert(p); dom.window.close(); }
|
||||
});
|
||||
}
|
||||
|
||||
test("splitoff:同來源多層切分仍完整還原作者結構", () =>
|
||||
{
|
||||
const { dom, document } = setupDom('<p>「<strong><em>甲乙丙丁</em></strong>」</p>');
|
||||
const p = document.querySelector("p");
|
||||
const original = p.innerHTML;
|
||||
const jz = createJuzhen();
|
||||
try { jz.render(p); jz.revert(p); assert.equal(p.innerHTML, original); }
|
||||
finally { jz.revert(p); dom.window.close(); }
|
||||
});
|
||||
|
||||
for (const [ html, defaultLang, half ] of [
|
||||
[ '<main lang="zh-Hans"><p lang="">甲,乙</p></main>', "zh-Hans", false ],
|
||||
[ '<main><p>甲,乙</p></main>', "", false ],
|
||||
[ '<main lang="zh-Hant"><p lang="zh-Hans-x-hant">甲,乙</p></main>', "zh-Hant", true ],
|
||||
[ '<main lang="zh-Hans"><p lang="zh-TW-x-hans">甲,乙</p></main>', "zh-Hans", false ],
|
||||
])
|
||||
{
|
||||
test("locale:空值及私用內容不冒充繼承語言 " + html, () =>
|
||||
{
|
||||
const { dom, document } = setupDom(html);
|
||||
const jz = createJuzhen({ lang: { default: defaultLang }, jinze: false, orphan: false });
|
||||
try
|
||||
{
|
||||
jz.render(document.querySelector("main"));
|
||||
assert.equal(document.querySelector("jz-char").classList.contains("jz-half"), half);
|
||||
}
|
||||
finally { jz.revert(); dom.window.close(); }
|
||||
});
|
||||
}
|
||||
|
||||
test("splitoff:多實例六種回退順序恢復原結構,36 組合", () =>
|
||||
{
|
||||
const fixtures = [
|
||||
'<strong>甲乙丙丁</strong>,戊己',
|
||||
'「<strong>甲乙丙丁</strong>」',
|
||||
'<strong><em>甲乙丙丁</em></strong>,戊己',
|
||||
'<strong class="author" style="color:red">甲乙丙丁</strong>,戊己',
|
||||
'<strong>甲乙𠀀\uDB40\uDD00丙丁</strong>,戊己',
|
||||
'<strong data-jz="author" data-jz-kind="author">甲乙丙丁</strong>,戊己',
|
||||
];
|
||||
const orders = [ [ 0, 1, 2 ], [ 0, 2, 1 ], [ 1, 0, 2 ], [ 1, 2, 0 ], [ 2, 0, 1 ], [ 2, 1, 0 ] ];
|
||||
for (const html of fixtures)
|
||||
{
|
||||
for (const order of orders)
|
||||
{
|
||||
const { dom, document } = setupDom('<p lang="zh-Hant">' + html + '</p>');
|
||||
const p = document.querySelector("p");
|
||||
const original = p.innerHTML;
|
||||
const owners = [
|
||||
createJuzhen({ jiya: false, spacing: false, orphan: false, longWord: false }),
|
||||
createJuzhen({ jiya: false, spacing: false, jinze: false, longWord: false }),
|
||||
createJuzhen({ spacing: false, orphan: false, longWord: false }),
|
||||
];
|
||||
try
|
||||
{
|
||||
owners.forEach((owner) => owner.render(p));
|
||||
order.forEach((index) => owners[index].revert(p));
|
||||
assert.equal(p.innerHTML, original, html + " order=" + order.join(","));
|
||||
}
|
||||
finally { owners.forEach((owner) => owner.disconnect()); dom.window.close(); }
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -187,7 +187,7 @@ test("shared rules: Ajv 2020 strict validation, rejection cases, exports, and co
|
||||
assert.equal(rulesSchema.$schema, "https://json-schema.org/draft/2020-12/schema");
|
||||
assert.equal(vectorsSchema.$schema, "https://json-schema.org/draft/2020-12/schema");
|
||||
assert.equal(rules.schemaVersion, 1);
|
||||
assert.equal(rules.rulesetVersion, "1.0.0");
|
||||
assert.equal(rules.rulesetVersion, "1.0.1");
|
||||
assert.equal(rules.unicodeProfile.version, "17.0.0");
|
||||
assert.equal(vectorsDoc.schemaVersion, 1);
|
||||
assert.equal(vectorsDoc.rulesetVersion, rules.rulesetVersion);
|
||||
@@ -312,6 +312,18 @@ test("shared rules: explicit script identifiers match whole subtags", () =>
|
||||
}
|
||||
});
|
||||
|
||||
test("Web locale: own lang and explicit unknown stop inheritance", () =>
|
||||
{
|
||||
const dom = new JSDOM('<main lang="zh-Hans"><p lang=""><span>甲</span></p></main>');
|
||||
const paragraph = dom.window.document.querySelector("p");
|
||||
assert.equal(adapter.effectiveLang(paragraph, "ja"), "");
|
||||
assert.equal(adapter.effectiveLang(paragraph.firstChild.firstChild, "ja"), "");
|
||||
assert.equal(adapter.effectiveLang(dom.window.document.querySelector("main"), "ja"), "zh-hans");
|
||||
paragraph.removeAttribute("lang");
|
||||
assert.equal(adapter.effectiveLang(paragraph, "ja"), "zh-hans");
|
||||
dom.window.close();
|
||||
});
|
||||
|
||||
test("shared rules: locale defaults and all language vectors match the Web adapter", () =>
|
||||
{
|
||||
const config = { default: "zh-Hant", policy: {} };
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
量各標點 glyph 之 ink bounding box,依墨色中心建議 left/center/right/full,並印出
|
||||
可貼入 `options.jiya.bias` 之 JSON 片段。
|
||||
|
||||
只量預設 cmap glyph,未做 shaping、未應用 `locl`;語言參數僅選 JSON 的語言分類鍵,
|
||||
不切換字型的語言字形。`halt` feature 存在也不保證涵蓋所有標點。建議表須按瀏覽器
|
||||
實際字形逐字微調,尤其是會依語言替換 glyph 的字型。
|
||||
|
||||
依賴:fonttools(`pip install fonttools`)。
|
||||
用法:python3 tools/measure-bias.py /path/to/font.ttf [zh-Hans|zh-Hant|ja|other]
|
||||
|
||||
@@ -26,7 +30,7 @@ from fontTools.ttLib import TTFont
|
||||
|
||||
# 聚珍 margin 模式會收半形之標點(句末點號僅半角式收,此處一併量供參考)。
|
||||
PUNCT = {
|
||||
"pause_stop": ",、。;:!?",
|
||||
"pause_stop": ",、。.;:!?",
|
||||
"open": "「『(《〈【〖〔[{“‘",
|
||||
"close": "」』)》〉】〗〕]}”’",
|
||||
}
|
||||
@@ -75,8 +79,10 @@ def main():
|
||||
|
||||
halt = has_feature(font, "halt")
|
||||
print(f"# 字型:{path}")
|
||||
print(f"# OpenType halt:{'有(建議直接用 halt 預設模式,無需 margin 後備)' if halt else '無 → 需 margin 後備模式'}")
|
||||
print(f"# OpenType halt:{'有(feature 存在不保證涵蓋所有標點,須逐字確認)' if halt else '無 → 可用 margin 後備模式'}")
|
||||
print(f"# unitsPerEm={upm}")
|
||||
print("# 量測預設 cmap glyph,未做 shaping/未應用 locl;語言參數僅選 JSON 分類鍵。")
|
||||
print("# 請按瀏覽器實際字形逐字微調建議表。")
|
||||
print()
|
||||
|
||||
table = {}
|
||||
|
||||
Reference in New Issue
Block a user