feat(rules): 建立共享排版契约并收束 WebKit 与 Unicode 基线

This commit is contained in:
2026-09-04 21:44:11 +08:00
parent 8ee9895247
commit 993cf576c6
42 changed files with 8511 additions and 1422 deletions
+157 -122
View File
@@ -5,8 +5,8 @@
> 單份間距、跨瀏覽器一致)作調整。 > 單份間距、跨瀏覽器一致)作調整。
> >
> 倉庫名 `cjk-autospace` 不變;公開 API 為 `createJuzhen` / 全域 `Juzhen`; > 倉庫名 `cjk-autospace` 不變;公開 API 為 `createJuzhen` / 全域 `Juzhen`;
> 自訂元素前綴 `jz-`。本文件先於任何程式碼,作為實作契約——後續每個功能點 > 自訂元素前綴 `jz-`。平台中立之排版語義以 `rules/juzhen-rules.json`、其 Schema 與
> 皆按此落地,以避免不一致與重大返工。 > 黃金向量為唯一權威源;本文件描述 Web adapter 架構及設計理由,不得覆蓋或分叉共享規則。
--- ---
@@ -14,28 +14,38 @@
1. **第一垂直切片 = v1(cjk-autospace)全部功能**,在新引擎上重寫:中西間隙 1. **第一垂直切片 = v1(cjk-autospace)全部功能**,在新引擎上重寫:中西間隙
(Pass A/B)、長英文詞斷詞包裝(Pass C)、開明式標點擠壓(Pass D)。 (Pass A/B)、長英文詞斷詞包裝(Pass C)、開明式標點擠壓(Pass D)。
2. **架構須預留完整復刻 Han.css 的能力**:標點修正、擠壓、懸掛、禁則、著重號、 2. **架構保留擴充 Han.css 功能版圖的能力**:標點修正、著重號與注音/ruby 等目前
注音/ruby、樣式正常化、字體偵測、計數器等,皆能在不改動切片 1 既有程式碼 **尚未實作**,若未來加入,須沿既有 pass、finder 與 CSS 契約擴充。標點懸掛之舊
的前提下,以「新增一個 pass」的方式加入。 負 margin 實作則已移除;四個同名公開選項現階段僅為相容 no-op(見 §3.1)。
3. **跨瀏覽器一致**(Chrome/Edge/Safari/WebKit/Firefox):一致性關鍵的版面 3. **跨瀏覽器一致**(Chrome/Edge/Safari/WebKit/Firefox):一致性關鍵的版面
行為,只用普遍支援的 CSS 原語;原生新特性僅作非必要增強。 行為,只用普遍支援的 CSS 原語;原生新特性僅作非必要增強。
非目標:不依賴 Han.css/Heti 等既有庫之執行期程式碼(MIT 條款下可借鑑其思路與 非目標:不依賴 Han.css/Heti 等既有庫之執行期程式碼(MIT 條款下可借鑑其思路與
分類表,但成品自含)。 分類表,但成品自含)。
### 1.1 共享規則與 adapter 所有權
`rules/` 固定字符分類、語言政策、標點目標寬度、四分之一字間隙、禁斷、段末約束與條件
行端結果;Web/CSS 與並行 XeLaTeX 實作均消費同一 `rulesetVersion` 及黃金向量。DOM wrapper、
CSS box、observer、瀏覽器量測,以及 TeX node/glue/penalty/macro 都只是各 adapter 可選之
非規範機制。共享規則由本項目維護;任何變更須先更新 Schema/向量,再由兩個 adapter 分別
證明相容,不能在某一實作內靜默改義。
--- ---
## 2. 設計不變量(所有 pass 必須遵守) ## 2. 設計不變量(所有 pass 必須遵守)
這些不變量是「避免返工」的核心。任何新功能點都不得違反。 這些不變量是「避免返工」的核心。任何新功能點都不得違反。
- **I1 複製乾淨(copy-clean)**:注入之間隙與標記一律 `user-select: none`,不改變邏輯 - **I1 複製乾淨(copy-clean)**:`spacing` 以 `jz-hws` 的 margin 留隙,**不插入空白字元**;
`textContent` 的**語義**內容。**一項刻意之常態正規化例外**:`spacing` pass 剝除**贅餘之作者 唯跨節點 IVS 間隙以 `::after` 生成 `U+200B` 斷行機會,且只對此生成內容設
`user-select:none`。該偽元素不進入 DOM/`textContent`。**一項刻意之常態正規化例外**:
`spacing` pass 剝除**贅餘之作者
空格**——CJK↔西文之作者空格吸收為受管 `margin` 間隙、CJK↔CJK(含跨 inline 標籤)之作者 空格**——CJK↔西文之作者空格吸收為受管 `margin` 間隙、CJK↔CJK(含跨 inline 標籤)之作者
空格剝除不留隙。此為 CJK 排版正解(該空格屬裝飾性、間隙改由 margin 承載,語義內容不變), 空格剝除不留隙。此為 CJK 排版正解(該空格屬裝飾性、間隙改由 margin 承載,語義內容不變),
但**確實刪除空格字元、且此刪除不經 `revert` 還原**(見 I6)。除此常態正規化外,預設「只包裝、 但**確實刪除空格字元、且此刪除不經 `revert` 還原**(見 I6)。除此常態正規化外,預設「只包裝、
不改寫文字」;其餘真正會改寫文字之 pass(如標點替換 `biaodian`)須為顯式 opt-in 且可 `revert` 不改寫文字」。未來若新增會改寫文字之 pass,須為顯式 opt-in 且可 `revert` 還原;目前不存在
還原。(實測 4 份真人撰寫之真實文檔:非空白字元多重集守恆、無任何字元改/增/漏/亂序,唯 `biaodian` 改寫 pass。(實測 4 份真人撰寫之真實文檔:非空白字元多重集守恆、無任何字元改/增/漏/亂序,唯
贅餘作者空格依此契約規範化。) 贅餘作者空格依此契約規範化。)
- **I2 JS/CSS 職責分離**:JS 只注入**結構**(`jz-*` 元素、class、`data-jz-*` - **I2 JS/CSS 職責分離**:JS 只注入**結構**(`jz-*` 元素、class、`data-jz-*`
屬性);**所有視覺尺寸(margin/letter-spacing/寬度/懸掛位移)一律寫在 屬性);**所有視覺尺寸(margin/letter-spacing/寬度/懸掛位移)一律寫在
@@ -107,8 +117,6 @@ jz.disconnect(); // 停止觀察
// 全域。style = 全域擠壓預設(quanjiao|kaiming|banjiao);policy = 逐 lang // 全域。style = 全域擠壓預設(quanjiao|kaiming|banjiao);policy = 逐 lang
// 覆寫。優先序:policy[lang] > style > 內建預設(繁→全角、簡→開明)。 // 覆寫。優先序:policy[lang] > style > 內建預設(繁→全角、簡→開明)。
lang: { default: "zh-Hant", style: "kaiming", policy: { "zh-Hant": "quanjiao" } }, lang: { default: "zh-Hant", style: "kaiming", policy: { "zh-Hant": "quanjiao" } },
classPrefix: "jz",
// 作用域限定(§3.6) // 作用域限定(§3.6)
scope: { scope: {
root: document.body, // 預設處理根;render(root) 可逐次覆寫 root: document.body, // 預設處理根;render(root) 可逐次覆寫
@@ -122,86 +130,97 @@ jz.disconnect(); // 停止觀察
longWord: { minLength: 6, lang: "en" }, longWord: { minLength: 6, lang: "en" },
jiya: true, // 標點擠壓 jiya: true, // 標點擠壓
jinze: true, // 禁則(含 justify 綁定) jinze: true, // 禁則(含 justify 綁定)
hanging: false, // 標點懸掛(預設關,繁體橫排不宜) orphan: { chars: 2 }, // 段末垂懸字避免
biaodian: false, // 標點修正(opt-in,可能改寫文字) level: { text: "h1,h2,h3,button,.ui" },
emphasis: false,
ruby: false,
// 分塊功能選擇(§3.6):選擇器 → 該塊內生效之功能集(覆寫全域) // 分塊功能選擇(§3.6):選擇器 → 該塊內生效之功能集(覆寫全域)
blocks: [ blocks: [
{ selector: ".prose", features: ["spacing", "jiya", "jinze", "emphasis"] }, { selector: ".prose", features: ["spacing", "jiya", "jinze"] },
{ selector: ".code-comment", features: ["spacing"] }, // 此塊只間隙、不擠壓 { selector: ".code-comment", features: ["spacing"] }, // 此塊只間隙、不擠壓
], ],
} }
``` ```
`hanging`/`biaodian`/`emphasis`/`ruby` 仍可作為頂層選項傳入,但目前全部為
**相容 no-op**:沒有註冊 pass,也不產生 DOM 或 CSS 效果。`hanging` 的舊負 margin
實作已移除;其餘三項僅保留為明確標記的未來設計。`charClass.biaodian` 則是已實作的
標點字符分類擴充,與同名頂層 no-op 選項無關。
### 3.2 Pass 管線 ### 3.2 Pass 管線
每個功能點是一個 `Pass`: 每個功能點是一個 `Pass`;目前型別是 `CharifyPass | StandalonePass`:
``` ```
interface Pass { interface BasePass {
name: string; name: string;
phase: "P0" | "P1" | "P2"; // 遍歷階段,供聚合(§3.4) order: number;
order: number; // phase 內 apply 先後(charify→擠壓→間隙→…) kind: "charify" | "standalone";
enabled(options): boolean; level: "text" | "paragraph";
// 聚合模式:collect 純判定(同階段共享單次走訪),apply 批次變更 enabled(options: ResolvedOptions): boolean;
collect(node: Text, ctx: RenderContext): Target[];
apply(targets: Target[], ctx: RenderContext): void;
revert(ctx: RenderContext): void; revert(ctx: RenderContext): void;
} }
interface CharifyPass extends BasePass {
kind: "charify";
collect(node: Text, ctx: RenderContext): WrapRequest[];
}
interface StandalonePass extends BasePass {
kind: "standalone";
render(ctx: RenderContext): void;
}
type Pass = CharifyPass | StandalonePass;
``` ```
需獨立走訪/layout 之 pass(如 hanging)可改採 `render(ctx)` 整段式而不參與 `CharifyPass` 只收集文本包裝請求;`StandalonePass` 自行執行功能所需的掃描或 layout。
collect 聚合;執行器兩種皆容納。 兩類皆由同一註冊表管理,且均按 `level` 與功能閘判定是否生效。
`createJuzhen` 內建一個**有序、可開關**的 pass 註冊表。`render` 依 `order` 執行 `createJuzhen` 內建一個**有序、可開關**的 pass 註冊表。`render` 依 `order` 執行
已啟用者。順序契約(避免互相破壞): 已啟用者。順序契約(避免互相破壞):
``` ```
10 biaodian 標點修正(若開,最先;可能改寫文字/包裝) Charify group(共享一次文本遍歷):
20 charify 標點字元包裝(jz-char/jz-inner)——擠壓、禁則、著重號之共同前置 20 jiya 標點字元包裝(jz-char/jz-inner)
30 jiya 標點擠壓(依 class + :lang 之 CSS) 60 longWord 長英文詞 lang 包裝
Standalone group(各自 render):
10 levelMark 寫回 resolved block level
25 jiyaAdjacency 連續標點擠壓標記
40 jinze 禁則 + justify 綁定 40 jinze 禁則 + justify 綁定
50 spacing 中西間隙(jz-hws) 50 spacing 中西間隙(jz-hws)
60 longWord 長英文詞 lang 包裝 70 orphan 垂懸字避免
70 emphasis 著重號 90 jiyaLineEdge 行端半形擠壓
80 ruby 注音/ruby 92 spacingGapTrim 行末中西間隙裁切
90 hanging 標點懸掛(需 layout,最後;掛 ResizeObserver)
``` ```
> 順序理由:charify 先把標點變成可定址的 `jz-char`,擠壓/禁則/著重號才有對象; 執行器先跑完整個 charify group,再跑 standalone group;因此跨組不以數字互相比較。
> 間隙在標點結構穩定後插入;懸掛依賴最終行盒,必須最後且 layout 後再算。 例如 `levelMark` 的 `order=10` 仍在 charify 完成後執行。`revert` 則把全部 pass 合併後
按 `order` 逆序呼叫,產物的 ownership/kind 標記負責精確還原。
### 3.3 RenderContext ### 3.3 RenderContext
`render`/`revert` 收到統一上下文:`{ root, options, finder, locale }`。各 pass `render`/`revert` 收到統一上下文:`{ root, options, finder, ownedElements,
共用同一 `finder` 實例與 locale 解析,確保 avoid 名單、邊界規則、語言判定一致。 ownerToken, setOwnedClass }`。各 pass 共用同一 `finder` 與 resolved options;產物透過
本實例的 ownership 集合/token 辨識,避免把外來或偽造的 `jz-*` 當成本實例產物。
### 3.4 遍歷聚合(減少 pass 遍歷次數,I9) ### 3.4 遍歷聚合(減少 pass 遍歷次數,I9)
樸素做法是每個 pass 各跑一次 `TreeWalker`,9 個 pass 即 9 次全樹走訪。為此引入 執行器先篩出已啟用的 pass,再按 `kind` 分組。所有 `CharifyPass` 共用一次
**遍歷階段(phase)**:相容之 pass 共享單次走訪。Pass 介面拆為 `finder.eachTextNode(root)`:對每個文本節點依 `order` 呼叫各 pass 的 `collect`,
`collect(node, ctx) → targets[]`(純判定、不變更 DOM)與 `apply(targets, ctx)` 先把節點與 `WrapRequest` 累積為 work,遍歷結束後才由管線執行器統一套用,避免走訪途中改 DOM。
(批次變更),執行器: 之後 `StandalonePass` 依 `order` 各自呼叫 `render(ctx)`:
``` ```
for phase in phases: charify = enabled.filter(kind == "charify").sort(order)
walk(root) 一次:對每個文本節點,依序呼叫該 phase 內各 pass 的 collect() standalone = enabled.filter(kind == "standalone").sort(order)
依 order 對收集到的 targets 做 apply()(兩階段:先收集後變更,避免遍歷中改 DOM)
walk(root) 一次:對每個 Text,依序把 charify 的 WrapRequest 收入 work
for item in work: applyRequests(item.node, item.requests, ownedElements)
for pass in standalone: pass.render(ctx)
``` ```
階段劃分(把 ~9 次走訪壓到 3 次): 聚合僅是**執行優化**:邏輯上仍「一功能一 pass」、可獨立開關與 `revert`(I6)。
需要自訂掃描、跨節點 run 或 layout 的功能採 standalone,不假裝參與 charify 聚合。
| phase | 內含 pass | 走訪 | 備註 |
|---|---|---|---|
| P0 文字修正 | biaodian(opt-in) | 1 次 | 唯一可能改寫文字者,須最先 |
| P1 行內結構 | charify/jiya 標記/jinze/spacing/longWord/emphasis | 1 次 | 共享走訪,`apply` 依 order |
| P2 版面後處理 | hanging | 0 次走訪+layout | 需最終行盒,掛 ResizeObserver |
> 聚合僅是**執行優化**:邏輯上仍「一功能一 pass」、可獨立開關與 `revert`(I6);
> 只是 P1 諸 pass 的 `collect` 在同一走訪內完成。若某 pass 不相容(需獨立走訪),
> 退化為自跑一次,不影響正確性。
### 3.5 v1 向下相容層 — 已移除(I8 退役) ### 3.5 v1 向下相容層 — 已移除(I8 退役)
@@ -227,14 +246,14 @@ Nvim peek/Claude 模板等 v1 消費端之過渡期。**最後一批 v1 消費
- **宣告式(屬性,就近繼承)**: - **宣告式(屬性,就近繼承)**:
- `data-juzhen="spacing jiya"`——白名單:此塊內僅啟用列出之功能。 - `data-juzhen="spacing jiya"`——白名單:此塊內僅啟用列出之功能。
- `data-juzhen-off="jiya hanging"`——黑名單:此塊停用列出者,其餘繼承。 - `data-juzhen-off="jiya orphan"`——黑名單:此塊停用列出者,其餘繼承。
- `lang`——驅動全角/開明(正交於功能開關)。 - `lang`——驅動全角/開明(正交於功能開關)。
- **程式式**:`options.blocks: [{ selector, features }]`,選擇器 → 功能集。 - **程式式**:`options.blocks: [{ selector, features }]`,選擇器 → 功能集。
- **解析(per-node 閘)**:對每個節點,向上找最近帶功能指令之祖先(屬性或命中 - **解析(per-node 閘)**:對每個節點,向上找最近帶功能指令之祖先(屬性或命中
`blocks` 選擇器者)決定其**有效功能集**,無則用全域 `options`;render 期間以 `blocks` 選擇器者)決定其**有效功能集**,無則用全域 `options`;render 期間以
元素為鍵快取。每個 pass 之 `collect(node)` 先問 `featureEnabledFor(node, name)`, 元素為鍵快取。charify pass 在 `collect(node)`、standalone pass 在自身掃描時查詢
否則跳過——**故分塊閘只是單次走訪內的 per-node 判定,不增遍歷、不破壞聚合 `featureEnabledFor(node, name)`,未啟用即跳過。分塊閘不改變 §3.4 的兩類 pass
(§3.4)**。 執行模型。
- **與 revert 之關係**:`revert` 依標記移除某功能產物,**不受當前閘狀態影響**,故 - **與 revert 之關係**:`revert` 依標記移除某功能產物,**不受當前閘狀態影響**,故
「關閉某功能 + revert」恆能乾淨還原(I6)。 「關閉某功能 + revert」恆能乾淨還原(I6)。
@@ -358,9 +377,9 @@ avoid」用組合入口 `isAvoided`(=自身或祖先,階段二歸一,免
- `jinze`:`anyCjk = RegExp(ruleset.cjk)` 決定詞元邊界之 CJK 判定。 - `jinze`:`anyCjk = RegExp(ruleset.cjk)` 決定詞元邊界之 CJK 判定。
**設計取捨**:① **向下兼容**——不傳 `charClass` 則 `ruleset === DEFAULT_RULESET`,行為與 **設計取捨**:① **向下兼容**——不傳 `charClass` 則 `ruleset === DEFAULT_RULESET`,行為與
重構前完全一致(現有測試全綠)。② **追加語義(v1)**——擴充內置類(把未分類字符如英文全角 重構前完全一致(現有測試全綠)。② **追加語義(v1)**——擴充尚未內置的字符;全角句點
句號 `.`U+FF0E 歸入、或納新符號);不支援重分類/移除內置字符(範圍式集合難以挖洞、且需 `.`(U+FF0E)已是內置 `BD_STOP`,無須追加。規則不支援重分類/移除內置字符(範圍式集合
求罕見,列為後續)。③ 結構性 ASCII 不開放(非「字符類型」)。 難以挖洞、且需求罕見,列為後續)。③ 結構性 ASCII 不開放(非「字符類型」)。
> 借鑑 Han 之 `regex/typeset.js`、`regex/unicode.js` 與 Heti 之分類字集,重寫為 > 借鑑 Han 之 `regex/typeset.js`、`regex/unicode.js` 與 Heti 之分類字集,重寫為
> 現代 TS 常數表+可配置規則集。 > 現代 TS 常數表+可配置規則集。
@@ -400,17 +419,17 @@ CSS 端對應以 `:lang()` 選擇器落實 lang 層政策(I2/I3);逐元
| 元素 | 用途 | | 元素 | 用途 |
|---|---| |---|---|
| `jz-hws` | 中西間隙。`user-select:none`,copy-clean,justify 安全(§8) | | `jz-hws` | 中西間隙,以 margin 留隙、不插入空白字元;跨節點 IVS 例外見 §6.1 |
| `jz-char` | 包裝單一標點/漢字,承載 `bd-*` class 與 `data-jz` 屬性 | | `jz-char` | 包裝單一標點/漢字,承載 `bd-*` class 與 `data-jz` 屬性 |
| `jz-inner` | `jz-char` 內層字面容器,供 margin/letter-spacing 擠壓而不動外盒 | | `jz-inner` | `jz-char` 內層字面容器,供 margin/letter-spacing 擠壓而不動外盒 |
| `jz-cs` | 字間補償空白(連續/行端擠壓用,於行邊界折疊) |
| `jz-jinze` | 禁則群組:綁定不可斷單元(亦為 justify 綁定載體) | | `jz-jinze` | 禁則群組:綁定不可斷單元(亦為 justify 綁定載體) |
| `jz-orphan` | 垂懸字避免群組:段末末 N 實義字 `nowrap`(§6.5.1) | | `jz-orphan` | 垂懸字避免群組:段末末 N 實義字 `nowrap`(§6.5.1) |
| `jz-em` | 著重號載體 |
| `jz-ruby`/`jz-rb`/`jz-rt` | ruby/注音 |
class 約定:`bd`(標點)、`bd-open/close/cop/stop/sep/liga`、`cjk`、`jiya`、 `jz-cs`、`jz-em`、`jz-ruby`/`jz-rb`/`jz-rt` 只屬未實作之未來設計名稱,當前程式
`jiya-consecutive`、`hangable`、`jinze-{tou,wei,touwei}` 等。 不會產生。
class 約定以現有 CSS/pass 為準:標點採 `bd-open`/`bd-close`/`bd-pause`/
`bd-stop`/`bd-middle`/`bd-liga`;擠壓另用 `jz-half`/`jz-half-le` 與 `jz-ink-*`。
--- ---
@@ -426,6 +445,9 @@ class 約定:`bd`(標點)、`bd-open/close/cop/stop/sep/liga`、`cjk`、`j
包進 `jz-hws`,間隙由 CSS `margin-right`(0.25em)提供——**不注入任何空白字元**。 包進 `jz-hws`,間隙由 CSS `margin-right`(0.25em)提供——**不注入任何空白字元**。
WHERE 之規則沿用 v1 pangu 規則表(已驗證);作者原寫之 CJK↔西文空格一律移除 WHERE 之規則沿用 v1 pangu 規則表(已驗證);作者原寫之 CJK↔西文空格一律移除
(改由 margin 留隙)。 (改由 margin 留隙)。
- **跨節點 IVS**:當 base 與 variation selector 分居文本節點時,`jz-hws` 承載完整
base+VS 序列;僅此類 gap 以 `::after` 生成 `U+200B` 斷行機會,並只對該偽元素設
`user-select:none`。它不是 DOM 文本,不改變 `textContent` 或複製結果。
- **為何不用空白字元/inline-block(justify 三難)**:純 CSS 下,CJK↔西文間隙在 - **為何不用空白字元/inline-block(justify 三難)**:純 CSS 下,CJK↔西文間隙在
`justify` 必有一個邊緣瑕疵——①真實空白→被當詞間空白**暴增**;②inline-block 固定 `justify` 必有一個邊緣瑕疵——①真實空白→被當詞間空白**暴增**;②inline-block 固定
寬→罕見落**行首縮排**;③`margin-right`→**行末右緣內縮 0.25em**。選 ③(margin), 寬→罕見落**行首縮排**;③`margin-right`→**行末右緣內縮 0.25em**。選 ③(margin),
@@ -628,7 +650,7 @@ class 約定:`bd`(標點)、`bd-open/close/cop/stop/sep/liga`、`cjk`、`j
#### 6.5.0a 功能分級:文本級 vs 段落級 + 元素標記(前置基礎設施) #### 6.5.0a 功能分級:文本級 vs 段落級 + 元素標記(前置基礎設施)
**動機**:懸掛/垂懸字/禁則等為**段落級**功能(依賴行/段布局),不應作用於標題、按鈕、單行 **動機**:垂懸字/禁則等為**段落級**功能(依賴行/段布局),不應作用於標題、按鈕、單行
或單元素文本;而中西間隙、標點挤压為**文本級**,任意文本片段皆適用且想要。下游須能**方便標記** 或單元素文本;而中西間隙、標點挤压為**文本級**,任意文本片段皆適用且想要。下游須能**方便標記**
元素屬哪級。此為貫穿性基礎設施(已實作:`Finder.levelFor`/`levelAllows`、各 pass `level` 元素屬哪級。此為貫穿性基礎設施(已實作:`Finder.levelFor`/`levelAllows`、各 pass `level`
元數據、選項 `level.text`/屬性 `data-jz-level`)。 元數據、選項 `level.text`/屬性 `data-jz-level`)。
@@ -638,7 +660,7 @@ class 約定:`bd`(標點)、`bd-open/close/cop/stop/sep/liga`、`cjk`、`j
| 級別 | 功能 | 判據/依賴 | | 級別 | 功能 | 判據/依賴 |
|---|---|---| |---|---|---|
| **文本級** | `spacing`(中西間隙)、`jiya` baseline 政策半形、`jiya` 連續挤压(adjacency) | 僅字符鄰接,對單行/單元素文本即有意義 | | **文本級** | `spacing`(中西間隙)、`jiya` baseline 政策半形、`jiya` 連續挤压(adjacency) | 僅字符鄰接,對單行/單元素文本即有意義 |
| **段落級** | `jinze` 避頭尾、行端(半形/懸掛)、`gapTrim`、`orphan` 垂懸字、`longWord` 斷詞 | 需行/段布局(換行、行端、段末、justify)才有意義 | | **段落級** | `jinze` 避頭尾、行端半形、`gapTrim`、`orphan` 垂懸字、`longWord` 斷詞 | 需行/段布局(換行、行端、段末、justify)才有意義 |
| 貫穿 | `charClass`、原子透明、normalize | 基礎設施,不分級 | | 貫穿 | `charClass`、原子透明、normalize | 基礎設施,不分級 |
直覺:文本級=「字符級排版」(間隙、標點形態);段落級=「行/段級排版」(換行、行端、段末)。 直覺:文本級=「字符級排版」(間隙、標點形態);段落級=「行/段級排版」(換行、行端、段末)。
@@ -648,7 +670,7 @@ class 約定:`bd`(標點)、`bd-open/close/cop/stop/sep/liga`、`cjk`、`j
- 顯式降級:`data-jz-level="text"` 於元素/祖先 → 該子樹**僅文本級**(段落級功能自動關); - 顯式降級:`data-jz-level="text"` 於元素/祖先 → 該子樹**僅文本級**(段落級功能自動關);
`data-jz-level="paragraph"`(或不標)→ 全功能。 `data-jz-level="paragraph"`(或不標)→ 全功能。
- 選項 `level?: { text?: string }`:選擇器一鍵標記(如 `level:{ text: "h1,h2,h3,button,.ui" }`)。 - 選項 `level?: { text?: string }`:選擇器一鍵標記(如 `level:{ text: "h1,h2,h3,button,.ui" }`)。
- 典型:標題/按鈕/UI 文字標 **text-level**(要間隙與標點半形、不要懸掛/避孤字/避頭尾); - 典型:標題/按鈕/UI 文字標 **text-level**(要間隙與標點半形、不要避孤字/避頭尾);
正文 `<p>` 預設全功能。 正文 `<p>` 預設全功能。
**實現**: **實現**:
@@ -659,8 +681,8 @@ class 約定:`bd`(標點)、`bd-open/close/cop/stop/sep/liga`、`cjk`、`j
- **文本級功能須支持非塊/單元素**:`spacing` 現用 `eachBlock`,須確認 scope root 為 inline/ - **文本級功能須支持非塊/單元素**:`spacing` 現用 `eachBlock`,須確認 scope root 為 inline/
單元素時亦能處理(或放寬走訪),使單行標題等之中西間隙、標點半形生效。 單元素時亦能處理(或放寬走訪),使單行標題等之中西間隙、標點半形生效。
**與本批關係**:先落地分級(pass 標 level、`levelFor`、`data-jz-level`/選項)→ orphan/hanging **與本批關係**:分級已落地(pass 標 level、`levelFor`、`data-jz-level`/選項);`orphan`
註冊為段落級,自動只作用段落級元素、跳過 text-level 者。實作順序插於 6.5.5 之首。 等段落級 pass 只作用於段落級元素、跳過 text-level 者。`hanging` 未註冊為 pass。
**Resolved 分類之 CSS 鉤子(下游建議七,`typeset/levelmark.ts`)**:消費端之段落級 CSS(首要 **Resolved 分類之 CSS 鉤子(下游建議七,`typeset/levelmark.ts`)**:消費端之段落級 CSS(首要
`text-align:justify`)語義上與段落級 pass 應作用於**同一集合**,但 level 判定原僅存內部快取, `text-align:justify`)語義上與段落級 pass 應作用於**同一集合**,但 level 判定原僅存內部快取,
@@ -707,7 +729,8 @@ class 約定:`bd`(標點)、`bd-open/close/cop/stop/sep/liga`、`cjk`、`j
逐塊 `block.querySelector(sel)` 掃整棵子樹判葉塊,累加 O(Σ塊子樹)、深塊嵌套下 O(n²);改以一趟 逐塊 `block.querySelector(sel)` 掃整棵子樹判葉塊,累加 O(Σ塊子樹)、深塊嵌套下 O(n²);改以一趟
上攀預計算「非葉塊」Set(某塊為另一塊之最近塊祖先 ⟺ 含塊級後代),scope 無涉、與原 querySelector 上攀預計算「非葉塊」Set(某塊為另一塊之最近塊祖先 ⟺ 含塊級後代),scope 無涉、與原 querySelector
同口徑,行為等價。 同口徑,行為等價。
- **order=70**(charify 20 後、layout pass 前;longWord 60 後)。 - **order=70**(standalone 群中位於 jinze 40/spacing 50 後、layout pass 前;整個
standalone 群又在含 longWord 60 的 charify 群之後)。
- **與禁則協同**:段末範圍可能含 jinze 已綁之 `jz-jinze`;置於 jinze(40) 後,將 jz-jinze 整體 - **與禁則協同**:段末範圍可能含 jinze 已綁之 `jz-jinze`;置於 jinze(40) 後,將 jz-jinze 整體
納入 jz-orphan,並處理邊界字定位**跨 jz-jinze 攀爬**(實作細節)。免重複包裹。 納入 jz-orphan,並處理邊界字定位**跨 jz-jinze 攀爬**(實作細節)。免重複包裹。
- **CSS**:`jz-orphan { white-space: nowrap; }`。`revert`:wrap-class,隨 `revertPass` 還原。 - **CSS**:`jz-orphan { white-space: nowrap; }`。`revert`:wrap-class,隨 `revertPass` 還原。
@@ -723,7 +746,7 @@ class 約定:`bd`(標點)、`bd-open/close/cop/stop/sep/liga`、`cjk`、`j
lineedge 行端處理只餘 **squeeze**(行端半形):量測各 jz-char 是否在行首(開類)/行末(閉類), lineedge 行端處理只餘 **squeeze**(行端半形):量測各 jz-char 是否在行首(開類)/行末(閉類),
加 `jz-half-le`(CSS 同 halt)。兩階段免自我應驗(先 `clearEdge`、自然佈局量測全部、再一次套用); 加 `jz-half-le`(CSS 同 halt)。兩階段免自我應驗(先 `clearEdge`、自然佈局量測全部、再一次套用);
行歸屬以「垂直區間」`lowerLine`/`higherLine` 判定(吸收 inline-block 行盒與純文本基線差 ~5px)。 行歸屬以「垂直區間」`lowerLine`/`higherLine` 判定(吸收 inline-block 行盒與純文本基線差 ~5px)。
order `90 lineEdge`,`revert` 清 `jz-half-le`。純版面、jsdom 跳過、須瀏覽器目視。 order `90 jiyaLineEdge`,`revert` 清 `jz-half-le`。純版面、jsdom 跳過、須瀏覽器目視。
#### 6.5.3 行端禁則(§6.4)review #### 6.5.3 行端禁則(§6.4)review
@@ -733,8 +756,9 @@ order `90 lineEdge`,`revert` 清 `jz-half-le`。純版面、jsdom 跳過、須
#### 6.5.4 統一 pass order #### 6.5.4 統一 pass order
`20 charify → 25 jiyaAdj → 40 jinze → 50 spacing → 60 longWord → 70 orphan(垂懸字)→ 實際分兩群且不跨群比較 order:charify 群為 `20 jiya → 60 longWord`,共用一次文本
90 lineEdge(行端 squeeze 半形)→ 92 gapTrim`。 遍歷;其後 standalone 群為 `10 levelMark → 25 jiyaAdjacency → 40 jinze → 50 spacing →
70 orphan → 90 jiyaLineEdge → 92 spacingGapTrim`。
#### 6.5.5 實作順序與結果 #### 6.5.5 實作順序與結果
@@ -774,30 +798,33 @@ order `90 lineEdge`,`revert` 清 `jz-half-le`。純版面、jsdom 跳過、須
擠壓 + `hanging-punctuation` 接管懸掛,按 `CSS.supports` 能力門控,整體退出 jz-char 機制。 擠壓 + `hanging-punctuation` 接管懸掛,按 `CSS.supports` 能力門控,整體退出 jz-char 機制。
與已移除之負 margin 程式碼**沒有一行可複用**,故不保留休眠碼。 與已移除之負 margin 程式碼**沒有一行可複用**,故不保留休眠碼。
### 6.6 標點修正(Biaodian normalize)— 後續(opt-in) ### 6.6 標點修正(Biaodian normalize)— 未實作之未來設計
- **Han**:`subst`/`biaodian` 修正標點變體、位置。 - **Han**:`subst`/`biaodian` 修正標點變體、位置。
- **我們**:opt-in pass,預設**只分類+打 class**(不改文字,I1);顯式開啟才做 - **擬議方向**:未來可設計 opt-in pass;顯式開啟才做變體替換(簡↔繁標點、全半形
變體替換(簡↔繁標點、全半形修正),且可 `revert`。 修正),且須可 `revert`。目前沒有 `biaodian` 模組、pass 或文字改寫。
- **預留**:`order=10` 最先;產物為 `jz-char` 之 class/屬性,與 charify 銜接。 - **相容狀態**:頂層 `biaodian` 選項僅為 no-op;`charClass.biaodian` 是另一套已實作的
字符分類擴充 API。
### 6.7 著重號(Emphasis)— 後續 ### 6.7 著重號(Emphasis)— 未實作之未來設計
- **Han**:逐字包裝加圈點。 - **Han**:逐字包裝加圈點。
- **我們**:優先用原生 `text-emphasis`(Chrome/FF/Safari 支援良好);需精細 - **擬議方向**:可優先使用原生 `text-emphasis`;若未來需要精細控制,再評估逐字
控制時退回 `jz-em` 逐字包裝 + CSS 圓點。lang 感知(位置簡繁略異)。 包裝與 lang 感知。`jz-em` 只是設計草案名稱,當前沒有對應模組、pass、DOM 或 CSS。
- **預留**:charify 能力直接複用。 - **相容狀態**:頂層 `emphasis` 選項僅為 no-op。
### 6.8 注音/Ruby — 後續(最複雜) ### 6.8 注音/Ruby — 未實作之未來設計
- **我們**:基於原生 `<ruby>` + `jz-ruby/jz-rt` 處理注音符號直排與聲調定位。 - **擬議方向**:未來可基於原生 `<ruby>` 評估注音符號直排與聲調定位;`jz-ruby`/
- **預留**:獨立 pass,`order=80`;不影響其餘。 `jz-rb`/`jz-rt` 只是設計草案名稱,當前沒有對應模組、pass、DOM 或 CSS。
- **相容狀態**:頂層 `ruby` 選項僅為 no-op。
### 6.9 樣式正常化/字型歸屬 — 貫穿(CSS) ### 6.9 樣式正常化/字型歸屬 — 貫穿(CSS)
- **normalize**:`juzhen.css` 內以 `@supports`/`:where()` 提供 CJK normalize(關閉 - **normalize**:`juzhen.css` 內以 `@supports`/`:where()` 提供 CJK normalize(關閉
與託管擠壓重複作用之原生 `text-spacing-trim`/`text-autospace`、間隙 `user-select` 與託管擠壓重複作用之原生 `text-spacing-trim`/`text-autospace` 等),限 `.juzhen`
等),限 `.juzhen` 域、非破壞。 域、非破壞。間隙本身不插入空白;只有跨節點 IVS 的 `U+200B` 偽元素設
`user-select:none`。
- **字型不歸聚珍管(設計決策,下游回饋)**:曾內建依 `:lang` 之 Noto 字型堆疊作「後備」, - **字型不歸聚珍管(設計決策,下游回饋)**:曾內建依 `:lang` 之 Noto 字型堆疊作「後備」,
但字型屬消費端領域、內建即越權(且曾蓋掉消費端字型釀 bug)。故**移除 `_lang.css`**, 但字型屬消費端領域、內建即越權(且曾蓋掉消費端字型釀 bug)。故**移除 `_lang.css`**,
逐語言 `font-family` 完全交消費端 CSS。聚珍僅守三項非管理性本分:① `_normalize.css` 逐語言 `font-family` 完全交消費端 CSS。聚珍僅守三項非管理性本分:① `_normalize.css`
@@ -825,7 +852,7 @@ order `90 lineEdge`,`revert` 清 `jz-half-le`。純版面、jsdom 跳過、須
| Pass A 中西間隙 | `spacing` | `jz-hws`,copy-clean,justify 安全 | | Pass A 中西間隙 | `spacing` | `jz-hws`,copy-clean,justify 安全 |
| Pass B pill 邊界 | `spacing`(同引擎) | avoid + 鄰接判定 | | Pass B pill 邊界 | `spacing`(同引擎) | avoid + 鄰接判定 |
| Pass C 長英文詞 | `longWord` | `<span lang>` + `hyphens` | | Pass C 長英文詞 | `longWord` | `<span lang>` + `hyphens` |
| Pass D 開明式擠壓 | `jiya` + `jinze` | 跨瀏覽器負 margin + `:lang` 全角/開明 + justify 綁定 | | Pass D 開明式擠壓 | `jiya` + `jinze` | OpenType `halt`;缺 `halt` 時用 0.5em 盒寬+ink bias 後備;lang/style 政策+justify 綁定 |
**交付**:可 `build` 出 ESM/IIFE/CSS/d.ts;jsdom 測試覆蓋 DOM 變換正確性 **交付**:可 `build` 出 ESM/IIFE/CSS/d.ts;jsdom 測試覆蓋 DOM 變換正確性
(包裝、class、邊界、冪等、copy-clean、lang 政策);justify/簡繁 demo 頁供瀏覽器 (包裝、class、邊界、冪等、copy-clean、lang 政策);justify/簡繁 demo 頁供瀏覽器
@@ -907,9 +934,9 @@ inline-block,於 `_jiya.css` 補一條)。實測:halt/margin 兩模式 c
支援。**注意**:標點半形預設不用負 `margin`(對居中字形會重疊),改用 `halt`(見 支援。**注意**:標點半形預設不用負 `margin`(對居中字形會重疊),改用 `halt`(見
§6.3 修訂)。**缺 `halt` 字型之後備**:`jiya.halfWidth:"margin"` 以 `width:0.5em`+ §6.3 修訂)。**缺 `halt` 字型之後備**:`jiya.halfWidth:"margin"` 以 `width:0.5em`+
字身依偏側(`jz-ink-*`)溢出定位,於 CSS 重現 halt、真半形 advance(見 §6.3)。 字身依偏側(`jz-ink-*`)溢出定位,於 CSS 重現 halt、真半形 advance(見 §6.3)。
- **漸進增強(非必要)**:`text-spacing-trim`(Blink)、`hanging-punctuation` - **原生原語政策**:`text-spacing-trim`/`text-autospace` 在現行託管功能中須避免
(WebKit)、`text-emphasis`、`text-autospace`——僅在支援時錦上添花,缺席不影響 重複作用;`hanging-punctuation`/`text-emphasis` 只屬尚未實作之未來設計評估,
基本排版(I4)。 不是當前 pass 或驗收項目。
- **驗證矩陣**:Chrome(Blink,含 VSCode/Electron)、Safari/WebKit、Firefox - **驗證矩陣**:Chrome(Blink,含 VSCode/Electron)、Safari/WebKit、Firefox
(Gecko)。demo 頁含三引擎可開的測試案例。 (Gecko)。demo 頁含三引擎可開的測試案例。
@@ -929,8 +956,8 @@ inline-block,於 `_jiya.css` 補一條)。實測:halt/margin 兩模式 c
``` ```
政策:凡屬「一致性關鍵層」者(間隙、擠壓),一律由我們統一處理並禁用對應原生 政策:凡屬「一致性關鍵層」者(間隙、擠壓),一律由我們統一處理並禁用對應原生
原語;凡屬「漸進增強」且不致衝突者(如 Safari 之 `hanging-punctuation`,在我們未 原語。`hanging-punctuation` 等未來設計若要加入,須另立能力門控與三引擎驗收,不能
託管懸掛時),方任其生效。此清單隨功能演進維護於 `css/_normalize.css`。 從目前保留的 no-op 選項推定已實作。此清單隨功能演進維護於 `css/_normalize.css`。
--- ---
@@ -938,31 +965,34 @@ inline-block,於 `_jiya.css` 補一條)。實測:halt/margin 兩模式 c
``` ```
src/ src/
index.ts createJuzhen / Juzhen / pass 註冊 / render / revert index.ts createJuzhen / Juzhen / pass 註冊 / observer / render / revert
types.ts 公開選項、RenderContext、CharifyPass / StandalonePass 型別
core/ core/
pass.ts Pass 介面 + 管線執行器 pass.ts 兩類 pass 之分組、排序與管線執行器
finder.ts 遍歷/avoid/charify/邊界感知 replace finder.ts 遍歷/avoid/charify/邊界感知與跨節點 run
unicode.ts 字元類 + 標點子類 + 規則正則表 unicode.ts 字元類 + 標點子類 + 規則正則表
locale.ts 有效 lang / 分類 / 全角開明政策 locale.ts 有效 lang / 分類 / 全角開明政策
dom.ts jz-* 工廠 / wrap / split / marker / revert dom.ts jz-* 工廠 / ownership / wrap / revert
split.ts 禁則/垂懸字之行內元素邊界切分
typeset/ typeset/
spacing.ts 中西間隙 [切片 1] spacing.ts 中西間隙
longword.ts 長英文詞包裝 [切片 1] longword.ts 長英文詞包裝
jiya.ts 標點擠壓 + 全角開明 [切片 1] jiya.ts 標點擠壓 + 全角開明
jinze.ts 禁則 + justify 綁定 [切片 1 基礎] jinze.ts 禁則 + justify 綁定
hanging.ts 標點懸掛 [後續] orphan.ts 段末垂懸字避免
biaodian.ts 標點修正 [後續] levelmark.ts resolved block level 寫回
emphasis.ts 著重號 [後續] lineedge.ts 行端半形擠壓
ruby.ts 注音/ruby [後續]
css/ css/
juzhen.css 入口(@import 各 partial) juzhen.css 入口(@import 各 partial)
_normalize.css _spacing.css _jiya.css _jinze.css _normalize.css _spacing.css _jiya.css _jinze.css _orphan.css
_hanging.css _emphasis.css _ruby.css (字型不內建,交消費端)
test/ jsdom DOM 正確性測試 test/ jsdom DOM 正確性測試
demo/ 瀏覽器 layout/justify/簡繁 目視驗證頁 demo/ 瀏覽器 layout/justify/簡繁 目視驗證頁
build.mjs esbuild:ESM + IIFE + CSS;tsc:d.ts build.mjs esbuild:ESM + IIFE + CSS;tsc:d.ts
``` ```
`biaodian.ts`/`emphasis.ts`/`ruby.ts`/`hanging.ts` 及同名 CSS partial 均不存在;
前述章節是明確標記的未來設計或已移除機制,不是當前模組清單。
--- ---
## 11. 構建與發佈 ## 11. 構建與發佈
@@ -973,7 +1003,7 @@ build.mjs esbuild:ESM + IIFE + CSS;tsc:d.ts
- `dist/juzhen.iife.js`(IIFE,全域 `Juzhen`,原生 `<script>` 直跑——保留 - `dist/juzhen.iife.js`(IIFE,全域 `Juzhen`,原生 `<script>` 直跑——保留
build.sh/Nvim peek 之單檔串接消費) build.sh/Nvim peek 之單檔串接消費)
- `dist/juzhen.css`(合併之樣式) - `dist/juzhen.css`(合併之樣式)
- `tsc --emitDeclarationOnly` → `dist/juzhen.d.ts` - `tsc --emitDeclarationOnly` → `dist/index.d.ts`
- **發佈物**:`dist/` 提交入庫(消費端可直接取單檔/CSS)。 - **發佈物**:`dist/` 提交入庫(消費端可直接取單檔/CSS)。
--- ---
@@ -983,7 +1013,7 @@ build.mjs esbuild:ESM + IIFE + CSS;tsc:d.ts
- **jsdom(自動)**:驗證 DOM 變換——包裝結構、`bd-*` class、邊界處理、冪等、 - **jsdom(自動)**:驗證 DOM 變換——包裝結構、`bd-*` class、邊界處理、冪等、
`revert` 還原、copy-clean(`textContent` 不被注入污染)、lang 政策分支。 `revert` 還原、copy-clean(`textContent` 不被注入污染)、lang 政策分支。
- **瀏覽器(目視,demo 頁)**:layout 相關**無法**於 jsdom 驗證——justify 單份 - **瀏覽器(目視,demo 頁)**:layout 相關**無法**於 jsdom 驗證——justify 單份
間距、擠壓視覺量、行端折疊、懸掛、inline-block 基線。demo 頁載 Noto CJK,簡繁 間距、擠壓視覺量、行端折疊、inline-block 基線。demo 頁載 Noto CJK,簡繁
並列、可切 justify、附選取/複製測試區,於 Chrome/Safari/FF 目視核對。 並列、可切 justify、附選取/複製測試區,於 Chrome/Safari/FF 目視核對。
- 文件明確標注哪些結論來自自動測試、哪些待瀏覽器目視。 - 文件明確標注哪些結論來自自動測試、哪些待瀏覽器目視。
@@ -994,7 +1024,8 @@ build.mjs esbuild:ESM + IIFE + CSS;tsc:d.ts
新增任一 Han 功能點,標準流程: 新增任一 Han 功能點,標準流程:
1. 在 `unicode.ts` 補該功能所需字元類/正則(集中、不散落)。 1. 在 `unicode.ts` 補該功能所需字元類/正則(集中、不散落)。
2. 新增 `typeset/<feature>.ts`,實作 `Pass`(`render`/`revert`/`order`)。 2. 新增 `typeset/<feature>.ts`,依需求實作 `CharifyPass`(`collect`)或
`StandalonePass`(`render`),並提供 `revert`/`order`/`level`。
3. 在 `index.ts` 註冊,加 `options.<feature>` 開關。 3. 在 `index.ts` 註冊,加 `options.<feature>` 開關。
4. 在 `css/_<feature>.css` 寫視覺規則(I2:尺寸只在 CSS)。 4. 在 `css/_<feature>.css` 寫視覺規則(I2:尺寸只在 CSS)。
5. 補 jsdom 測試 + demo 案例。 5. 補 jsdom 測試 + demo 案例。
@@ -1012,15 +1043,19 @@ build.mjs esbuild:ESM + IIFE + CSS;tsc:d.ts
- **R1 justify/inline-block 基線**(§8)。觀察:句末標點在三引擎 justify 下是否 - **R1 justify/inline-block 基線**(§8)。觀察:句末標點在三引擎 justify 下是否
單份間距、inline-block 是否擾動基線/行高/行內換行。**預案**:改採 Han 路線之 單份間距、inline-block 是否擾動基線/行高/行內換行。**預案**:改採 Han 路線之
`jz-cs` 補償空白法(標點維持 inline,以行邊界可折疊之 `jz-cs` 控制伸縮側), `jz-cs` 補償空白法(標點維持 inline,以行邊界可折疊之 `jz-cs` 控制伸縮側),
結構已預留,切換僅換 CSS 機制不動 JS。 但此為未實作設計;採用時須新增 JS 生產/`revert` 契約、CSS 與三引擎驗收,不能只換
- **R2 行端擠壓免量測**。觀察:純 CSS(`jz-cs` 折疊)能否在三引擎一致達成行首行尾 CSS。
擠壓。**預案**:退化為「連續+行內擠壓用 CSS(一致)+ 行端擠壓交由 layout pass - **R2 行端擠壓免量測(未實作設計)**。觀察:純 CSS(未來若引入 `jz-cs` 折疊)能否
量測或漸進增強原語」,行端與連續解耦,互不影響。 在三引擎一致達成行首行尾擠壓。採用時同樣須新增 JS 生產/`revert` 契約、CSS 與三引擎
- **R3 標點修正改寫文字 vs I1**。觀察:opt-in 開啟後複製/序列化是否仍可接受。 驗收;否則維持現行 layout pass 量測或改用能力門控之漸進增強原語,令行端與連續機制
**預案**:預設只分類不改寫;改寫模式強制可 `revert`,並提供「僅視覺替換(CSS 解耦。
- **R3 標點修正改寫文字 vs I1(未實作之未來設計)**。觀察:若未來加入 opt-in
改寫,複製/序列化是否仍可接受。**擬議預案**:預設只分類不改寫;改寫模式強制可
`revert`,並提供「僅視覺替換(CSS
字符替身)不動文字」之次級模式作折衷。 字符替身)不動文字」之次級模式作折衷。
- **R4 注音/ruby 複雜度**。觀察:原生 `<ruby>` 對注音直排/聲調定位之不足。 - **R4 注音/ruby 複雜度(未實作之未來設計)**。觀察:原生 `<ruby>` 對注音直排/
**預案**:獨立 pass、最後做;先以原生 `<ruby>` 覆蓋多數,注音特例再以 `jz-ruby` 聲調定位之不足。**擬議預案**:獨立 pass、最後做;先以原生 `<ruby>` 覆蓋多數,
注音特例再評估擬議的 `jz-ruby`
子結構補強,不影響其餘 pass。 子結構補強,不影響其餘 pass。
- **R5 DOM 重量**。觀察:長文 charify 之節點數與 render 耗時。**預案**:兩階段批次 - **R5 DOM 重量**。觀察:長文 charify 之節點數與 render 耗時。**預案**:兩階段批次
變更(§3.4)+ 僅包裝必要標點 + 可選分塊/`requestIdleCallback` 漸進渲染;demo 變更(§3.4)+ 僅包裝必要標點 + 可選分塊/`requestIdleCallback` 漸進渲染;demo
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 cjk-autospace contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+43 -15
View File
@@ -19,6 +19,8 @@
npm install npm install
npm run build # esbuild → dist/juzhen.{mjs,iife.js,css};tsc → dist/index.d.ts npm run build # esbuild → dist/juzhen.{mjs,iife.js,css};tsc → dist/index.d.ts
npm test # jsdom DOM 正確性測試 npm test # jsdom DOM 正確性測試
node --test test/shared-rules.test.mjs # 共享規則 Schema/黃金向量/Web 漂移守門
npm run test:wkwebview # macOS 原生 WKWebView 版面/observer E2E(需 Xcode 與 GUI 會話)
``` ```
產物(`dist/`,已入庫): 產物(`dist/`,已入庫):
@@ -29,6 +31,29 @@ npm test # jsdom DOM 正確性測試
| `dist/juzhen.iife.js` | IIFE,全域 `Juzhen`,原生 `<script>` 直跑 | | `dist/juzhen.iife.js` | IIFE,全域 `Juzhen`,原生 `<script>` 直跑 |
| `dist/juzhen.css` | 合併樣式(**必須引入**,視覺尺寸全在此) | | `dist/juzhen.css` | 合併樣式(**必須引入**,視覺尺寸全在此) |
| `dist/index.d.ts` | TypeScript 型別宣告 | | `dist/index.d.ts` | TypeScript 型別宣告 |
| `rules/juzhen-rules.json` | 平台中立之共享排版規則(Web/XeLaTeX 共用) |
| `rules/golden-vectors.json` | 跨實作語義黃金向量 |
---
## 共享規則契約
`rules/` 是 Web/CSS 與並行 XeLaTeX 實作之唯一規則權威源;初版規則版本為 `1.0.0`。
規則以 Unicode code-point 區間、語言政策、標點寬度、間隙、禁斷與段末約束描述「應發生
什麼」,不規定 DOM/CSS、TeX node/glue/penalty 或其他 adapter 的實作機制。
套件提供四個唯讀子路徑:
```js
import rules from "cjk-autospace/rules" with { type: "json" };
import schema from "cjk-autospace/rules/schema" with { type: "json" };
import vectors from "cjk-autospace/rules/vectors" with { type: "json" };
import vectorsSchema from "cjk-autospace/rules/vectors-schema" with { type: "json" };
```
規則版本、Schema 版本、變更程序與 adapter 邊界詳見 [`rules/README.md`](./rules/README.md)。
任何規則變更必須同步更新 canonical JSON、Schema、黃金向量,並讓 Web 與 XeLaTeX adapter
同時通過相同語義向量;任一實作不得單邊分叉規則。
--- ---
@@ -110,16 +135,17 @@ jz.observe(document.body); // 之後 DOM 變動即自動增量恢復
| 功能 | 說明 | | 功能 | 說明 |
|---|---| |---|---|
| **中西間隙** `spacing` | CJK↔西文邊界插入 copy-clean 之 `jz-hws`(`user-select:none`,剪貼板跳過)。「西文」涵蓋拉丁(含擴充 A/B,拼音聲調 `ā ǎ ē`)、希臘、西里爾、數字、貨幣(`€ ₩ ₿`)、數學運算符等;CJK 標點(`。,「」()……` 等)不誤判為西文、不生隙。含跨樣式行內邊界與 pill/隔離元素邊界(`code`/`kbd`/`.katex` 與 `mark`,兩端補隙口徑一致;pill 緊鄰標點時穿越 `jz-jinze` 仍補間隙;pill 跨行右隙、間隙落行末之自我應驗皆已修)。**pill 之邏輯鄰字為標點時(如 `漢字。<code>`)不誤加間隙**(標點非 pill 鄰字;`jz-char`/`jz-inner` 透明穿越,與 `collectRuns` 一致)。贅餘作者空格一律正規化:CJK↔西文吸收為受管間隙、CJK↔CJK(含跨 inline 標籤)剝除不留隙。 | | **中西間隙** `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 無連字符 → 複製不污染)。 | | **長英文詞** `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` 後備模式**(見下節)。 | | **標點擠壓** `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/隔離/avoid**——見「行內元素三分類」)退化為**整元素綁定**——pill(如行內公式 `.katex`)即作為原子鄰字單位整體綁入 `jz-jinze`,絕不下潛肢解其內部。 | | **禁則** `jinze` | 行首行末避頭尾:閉類/點號綁前一字(不置行首)、開類綁後一字(不置行末),只隔離邊界字故長引文中段仍可斷。亦為 `justify` 綁定載體。邊界字落於**透明**行內樣式元素(`strong`/`em` 等)內時**克隆切分**只綁邊界詞元(避免整個元素入 `nowrap` 致行末整體掉行、上行留巨大空隙),切出之克隆標 `data-jz-kind="splitoff"`、`revert` 時自動併回原元素;不可切元素(`a`/`abbr`/`ruby`/帶 `id`,以及 **pill/隔離/avoid**——見「行內元素三分類」)退化為**整元素綁定**——pill(如行內公式 `.katex`)即作為原子鄰字單位整體綁入 `jz-jinze`,絕不下潛肢解其內部。 |
| **垂懸字避免** `orphan` | 段末行 CJK 實義字 ≥ N(預設 2;標點/符號不計)。方案 C(斷行層、無測量):把段末「第 N 個實義字起至段末」包入 `jz-orphan`(`white-space:nowrap`),整體不可斷 → 末行恒含之 ⇒ 末行實義字 ≥ N,任意寬度/字型成立。單行段無害。邊界字落於行內元素內時同樣**克隆切分**(只綁末 N 字、避免整個長元素入 `nowrap` 溢出容器;機制同禁則)。**預設開**(`orphan:false` 關,`{chars:n}` 調)。段落級功能。 | | **垂懸字避免** `orphan` | 段末行 CJK 實義字 ≥ N(預設 2;標點/符號不計)。方案 C(斷行層、無測量):把段末「第 N 個實義字起至段末」包入 `jz-orphan`(`white-space:nowrap`),整體不可斷 → 末行恒含之 ⇒ 末行實義字 ≥ N,任意寬度/字型成立。單行段無害。邊界字落於行內元素內時同樣**克隆切分**(只綁末 N 字、避免整個長元素入 `nowrap` 溢出容器;機制同禁則)。**預設開**(`orphan:false` 關,`{chars:n}` 調)。段落級功能。 |
| ~~**標點懸掛** `hanging`~~ | **已移除**(§6.5.6):純負 margin 機制為機制級死路(寬度守恆矛盾,CSS 接觸不到斷行器),原生 `hanging-punctuation` 為唯一正解但僅 Safari 支援、且與本庫 inline-block 原子互斥。`hanging` 選項仍被接受(API 相容)但**無任何效果**;相關程式碼/CSS 已全數刪除,原生路徑屬遠期「原生管線」新功能。 | | **功能分級** `level` | 功能分文本級(中西間隙、標點擠壓——任意文本片段適用)與段落級(禁則、垂懸字、長詞——需行/段布局)。塊級元素預設全功能;`data-jz-level="text"` 子樹或選項 `level:{text:"選擇器"}`(如標題/按鈕)僅跑文本級。 |
| **功能分級** `level` | 功能分文本級(中西間隙、標點擠壓——任意文本片段適用)與段落級(禁則、垂懸字、懸掛、長詞——需行/段布局)。塊級元素預設全功能;`data-jz-level="text"` 子樹或選項 `level:{text:"選擇器"}`(如標題/按鈕)僅跑文本級。 |
| **justify 單份間距** | 每個 `jz-char` 為 `inline-block` 原子項,句末標點只貢獻一份對齊伸縮量。 | | **justify 單份間距** | 每個 `jz-char` 為 `inline-block` 原子項,句末標點只貢獻一份對齊伸縮量。 |
後續(架構已預留,不返工):標點修正(簡↔繁)、著重號、注音/Ruby。 相容選項 `hanging`/`biaodian`/`emphasis`/`ruby` 仍被接受,但當前均為
**no-op**:沒有對應 pass、DOM 產物或 CSS。後三者僅屬明確標記的未來設計;
`hanging` 之舊負 margin 實作已移除,僅保留 API 相容。
--- ---
@@ -142,7 +168,6 @@ createJuzhen({
jiya: true, // 或物件:{ halfWidth: "halt" | "margin", bias } jiya: true, // 或物件:{ halfWidth: "halt" | "margin", bias }
jinze: true, jinze: true,
orphan: true, // 垂懸字避免(預設開);或 { chars: 2 };false 關 orphan: true, // 垂懸字避免(預設開);或 { chars: 2 };false 關
// hanging:已移除、無效果(見「標點懸掛」一節)
level: { text: "h1,h2,h3,button,.ui" }, // 此選擇器子樹僅跑文本級功能(見下) level: { text: "h1,h2,h3,button,.ui" }, // 此選擇器子樹僅跑文本級功能(見下)
pillSelector: "code, kbd, .katex", // 整體(pill)元素(見「行內元素三分類」) pillSelector: "code, kbd, .katex", // 整體(pill)元素(見「行內元素三分類」)
isolateSelector: "mark", // 內外隔離元素(預設 mark;"" 停用) isolateSelector: "mark", // 內外隔離元素(預設 mark;"" 停用)
@@ -214,8 +239,9 @@ createJuzhen({
| `data-jz-skip` | (存在即可) | 標記元素為 avoid(內外皆不處理) | | `data-jz-skip` | (存在即可) | 標記元素為 avoid(內外皆不處理) |
| `data-jz-halfwidth` | `halt|margin` | 逐子樹半形渲染模式 | | `data-jz-halfwidth` | `halt|margin` | 逐子樹半形渲染模式 |
**預留、當前無效果**:`hanging`(已移除,見下);`biaodian`/`emphasis`/`ruby`(架構預留切片 **相容 no-op 選項**:`hanging`/`biaodian`/`emphasis`/`ruby` 均可傳入,但當前不註冊
#7/#8)。注意頂層 `biaodian`(功能開關)與 `charClass.biaodian`(標點子類擴充表)**同名異義**。 pass,也不產生 DOM/CSS 效果。注意頂層 `biaodian` 與 `charClass.biaodian`(已實作的
標點子類擴充表)**同名異義**。
### 逐元素標點樣式(`data-jz-style`/`stylePolicy`) ### 逐元素標點樣式(`data-jz-style`/`stylePolicy`)
@@ -303,11 +329,11 @@ web component 等)採**嚴格白名單**、一律當**邊界**(語義同隔
- **文本級**(中西間隙 `spacing`、標點擠壓 `jiya`):僅依字符鄰接,**任意文本片段**(單行 - **文本級**(中西間隙 `spacing`、標點擠壓 `jiya`):僅依字符鄰接,**任意文本片段**(單行
標題、按鈕、單一行內元素)皆適用且想要。 標題、按鈕、單一行內元素)皆適用且想要。
- **段落級**(禁則 `jinze`、垂懸字 `orphan`、標點懸掛 `hanging`、長詞 `longWord`):依賴 - **段落級**(禁則 `jinze`、垂懸字 `orphan`、長詞 `longWord`):依賴
行/段布局(換行、行端、段末),對單行/非段落文本無意義甚至有害。 行/段布局(換行、行端、段末),對單行/非段落文本無意義甚至有害。
塊級元素(`p`/`div`/`li`/`h1`…)**預設段落級(全功能)**。把標題/按鈕/UI 文字標為 塊級元素(`p`/`div`/`li`/`h1`…)**預設段落級(全功能)**。把標題/按鈕/UI 文字標為
**文本級**(要間隙與標點半形、不要懸掛/避孤字/避頭尾): **文本級**(要間隙與標點半形、不要避孤字/避頭尾):
```html ```html
<h1 data-jz-level="text">標題:只跑中西間隙與標點擠壓</h1> <h1 data-jz-level="text">標題:只跑中西間隙與標點擠壓</h1>
@@ -484,7 +510,8 @@ JS 只注入 `jz-*` 結構,**所有視覺尺寸在 `juzhen.css`**——故 CSS
`jiya.bias` 逐字微調墨色偏側(見「字型適配工作流」)。 `jiya.bias` 逐字微調墨色偏側(見「字型適配工作流」)。
7. **自動處理、無需消費端介入**:`juzhen.css` 已在 `.juzhen` 範圍內以 `@supports` 7. **自動處理、無需消費端介入**:`juzhen.css` 已在 `.juzhen` 範圍內以 `@supports`
關閉 Blink 原生 `text-spacing-trim`/`text-autospace`(避免與託管擠壓重複作用), 關閉 Blink 原生 `text-spacing-trim`/`text-autospace`(避免與託管擠壓重複作用),
並令注入間隙 `user-select: none`(複製乾淨)。 間隙不注入空白字元;僅跨節點 IVS gap 之 `U+200B ::after`
設 `user-select:none`,避免該斷行提示進入選取/複製。
8. **段落屬性照常設、原子透明**:聚珍把標點拆成 inline-block 原子,但已確保這些原子對 8. **段落屬性照常設、原子透明**:聚珍把標點拆成 inline-block 原子,但已確保這些原子對
消費端段落樣式透明——可在段落正常設 `text-indent`(首行縮排)、`hanging-punctuation`、 消費端段落樣式透明——可在段落正常設 `text-indent`(首行縮排)、`hanging-punctuation`、
`text-decoration`(下劃線)等;庫內已歸零會被原子重複套用之繼承屬性、並令原子承接段落 `text-decoration`(下劃線)等;庫內已歸零會被原子重複套用之繼承屬性、並令原子承接段落
@@ -528,7 +555,7 @@ createJuzhen({
cjk: "〇", // 追加為中文(CJK):與西文邊界補中西間隙、禁則/justify 視為單字 cjk: "〇", // 追加為中文(CJK):與西文邊界補中西間隙、禁則/justify 視為單字
western: "★☆", // 追加為西文(半寬標點亦歸此):與 CJK 邊界補間隙 western: "★☆", // 追加為西文(半寬標點亦歸此):與 CJK 邊界補間隙
biaodian: { // 追加為全寬標點,按子類(jiya charify/擠壓、禁則避頭尾) biaodian: { // 追加為全寬標點,按子類(jiya charify/擠壓、禁則避頭尾)
stop: ".", // 句末(。!?類) stop: "‼", // 追加為句末點號(。.!?已內置)
pause: "", // 句內(,、;:類) pause: "", // 句內(,、;:類)
open: "", close: "", // 開/閉括號引號 open: "", close: "", // 開/閉括號引號
middle: "", liga: "", // 間隔號/省略破折 middle: "", liga: "", // 間隔號/省略破折
@@ -537,10 +564,9 @@ createJuzhen({
}); });
``` ```
**典型用例**:英文全角句號 `.`(U+FF0E,有別於中文句號 `。` U+3002)預設**未分類** `.`(U+FF0E,全角句點)已是內置 `BD_STOP` 成員,無需再經
——不被 charify、不挤壓、與 CJK 間不補隙。若內容用 `.` 作句末標點,以 `charClass` 追加;其政策與 `。!?` 一致。`charClass.biaodian` 用於追加尚未內置的
`charClass: { biaodian: { stop: "." } }` 歸為全寬句末標點,即比照 `。` 處理(開明式挤半形、 符號(如上例 `‼`),不用來重複宣告內置字符。
全角式佔全形、行首避頭尾)。
要點: 要點:
@@ -566,6 +592,8 @@ v1 相容層(`createCjkAutospace` 導出、IIFE 之 `globalThis.createCjkAutos
## 目視驗證 ## 目視驗證
`demo/index.html` 載入 `dist/` 產物,簡繁並列、可切 `justify`、附 copy-clean 測試區。 `demo/index.html` 載入 `dist/` 產物,簡繁並列、可切 `justify`、附 copy-clean 測試區。
`demo/e2e.html` 是可機讀的核心版面與 MutationObserver 驗收頁;macOS 上可以
`npm run test:wkwebview` 將同一頁面載入原生 `WKWebView`,自動執行全部斷言。
`demo/fallback.html` 以**方正書宋 GBK**(無 `halt`)對照 `halt` 模式失效 vs `margin` `demo/fallback.html` 以**方正書宋 GBK**(無 `halt`)對照 `halt` 模式失效 vs `margin`
後備生效。`demo/resize-check.html` 為**可拖曳容器**檢查頁:拖動方框寬度,驗證中西間隙在 後備生效。`demo/resize-check.html` 為**可拖曳容器**檢查頁:拖動方框寬度,驗證中西間隙在
各寬度下恆穩(含行末去隙之自我應驗、pill 跨行右隙、貨幣/拼音/西里爾外文補隙、全角省略號 各寬度下恆穩(含行末去隙之自我應驗、pill 跨行右隙、貨幣/拼音/西里爾外文補隙、全角省略號
+374
View File
@@ -0,0 +1,374 @@
<!doctype html>
<html lang="zh-Hans">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>聚珍浏览器验收</title>
<link rel="stylesheet" href="../dist/juzhen.css">
<style>
body
{
margin: 0;
padding: 2rem;
color: #172033;
background: #f5f7fb;
font: 16px/1.75 system-ui, sans-serif;
}
h1 { margin-top: 0; font-size: 1.5rem; }
#summary, .panel
{
max-width: 52rem;
margin: 1rem 0;
padding: 1rem 1.25rem;
border: 1px solid #cbd4e2;
border-radius: .5rem;
background: #fff;
}
#summary[data-state="pass"] { border-color: #238636; }
#summary[data-state="fail"] { border-color: #cf222e; }
#results { padding-left: 1.5rem; }
#results li[data-state="pass"] { color: #1a7f37; }
#results li[data-state="fail"] { color: #cf222e; font-weight: 600; }
#fixture
{
max-width: 18em;
font-family: "Noto Serif SC", "Noto Serif CJK SC", serif;
}
#fixture p { text-align: justify; }
code { font-family: ui-monospace, monospace; }
</style>
</head>
<body>
<h1>聚珍浏览器验收</h1>
<section id="summary" data-state="running" aria-live="polite">
<strong id="summary-text">正在运行……</strong>
<button id="run-core" type="button">运行核心验收</button>
<button id="run-observer" type="button" disabled>运行 MutationObserver 验收</button>
<ul id="results"></ul>
</section>
<main id="fixture" class="panel juzhen" lang="zh-Hans">
<p id="spacing-case">中文ABC与TypeScript混排。</p>
<p id="punctuation-case">开明式:中文,测试(括号)与句号。</p>
<p id="ivs-forward"><strong id="author-forward">甲&#x20000;</strong>&#xE0100;A</p>
<p id="ivs-forward-no-orphan" data-juzhen-off="orphan"><strong>甲&#x20000;</strong>&#xE0100;A</p>
<p id="ivs-forward-same-node" data-juzhen-off="orphan">&#x20000;&#xE0100;A</p>
<p id="ivs-forward-same-node-prefix" data-juzhen-off="orphan">甲&#x20000;&#xE0100;A</p>
<p id="ivs-reverse">A<strong id="author-reverse">&#x20000;</strong>&#xE0100;乙</p>
<p id="ivs-line-break" data-juzhen-off="orphan" style="width:2.5em;text-align:justify"><strong>甲&#x20000;</strong>&#xE0100;A</p>
<p id="ivs-nowrap" data-juzhen-off="orphan" style="width:1em;white-space:nowrap"><strong>甲&#x20000;</strong>&#xE0100;A</p>
<p id="ivs-lineedge-open" lang="zh-Hant">「&#x20000;&#xE0100;甲</p>
<p id="ivs-lineedge-close" lang="zh-Hant">甲&#x20000;&#xE0100;」</p>
<p id="observer-case">观察初始内容</p>
</main>
<script src="../dist/juzhen.iife.js"></script>
<script>
(function ()
{
"use strict";
var results = document.getElementById("results");
var summary = document.getElementById("summary");
var summaryText = document.getElementById("summary-text");
var coreButton = document.getElementById("run-core");
var observerButton = document.getElementById("run-observer");
var fixture = document.getElementById("fixture");
var authorForward = document.getElementById("author-forward");
var authorReverse = document.getElementById("author-reverse");
var originalHtml = fixture.innerHTML;
var originalText = fixture.textContent;
var clickCount = 0;
var failures = 0;
function record(name, condition, detail)
{
var item = document.createElement("li");
item.dataset.state = condition ? "pass" : "fail";
item.textContent = (condition ? "PASS " : "FAIL ") + name
+ (detail ? " — " + detail : "");
results.appendChild(item);
if (!condition) { failures += 1; }
}
function hasUnpairedSurrogate(text)
{
for (var i = 0; i < text.length; i += 1)
{
var unit = text.charCodeAt(i);
if (unit >= 0xD800 && unit <= 0xDBFF)
{
if (i + 1 >= text.length) { return true; }
var next = text.charCodeAt(i + 1);
if (next < 0xDC00 || next > 0xDFFF) { return true; }
i += 1;
}
else if (unit >= 0xDC00 && unit <= 0xDFFF)
{
return true;
}
}
return false;
}
function delay()
{
return new Promise(function (resolve)
{
setTimeout(resolve, 30);
});
}
authorForward.addEventListener("click", function () { clickCount += 1; });
authorReverse.addEventListener("click", function () { clickCount += 1; });
coreButton.addEventListener("click", async function ()
{
coreButton.disabled = true;
try
{
// 浏览器扩展可能在 DOMContentLoaded 后异步补写作者属性;以验收
// 按钮真正启动前的 DOM 为 revert 基线,避免把外部并发变更误报为库回归。
originalHtml = fixture.innerHTML;
originalText = fixture.textContent;
var jz = Juzhen.createJuzhen({
scope: { include: "#fixture" },
jiya: { halfWidth: "margin" },
});
jz.render(fixture);
var firstGapCount = fixture.querySelectorAll("jz-hws").length;
var firstCharCount = fixture.querySelectorAll("jz-char").length;
record("已生成中西间隙", firstGapCount > 0, firstGapCount + " 个");
record("已生成标点原子", firstCharCount > 0, firstCharCount + " 个");
record("copy-clean 文本保持", fixture.textContent === originalText);
record("无孤立 UTF-16 代理项", !hasUnpairedSurrogate(fixture.textContent));
var ivs = String.fromCodePoint(0x20000, 0xE0100);
var forward = document.getElementById("ivs-forward");
var forwardNoOrphan = document.getElementById("ivs-forward-no-orphan");
var forwardSameNode = document.getElementById("ivs-forward-same-node");
var forwardSameNodePrefix = document.getElementById("ivs-forward-same-node-prefix");
var reverse = document.getElementById("ivs-reverse");
var forwardCarrier = forward.querySelector("jz-hws");
var carrierKind = forwardCarrier?.getAttribute("data-jz-kind") ?? "none";
record("跨节点 IVS 正向完整", forward.textContent.includes(ivs + "A"));
record(
"关闭 orphan 时 IVS 正向间隙保留",
forwardNoOrphan.querySelectorAll("jz-hws").length === 1,
"gaps=" + forwardNoOrphan.querySelectorAll("jz-hws").length
+ ",HTML=" + forwardNoOrphan.innerHTML,
);
record(
"同一 Text 节点的 IVS 正向间隙保留",
forwardSameNode.querySelectorAll("jz-hws").length === 1,
"gaps=" + forwardSameNode.querySelectorAll("jz-hws").length
+ ",HTML=" + forwardSameNode.innerHTML,
);
record(
"带前导 CJK 的同节点 IVS 正向间隙保留",
forwardSameNodePrefix.querySelectorAll("jz-hws").length === 1,
"gaps=" + forwardSameNodePrefix.querySelectorAll("jz-hws").length
+ ",HTML=" + forwardSameNodePrefix.innerHTML,
);
record("跨节点 IVS 反向完整", reverse.textContent.includes("A" + ivs));
record(
"跨节点 IVS 在间隙载体中保持完整",
carrierKind === "wrap"
&& forwardCarrier?.textContent === ivs
&& forwardCarrier?.hasAttribute("data-jz-cross-node-atom")
&& Array.from(forwardCarrier.childNodes)
.filter(function (node) { return node.nodeType === Node.TEXT_NODE; })
.length === 2,
"forward gaps=" + forward.querySelectorAll("jz-hws").length
+ ",reverse gaps=" + reverse.querySelectorAll("jz-hws").length
+ ",carrier kind=" + carrierKind
+ ",carrier text=" + JSON.stringify(forwardCarrier?.textContent ?? null)
+ ",HTML=" + forward.innerHTML,
);
authorForward.click();
authorReverse.click();
record(
"作者元素身份与监听器保持",
document.getElementById("author-forward") === authorForward
&& document.getElementById("author-reverse") === authorReverse
&& document.querySelectorAll("#author-forward").length === 1
&& document.querySelectorAll("#author-reverse").length === 1
&& clickCount === 2,
);
var gap = fixture.querySelector("jz-hws");
var gapStyle = getComputedStyle(gap);
var gapRatio = parseFloat(gapStyle.marginRight) / parseFloat(gapStyle.fontSize);
record("间隙视觉宽度约为四分之一字", gapRatio >= 0.2 && gapRatio <= 0.3, gapRatio.toFixed(3) + "em");
var selection = window.getSelection();
var selectionRange = document.createRange();
selectionRange.selectNodeContents(forwardNoOrphan);
selection?.removeAllRanges();
selection?.addRange(selectionRange);
var selectionText = selection?.toString() ?? "";
record(
"Selection 文本不含生成断行字符",
selectionText === forwardNoOrphan.textContent,
"zwsp=" + selectionText.includes("\u200B")
+ ",length delta=" + (selectionText.length - forwardNoOrphan.textContent.length),
);
selection?.removeAllRanges();
await delay();
await delay();
var lineBreak = document.getElementById("ivs-line-break");
var lineGap = lineBreak.querySelector("jz-hws");
var lineNext = lineBreak.lastChild;
var lineGapRect = lineGap?.getBoundingClientRect();
var lineNextRange = document.createRange();
if (lineNext?.nodeType === Node.TEXT_NODE)
{
lineNextRange.setStart(lineNext, 0);
lineNextRange.setEnd(lineNext, 1);
}
var lineNextRect = lineNextRange.getBoundingClientRect();
var lineBoxRect = lineBreak.getBoundingClientRect();
var lineGapStyle = lineGap ? getComputedStyle(lineGap) : null;
var lineGapMargin = lineGapStyle
? parseFloat(lineGapStyle.marginRight) / parseFloat(lineGapStyle.fontSize)
: -1;
var nextLineIndent = (lineNextRect.left - lineBoxRect.left)
/ parseFloat(lineGapStyle?.fontSize || "16");
record(
"行末间隙载体留在前行并清零,下一行无缩进",
!!lineGapRect
&& lineNextRect.top > lineGapRect.top + 2
&& lineGap.classList.contains("jz-hws-trim")
&& Math.abs(lineGapMargin) <= 0.01
&& Math.abs(nextLineIndent) <= 0.05,
"trim=" + !!lineGap?.classList.contains("jz-hws-trim")
+ ",margin=" + lineGapMargin.toFixed(3)
+ "em,indent=" + nextLineIndent.toFixed(3) + "em",
);
var nowrapCase = document.getElementById("ivs-nowrap");
var nowrapGap = nowrapCase.querySelector("jz-hws");
var nowrapNext = nowrapCase.lastChild;
var nowrapNextRange = document.createRange();
if (nowrapNext?.nodeType === Node.TEXT_NODE)
{
nowrapNextRange.setStart(nowrapNext, 0);
nowrapNextRange.setEnd(nowrapNext, 1);
}
var nowrapGapRect = nowrapGap?.getBoundingClientRect();
var nowrapNextRect = nowrapNextRange.getBoundingClientRect();
record(
"生成断行机会不突破 nowrap",
!!nowrapGapRect && Math.abs(nowrapGapRect.top - nowrapNextRect.top) <= 2,
);
var ivsOpen = document.getElementById("ivs-lineedge-open").querySelector("jz-char");
var ivsClose = document.getElementById("ivs-lineedge-close").querySelector("jz-char");
record(
"IVS 相邻行首开标点保持行缘半形",
!!ivsOpen?.classList.contains("jz-half-le"),
);
record(
"IVS 相邻行尾闭标点保持行缘半形",
!!ivsClose?.classList.contains("jz-half-le"),
);
var comma = Array.from(fixture.querySelectorAll("jz-char"))
.find(function (item) { return item.textContent === ","; });
var commaStyle = comma ? getComputedStyle(comma) : null;
var commaRatio = comma && commaStyle
? comma.getBoundingClientRect().width / parseFloat(commaStyle.fontSize)
: 0;
record(
"margin 后备的句内逗号为半形",
commaRatio >= 0.45 && commaRatio <= 0.55,
commaRatio.toFixed(3) + "em",
);
jz.render(fixture);
record(
"重复 render 幂等",
fixture.querySelectorAll("jz-hws").length === firstGapCount
&& fixture.querySelectorAll("jz-char").length === firstCharCount,
);
jz.revert(fixture);
var internalComments = [];
var commentWalker = document.createTreeWalker(fixture, NodeFilter.SHOW_COMMENT);
while (commentWalker.nextNode())
{
if (commentWalker.currentNode.textContent?.startsWith("jz-spacing-") === true)
{
internalComments.push(commentWalker.currentNode);
}
}
record(
"revert 清除全部产物",
fixture.querySelectorAll("[data-jz]").length === 0
&& internalComments.length === 0
&& document.getElementById("author-forward") === authorForward
&& document.getElementById("author-reverse") === authorReverse
&& fixture.innerHTML === originalHtml
&& fixture.textContent === originalText,
);
jz.render(fixture);
observerButton.disabled = false;
observerButton.addEventListener("click", async function ()
{
observerButton.disabled = true;
try
{
jz.observe(fixture);
document.getElementById("observer-case").textContent = "观察Observer恢复";
await delay();
await delay();
record(
"MutationObserver 增量重排",
document.getElementById("observer-case").querySelectorAll("jz-hws").length > 0,
);
}
catch (error)
{
record(
"MutationObserver 增量重排",
false,
error instanceof Error ? error.message : String(error),
);
}
finally
{
jz.disconnect();
summary.dataset.state = failures === 0 ? "pass" : "fail";
summaryText.textContent = failures === 0
? "PASS:全部浏览器验收项通过"
: "FAIL:" + failures + " 项浏览器验收失败";
document.title = failures === 0
? "PASS — 聚珍浏览器验收"
: "FAIL — 聚珍浏览器验收";
}
});
}
catch (error)
{
record("验收脚本未抛错", false, error instanceof Error ? error.message : String(error));
}
summary.dataset.state = failures === 0 ? "pass" : "fail";
summaryText.textContent = failures === 0
? "核心验收通过;请运行 MutationObserver 验收"
: "FAIL:" + failures + " 项浏览器验收失败";
document.title = failures === 0
? "CORE PASS — 聚珍浏览器验收"
: "FAIL — 聚珍浏览器验收";
});
summaryText.textContent = "待运行核心验收";
document.title = "READY — 聚珍浏览器验收";
}());
</script>
</body>
</html>
+10 -4
View File
@@ -1,6 +1,6 @@
/** jz-* 自訂元素名(皆含連字號,合法 custom element,預設 inline)。 */ /** jz-* 自訂元素名(皆含連字號,合法 custom element,預設 inline)。 */
export type JzTag = "jz-hws" | "jz-char" | "jz-inner" | "jz-cs" | "jz-jinze" | "jz-orphan" | "jz-em" | "jz-ruby" | "jz-rb" | "jz-rt"; export type JzTag = "jz-hws" | "jz-char" | "jz-inner" | "jz-cs" | "jz-jinze" | "jz-orphan" | "jz-em" | "jz-ruby" | "jz-rb" | "jz-rt";
type JzKind = "marker" | "wrap"; type JzKind = "wrap";
export interface CreateOptions { export interface CreateOptions {
/** 追加之 class(空白分隔或陣列)。 */ /** 追加之 class(空白分隔或陣列)。 */
classes?: string | string[]; classes?: string | string[];
@@ -8,16 +8,22 @@ export interface CreateOptions {
text?: string; text?: string;
} }
/** 造一個帶 pass 標記之 jz-* 元素。 */ /** 造一個帶 pass 標記之 jz-* 元素。 */
export declare function createJz(tag: JzTag, pass: string, kind: JzKind, opts?: CreateOptions): HTMLElement; export declare function createJz(ownedElements: WeakSet<Element>, tag: JzTag, pass: string, kind: JzKind, opts?: CreateOptions): HTMLElement;
/** 記錄後寫屬性;同實例重寫時仍保留最初原值。 */
export declare function setOwnedAttribute(el: Element, name: string, value: string, owner: object): void;
/** 僅當 DOM 仍是本實例最後寫入之值時還原;外部變更時 fail closed。 */
export declare function revertOwnedAttribute(el: Element, name: string, owner: object): void;
/** 還原 owner 於 root 本身或子樹寫入之指定屬性,包括已被外部刪除者。 */
export declare function revertOwnedAttributes(root: Element, name: string, owner: object): void;
/** /**
* 還原某 pass 之全部產物(I6)。以 data-jz 定址,逆文件序處理(先內層後 * 還原某 pass 之全部產物(I6)。以 data-jz 定址,逆文件序處理(先內層後
* 外層),marker 移除、wrap 解包,最後 normalize 受影響父節點合併文本。 * 外層),wrap 解包,最後 normalize 受影響父節點合併文本。
* *
* splitoff(克隆切分之邊界詞元,見 core/split.ts)須在 wrap 全部解包**之後**才併回: * splitoff(克隆切分之邊界詞元,見 core/split.ts)須在 wrap 全部解包**之後**才併回:
* 否則其前兄弟可能仍是 wrap 殼(如 jz-jinze)而非原元素。故主迴圈跳過 splitoff,於 * 否則其前兄弟可能仍是 wrap 殼(如 jz-jinze)而非原元素。故主迴圈跳過 splitoff,於
* 解包後第二階段以文件序逐一併回前兄弟(同標籤元素),還原為單一元素(I6 一致)。 * 解包後第二階段以文件序逐一併回前兄弟(同標籤元素),還原為單一元素(I6 一致)。
*/ */
export declare function revertPass(pass: string, root: ParentNode): void; export declare function revertPass(pass: string, root: ParentNode, ownedElements: WeakSet<Element>): void;
/** 讀取 jz-char 之 bd-* 子類(無則 null)。 */ /** 讀取 jz-char 之 bd-* 子類(無則 null)。 */
export declare function bdClassOf(el: Element): string | null; export declare function bdClassOf(el: Element): string | null;
/** 該元素是否為某/任一 pass 之 jz 產物。 */ /** 該元素是否為某/任一 pass 之 jz 產物。 */
+15 -4
View File
@@ -1,15 +1,26 @@
import type { FeatureLevel, ResolvedOptions } from "../types.js"; import type { FeatureLevel, ResolvedOptions } from "../types.js";
/** 邏輯 run 中之單一字元來源位置。 */ /** 邏輯 run 中之單一 base+optional-VS 來源 UTF-16 位置。 */
export interface CharPos { export interface CharPos {
textNode: Text; textNode: Text;
offset: number; offset: number;
} }
/** 一段邏輯文本:剝離樣式後之連續字元,及其逐字回映射。 */ /** 邏輯字元在單一 Text 節點內之 UTF-16 區間。 */
export interface TextSpan {
textNode: Text;
start: number;
end: number;
}
/** 邏輯字元之完整 DOM 來源跨度;base 與 VS 可分居多個 Text 節點。 */
export interface CharSpan {
text: string;
parts: TextSpan[];
}
/** 一段邏輯文本:剝離樣式後之連續字元,及其逐邏輯字元回映射。 */
export interface LogicalRun { export interface LogicalRun {
text: string; text: string;
map: CharPos[]; map: CharSpan[];
} }
/** pill 鄰接之邏輯鄰字。 */ /** pill 鄰接之邏輯鄰字(base+optional-VS)。 */
export interface AdjacentChar { export interface AdjacentChar {
textNode: Text; textNode: Text;
offset: number; offset: number;
+2 -2
View File
@@ -6,9 +6,9 @@ import type { Finder } from "./finder.js";
* · 不可切/無法定位切點/整元素即該詞元 → 回 el 本身(綁整個元素,退化但安全)。 * · 不可切/無法定位切點/整元素即該詞元 → 回 el 本身(綁整個元素,退化但安全)。
* 切出之克隆標 splitoff,由 revertPass 併回;故 render→revert 還原一致(I6)。 * 切出之克隆標 splitoff,由 revertPass 併回;故 render→revert 還原一致(I6)。
*/ */
export declare function isolateBoundaryToken(el: Element, side: "head" | "tail", anyCjk: RegExp, pass: string, finder: Finder): Element; export declare function isolateBoundaryToken(el: Element, side: "head" | "tail", anyCjk: RegExp, pass: string, finder: Finder, ownedElements: WeakSet<Element>): Element;
/** /**
* 把 el 之**指定切點**(精確字位)起至末尾剝入克隆並回傳(供 orphan:自實義字綁至塊末)。 * 把 el 之**指定切點**(精確字位)起至末尾剝入克隆並回傳(供 orphan:自實義字綁至塊末)。
* 不可切或切點在最起點 → 回 null(呼叫端綁整個直接子)。 * 不可切或切點在最起點 → 回 null(呼叫端綁整個直接子)。
*/ */
export declare function splitElementAt(el: Element, textNode: Text, offset: number, pass: string, finder: Finder): Element | null; export declare function splitElementAt(el: Element, textNode: Text, offset: number, pass: string, finder: Finder, ownedElements: WeakSet<Element>): Element | null;
+17
View File
@@ -1,6 +1,23 @@
import type { LangClass } from "./locale.js"; import type { LangClass } from "./locale.js";
/** CJK 表意文字/假名/注音等範圍(沿用並擴充 v1 之 PANGU_CJK)。 */ /** CJK 表意文字/假名/注音等範圍(沿用並擴充 v1 之 PANGU_CJK)。 */
export declare const CJK: string; export declare const CJK: string;
/** Unicode variation selector 字元類片段;僅可作緊隨 base 的後綴。 */
export declare const VARIATION_SELECTORS = "\\uFE00-\\uFE0F\\u{E0100}-\\u{E01EF}";
/** 取 UTF-16 offset 處的完整 Unicode code point;越界時回空字串。 */
export declare function codePointAtUtf16(text: string, offset: number): string;
/** 回傳 `offset` 之前一個 code point 的 UTF-16 起始位置。 */
export declare function previousCodePointStart(text: string, offset: number): number;
/** 是否為 Unicode variation selector(BMP VS1–VS16 或補充 VS17–VS256)。 */
export declare function isVariationSelector(ch: string): boolean;
/**
* 取 UTF-16 offset 處之邏輯字元:一個 base code point,加上緊隨的單一 VS。
* 孤立 VS 只回自身,不反向吞併;回傳字串長度仍是 UTF-16 code units。
*/
export declare function variationSequenceAtUtf16(text: string, offset: number): string;
/** 回傳 `offset` 之前一個 base+optional-VS 邏輯字元的 UTF-16 起點。 */
export declare function previousVariationSequenceStart(text: string, offset: number): number;
/** 以 base+optional-VS 為單位切分字串;孤立 VS 仍是單獨單位。 */
export declare function splitVariationSequences(text: string): string[];
/** 西文字母與數字。 */ /** 西文字母與數字。 */
export declare const ALNUM = "A-Za-z0-9"; export declare const ALNUM = "A-Za-z0-9";
/** 西文字母。 */ /** 西文字母。 */
+6
View File
@@ -24,6 +24,12 @@ jz-jinze {
jz-hws { jz-hws {
margin-right: 0.25em; margin-right: 0.25em;
} }
jz-hws[data-jz-cross-node-atom]::after {
content: "\200b";
font-size: 0;
-webkit-user-select: none;
user-select: none;
}
jz-hws.jz-hws-trim { jz-hws.jz-hws-trim {
margin-right: 0; margin-right: 0;
} }
+1119 -418
View File
File diff suppressed because it is too large Load Diff
+1119 -418
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -146,6 +146,12 @@ export interface RenderContext {
root: Element; root: Element;
options: ResolvedOptions; options: ResolvedOptions;
finder: Finder; finder: Finder;
/** 當前 createJuzhen 實例造出之元素;DOM 屬性不能作為所有權憑證。 */
ownedElements: WeakSet<Element>;
/** 實例不可偽造之屬性寫者身分;狀態由 core/dom 共享堆疊管理。 */
ownerToken: object;
/** 寫入本實例產物之內部 class,並在觀察啟用時留下可精確核對之 mutation provenance。 */
setOwnedClass: (element: Element, className: string, present: boolean) => void;
} }
/** /**
* charify 階段之包裝請求:把 node.data[start, end) 包進 build() 造出之元素。 * charify 階段之包裝請求:把 node.data[start, end) 包進 build() 造出之元素。
+186 -109
View File
@@ -10,7 +10,8 @@
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/jsdom": "^21.1.7", "@types/jsdom": "^21.1.7",
"esbuild": "^0.24.0", "ajv": "^8.20.0",
"esbuild": "^0.28.2",
"jsdom": "^25.0.0", "jsdom": "^25.0.0",
"typescript": "^5.6.0" "typescript": "^5.6.0"
} }
@@ -145,9 +146,9 @@
} }
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.24.2", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -162,9 +163,9 @@
} }
}, },
"node_modules/@esbuild/android-arm": { "node_modules/@esbuild/android-arm": {
"version": "0.24.2", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -179,9 +180,9 @@
} }
}, },
"node_modules/@esbuild/android-arm64": { "node_modules/@esbuild/android-arm64": {
"version": "0.24.2", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -196,9 +197,9 @@
} }
}, },
"node_modules/@esbuild/android-x64": { "node_modules/@esbuild/android-x64": {
"version": "0.24.2", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -213,9 +214,9 @@
} }
}, },
"node_modules/@esbuild/darwin-arm64": { "node_modules/@esbuild/darwin-arm64": {
"version": "0.24.2", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -230,9 +231,9 @@
} }
}, },
"node_modules/@esbuild/darwin-x64": { "node_modules/@esbuild/darwin-x64": {
"version": "0.24.2", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -247,9 +248,9 @@
} }
}, },
"node_modules/@esbuild/freebsd-arm64": { "node_modules/@esbuild/freebsd-arm64": {
"version": "0.24.2", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -264,9 +265,9 @@
} }
}, },
"node_modules/@esbuild/freebsd-x64": { "node_modules/@esbuild/freebsd-x64": {
"version": "0.24.2", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -281,9 +282,9 @@
} }
}, },
"node_modules/@esbuild/linux-arm": { "node_modules/@esbuild/linux-arm": {
"version": "0.24.2", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -298,9 +299,9 @@
} }
}, },
"node_modules/@esbuild/linux-arm64": { "node_modules/@esbuild/linux-arm64": {
"version": "0.24.2", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -315,9 +316,9 @@
} }
}, },
"node_modules/@esbuild/linux-ia32": { "node_modules/@esbuild/linux-ia32": {
"version": "0.24.2", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -332,9 +333,9 @@
} }
}, },
"node_modules/@esbuild/linux-loong64": { "node_modules/@esbuild/linux-loong64": {
"version": "0.24.2", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@@ -349,9 +350,9 @@
} }
}, },
"node_modules/@esbuild/linux-mips64el": { "node_modules/@esbuild/linux-mips64el": {
"version": "0.24.2", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
"cpu": [ "cpu": [
"mips64el" "mips64el"
], ],
@@ -366,9 +367,9 @@
} }
}, },
"node_modules/@esbuild/linux-ppc64": { "node_modules/@esbuild/linux-ppc64": {
"version": "0.24.2", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -383,9 +384,9 @@
} }
}, },
"node_modules/@esbuild/linux-riscv64": { "node_modules/@esbuild/linux-riscv64": {
"version": "0.24.2", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -400,9 +401,9 @@
} }
}, },
"node_modules/@esbuild/linux-s390x": { "node_modules/@esbuild/linux-s390x": {
"version": "0.24.2", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -417,9 +418,9 @@
} }
}, },
"node_modules/@esbuild/linux-x64": { "node_modules/@esbuild/linux-x64": {
"version": "0.24.2", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -434,9 +435,9 @@
} }
}, },
"node_modules/@esbuild/netbsd-arm64": { "node_modules/@esbuild/netbsd-arm64": {
"version": "0.24.2", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -451,9 +452,9 @@
} }
}, },
"node_modules/@esbuild/netbsd-x64": { "node_modules/@esbuild/netbsd-x64": {
"version": "0.24.2", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -468,9 +469,9 @@
} }
}, },
"node_modules/@esbuild/openbsd-arm64": { "node_modules/@esbuild/openbsd-arm64": {
"version": "0.24.2", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -485,9 +486,9 @@
} }
}, },
"node_modules/@esbuild/openbsd-x64": { "node_modules/@esbuild/openbsd-x64": {
"version": "0.24.2", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -501,10 +502,27 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": { "node_modules/@esbuild/sunos-x64": {
"version": "0.24.2", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -519,9 +537,9 @@
} }
}, },
"node_modules/@esbuild/win32-arm64": { "node_modules/@esbuild/win32-arm64": {
"version": "0.24.2", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -536,9 +554,9 @@
} }
}, },
"node_modules/@esbuild/win32-ia32": { "node_modules/@esbuild/win32-ia32": {
"version": "0.24.2", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -553,9 +571,9 @@
} }
}, },
"node_modules/@esbuild/win32-x64": { "node_modules/@esbuild/win32-x64": {
"version": "0.24.2", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -608,6 +626,23 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/asynckit": { "node_modules/asynckit": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -790,9 +825,9 @@
} }
}, },
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.24.2", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
@@ -803,45 +838,70 @@
"node": ">=18" "node": ">=18"
}, },
"optionalDependencies": { "optionalDependencies": {
"@esbuild/aix-ppc64": "0.24.2", "@esbuild/aix-ppc64": "0.28.2",
"@esbuild/android-arm": "0.24.2", "@esbuild/android-arm": "0.28.2",
"@esbuild/android-arm64": "0.24.2", "@esbuild/android-arm64": "0.28.2",
"@esbuild/android-x64": "0.24.2", "@esbuild/android-x64": "0.28.2",
"@esbuild/darwin-arm64": "0.24.2", "@esbuild/darwin-arm64": "0.28.2",
"@esbuild/darwin-x64": "0.24.2", "@esbuild/darwin-x64": "0.28.2",
"@esbuild/freebsd-arm64": "0.24.2", "@esbuild/freebsd-arm64": "0.28.2",
"@esbuild/freebsd-x64": "0.24.2", "@esbuild/freebsd-x64": "0.28.2",
"@esbuild/linux-arm": "0.24.2", "@esbuild/linux-arm": "0.28.2",
"@esbuild/linux-arm64": "0.24.2", "@esbuild/linux-arm64": "0.28.2",
"@esbuild/linux-ia32": "0.24.2", "@esbuild/linux-ia32": "0.28.2",
"@esbuild/linux-loong64": "0.24.2", "@esbuild/linux-loong64": "0.28.2",
"@esbuild/linux-mips64el": "0.24.2", "@esbuild/linux-mips64el": "0.28.2",
"@esbuild/linux-ppc64": "0.24.2", "@esbuild/linux-ppc64": "0.28.2",
"@esbuild/linux-riscv64": "0.24.2", "@esbuild/linux-riscv64": "0.28.2",
"@esbuild/linux-s390x": "0.24.2", "@esbuild/linux-s390x": "0.28.2",
"@esbuild/linux-x64": "0.24.2", "@esbuild/linux-x64": "0.28.2",
"@esbuild/netbsd-arm64": "0.24.2", "@esbuild/netbsd-arm64": "0.28.2",
"@esbuild/netbsd-x64": "0.24.2", "@esbuild/netbsd-x64": "0.28.2",
"@esbuild/openbsd-arm64": "0.24.2", "@esbuild/openbsd-arm64": "0.28.2",
"@esbuild/openbsd-x64": "0.24.2", "@esbuild/openbsd-x64": "0.28.2",
"@esbuild/sunos-x64": "0.24.2", "@esbuild/openharmony-arm64": "0.28.2",
"@esbuild/win32-arm64": "0.24.2", "@esbuild/sunos-x64": "0.28.2",
"@esbuild/win32-ia32": "0.24.2", "@esbuild/win32-arm64": "0.28.2",
"@esbuild/win32-x64": "0.24.2" "@esbuild/win32-ia32": "0.28.2",
"@esbuild/win32-x64": "0.28.2"
} }
}, },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/form-data": { "node_modules/form-data": {
"version": "4.0.5", "version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"asynckit": "^0.4.0", "asynckit": "^0.4.0",
"combined-stream": "^1.0.8", "combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0", "es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2", "hasown": "^2.0.4",
"mime-types": "^2.1.12" "mime-types": "^2.1.35"
}, },
"engines": { "engines": {
"node": ">= 6" "node": ">= 6"
@@ -1053,6 +1113,13 @@
} }
} }
}, },
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"license": "MIT"
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "10.4.3", "version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
@@ -1130,6 +1197,16 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rrweb-cssom": { "node_modules/rrweb-cssom": {
"version": "0.7.1", "version": "0.7.1",
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz",
+10 -3
View File
@@ -12,17 +12,23 @@
"import": "./dist/juzhen.mjs", "import": "./dist/juzhen.mjs",
"default": "./dist/juzhen.iife.js" "default": "./dist/juzhen.iife.js"
}, },
"./css": "./dist/juzhen.css" "./css": "./dist/juzhen.css",
"./rules": "./rules/juzhen-rules.json",
"./rules/schema": "./rules/juzhen-rules.schema.json",
"./rules/vectors": "./rules/golden-vectors.json",
"./rules/vectors-schema": "./rules/golden-vectors.schema.json"
}, },
"files": [ "files": [
"dist", "dist",
"src", "src",
"rules",
"ARCHITECTURE.md", "ARCHITECTURE.md",
"README.md" "README.md"
], ],
"scripts": { "scripts": {
"build": "node build.mjs && tsc --emitDeclarationOnly", "build": "node build.mjs && tsc --emitDeclarationOnly",
"test": "node build.mjs && node --test test/*.test.mjs" "test": "node build.mjs && node --test test/*.test.mjs",
"test:wkwebview": "node test/run-wkwebview.mjs"
}, },
"keywords": [ "keywords": [
"cjk", "cjk",
@@ -36,7 +42,8 @@
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/jsdom": "^21.1.7", "@types/jsdom": "^21.1.7",
"esbuild": "^0.24.0", "ajv": "^8.20.0",
"esbuild": "^0.28.2",
"jsdom": "^25.0.0", "jsdom": "^25.0.0",
"typescript": "^5.6.0" "typescript": "^5.6.0"
} }
+61
View File
@@ -0,0 +1,61 @@
# 聚珍共享規則
本目錄是聚珍跨平台排版語義的機讀權威來源。Web/CSS 實作與未來其他 adapter
都應以 [`juzhen-rules.json`](./juzhen-rules.json) 為準;程式內常數不得另立一套未經
驗證的規則。`golden-vectors.json` 則固定跨 adapter 必須得到的語義結果,向量只描述
字符分類、間距、禁斷與段末保字等結果,不指定 DOM 結構或 CSS 技巧。
## 檔案
- `juzhen-rules.json`:版本化的共享規則資料。
- `juzhen-rules.schema.json`:JSON Schema 2020-12,約束共享規則格式。
- `golden-vectors.json`:跨平台黃金向量。
- `golden-vectors.schema.json`:JSON Schema 2020-12,約束向量格式。
套件公開子路徑分別為 `cjk-autospace/rules`、`cjk-autospace/rules/schema`、
`cjk-autospace/rules/vectors` 與 `cjk-autospace/rules/vectors-schema`。
## 版本
`schemaVersion` 是整數格式版本;讀取器遇到不認識的值必須拒絕,不得猜測。
`rulesetVersion` 採語義化版本:
- patch:澄清文字或新增不改變既有結果的向量;
- minor:向後相容地擴充字符或新增可選語義;
- major:既有輸入的分類、間距、禁斷、預設政策或 adapter 義務改變。
Unicode profile 另行記錄。更新 Unicode 範圍時,即使 JSON 結構不變,也必須同步提升
`rulesetVersion`、補向量並驗證所有 adapter。
## 變更協議
任何規則變更須在同一變更集中完成以下事項:
1. 更新 canonical JSON 及必要的 schema;
2. 新增或修訂能暴露行為差異的黃金向量,不以刪除反例來換取通過;
3. 更新 Web adapter 的對照測試;未來其他 adapter 亦須執行同一批語義向量;
4. 說明版本提升級別、相容性與 Unicode 依據;
5. 通過 Ajv 2020 strict schema 驗證、內部常數漂移檢查與全部 adapter 測試後方可發佈。
字符覆寫固定為追加語義:不得經 override 移除或重分類內置字符。若未來需要破壞此
規則,屬 major 變更,須先作產品政策決策,不能由單一 adapter 私自實作。
## Adapter 邊界
字符集合一律以大寫十六進位 Unicode code point 的閉區間/單點表示;陣列須按起點
遞增、彼此不重疊。adapter 可序列化成自身所需的 regex、查表或其他等價結構,但該
序列化不是 canonical 格式。
共享層只定義平台中立結果:Unicode 字符類、base+至多一枚 VS 的邏輯 atom、locale
分類與政策、標點半形語義、中西間距與作者空格處置、禁斷關係、段末保字數、功能級別
及語義依賴。
adapter 的節點表示、ownership/revert 帳務、渲染原語、生成式斷行提示、重跑生命
週期、layout 量測與平台 workaround 均不屬共享規則。例如 Web 目前可使用 `jz-*` DOM、
CSS margin/OpenType `halt` 與 MutationObserver;未來 TeX 類 adapter 也可能選擇 node、
glue、penalty 或 macro。這些都只是**非規範示例**,adapter 可採任何能通過黃金向量的
機制,不得把示例反寫為 canonical 要求。
Web 目前保留的 `hanging`/`biaodian`/`emphasis`/`ruby` 相容 no-op 亦屬
**非規範 adapter 相容性資訊**,不列入 canonical 規則,也不代表其他 adapter 必須公開
同名選項或未來功能介面已定案。
+373
View File
@@ -0,0 +1,373 @@
{
"$schema": "./golden-vectors.schema.json",
"schemaVersion": 1,
"rulesetVersion": "1.0.0",
"vectors": [
{
"id": "locale-ja",
"category": "language-classification",
"input": { "lang": "ja" },
"expected": { "langClass": "ja" }
},
{
"id": "locale-ja-jp",
"category": "language-classification",
"input": { "lang": "ja-JP" },
"expected": { "langClass": "ja" }
},
{
"id": "locale-zh-bare",
"category": "language-classification",
"input": { "lang": "zh" },
"expected": { "langClass": "zh-Hant" }
},
{
"id": "locale-zh-hant",
"category": "language-classification",
"input": { "lang": "zh-Hant" },
"expected": { "langClass": "zh-Hant" }
},
{
"id": "locale-zh-hans",
"category": "language-classification",
"input": { "lang": "zh-Hans" },
"expected": { "langClass": "zh-Hans" }
},
{
"id": "locale-zh-tw",
"category": "language-classification",
"input": { "lang": "zh-TW" },
"expected": { "langClass": "zh-Hant" }
},
{
"id": "locale-zh-hk",
"category": "language-classification",
"input": { "lang": "zh-HK" },
"expected": { "langClass": "zh-Hant" }
},
{
"id": "locale-zh-cn",
"category": "language-classification",
"input": { "lang": "zh-CN" },
"expected": { "langClass": "zh-Hans" }
},
{
"id": "locale-zhx-boundary",
"category": "language-classification",
"input": { "lang": "zhx" },
"expected": { "langClass": "other" }
},
{
"id": "locale-en",
"category": "language-classification",
"input": { "lang": "en" },
"expected": { "langClass": "other" }
},
{
"id": "punct-stop",
"category": "punctuation-classification",
"input": { "character": "." },
"expected": { "punctuationClass": "stop" }
},
{
"id": "punct-pause",
"category": "punctuation-classification",
"input": { "character": "," },
"expected": { "punctuationClass": "pause" }
},
{
"id": "punct-open",
"category": "punctuation-classification",
"input": { "character": "「" },
"expected": { "punctuationClass": "open" }
},
{
"id": "punct-close",
"category": "punctuation-classification",
"input": { "character": "」" },
"expected": { "punctuationClass": "close" }
},
{
"id": "punct-middle",
"category": "punctuation-classification",
"input": { "character": "·" },
"expected": { "punctuationClass": "middle" }
},
{
"id": "punct-liga",
"category": "punctuation-classification",
"input": { "character": "…" },
"expected": { "punctuationClass": "liga" }
},
{
"id": "punct-base-with-vs",
"category": "punctuation-classification",
"input": { "character": "。︀" },
"expected": { "punctuationClass": "stop" }
},
{
"id": "punct-other",
"category": "punctuation-classification",
"input": { "character": "A" },
"expected": { "punctuationClass": null }
},
{
"id": "style-quanjiao-pause",
"category": "punctuation-style",
"input": { "style": "quanjiao", "punctuationClass": "pause" },
"expected": { "halfWidth": false }
},
{
"id": "style-kaiming-pause",
"category": "punctuation-style",
"input": { "style": "kaiming", "punctuationClass": "pause" },
"expected": { "halfWidth": true }
},
{
"id": "style-kaiming-open",
"category": "punctuation-style",
"input": { "style": "kaiming", "punctuationClass": "open" },
"expected": { "halfWidth": true }
},
{
"id": "style-kaiming-stop",
"category": "punctuation-style",
"input": { "style": "kaiming", "punctuationClass": "stop" },
"expected": { "halfWidth": false }
},
{
"id": "style-banjiao-stop",
"category": "punctuation-style",
"input": { "style": "banjiao", "punctuationClass": "stop" },
"expected": { "halfWidth": true }
},
{
"id": "style-banjiao-liga",
"category": "punctuation-style",
"input": { "style": "banjiao", "punctuationClass": "liga" },
"expected": { "halfWidth": false }
},
{
"id": "adjacency-pause-open",
"category": "punctuation-adjacency",
"input": { "left": "pause", "right": "open" },
"expected": { "squeezeLeft": true, "squeezeRight": true }
},
{
"id": "adjacency-stop-open",
"category": "punctuation-adjacency",
"input": { "left": "stop", "right": "open" },
"expected": { "squeezeLeft": false, "squeezeRight": true }
},
{
"id": "adjacency-stop-close",
"category": "punctuation-adjacency",
"input": { "left": "stop", "right": "close" },
"expected": { "squeezeLeft": true, "squeezeRight": false }
},
{
"id": "adjacency-close-open",
"category": "punctuation-adjacency",
"input": { "left": "close", "right": "open" },
"expected": { "squeezeLeft": true, "squeezeRight": true }
},
{
"id": "adjacency-middle-liga",
"category": "punctuation-adjacency",
"input": { "left": "middle", "right": "liga" },
"expected": { "squeezeLeft": false, "squeezeRight": false }
},
{
"id": "spacing-cjk-latin",
"category": "spacing",
"input": { "text": "中A" },
"expected": { "managedGaps": 1, "normalizedText": "中A", "gapEm": 0.25 }
},
{
"id": "spacing-latin-cjk",
"category": "spacing",
"input": { "text": "A中" },
"expected": { "managedGaps": 1, "normalizedText": "A中", "gapEm": 0.25 }
},
{
"id": "spacing-cjk-at",
"category": "spacing",
"input": { "text": "中@" },
"expected": { "managedGaps": 1, "normalizedText": "中@", "gapEm": 0.25 }
},
{
"id": "spacing-at-cjk-asymmetric",
"category": "spacing",
"input": { "text": "@中" },
"expected": { "managedGaps": 0, "normalizedText": "@中" }
},
{
"id": "spacing-author-space-cjk-latin",
"category": "spacing",
"input": { "text": "中 A" },
"expected": {
"managedGaps": 1,
"normalizedText": "中A",
"authorSpaceDisposition": "remove-and-use-managed-gap"
}
},
{
"id": "spacing-author-space-latin-cjk",
"category": "spacing",
"input": { "text": "A 中" },
"expected": {
"managedGaps": 1,
"normalizedText": "A中",
"authorSpaceDisposition": "remove-and-use-managed-gap"
}
},
{
"id": "spacing-author-space-cjk-cjk",
"category": "spacing",
"input": { "text": "中 文" },
"expected": {
"managedGaps": 0,
"normalizedText": "中文",
"authorSpaceDisposition": "remove-without-gap"
}
},
{
"id": "spacing-cjk-punctuation-latin",
"category": "spacing",
"input": { "text": "中。A" },
"expected": { "managedGaps": 0, "normalizedText": "中。A" }
},
{
"id": "spacing-astral-ivs-latin",
"category": "spacing",
"input": { "text": "𠀀󠄀A" },
"expected": { "managedGaps": 1, "normalizedText": "𠀀󠄀A", "gapEm": 0.25 }
},
{
"id": "atom-bmp-vs",
"category": "ivs-atom",
"input": { "text": "中︀A" },
"expected": { "atoms": [ "中︀", "A" ] }
},
{
"id": "atom-astral-ivs",
"category": "ivs-atom",
"input": { "text": "𠀀󠄀A" },
"expected": { "atoms": [ "𠀀󠄀", "A" ] }
},
{
"id": "atom-at-most-one-vs",
"category": "ivs-atom",
"input": { "text": "中︀︁" },
"expected": { "atoms": [ "中︀", "︁" ] }
},
{
"id": "atom-isolated-vs",
"category": "ivs-atom",
"input": { "text": "︀A" },
"expected": { "atoms": [ "︀", "A" ] }
},
{
"id": "jinze-before-stop",
"category": "jinze",
"input": { "left": "A", "right": "。" },
"expected": { "prohibitedBreak": true, "reason": "before-stop" }
},
{
"id": "jinze-before-close",
"category": "jinze",
"input": { "left": "中", "right": "」" },
"expected": { "prohibitedBreak": true, "reason": "before-close" }
},
{
"id": "jinze-after-open",
"category": "jinze",
"input": { "left": "「", "right": "A" },
"expected": { "prohibitedBreak": true, "reason": "after-open" }
},
{
"id": "jinze-ordinary-boundary",
"category": "jinze",
"input": { "left": "中", "right": "A" },
"expected": { "prohibitedBreak": false }
},
{
"id": "jinze-supplementary-ivs-before-stop",
"category": "jinze",
"input": { "left": "𠀀\uDB40\uDD00", "right": "。" },
"expected": {
"prohibitedBreak": true,
"reason": "before-stop",
"atoms": [ "𠀀\uDB40\uDD00", "。" ]
}
},
{
"id": "jinze-second-vs-before-stop",
"category": "jinze",
"input": { "left": "𠀀\uDB40\uDD00\uDB40\uDD01", "right": "。" },
"expected": {
"prohibitedBreak": true,
"reason": "before-stop",
"atoms": [ "𠀀\uDB40\uDD00", "\uDB40\uDD01", "。" ]
}
},
{
"id": "jinze-open-before-second-isolated-vs",
"category": "jinze",
"input": { "left": "「\uFE00", "right": "\uDB40\uDD00" },
"expected": {
"prohibitedBreak": true,
"reason": "after-open",
"atoms": [ "「\uFE00", "\uDB40\uDD00" ]
}
},
{
"id": "orphan-default-two",
"category": "orphan",
"input": { "text": "天地玄黃", "minimum": 2 },
"expected": { "meaningfulCjkCount": 4, "protectedSuffix": "玄黃" }
},
{
"id": "orphan-trailing-punctuation",
"category": "orphan",
"input": { "text": "天地。", "minimum": 2 },
"expected": { "meaningfulCjkCount": 2, "protectedSuffix": "天地。" }
},
{
"id": "orphan-insufficient-cjk",
"category": "orphan",
"input": { "text": "天。", "minimum": 2 },
"expected": { "meaningfulCjkCount": 1, "protectedSuffix": null }
},
{
"id": "orphan-supplementary-ivs",
"category": "orphan",
"input": { "text": "甲𠀀\uDB40\uDD00", "minimum": 2 },
"expected": {
"meaningfulCjkCount": 2,
"protectedSuffix": "甲𠀀\uDB40\uDD00",
"atoms": [ "甲", "𠀀\uDB40\uDD00" ]
}
},
{
"id": "orphan-second-vs",
"category": "orphan",
"input": { "text": "甲𠀀\uDB40\uDD00\uDB40\uDD01", "minimum": 2 },
"expected": {
"meaningfulCjkCount": 2,
"protectedSuffix": "甲𠀀\uDB40\uDD00\uDB40\uDD01",
"atoms": [ "甲", "𠀀\uDB40\uDD00", "\uDB40\uDD01" ]
}
},
{
"id": "orphan-isolated-vs-prefix",
"category": "orphan",
"input": { "text": "\uDB40\uDD00甲𠀀\uDB40\uDD01", "minimum": 2 },
"expected": {
"meaningfulCjkCount": 2,
"protectedSuffix": "甲𠀀\uDB40\uDD01",
"atoms": [ "\uDB40\uDD00", "甲", "𠀀\uDB40\uDD01" ]
}
}
]
}
+267
View File
@@ -0,0 +1,267 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://juzhen.dev/schemas/golden-vectors.schema.json",
"title": "Juzhen shared rules golden vectors",
"type": "object",
"additionalProperties": false,
"required": [ "schemaVersion", "rulesetVersion", "vectors" ],
"properties": {
"$schema": { "type": "string" },
"schemaVersion": { "const": 1 },
"rulesetVersion": {
"type": "string",
"pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$"
},
"vectors": {
"type": "array",
"minItems": 20,
"items": {
"oneOf": [
{ "$ref": "#/$defs/languageVector" },
{ "$ref": "#/$defs/punctuationClassVector" },
{ "$ref": "#/$defs/punctuationStyleVector" },
{ "$ref": "#/$defs/punctuationAdjacencyVector" },
{ "$ref": "#/$defs/spacingVector" },
{ "$ref": "#/$defs/ivsAtomVector" },
{ "$ref": "#/$defs/jinzeVector" },
{ "$ref": "#/$defs/orphanVector" }
]
}
}
},
"$defs": {
"id": {
"type": "string",
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"
},
"punctuationClass": {
"enum": [ "stop", "pause", "open", "close", "middle", "liga" ]
},
"style": {
"enum": [ "quanjiao", "kaiming", "banjiao" ]
},
"atoms": {
"type": "array",
"minItems": 1,
"items": { "type": "string", "minLength": 1 }
},
"languageVector": {
"type": "object",
"additionalProperties": false,
"required": [ "id", "category", "input", "expected" ],
"properties": {
"id": { "$ref": "#/$defs/id" },
"category": { "const": "language-classification" },
"input": {
"type": "object",
"additionalProperties": false,
"required": [ "lang" ],
"properties": {
"lang": { "type": "string" }
}
},
"expected": {
"type": "object",
"additionalProperties": false,
"required": [ "langClass" ],
"properties": {
"langClass": { "enum": [ "zh-Hans", "zh-Hant", "ja", "other" ] }
}
}
}
},
"punctuationClassVector": {
"type": "object",
"additionalProperties": false,
"required": [ "id", "category", "input", "expected" ],
"properties": {
"id": { "$ref": "#/$defs/id" },
"category": { "const": "punctuation-classification" },
"input": {
"type": "object",
"additionalProperties": false,
"required": [ "character" ],
"properties": {
"character": { "type": "string", "minLength": 1 }
}
},
"expected": {
"type": "object",
"additionalProperties": false,
"required": [ "punctuationClass" ],
"properties": {
"punctuationClass": {
"oneOf": [ { "$ref": "#/$defs/punctuationClass" }, { "type": "null" } ]
}
}
}
}
},
"punctuationStyleVector": {
"type": "object",
"additionalProperties": false,
"required": [ "id", "category", "input", "expected" ],
"properties": {
"id": { "$ref": "#/$defs/id" },
"category": { "const": "punctuation-style" },
"input": {
"type": "object",
"additionalProperties": false,
"required": [ "style", "punctuationClass" ],
"properties": {
"style": { "$ref": "#/$defs/style" },
"punctuationClass": { "$ref": "#/$defs/punctuationClass" }
}
},
"expected": {
"type": "object",
"additionalProperties": false,
"required": [ "halfWidth" ],
"properties": {
"halfWidth": { "type": "boolean" }
}
}
}
},
"punctuationAdjacencyVector": {
"type": "object",
"additionalProperties": false,
"required": [ "id", "category", "input", "expected" ],
"properties": {
"id": { "$ref": "#/$defs/id" },
"category": { "const": "punctuation-adjacency" },
"input": {
"type": "object",
"additionalProperties": false,
"required": [ "left", "right" ],
"properties": {
"left": { "$ref": "#/$defs/punctuationClass" },
"right": { "$ref": "#/$defs/punctuationClass" }
}
},
"expected": {
"type": "object",
"additionalProperties": false,
"required": [ "squeezeLeft", "squeezeRight" ],
"properties": {
"squeezeLeft": { "type": "boolean" },
"squeezeRight": { "type": "boolean" }
}
}
}
},
"spacingVector": {
"type": "object",
"additionalProperties": false,
"required": [ "id", "category", "input", "expected" ],
"properties": {
"id": { "$ref": "#/$defs/id" },
"category": { "const": "spacing" },
"input": {
"type": "object",
"additionalProperties": false,
"required": [ "text" ],
"properties": {
"text": { "type": "string" }
}
},
"expected": {
"type": "object",
"additionalProperties": false,
"required": [ "managedGaps", "normalizedText" ],
"properties": {
"managedGaps": { "type": "integer", "minimum": 0 },
"normalizedText": { "type": "string" },
"gapEm": { "const": 0.25 },
"authorSpaceDisposition": {
"enum": [ "remove-and-use-managed-gap", "remove-without-gap" ]
}
}
}
}
},
"ivsAtomVector": {
"type": "object",
"additionalProperties": false,
"required": [ "id", "category", "input", "expected" ],
"properties": {
"id": { "$ref": "#/$defs/id" },
"category": { "const": "ivs-atom" },
"input": {
"type": "object",
"additionalProperties": false,
"required": [ "text" ],
"properties": {
"text": { "type": "string", "minLength": 1 }
}
},
"expected": {
"type": "object",
"additionalProperties": false,
"required": [ "atoms" ],
"properties": {
"atoms": { "$ref": "#/$defs/atoms" }
}
}
}
},
"jinzeVector": {
"type": "object",
"additionalProperties": false,
"required": [ "id", "category", "input", "expected" ],
"properties": {
"id": { "$ref": "#/$defs/id" },
"category": { "const": "jinze" },
"input": {
"type": "object",
"additionalProperties": false,
"required": [ "left", "right" ],
"properties": {
"left": { "type": "string", "minLength": 1 },
"right": { "type": "string", "minLength": 1 }
}
},
"expected": {
"type": "object",
"additionalProperties": false,
"required": [ "prohibitedBreak" ],
"properties": {
"prohibitedBreak": { "type": "boolean" },
"reason": { "enum": [ "before-stop", "before-close", "after-open" ] },
"atoms": { "$ref": "#/$defs/atoms" }
}
}
}
},
"orphanVector": {
"type": "object",
"additionalProperties": false,
"required": [ "id", "category", "input", "expected" ],
"properties": {
"id": { "$ref": "#/$defs/id" },
"category": { "const": "orphan" },
"input": {
"type": "object",
"additionalProperties": false,
"required": [ "text", "minimum" ],
"properties": {
"text": { "type": "string", "minLength": 1 },
"minimum": { "type": "integer", "minimum": 1 }
}
},
"expected": {
"type": "object",
"additionalProperties": false,
"required": [ "meaningfulCjkCount", "protectedSuffix" ],
"properties": {
"meaningfulCjkCount": { "type": "integer", "minimum": 0 },
"protectedSuffix": {
"oneOf": [ { "type": "string" }, { "type": "null" } ]
},
"atoms": { "$ref": "#/$defs/atoms" }
}
}
}
}
}
}
+255
View File
@@ -0,0 +1,255 @@
{
"$schema": "./juzhen-rules.schema.json",
"schemaVersion": 1,
"rulesetVersion": "1.0.0",
"unicodeProfile": {
"version": "17.0.0",
"coverage": "CJK Unified Ideographs through Extension J, compatibility ideographs, kana, bopomofo, radicals, and enclosed CJK characters"
},
"characterClasses": {
"representation": "unicode-code-point-ranges",
"cjk": [
{ "start": "2E80", "end": "2EFF" },
{ "start": "2F00", "end": "2FDF" },
{ "start": "3040", "end": "309F" },
{ "start": "30A0", "end": "30FA" },
{ "start": "30FC", "end": "30FF" },
{ "start": "3100", "end": "312F" },
{ "start": "3200", "end": "32FF" },
{ "start": "3400", "end": "4DBF" },
{ "start": "4E00", "end": "9FFF" },
{ "start": "F900", "end": "FAFF" },
{ "start": "20000", "end": "2A6DF" },
{ "start": "2A700", "end": "2B73F" },
{ "start": "2B740", "end": "2B81F" },
{ "start": "2B820", "end": "2CEAF" },
{ "start": "2CEB0", "end": "2EBEF" },
{ "start": "2EBF0", "end": "2EE5F" },
{ "start": "2F800", "end": "2FA1F" },
{ "start": "30000", "end": "3134F" },
{ "start": "31350", "end": "323AF" },
{ "start": "323B0", "end": "3347F" }
],
"ansAfterCjk": [
{ "start": "0024", "end": "0026" },
{ "start": "002A", "end": "002B" },
{ "codePoint": "002D" },
{ "start": "0030", "end": "0039" },
{ "codePoint": "003D" },
{ "codePoint": "0040" },
{ "start": "0041", "end": "005A" },
{ "codePoint": "005C" },
{ "codePoint": "005E" },
{ "start": "0061", "end": "007A" },
{ "start": "00A1", "end": "00B6" },
{ "start": "00B8", "end": "00FF" },
{ "start": "0100", "end": "024F" },
{ "start": "0370", "end": "03FF" },
{ "start": "0400", "end": "04FF" },
{ "start": "20A0", "end": "20BF" },
{ "start": "2150", "end": "21FF" },
{ "start": "2200", "end": "22FF" },
{ "start": "2700", "end": "27BF" }
],
"ansBeforeCjk": [
{ "start": "0024", "end": "0026" },
{ "start": "002A", "end": "002B" },
{ "codePoint": "002D" },
{ "start": "0030", "end": "0039" },
{ "codePoint": "003D" },
{ "start": "0041", "end": "005A" },
{ "codePoint": "005C" },
{ "codePoint": "005E" },
{ "start": "0061", "end": "007A" },
{ "start": "00A1", "end": "00B6" },
{ "start": "00B8", "end": "00FF" },
{ "start": "0100", "end": "024F" },
{ "start": "0370", "end": "03FF" },
{ "start": "0400", "end": "04FF" },
{ "start": "20A0", "end": "20BF" },
{ "start": "2150", "end": "21FF" },
{ "start": "2200", "end": "22FF" },
{ "start": "2700", "end": "27BF" }
],
"variationSelectors": [
{ "start": "FE00", "end": "FE0F" },
{ "start": "E0100", "end": "E01EF" }
],
"biaodian": {
"classificationOrder": [ "stop", "pause", "open", "close", "middle", "liga" ],
"classes": {
"stop": [
{ "codePoint": "3002" },
{ "codePoint": "FF01" },
{ "codePoint": "FF0E" },
{ "codePoint": "FF1F" }
],
"pause": [
{ "codePoint": "3001" },
{ "codePoint": "FF0C" },
{ "codePoint": "FF1A" },
{ "codePoint": "FF1B" }
],
"open": [
{ "codePoint": "2018" },
{ "codePoint": "201C" },
{ "codePoint": "3008" },
{ "codePoint": "300A" },
{ "codePoint": "300C" },
{ "codePoint": "300E" },
{ "codePoint": "3010" },
{ "codePoint": "3014" },
{ "codePoint": "3016" },
{ "codePoint": "FF08" },
{ "codePoint": "FF3B" },
{ "codePoint": "FF5B" }
],
"close": [
{ "codePoint": "2019" },
{ "codePoint": "201D" },
{ "codePoint": "3009" },
{ "codePoint": "300B" },
{ "codePoint": "300D" },
{ "codePoint": "300F" },
{ "codePoint": "3011" },
{ "codePoint": "3015" },
{ "codePoint": "3017" },
{ "codePoint": "FF09" },
{ "codePoint": "FF3D" },
{ "codePoint": "FF5D" }
],
"middle": [
{ "codePoint": "00B7" },
{ "codePoint": "2027" },
{ "codePoint": "30FB" }
],
"liga": [
{ "codePoint": "2014" },
{ "codePoint": "2026" }
]
}
},
"overrideSemantics": {
"mode": "append",
"westernTargets": [ "ansAfterCjk", "ansBeforeCjk" ],
"removalAllowed": false,
"reclassificationAllowed": false
}
},
"atoms": {
"unit": "base-code-point-plus-optional-variation-selector",
"maxVariationSelectors": 1,
"variationSelectorMustFollowBase": true,
"isolatedVariationSelector": "independent-atom"
},
"locales": {
"classes": [ "zh-Hans", "zh-Hant", "ja", "other" ],
"primaryLanguageBoundary": "exact-or-hyphen-subtag",
"classificationOrder": [
"ja-primary-language",
"zh-explicit-script",
"zh-traditional-region",
"zh-bare-default",
"zh-remaining",
"other"
],
"zh": {
"explicitTraditionalSubtag": "hant",
"explicitSimplifiedSubtag": "hans",
"traditionalRegions": [ "tw", "hk", "mo" ],
"bare": "zh-Hant",
"remaining": "zh-Hans"
},
"ja": "ja",
"defaultPolicies": {
"zh-Hans": "kaiming",
"zh-Hant": "quanjiao",
"ja": "quanjiao",
"other": "quanjiao"
},
"resolutionPriority": [
"element-attribute-or-style-policy-nearest-ancestor",
"locale-class-policy",
"global-style",
"default-policy"
]
},
"punctuationStyles": {
"baselineHalfWidthClasses": {
"quanjiao": [],
"kaiming": [ "pause", "open", "close" ],
"banjiao": [ "stop", "pause", "open", "close" ]
},
"adjacency": {
"rule": "squeeze-each-facing-blank-side-independently",
"sentenceBoundaryException": {
"left": "stop",
"right": "open",
"preserve": "left-right-blank"
}
}
},
"spacing": {
"gapEm": 0.25,
"directions": {
"cjkToAns": "ansAfterCjk",
"ansToCjk": "ansBeforeCjk",
"asymmetry": "at-sign-only-after-cjk"
},
"authorSpaceDisposition": {
"cjkAns": "remove-and-use-managed-gap",
"ansCjk": "remove-and-use-managed-gap",
"cjkCjk": "remove-without-gap",
"revertRestoresRemovedSpace": false
}
},
"lineBreaking": {
"prohibitedBreaks": {
"before": [ "close", "pause", "stop", "middle", "liga" ],
"after": [ "open" ]
},
"orphan": {
"defaultMeaningfulCjkChars": 2,
"counts": "cjk-atoms-not-classified-as-biaodian",
"excludes": [ "biaodian", "non-cjk" ]
}
},
"applicabilityLevels": {
"text": [ "spacing", "punctuation-classification-and-width" ],
"paragraph": [ "prohibited-breaks", "orphan-minimum", "long-word-hyphenation" ]
},
"semanticProcessing": {
"dependencies": [
{ "before": "atomize", "after": "classify" },
{ "before": "classify", "after": "derive-spacing" },
{ "before": "classify", "after": "derive-punctuation-width" },
{ "before": "classify", "after": "derive-break-constraints" },
{ "before": "classify", "after": "derive-orphan-constraint" }
],
"conditionalLineEdgeOutcomes": [
"punctuation-half-width-at-line-edge",
"remove-managed-gap-at-line-end"
]
},
"adapterBoundary": {
"platformNeutral": [
"unicode-character-classes",
"logical-atom-boundaries",
"locale-classification-and-policy",
"punctuation-width-semantics",
"spacing-decisions-and-distance",
"prohibited-break-relations",
"orphan-count",
"applicability-levels-and-semantic-dependencies"
],
"excludedAdapterMechanisms": [
"generated-node-or-element-representation",
"ownership-and-reversion-bookkeeping",
"rendering-primitive-selection",
"generated-line-break-hints",
"mutation-observation-or-rerun-lifecycle",
"layout-measurement",
"platform-specific-workarounds"
]
}
}
+389
View File
@@ -0,0 +1,389 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://juzhen.dev/schemas/juzhen-rules.schema.json",
"title": "Juzhen shared typography rules",
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"rulesetVersion",
"unicodeProfile",
"characterClasses",
"atoms",
"locales",
"punctuationStyles",
"spacing",
"lineBreaking",
"applicabilityLevels",
"semanticProcessing",
"adapterBoundary"
],
"properties": {
"$schema": { "type": "string" },
"schemaVersion": { "const": 1 },
"rulesetVersion": {
"type": "string",
"pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$"
},
"unicodeProfile": {
"type": "object",
"additionalProperties": false,
"required": [ "version", "coverage" ],
"properties": {
"version": { "const": "17.0.0" },
"coverage": { "type": "string", "minLength": 1 }
}
},
"characterClasses": {
"type": "object",
"additionalProperties": false,
"required": [
"representation",
"cjk",
"ansAfterCjk",
"ansBeforeCjk",
"variationSelectors",
"biaodian",
"overrideSemantics"
],
"properties": {
"representation": { "const": "unicode-code-point-ranges" },
"cjk": { "$ref": "#/$defs/codePointSet" },
"ansAfterCjk": { "$ref": "#/$defs/codePointSet" },
"ansBeforeCjk": { "$ref": "#/$defs/codePointSet" },
"variationSelectors": { "$ref": "#/$defs/codePointSet" },
"biaodian": {
"type": "object",
"additionalProperties": false,
"required": [ "classificationOrder", "classes" ],
"properties": {
"classificationOrder": {
"type": "array",
"prefixItems": [
{ "const": "stop" },
{ "const": "pause" },
{ "const": "open" },
{ "const": "close" },
{ "const": "middle" },
{ "const": "liga" }
],
"items": false,
"minItems": 6,
"maxItems": 6
},
"classes": {
"type": "object",
"additionalProperties": false,
"required": [ "stop", "pause", "open", "close", "middle", "liga" ],
"properties": {
"stop": { "$ref": "#/$defs/codePointSet" },
"pause": { "$ref": "#/$defs/codePointSet" },
"open": { "$ref": "#/$defs/codePointSet" },
"close": { "$ref": "#/$defs/codePointSet" },
"middle": { "$ref": "#/$defs/codePointSet" },
"liga": { "$ref": "#/$defs/codePointSet" }
}
}
}
},
"overrideSemantics": {
"type": "object",
"additionalProperties": false,
"required": [ "mode", "westernTargets", "removalAllowed", "reclassificationAllowed" ],
"properties": {
"mode": { "const": "append" },
"westernTargets": {
"type": "array",
"prefixItems": [ { "const": "ansAfterCjk" }, { "const": "ansBeforeCjk" } ],
"items": false,
"minItems": 2,
"maxItems": 2
},
"removalAllowed": { "const": false },
"reclassificationAllowed": { "const": false }
}
}
}
},
"atoms": {
"type": "object",
"additionalProperties": false,
"required": [
"unit",
"maxVariationSelectors",
"variationSelectorMustFollowBase",
"isolatedVariationSelector"
],
"properties": {
"unit": { "const": "base-code-point-plus-optional-variation-selector" },
"maxVariationSelectors": { "const": 1 },
"variationSelectorMustFollowBase": { "const": true },
"isolatedVariationSelector": { "const": "independent-atom" }
}
},
"locales": { "$ref": "#/$defs/locales" },
"punctuationStyles": { "$ref": "#/$defs/punctuationStyles" },
"spacing": { "$ref": "#/$defs/spacing" },
"lineBreaking": { "$ref": "#/$defs/lineBreaking" },
"applicabilityLevels": {
"type": "object",
"additionalProperties": false,
"required": [ "text", "paragraph" ],
"properties": {
"text": { "$ref": "#/$defs/stringArray" },
"paragraph": { "$ref": "#/$defs/stringArray" }
}
},
"semanticProcessing": {
"type": "object",
"additionalProperties": false,
"required": [ "dependencies", "conditionalLineEdgeOutcomes" ],
"properties": {
"dependencies": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": [ "before", "after" ],
"properties": {
"before": { "$ref": "#/$defs/semanticStage" },
"after": { "$ref": "#/$defs/semanticStage" }
}
}
},
"conditionalLineEdgeOutcomes": {
"type": "array",
"prefixItems": [
{ "const": "punctuation-half-width-at-line-edge" },
{ "const": "remove-managed-gap-at-line-end" }
],
"items": false,
"minItems": 2,
"maxItems": 2
}
}
},
"adapterBoundary": {
"type": "object",
"additionalProperties": false,
"required": [ "platformNeutral", "excludedAdapterMechanisms" ],
"properties": {
"platformNeutral": { "$ref": "#/$defs/stringArray" },
"excludedAdapterMechanisms": { "$ref": "#/$defs/stringArray" }
}
}
},
"$defs": {
"hexCodePoint": {
"type": "string",
"pattern": "^(?:[0-9A-F]{4}|[0-9A-F]{5}|10[0-9A-F]{4})$"
},
"codePointEntry": {
"oneOf": [
{
"type": "object",
"additionalProperties": false,
"required": [ "codePoint" ],
"properties": {
"codePoint": { "$ref": "#/$defs/hexCodePoint" }
}
},
{
"type": "object",
"additionalProperties": false,
"required": [ "start", "end" ],
"properties": {
"start": { "$ref": "#/$defs/hexCodePoint" },
"end": { "$ref": "#/$defs/hexCodePoint" }
}
}
]
},
"codePointSet": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "$ref": "#/$defs/codePointEntry" }
},
"stringArray": {
"type": "array",
"items": { "type": "string", "minLength": 1 },
"uniqueItems": true
},
"style": {
"enum": [ "quanjiao", "kaiming", "banjiao" ]
},
"semanticStage": {
"enum": [
"atomize",
"classify",
"derive-spacing",
"derive-punctuation-width",
"derive-break-constraints",
"derive-orphan-constraint"
]
},
"locales": {
"type": "object",
"additionalProperties": false,
"required": [
"classes",
"primaryLanguageBoundary",
"classificationOrder",
"zh",
"ja",
"defaultPolicies",
"resolutionPriority"
],
"properties": {
"classes": {
"type": "array",
"prefixItems": [
{ "const": "zh-Hans" },
{ "const": "zh-Hant" },
{ "const": "ja" },
{ "const": "other" }
],
"items": false,
"minItems": 4,
"maxItems": 4
},
"primaryLanguageBoundary": { "const": "exact-or-hyphen-subtag" },
"classificationOrder": { "$ref": "#/$defs/stringArray" },
"zh": {
"type": "object",
"additionalProperties": false,
"required": [ "explicitTraditionalSubtag", "explicitSimplifiedSubtag", "traditionalRegions", "bare", "remaining" ],
"properties": {
"explicitTraditionalSubtag": { "const": "hant" },
"explicitSimplifiedSubtag": { "const": "hans" },
"traditionalRegions": {
"type": "array",
"prefixItems": [ { "const": "tw" }, { "const": "hk" }, { "const": "mo" } ],
"items": false,
"minItems": 3,
"maxItems": 3
},
"bare": { "const": "zh-Hant" },
"remaining": { "const": "zh-Hans" }
}
},
"ja": { "const": "ja" },
"defaultPolicies": {
"type": "object",
"additionalProperties": false,
"required": [ "zh-Hans", "zh-Hant", "ja", "other" ],
"properties": {
"zh-Hans": { "$ref": "#/$defs/style" },
"zh-Hant": { "$ref": "#/$defs/style" },
"ja": { "$ref": "#/$defs/style" },
"other": { "$ref": "#/$defs/style" }
}
},
"resolutionPriority": {
"type": "array",
"prefixItems": [
{ "const": "element-attribute-or-style-policy-nearest-ancestor" },
{ "const": "locale-class-policy" },
{ "const": "global-style" },
{ "const": "default-policy" }
],
"items": false,
"minItems": 4,
"maxItems": 4
}
}
},
"punctuationStyles": {
"type": "object",
"additionalProperties": false,
"required": [ "baselineHalfWidthClasses", "adjacency" ],
"properties": {
"baselineHalfWidthClasses": {
"type": "object",
"additionalProperties": false,
"required": [ "quanjiao", "kaiming", "banjiao" ],
"properties": {
"quanjiao": { "$ref": "#/$defs/stringArray" },
"kaiming": { "$ref": "#/$defs/stringArray" },
"banjiao": { "$ref": "#/$defs/stringArray" }
}
},
"adjacency": {
"type": "object",
"additionalProperties": false,
"required": [ "rule", "sentenceBoundaryException" ],
"properties": {
"rule": { "const": "squeeze-each-facing-blank-side-independently" },
"sentenceBoundaryException": {
"type": "object",
"additionalProperties": false,
"required": [ "left", "right", "preserve" ],
"properties": {
"left": { "const": "stop" },
"right": { "const": "open" },
"preserve": { "const": "left-right-blank" }
}
}
}
}
}
},
"spacing": {
"type": "object",
"additionalProperties": false,
"required": [ "gapEm", "directions", "authorSpaceDisposition" ],
"properties": {
"gapEm": { "const": 0.25 },
"directions": {
"type": "object",
"additionalProperties": false,
"required": [ "cjkToAns", "ansToCjk", "asymmetry" ],
"properties": {
"cjkToAns": { "const": "ansAfterCjk" },
"ansToCjk": { "const": "ansBeforeCjk" },
"asymmetry": { "const": "at-sign-only-after-cjk" }
}
},
"authorSpaceDisposition": {
"type": "object",
"additionalProperties": false,
"required": [ "cjkAns", "ansCjk", "cjkCjk", "revertRestoresRemovedSpace" ],
"properties": {
"cjkAns": { "const": "remove-and-use-managed-gap" },
"ansCjk": { "const": "remove-and-use-managed-gap" },
"cjkCjk": { "const": "remove-without-gap" },
"revertRestoresRemovedSpace": { "const": false }
}
}
}
},
"lineBreaking": {
"type": "object",
"additionalProperties": false,
"required": [ "prohibitedBreaks", "orphan" ],
"properties": {
"prohibitedBreaks": {
"type": "object",
"additionalProperties": false,
"required": [ "before", "after" ],
"properties": {
"before": { "$ref": "#/$defs/stringArray" },
"after": { "$ref": "#/$defs/stringArray" }
}
},
"orphan": {
"type": "object",
"additionalProperties": false,
"required": [ "defaultMeaningfulCjkChars", "counts", "excludes" ],
"properties": {
"defaultMeaningfulCjkChars": { "const": 2 },
"counts": { "const": "cjk-atoms-not-classified-as-biaodian" },
"excludes": { "$ref": "#/$defs/stringArray" }
}
}
}
}
}
}
+147 -13
View File
@@ -17,10 +17,10 @@ export type JzTag =
| "jz-rb" | "jz-rb"
| "jz-rt"; | "jz-rt";
// createJz 之 kind:marker(純注入,還原時移除)/wrap(包裝原內容,還原時解包)。 // createJz 目前僅有 wrap(包裝原內容,還原時解包)生產者。
// data-jz-kind 另有第三種值 "splitoff"(克隆切分之邊界詞元),由 core/split.ts 直接以 // data-jz-kind 另有第三種值 "splitoff"(克隆切分之邊界詞元),由 core/split.ts 直接以
// setAttribute 設定、不經 createJz;revertPass 於 wrap 全解包後之第二階段併回(見下)。 // setAttribute 設定、不經 createJz;revertPass 於 wrap 全解包後之第二階段併回(見下)。
type JzKind = "marker" | "wrap"; type JzKind = "wrap";
export interface CreateOptions export interface CreateOptions
{ {
@@ -32,6 +32,50 @@ export interface CreateOptions
const KIND_ATTR = "data-jz-kind"; const KIND_ATTR = "data-jz-kind";
const PASS_ATTR = "data-jz"; const PASS_ATTR = "data-jz";
const WRAP_TAGS = new Map<string, Set<string>>([
[ "spacing", new Set([ "JZ-HWS" ]) ],
[ "jiya", new Set([ "JZ-CHAR", "JZ-INNER" ]) ],
[ "jinze", new Set([ "JZ-JINZE" ]) ],
[ "longWord", new Set([ "SPAN" ]) ],
[ "orphan", new Set([ "JZ-ORPHAN" ]) ],
]);
const SPLITOFF_TAGS = new Set([
"STRONG", "EM", "B", "I", "U", "S", "SPAN",
"SMALL", "INS", "DEL", "SUB", "SUP",
]);
interface AttributeWriter
{
owner: object;
value: string;
}
interface AttributeState
{
original: string | null;
writers: AttributeWriter[];
}
const ATTRIBUTE_WRITERS = new WeakMap<Element, Map<string, AttributeState>>();
const OWNER_ATTRIBUTES = new WeakMap<object, Map<string, Set<Element>>>();
function trackOwnedAttribute(owner: object, name: string, el: Element): void
{
let attrs = OWNER_ATTRIBUTES.get(owner);
if (!attrs) { attrs = new Map(); OWNER_ATTRIBUTES.set(owner, attrs); }
let elements = attrs.get(name);
if (!elements) { elements = new Set(); attrs.set(name, elements); }
elements.add(el);
}
function untrackOwnedAttribute(owner: object, name: string, el: Element): void
{
const attrs = OWNER_ATTRIBUTES.get(owner);
const elements = attrs?.get(name);
if (!elements) { return; }
elements.delete(el);
if (elements.size === 0) { attrs!.delete(name); }
}
function doc(): Document function doc(): Document
{ {
@@ -44,6 +88,7 @@ function doc(): Document
/** 造一個帶 pass 標記之 jz-* 元素。 */ /** 造一個帶 pass 標記之 jz-* 元素。 */
export function createJz( export function createJz(
ownedElements: WeakSet<Element>,
tag: JzTag, tag: JzTag,
pass: string, pass: string,
kind: JzKind, kind: JzKind,
@@ -51,6 +96,7 @@ export function createJz(
): HTMLElement ): HTMLElement
{ {
const el = doc().createElement(tag); const el = doc().createElement(tag);
ownedElements.add(el);
el.setAttribute(PASS_ATTR, pass); el.setAttribute(PASS_ATTR, pass);
el.setAttribute(KIND_ATTR, kind); el.setAttribute(KIND_ATTR, kind);
if (opts.classes) if (opts.classes)
@@ -62,19 +108,111 @@ export function createJz(
return el; return el;
} }
/** 記錄後寫屬性;同實例重寫時仍保留最初原值。 */
export function setOwnedAttribute(
el: Element,
name: string,
value: string,
owner: object,
): void
{
let attrs = ATTRIBUTE_WRITERS.get(el);
if (!attrs)
{
attrs = new Map();
ATTRIBUTE_WRITERS.set(el, attrs);
}
let state = attrs.get(name);
if (!state)
{
state = { original: el.getAttribute(name), writers: [] };
attrs.set(name, state);
}
else
{
const top = state.writers[state.writers.length - 1];
if (top && el.getAttribute(name) !== top.value)
{
// 外部已改寫:新寫入視為另一段所有權,以當前 DOM 為新原值。
for (const writer of state.writers)
{
untrackOwnedAttribute(writer.owner, name, el);
}
state = { original: el.getAttribute(name), writers: [] };
attrs.set(name, state);
}
}
state.writers = state.writers.filter((writer) => writer.owner !== owner);
state.writers.push({ owner, value });
trackOwnedAttribute(owner, name, el);
el.setAttribute(name, value);
}
/** 僅當 DOM 仍是本實例最後寫入之值時還原;外部變更時 fail closed。 */
export function revertOwnedAttribute(
el: Element,
name: string,
owner: object,
): void
{
const attrs = ATTRIBUTE_WRITERS.get(el);
const state = attrs?.get(name);
if (!state) { untrackOwnedAttribute(owner, name, el); return; }
const index = state.writers.findIndex((writer) => writer.owner === owner);
if (index < 0) { untrackOwnedAttribute(owner, name, el); return; }
const wasTop = index === state.writers.length - 1;
const top = state.writers[state.writers.length - 1]!;
const externallyChanged = el.getAttribute(name) !== top.value;
state.writers.splice(index, 1);
untrackOwnedAttribute(owner, name, el);
if (!externallyChanged && wasTop)
{
const next = state.writers[state.writers.length - 1];
const restored = next ? next.value : state.original;
if (restored === null) { el.removeAttribute(name); }
else { el.setAttribute(name, restored); }
}
if (state.writers.length === 0) { attrs!.delete(name); }
}
/** 還原 owner 於 root 本身或子樹寫入之指定屬性,包括已被外部刪除者。 */
export function revertOwnedAttributes(root: Element, name: string, owner: object): void
{
const elements = OWNER_ATTRIBUTES.get(owner)?.get(name);
if (!elements) { return; }
for (const el of Array.from(elements))
{
if (el === root || root.contains(el)) { revertOwnedAttribute(el, name, owner); }
}
}
function knownProduct(pass: string, kind: string | null, el: Element): boolean
{
if (kind === "wrap") { return !!WRAP_TAGS.get(pass)?.has(el.nodeName); }
if (kind === "splitoff")
{
return (pass === "jinze" || pass === "orphan") && SPLITOFF_TAGS.has(el.nodeName);
}
return false;
}
/** /**
* 還原某 pass 之全部產物(I6)。以 data-jz 定址,逆文件序處理(先內層後 * 還原某 pass 之全部產物(I6)。以 data-jz 定址,逆文件序處理(先內層後
* 外層),marker 移除、wrap 解包,最後 normalize 受影響父節點合併文本。 * 外層),wrap 解包,最後 normalize 受影響父節點合併文本。
* *
* splitoff(克隆切分之邊界詞元,見 core/split.ts)須在 wrap 全部解包**之後**才併回: * splitoff(克隆切分之邊界詞元,見 core/split.ts)須在 wrap 全部解包**之後**才併回:
* 否則其前兄弟可能仍是 wrap 殼(如 jz-jinze)而非原元素。故主迴圈跳過 splitoff,於 * 否則其前兄弟可能仍是 wrap 殼(如 jz-jinze)而非原元素。故主迴圈跳過 splitoff,於
* 解包後第二階段以文件序逐一併回前兄弟(同標籤元素),還原為單一元素(I6 一致)。 * 解包後第二階段以文件序逐一併回前兄弟(同標籤元素),還原為單一元素(I6 一致)。
*/ */
export function revertPass(pass: string, root: ParentNode): void export function revertPass(
pass: string,
root: ParentNode,
ownedElements: WeakSet<Element>,
): void
{ {
const nodes = Array.from( const nodes = Array.from(
root.querySelectorAll<HTMLElement>('[' + PASS_ATTR + '="' + pass + '"]'), root.querySelectorAll<HTMLElement>('[' + PASS_ATTR + '="' + pass + '"]'),
); ).filter((el) => ownedElements.has(el));
// 逆序:子元素先於父元素處理,避免解包父後子引用失效。 // 逆序:子元素先於父元素處理,避免解包父後子引用失效。
const touched = new Set<Node>(); const touched = new Set<Node>();
for (let i = nodes.length - 1; i >= 0; i -= 1) for (let i = nodes.length - 1; i >= 0; i -= 1)
@@ -83,21 +221,16 @@ export function revertPass(pass: string, root: ParentNode): void
const parent = el.parentNode; const parent = el.parentNode;
if (!parent) { continue; } if (!parent) { continue; }
const kind = el.getAttribute(KIND_ATTR); const kind = el.getAttribute(KIND_ATTR);
if (!knownProduct(pass, kind, el)) { continue; }
if (kind === "splitoff") { continue; } // 延後階段處理。 if (kind === "splitoff") { continue; } // 延後階段處理。
touched.add(parent); touched.add(parent);
if (kind === "marker")
{
parent.removeChild(el);
}
else
{
// wrap:把子節點搬出後移除外殼。 // wrap:把子節點搬出後移除外殼。
while (el.firstChild) while (el.firstChild)
{ {
parent.insertBefore(el.firstChild, el); parent.insertBefore(el.firstChild, el);
} }
parent.removeChild(el); parent.removeChild(el);
} ownedElements.delete(el);
} }
// 第二階段:splitoff 併回(此時 wrap 已全解包,前兄弟即原元素)。文件序,使同一 // 第二階段:splitoff 併回(此時 wrap 已全解包,前兄弟即原元素)。文件序,使同一
// 原元素之多個 splitoff 依序併入。 // 原元素之多個 splitoff 依序併入。
@@ -105,7 +238,7 @@ export function revertPass(pass: string, root: ParentNode): void
root.querySelectorAll<HTMLElement>( root.querySelectorAll<HTMLElement>(
'[' + PASS_ATTR + '="' + pass + '"][' + KIND_ATTR + '="splitoff"]', '[' + PASS_ATTR + '="' + pass + '"][' + KIND_ATTR + '="splitoff"]',
), ),
); ).filter((el) => ownedElements.has(el) && knownProduct(pass, "splitoff", el));
for (const so of splitoffs) for (const so of splitoffs)
{ {
const parent = so.parentNode; const parent = so.parentNode;
@@ -124,6 +257,7 @@ export function revertPass(pass: string, root: ParentNode): void
while (so.firstChild) { parent.insertBefore(so.firstChild, so); } while (so.firstChild) { parent.insertBefore(so.firstChild, so); }
parent.removeChild(so); parent.removeChild(so);
} }
ownedElements.delete(so);
} }
for (const p of touched) for (const p of touched)
{ {
+52 -12
View File
@@ -19,22 +19,41 @@
// 與**物理版面**(typeset/lineedge——依 getClientRects 之物理相鄰,須跨 isolate 找真實鄰接)。 // 與**物理版面**(typeset/lineedge——依 getClientRects 之物理相鄰,須跨 isolate 找真實鄰接)。
import type { FeatureLevel, ResolvedOptions } from "../types.js"; import type { FeatureLevel, ResolvedOptions } from "../types.js";
import {
codePointAtUtf16, isVariationSelector, previousCodePointStart,
previousVariationSequenceStart, variationSequenceAtUtf16,
} from "./unicode.js";
/** 邏輯 run 中之單一字元來源位置。 */ /** 邏輯 run 中之單一 base+optional-VS 來源 UTF-16 位置。 */
export interface CharPos export interface CharPos
{ {
textNode: Text; textNode: Text;
offset: number; offset: number;
} }
/** 一段邏輯文本:剝離樣式後之連續字元,及其逐字回映射。 */ /** 邏輯字元在單一 Text 節點內之 UTF-16 區間。 */
export interface TextSpan
{
textNode: Text;
start: number;
end: number;
}
/** 邏輯字元之完整 DOM 來源跨度;base 與 VS 可分居多個 Text 節點。 */
export interface CharSpan
{
text: string;
parts: TextSpan[];
}
/** 一段邏輯文本:剝離樣式後之連續字元,及其逐邏輯字元回映射。 */
export interface LogicalRun export interface LogicalRun
{ {
text: string; text: string;
map: CharPos[]; map: CharSpan[];
} }
/** pill 鄰接之邏輯鄰字。 */ /** pill 鄰接之邏輯鄰字(base+optional-VS)。 */
export interface AdjacentChar export interface AdjacentChar
{ {
textNode: Text; textNode: Text;
@@ -329,11 +348,11 @@ export class Finder
{ {
const runs: LogicalRun[] = []; const runs: LogicalRun[] = [];
let curText = ""; let curText = "";
let curMap: CharPos[] = []; let curMap: CharSpan[] = [];
const flush = (): void => const flush = (): void =>
{ {
if (curText.length >= 2) { runs.push({ text: curText, map: curMap }); } if (curMap.length >= 2) { runs.push({ text: curText, map: curMap }); }
curText = ""; curText = "";
curMap = []; curMap = [];
}; };
@@ -346,10 +365,30 @@ export class Finder
const t = node as Text; const t = node as Text;
if (this.isAvoided(t) || !this.inScope(t)) { return; } if (this.isAvoided(t) || !this.inScope(t)) { return; }
const data = t.data; const data = t.data;
for (let i = 0; i < data.length; i += 1) for (let i = 0; i < data.length;)
{ {
curText += data.charAt(i); const ch = variationSequenceAtUtf16(data, i);
curMap.push({ textNode: t, offset: i }); curText += ch;
const part = { textNode: t, start: i, end: i + ch.length };
const previous = curMap[curMap.length - 1];
if (isVariationSelector(ch) && previous
&& !isVariationSelector(codePointAtUtf16(previous.text, 0))
&& !isVariationSelector(codePointAtUtf16(
previous.text,
previousCodePointStart(previous.text, previous.text.length),
)))
{
// 透明 inline 可令 base 與 VS 分居不同 Text 節點。map 必須與
// 拼接 run 之 variation-sequence 分組同構,否則後續刪除/包裝
// 會錯用 selector 作為下一字元之 DOM 位置。
previous.text += ch;
previous.parts.push(part);
}
else
{
curMap.push({ text: ch, parts: [ part ] });
}
i += ch.length;
} }
return; return;
} }
@@ -404,13 +443,14 @@ export class Finder
{ {
if (direction === -1) if (direction === -1)
{ {
const offset = previousVariationSequenceStart(tn.data, tn.data.length);
return { return {
textNode: tn, textNode: tn,
offset: tn.data.length - 1, offset,
ch: tn.data.charAt(tn.data.length - 1), ch: variationSequenceAtUtf16(tn.data, offset),
}; };
} }
return { textNode: tn, offset: 0, ch: tn.data.charAt(0) }; return { textNode: tn, offset: 0, ch: variationSequenceAtUtf16(tn.data, 0) };
}; };
// descend 三態:邊界字/null(子樹無內容,續找兄弟)/"blocked"(遇 pill/ // descend 三態:邊界字/null(子樹無內容,續找兄弟)/"blocked"(遇 pill/
+1 -1
View File
@@ -61,7 +61,7 @@ export function classify(lang: string): LangClass
{ {
const l = (lang || "").toLowerCase(); const l = (lang || "").toLowerCase();
if (l === "ja" || l.indexOf("ja-") === 0) { return "ja"; } if (l === "ja" || l.indexOf("ja-") === 0) { return "ja"; }
if (l === "zh" || l.indexOf("zh-") === 0 || l.indexOf("zh") === 0) if (l === "zh" || l.indexOf("zh-") === 0)
{ {
// 顯式子標籤優先。 // 顯式子標籤優先。
if (l.indexOf("hant") >= 0) { return "zh-Hant"; } if (l.indexOf("hant") >= 0) { return "zh-Hant"; }
+9 -3
View File
@@ -58,14 +58,18 @@ function runCharify(passes: CharifyPass[], ctx: RenderContext): void
if (requests.length > 0) { work.push({ node, requests }); } if (requests.length > 0) { work.push({ node, requests }); }
}); });
for (const item of work) { applyRequests(item.node, item.requests); } for (const item of work) { applyRequests(item.node, item.requests, ctx.ownedElements); }
} }
/** /**
* 把單一文本節點依 requests 重建為 fragment:間隙以原文,命中段以 build() * 把單一文本節點依 requests 重建為 fragment:間隙以原文,命中段以 build()
* 之元素。requests 互不重疊;防禦性地丟棄與已採用段重疊者。 * 之元素。requests 互不重疊;防禦性地丟棄與已採用段重疊者。
*/ */
function applyRequests(node: Text, requests: WrapRequest[]): void function applyRequests(
node: Text,
requests: WrapRequest[],
ownedElements: WeakSet<Element>,
): void
{ {
const parent = node.parentNode; const parent = node.parentNode;
if (!parent) { return; } if (!parent) { return; }
@@ -85,7 +89,9 @@ function applyRequests(node: Text, requests: WrapRequest[]): void
{ {
frag.appendChild(ownerDoc.createTextNode(data.slice(cursor, req.start))); frag.appendChild(ownerDoc.createTextNode(data.slice(cursor, req.start)));
} }
frag.appendChild(req.build(data.slice(req.start, req.end))); const built = req.build(data.slice(req.start, req.end));
ownedElements.add(built);
frag.appendChild(built);
cursor = req.end; cursor = req.end;
} }
if (cursor === 0) { return; } // 無有效段。 if (cursor === 0) { return; } // 無有效段。
+27 -10
View File
@@ -24,6 +24,9 @@
// <button>)會令中間 opaque 祖先被逐層克隆肢解——下游 A7 之 .selection-bar-message 病例。 // <button>)會令中間 opaque 祖先被逐層克隆肢解——下游 A7 之 .selection-bar-message 病例。
import type { Finder } from "./finder.js"; import type { Finder } from "./finder.js";
import {
previousVariationSequenceStart, variationSequenceAtUtf16,
} from "./unicode.js";
const PASS_ATTR = "data-jz"; const PASS_ATTR = "data-jz";
const KIND_ATTR = "data-jz-kind"; const KIND_ATTR = "data-jz-kind";
@@ -91,22 +94,32 @@ function isTokenChar(ch: string, anyCjk: RegExp): boolean
return !anyCjk.test(ch) && !/\s/.test(ch); return !anyCjk.test(ch) && !/\s/.test(ch);
} }
/** 文本節點之首詞元結束位置(CJK 字=1;連續非 CJK 非空白=整詞)。 */ /** 文本節點之首詞元結束 UTF-16 位置(CJK base+optional-VS=單一邏輯字)。 */
function leadingTokenEnd(d: string, anyCjk: RegExp): number function leadingTokenEnd(d: string, anyCjk: RegExp): number
{ {
if (anyCjk.test(d.charAt(0))) { return 1; } const first = variationSequenceAtUtf16(d, 0);
let e = 1; if (anyCjk.test(first)) { return first.length; }
while (e < d.length && isTokenChar(d.charAt(e), anyCjk)) { e += 1; } let e = first.length;
while (e < d.length)
{
const ch = variationSequenceAtUtf16(d, e);
if (!isTokenChar(ch, anyCjk)) { break; }
e += ch.length;
}
return e; return e;
} }
/** 文本節點之末詞元起始位置。 */ /** 文本節點之末詞元起始位置。 */
function trailingTokenStart(d: string, anyCjk: RegExp): number function trailingTokenStart(d: string, anyCjk: RegExp): number
{ {
const last = d.length - 1; let s = previousVariationSequenceStart(d, d.length);
if (anyCjk.test(d.charAt(last))) { return last; } if (anyCjk.test(variationSequenceAtUtf16(d, s))) { return s; }
let s = last; while (s > 0)
while (s > 0 && isTokenChar(d.charAt(s - 1), anyCjk)) { s -= 1; } {
const previous = previousVariationSequenceStart(d, s);
if (!isTokenChar(variationSequenceAtUtf16(d, previous), anyCjk)) { break; }
s = previous;
}
return s; return s;
} }
@@ -125,6 +138,7 @@ function splitTreeAfter(
offset: number, offset: number,
pass: string, pass: string,
finder: Finder, finder: Finder,
ownedElements: WeakSet<Element>,
): Element | null ): Element | null
{ {
// 攀升對稱守衛:firstText/lastText 守下潛(遇非可切回 null),但 splitElementAt // 攀升對稱守衛:firstText/lastText 守下潛(遇非可切回 null),但 splitElementAt
@@ -168,6 +182,7 @@ function splitTreeAfter(
clone.removeAttribute("id"); clone.removeAttribute("id");
clone.setAttribute(PASS_ATTR, pass); clone.setAttribute(PASS_ATTR, pass);
clone.setAttribute(KIND_ATTR, "splitoff"); clone.setAttribute(KIND_ATTR, "splitoff");
ownedElements.add(clone);
let n: Node | null = rightAtLevel; let n: Node | null = rightAtLevel;
while (n) while (n)
{ {
@@ -210,6 +225,7 @@ export function isolateBoundaryToken(
anyCjk: RegExp, anyCjk: RegExp,
pass: string, pass: string,
finder: Finder, finder: Finder,
ownedElements: WeakSet<Element>,
): Element ): Element
{ {
if (!canSplit(el, finder)) { return el; } if (!canSplit(el, finder)) { return el; }
@@ -220,7 +236,7 @@ export function isolateBoundaryToken(
? { textNode: text, offset: trailingTokenStart(text.data, anyCjk) } ? { textNode: text, offset: trailingTokenStart(text.data, anyCjk) }
: { textNode: text, offset: leadingTokenEnd(text.data, anyCjk) }; : { textNode: text, offset: leadingTokenEnd(text.data, anyCjk) };
const clone = splitTreeAfter(el, point.textNode, point.offset, pass, finder); const clone = splitTreeAfter(el, point.textNode, point.offset, pass, finder, ownedElements);
if (!clone) { return el; } // 整元素即邊界詞元 → 綁整個 el。 if (!clone) { return el; } // 整元素即邊界詞元 → 綁整個 el。
return side === "tail" ? clone : el; return side === "tail" ? clone : el;
} }
@@ -235,8 +251,9 @@ export function splitElementAt(
offset: number, offset: number,
pass: string, pass: string,
finder: Finder, finder: Finder,
ownedElements: WeakSet<Element>,
): Element | null ): Element | null
{ {
if (!canSplit(el, finder)) { return null; } if (!canSplit(el, finder)) { return null; }
return splitTreeAfter(el, textNode, offset, pass, finder); return splitTreeAfter(el, textNode, offset, pass, finder, ownedElements);
} }
+81 -2
View File
@@ -5,7 +5,8 @@
// 對應 CSS class(jz-char 之 bd-* 標記)。 // 對應 CSS class(jz-char 之 bd-* 標記)。
// //
// 這些常數以「regex 字元類片段」形式給出(已就 RegExp 字元類做必要轉義), // 這些常數以「regex 字元類片段」形式給出(已就 RegExp 字元類做必要轉義),
// 故可直接 `new RegExp("[" + X + "]")`。 // 故可直接 `new RegExp("[" + X + "]", "u")`;補充平面範圍使用 `\u{...}`,
// 所有動態拼入 CJK 片段的正則必須保持 Unicode mode。
import type { LangClass } from "./locale.js"; import type { LangClass } from "./locale.js";
@@ -15,8 +16,85 @@ export const CJK = (
+ "぀-ゟ゠-ヺー-ヿ" // 平假名 + 片假名 + "぀-ゟ゠-ヺー-ヿ" // 平假名 + 片假名
+ "㄀-ㄯ㈀-㋿" // 注音符號 + 圈號 CJK + "㄀-ㄯ㈀-㋿" // 注音符號 + 圈號 CJK
+ "㐀-䶿一-鿿豈-﫿" // 擴展 A + 統一表意 + 相容 + "㐀-䶿一-鿿豈-﫿" // 擴展 A + 統一表意 + 相容
+ "\\u{20000}-\\u{2A6DF}" // 擴展 B
+ "\\u{2A700}-\\u{2B73F}" // 擴展 C
+ "\\u{2B740}-\\u{2B81F}" // 擴展 D
+ "\\u{2B820}-\\u{2CEAF}" // 擴展 E
+ "\\u{2CEB0}-\\u{2EBEF}" // 擴展 F
+ "\\u{2EBF0}-\\u{2EE5F}" // 擴展 I
+ "\\u{2F800}-\\u{2FA1F}" // CJK 相容表意文字補充
+ "\\u{30000}-\\u{3134F}" // 擴展 G
+ "\\u{31350}-\\u{323AF}" // 擴展 H
+ "\\u{323B0}-\\u{3347F}" // 擴展 J
); );
/** Unicode variation selector 字元類片段;僅可作緊隨 base 的後綴。 */
export const VARIATION_SELECTORS = "\\uFE00-\\uFE0F\\u{E0100}-\\u{E01EF}";
/** 取 UTF-16 offset 處的完整 Unicode code point;越界時回空字串。 */
export function codePointAtUtf16(text: string, offset: number): string
{
const value = text.codePointAt(offset);
return value === undefined ? "" : String.fromCodePoint(value);
}
/** 回傳 `offset` 之前一個 code point 的 UTF-16 起始位置。 */
export function previousCodePointStart(text: string, offset: number): number
{
if (offset <= 0) { return 0; }
const last = text.charCodeAt(offset - 1);
if (last >= 0xDC00 && last <= 0xDFFF && offset >= 2)
{
const first = text.charCodeAt(offset - 2);
if (first >= 0xD800 && first <= 0xDBFF) { return offset - 2; }
}
return offset - 1;
}
/** 是否為 Unicode variation selector(BMP VS1–VS16 或補充 VS17–VS256)。 */
export function isVariationSelector(ch: string): boolean
{
const value = ch.codePointAt(0);
return value !== undefined
&& ((value >= 0xFE00 && value <= 0xFE0F)
|| (value >= 0xE0100 && value <= 0xE01EF));
}
/**
* 取 UTF-16 offset 處之邏輯字元:一個 base code point,加上緊隨的單一 VS。
* 孤立 VS 只回自身,不反向吞併;回傳字串長度仍是 UTF-16 code units。
*/
export function variationSequenceAtUtf16(text: string, offset: number): string
{
const base = codePointAtUtf16(text, offset);
if (!base || isVariationSelector(base)) { return base; }
const selector = codePointAtUtf16(text, offset + base.length);
return isVariationSelector(selector) ? base + selector : base;
}
/** 回傳 `offset` 之前一個 base+optional-VS 邏輯字元的 UTF-16 起點。 */
export function previousVariationSequenceStart(text: string, offset: number): number
{
const last = previousCodePointStart(text, offset);
const lastChar = codePointAtUtf16(text, last);
if (!isVariationSelector(lastChar) || last <= 0) { return last; }
const base = previousCodePointStart(text, last);
return isVariationSelector(codePointAtUtf16(text, base)) ? last : base;
}
/** 以 base+optional-VS 為單位切分字串;孤立 VS 仍是單獨單位。 */
export function splitVariationSequences(text: string): string[]
{
const out: string[] = [];
for (let offset = 0; offset < text.length;)
{
const sequence = variationSequenceAtUtf16(text, offset);
out.push(sequence);
offset += sequence.length;
}
return out;
}
/** 西文字母與數字。 */ /** 西文字母與數字。 */
export const ALNUM = "A-Za-z0-9"; export const ALNUM = "A-Za-z0-9";
@@ -158,9 +236,10 @@ export function classifyBiaodian(
biaodian: Record<BdSubclass, string> = DEFAULT_RULESET.biaodian, biaodian: Record<BdSubclass, string> = DEFAULT_RULESET.biaodian,
): string | null ): string | null
{ {
const base = codePointAtUtf16(ch, 0);
for (const sub of BD_ORDER) for (const sub of BD_ORDER)
{ {
if (biaodian[sub].indexOf(ch) >= 0) { return "bd-" + sub; } if (biaodian[sub].indexOf(base) >= 0) { return "bd-" + sub; }
} }
return null; return null;
} }
+12
View File
@@ -12,6 +12,18 @@ jz-hws
margin-right: 0.25em; /* clreq 四分之一字寬 */ margin-right: 0.25em; /* clreq 四分之一字寬 */
} }
/* WebKit 對「跨節點後聚合進行內包裹的 CJK+IVS」与其後文本不一定暴露斷行點,
會把左側原子連同後字整體推到下一行。僅此類 owned gap 取得限定屬性;
偽元素只向斷行器提供零寬機會,不進入 DOM/textContent。普通 BMP、pill 與
同節點 gap 不受影響。 */
jz-hws[data-jz-cross-node-atom]::after
{
content: "\200B";
font-size: 0;
-webkit-user-select: none;
user-select: none;
}
/* 行末去隙(由 gapTrim layout pass 標記):落在行末之間隙移除其 margin-right, /* 行末去隙(由 gapTrim layout pass 標記):落在行末之間隙移除其 margin-right,
使該行於 justify 下齊右、不內縮 0.25em。 */ 使該行於 justify 下齊右、不內縮 0.25em。 */
jz-hws.jz-hws-trim jz-hws.jz-hws-trim
+187 -15
View File
@@ -8,6 +8,7 @@
// v1 相容層(createCjkAutospace)已隨最後一批 v1 消費端遷移完成而移除。 // v1 相容層(createCjkAutospace)已隨最後一批 v1 消費端遷移完成而移除。
import { Finder } from "./core/finder.js"; import { Finder } from "./core/finder.js";
import { revertOwnedAttribute, setOwnedAttribute } from "./core/dom.js";
import { runPasses, revertPasses } from "./core/pass.js"; import { runPasses, revertPasses } from "./core/pass.js";
import { jiyaAdjacencyPass, jiyaPass } from "./typeset/jiya.js"; import { jiyaAdjacencyPass, jiyaPass } from "./typeset/jiya.js";
import { jinzePass } from "./typeset/jinze.js"; import { jinzePass } from "./typeset/jinze.js";
@@ -186,6 +187,13 @@ const OBSERVE_ATTRS = [
"data-jz-skip", "data-jz-level", "data-jz-style", "data-juzhen", "data-juzhen-off", "data-jz-skip", "data-jz-level", "data-jz-style", "data-juzhen", "data-juzhen-off",
]; ];
interface ExpectedClassMutation
{
target: Element;
oldValue: string | null;
newValue: string | null;
}
/** 建立一個聚珍實例。 */ /** 建立一個聚珍實例。 */
export function createJuzhen(opts: JuzhenOptions = {}): Juzhen export function createJuzhen(opts: JuzhenOptions = {}): Juzhen
{ {
@@ -196,32 +204,60 @@ export function createJuzhen(opts: JuzhenOptions = {}): Juzhen
// 自動清其子樹快取(安全預設),顯式 invalidate 供「改屬性不 revert」之情形。移除之元素由 // 自動清其子樹快取(安全預設),顯式 invalidate 供「改屬性不 revert」之情形。移除之元素由
// WeakMap 自動回收。 // WeakMap 自動回收。
const finder = new Finder(options); const finder = new Finder(options);
const ownedElements = new WeakSet<Element>();
const ownerToken = {};
const renderedRoots = new Set<Element>();
function makeCtx(root: Element): RenderContext function makeCtx(root: Element): RenderContext
{ {
return { root, options, finder }; return { root, options, finder, ownedElements, ownerToken, setOwnedClass };
} }
function renderImpl(el: Element): void function renderImpl(el: Element): void
{ {
renderedRoots.add(el);
runPasses(PASSES, makeCtx(el)); runPasses(PASSES, makeCtx(el));
// margin 後備模式以 root 屬性作 CSS 開關(見 css/_jiya.css);halt 模式不設。 // margin 後備模式以 root 屬性作 CSS 開關(見 css/_jiya.css);halt 模式不設。
if (options.features.jiya && options.jiyaConfig.halfWidth === "margin") if (options.features.jiya && options.jiyaConfig.halfWidth === "margin")
{ {
el.setAttribute("data-jz-halfwidth", "margin"); setOwnedAttribute(el, "data-jz-halfwidth", "margin", ownerToken);
} }
// justifyAtoms:false → jz-char/jz-jinze 改 inline(分頁器相容,見 css)。 // justifyAtoms:false → jz-char/jz-jinze 改 inline(分頁器相容,見 css)。
if (!options.justifyAtoms) if (!options.justifyAtoms)
{ {
el.setAttribute("data-jz-atoms", "inline"); setOwnedAttribute(el, "data-jz-atoms", "inline", ownerToken);
}
}
function rootsWithin(el: Element): Element[]
{
return Array.from(renderedRoots)
.filter((root) => root === el || el.contains(root))
.sort((a, b) => a.contains(b) ? 1 : b.contains(a) ? -1 : 0);
}
/** 精確清理已知 render 根;根可已脫離 document,故不依賴 isConnected。 */
function unwindRoots(roots: Element[]): void
{
for (const root of roots)
{
revertPasses(PASSES, makeCtx(root));
revertOwnedAttribute(root, "data-jz-halfwidth", ownerToken);
revertOwnedAttribute(root, "data-jz-atoms", ownerToken);
renderedRoots.delete(root);
finder.invalidate(root);
} }
} }
function revertImpl(el: Element): void function revertImpl(el: Element): void
{ {
const roots = rootsWithin(el);
if (roots.length === 0)
{
// 允許對已 render 根之子樹作局部還原。
revertPasses(PASSES, makeCtx(el)); revertPasses(PASSES, makeCtx(el));
el.removeAttribute("data-jz-halfwidth"); }
el.removeAttribute("data-jz-atoms"); else { unwindRoots(roots); }
// 失效(安全預設):清 el 子樹之元素鍵快取,使後續 render 對「屬性已變」之元素重新分類。 // 失效(安全預設):清 el 子樹之元素鍵快取,使後續 render 對「屬性已變」之元素重新分類。
// 內容變化雖不改分類(此清屬多餘),但廉價且無害,且 render 本就將重掃該子樹。 // 內容變化雖不改分類(此清屬多餘),但廉價且無害,且 render 本就將重掃該子樹。
finder.invalidate(el); finder.invalidate(el);
@@ -230,22 +266,131 @@ export function createJuzhen(opts: JuzhenOptions = {}): Juzhen
// ----- C2 觀察狀態 ----- // ----- C2 觀察狀態 -----
let observer: MutationObserver | null = null; let observer: MutationObserver | null = null;
let observeRoot: Element | null = null; let observeRoot: Element | null = null;
let observerPauseDepth = 0;
let observerActive = false;
const expectedClassMutations: ExpectedClassMutation[] = [];
let expectedClassMutationHead = 0;
const observeConfig: MutationObserverInit = { const observeConfig: MutationObserverInit = {
subtree: true, childList: true, characterData: true, subtree: true, childList: true, characterData: true,
attributes: true, attributeFilter: OBSERVE_ATTRS.slice(), attributes: true, attributeOldValue: true, attributeFilter: OBSERVE_ATTRS.slice(),
}; };
function clearExpectedClassMutations(): void
{
expectedClassMutations.length = 0;
expectedClassMutationHead = 0;
}
function setOwnedClass(element: Element, className: string, present: boolean): void
{
if (!ownedElements.has(element) || element.classList.contains(className) === present) { return; }
const oldValue = element.getAttribute("class");
const tokens = Array.from(element.classList).filter((token) => token !== className);
if (present) { tokens.push(className); }
const newValue = tokens.join(" ");
const shouldExpect = observerActive && observerPauseDepth === 0
&& !!observeRoot && observeRoot.contains(element);
const expectation = shouldExpect ? { target: element, oldValue, newValue } : null;
if (expectation) { expectedClassMutations.push(expectation); }
try
{
if (present) { element.classList.add(className); }
else { element.classList.remove(className); }
}
catch (error)
{
if (expectation) { expectedClassMutations.pop(); }
throw error;
}
if (expectation && element.getAttribute("class") !== newValue)
{
expectedClassMutations.pop();
}
}
function classMutationNewValues(records: MutationRecord[]): Array<string | null | undefined>
{
const values = new Array<string | null | undefined>(records.length);
const nextValues = new Map<Element, string | null>();
for (let i = records.length - 1; i >= 0; i -= 1)
{
const record = records[i]!;
if (record.type !== "attributes" || record.attributeName !== "class") { continue; }
const target = record.target as Element;
values[i] = nextValues.has(target)
? nextValues.get(target)!
: target.getAttribute("class");
nextValues.set(target, record.oldValue);
}
return values;
}
function consumeExpectedClassMutation(
record: MutationRecord,
newValue: string | null | undefined,
): boolean
{
if (record.type !== "attributes" || record.attributeName !== "class") { return false; }
const expected = expectedClassMutations[expectedClassMutationHead];
if (!expected || expected.target !== record.target || expected.oldValue !== record.oldValue
|| expected.newValue !== newValue)
{
return false;
}
expectedClassMutationHead += 1;
return true;
}
function compactExpectedClassMutations(): void
{
if (expectedClassMutationHead === 0) { return; }
if (expectedClassMutationHead === expectedClassMutations.length)
{
clearExpectedClassMutations();
return;
}
// 跨 callback 保留未交付之 expectation;僅在前綴已足夠大且至少佔半時一次性壓縮,
// 避免每 callback 搬移剩餘陣列,保持攤銷線性。
if (expectedClassMutationHead >= 1024
&& expectedClassMutationHead * 2 >= expectedClassMutations.length)
{
expectedClassMutations.splice(0, expectedClassMutationHead);
expectedClassMutationHead = 0;
}
}
// render/revert 期間暫停觀察 → juzhen 自身之 DOM 變更不入觀察隊列、絕不自觸發。 // render/revert 期間暫停觀察 → juzhen 自身之 DOM 變更不入觀察隊列、絕不自觸發。
function pauseObserver(): void { if (observer) { observer.disconnect(); } } // 深度計數使重入 render 不會於外層未完成時提前恢復觀察。
function pauseObserver(): void
{
if (observerPauseDepth === 0)
{
observerActive = false;
if (observer) { observer.disconnect(); }
clearExpectedClassMutations();
}
observerPauseDepth += 1;
}
function resumeObserver(): void function resumeObserver(): void
{ {
if (observer && observeRoot) if (observerPauseDepth <= 0) { return; }
observerPauseDepth -= 1;
if (observerPauseDepth === 0 && observer && observeRoot)
{ {
observer.takeRecords(); // 丟棄暫停期間之殘留(保險) observer.takeRecords(); // 丟棄暫停期間之殘留(保險)
clearExpectedClassMutations();
observer.observe(observeRoot, observeConfig); observer.observe(observeRoot, observeConfig);
observerActive = true;
} }
} }
function withObserverPaused<T>(run: () => T): T
{
pauseObserver();
try { return run(); }
finally { resumeObserver(); }
}
/** node 之最近塊祖先(含自身若為塊);達 observeRoot 仍無塊 → 回 observeRoot。 */ /** node 之最近塊祖先(含自身若為塊);達 observeRoot 仍無塊 → 回 observeRoot。 */
function nearestBlock(node: Node): Element | null function nearestBlock(node: Node): Element | null
{ {
@@ -263,8 +408,16 @@ export function createJuzhen(opts: JuzhenOptions = {}): Juzhen
{ {
const rerenderBlocks = new Set<Element>(); // 既有塊:revert+render const rerenderBlocks = new Set<Element>(); // 既有塊:revert+render
const renderNew: Element[] = []; // 新增之塊:直接 render(無須 revert) const renderNew: Element[] = []; // 新增之塊:直接 render(無須 revert)
for (const m of records) const removedRoots = new Set<Element>(); // 已脫離之獨立 render 根:先就地清理
const classNewValues = classMutationNewValues(records);
try
{ {
for (let recordIndex = 0; recordIndex < records.length; recordIndex += 1)
{
const m = records[recordIndex]!;
// 只消費由 setOwnedClass 預先登記、且 target/oldValue/newValue/順序皆精確吻合的
// 內部 class transition。作者或其他實例對同一產物的變更仍走正常增量重排。
if (consumeExpectedClassMutation(m, classNewValues[recordIndex])) { continue; }
if (m.type === "childList") if (m.type === "childList")
{ {
m.addedNodes.forEach((n) => m.addedNodes.forEach((n) =>
@@ -294,6 +447,11 @@ export function createJuzhen(opts: JuzhenOptions = {}): Juzhen
}); });
if (m.removedNodes.length) if (m.removedNodes.length)
{ {
m.removedNodes.forEach((node) =>
{
if (node.nodeType !== 1) { return; }
for (const root of rootsWithin(node as Element)) { removedRoots.add(root); }
});
const b = nearestBlock(m.target); const b = nearestBlock(m.target);
if (b) { rerenderBlocks.add(b); } if (b) { rerenderBlocks.add(b); }
} }
@@ -304,9 +462,15 @@ export function createJuzhen(opts: JuzhenOptions = {}): Juzhen
if (b) { rerenderBlocks.add(b); } if (b) { rerenderBlocks.add(b); }
} }
} }
if (!rerenderBlocks.size && !renderNew.length) { return; } }
finally { compactExpectedClassMutations(); }
if (!rerenderBlocks.size && !renderNew.length && !removedRoots.size) { return; }
pauseObserver(); withObserverPaused(() =>
{
// 去嵌套,並在父塊重排之前對脫離子樹做完結構/屬性/佈局清理。
unwindRoots(Array.from(removedRoots)
.sort((a, b) => a.contains(b) ? 1 : b.contains(a) ? -1 : 0));
// 去嵌套:只保留極大塊(移除為他者後代者),免重複重處理(重複亦正確、僅浪費)。 // 去嵌套:只保留極大塊(移除為他者後代者),免重複重處理(重複亦正確、僅浪費)。
const all = Array.from(rerenderBlocks).filter((b) => b.isConnected); const all = Array.from(rerenderBlocks).filter((b) => b.isConnected);
const blocks = all.filter((b) => !all.some((o) => o !== b && o.contains(b))); const blocks = all.filter((b) => !all.some((o) => o !== b && o.contains(b)));
@@ -317,7 +481,7 @@ export function createJuzhen(opts: JuzhenOptions = {}): Juzhen
if (blocks.some((b) => b.contains(el))) { continue; } // 已由塊重處理涵蓋 if (blocks.some((b) => b.contains(el))) { continue; } // 已由塊重處理涵蓋
renderImpl(el); renderImpl(el);
} }
resumeObserver(); });
} }
return { return {
@@ -326,19 +490,19 @@ export function createJuzhen(opts: JuzhenOptions = {}): Juzhen
{ {
const el = resolveRoot(root, options); const el = resolveRoot(root, options);
if (!el) { return; } if (!el) { return; }
pauseObserver(); renderImpl(el); resumeObserver(); withObserverPaused(() => renderImpl(el));
}, },
revert(root?: Element | string): void revert(root?: Element | string): void
{ {
const el = resolveRoot(root, options); const el = resolveRoot(root, options);
if (!el) { return; } if (!el) { return; }
pauseObserver(); revertImpl(el); resumeObserver(); withObserverPaused(() => revertImpl(el));
}, },
rerender(root?: Element | string): void rerender(root?: Element | string): void
{ {
const el = resolveRoot(root, options); const el = resolveRoot(root, options);
if (!el) { return; } if (!el) { return; }
pauseObserver(); revertImpl(el); renderImpl(el); resumeObserver(); withObserverPaused(() => { revertImpl(el); renderImpl(el); });
}, },
invalidate(root?: Element | string): void invalidate(root?: Element | string): void
{ {
@@ -350,14 +514,22 @@ export function createJuzhen(opts: JuzhenOptions = {}): Juzhen
if (typeof MutationObserver === "undefined") { return; } // 無版面環境:no-op if (typeof MutationObserver === "undefined") { return; } // 無版面環境:no-op
const el = resolveRoot(root, options); const el = resolveRoot(root, options);
if (!el) { return; } if (!el) { return; }
observerActive = false;
if (observer) { observer.disconnect(); } if (observer) { observer.disconnect(); }
clearExpectedClassMutations();
observeRoot = el; observeRoot = el;
observer = new MutationObserver(onMutations); observer = new MutationObserver(onMutations);
if (observerPauseDepth === 0)
{
observer.observe(el, observeConfig); observer.observe(el, observeConfig);
observerActive = true;
}
}, },
disconnect(): void disconnect(): void
{ {
observerActive = false;
if (observer) { observer.disconnect(); observer = null; observeRoot = null; } if (observer) { observer.disconnect(); observer = null; observeRoot = null; }
clearExpectedClassMutations();
}, },
}; };
} }
+6
View File
@@ -171,6 +171,12 @@ export interface RenderContext
root: Element; root: Element;
options: ResolvedOptions; options: ResolvedOptions;
finder: Finder; finder: Finder;
/** 當前 createJuzhen 實例造出之元素;DOM 屬性不能作為所有權憑證。 */
ownedElements: WeakSet<Element>;
/** 實例不可偽造之屬性寫者身分;狀態由 core/dom 共享堆疊管理。 */
ownerToken: object;
/** 寫入本實例產物之內部 class,並在觀察啟用時留下可精確核對之 mutation provenance。 */
setOwnedClass: (element: Element, className: string, present: boolean) => void;
} }
/** /**
+42 -14
View File
@@ -13,6 +13,9 @@
import { bdClassOf, createJz, isJz, revertPass } from "../core/dom.js"; import { bdClassOf, createJz, isJz, revertPass } from "../core/dom.js";
import type { Finder } from "../core/finder.js"; import type { Finder } from "../core/finder.js";
import { isolateBoundaryToken } from "../core/split.js"; import { isolateBoundaryToken } from "../core/split.js";
import {
previousVariationSequenceStart, variationSequenceAtUtf16,
} from "../core/unicode.js";
import type { RenderContext, StandalonePass } from "../types.js"; import type { RenderContext, StandalonePass } from "../types.js";
const PASS = "jinze"; const PASS = "jinze";
@@ -111,18 +114,28 @@ function isTokenChar(ch: string, anyCjk: RegExp): boolean
function leadingTokenEnd(d: string, anyCjk: RegExp): number function leadingTokenEnd(d: string, anyCjk: RegExp): number
{ {
if (anyCjk.test(d.charAt(0))) { return 1; } const first = variationSequenceAtUtf16(d, 0);
let e = 1; if (anyCjk.test(first)) { return first.length; }
while (e < d.length && isTokenChar(d.charAt(e), anyCjk)) { e += 1; } let e = first.length;
while (e < d.length)
{
const ch = variationSequenceAtUtf16(d, e);
if (!isTokenChar(ch, anyCjk)) { break; }
e += ch.length;
}
return e; return e;
} }
function trailingTokenStart(d: string, anyCjk: RegExp): number function trailingTokenStart(d: string, anyCjk: RegExp): number
{ {
const last = d.length - 1; let s = previousVariationSequenceStart(d, d.length);
if (anyCjk.test(d.charAt(last))) { return last; } if (anyCjk.test(variationSequenceAtUtf16(d, s))) { return s; }
let s = last; while (s > 0)
while (s > 0 && isTokenChar(d.charAt(s - 1), anyCjk)) { s -= 1; } {
const previous = previousVariationSequenceStart(d, s);
if (!isTokenChar(variationSequenceAtUtf16(d, previous), anyCjk)) { break; }
s = previous;
}
return s; return s;
} }
@@ -142,7 +155,12 @@ function splitBoundaries(t: Text, needFirst: boolean, needLast: boolean, anyCjk:
} }
} }
function processParent(parent: Node, finder: Finder, anyCjk: RegExp): void function processParent(
parent: Node,
finder: Finder,
anyCjk: RegExp,
ownedElements: WeakSet<Element>,
): void
{ {
// 冪等(I6):parent 自身即本 pass 產物——二次 render 未 revert 時,jz-char 之父 // 冪等(I6):parent 自身即本 pass 產物——二次 render 未 revert 時,jz-char 之父
// 正是上輪產生之 jz-jinze,若不擋則對已綁定內容再包一層、巢狀增殖(下游審計實測 3→6)。 // 正是上輪產生之 jz-jinze,若不擋則對已綁定內容再包一層、巢狀增殖(下游審計實測 3→6)。
@@ -187,8 +205,14 @@ function processParent(parent: Node, finder: Finder, anyCjk: RegExp): void
// isolate/avoid,由 canSplit 諮詢 finder 判定)退化整綁——pill 即「作為原子 // isolate/avoid,由 canSplit 諮詢 finder 判定)退化整綁——pill 即「作為原子
// 鄰字單位整體綁入 jz-jinze」之期望行為(下游問題六)。 // 鄰字單位整體綁入 jz-jinze」之期望行為(下游問題六)。
const el = u as Element; const el = u as Element;
if (needLast) { isolateBoundaryToken(el, "tail", anyCjk, PASS, finder); } if (needLast)
if (needFirst) { isolateBoundaryToken(el, "head", anyCjk, PASS, finder); } {
isolateBoundaryToken(el, "tail", anyCjk, PASS, finder, ownedElements);
}
if (needFirst)
{
isolateBoundaryToken(el, "head", anyCjk, PASS, finder, ownedElements);
}
} }
} }
@@ -207,7 +231,7 @@ function processParent(parent: Node, finder: Finder, anyCjk: RegExp): void
} }
if (run.length >= 2) if (run.length >= 2)
{ {
const group = createJz("jz-jinze", PASS, "wrap"); const group = createJz(ownedElements, "jz-jinze", PASS, "wrap");
parent.insertBefore(group, run[0]!); parent.insertBefore(group, run[0]!);
for (const node of run) { group.appendChild(node); } for (const node of run) { group.appendChild(node); }
} }
@@ -225,10 +249,11 @@ export const jinzePass: StandalonePass = {
{ {
const { finder } = ctx; const { finder } = ctx;
// 詞元邊界之 CJK 判定取自規則集(含 charClass 覆寫),與 spacing/jiya 一致。 // 詞元邊界之 CJK 判定取自規則集(含 charClass 覆寫),與 spacing/jiya 一致。
const anyCjk = new RegExp("[" + ctx.options.ruleset.cjk + "]"); const anyCjk = new RegExp("[" + ctx.options.ruleset.cjk + "]", "u");
const parents = new Set<Node>(); const parents = new Set<Node>();
ctx.root.querySelectorAll("jz-char").forEach((c) => ctx.root.querySelectorAll("jz-char").forEach((c) =>
{ {
if (!ctx.ownedElements.has(c)) { return; }
// 功能分級閘(§6.5.0a):禁則為段落級,text-level 子樹(標題/按鈕)跳過。 // 功能分級閘(§6.5.0a):禁則為段落級,text-level 子樹(標題/按鈕)跳過。
if (!finder.levelAllows(c, "paragraph")) { return; } if (!finder.levelAllows(c, "paragraph")) { return; }
// 收集 c 之直接父,並沿**透明**元素邊緣上攀:c 若位於某透明元素之邊緣,該元素 // 收集 c 之直接父,並沿**透明**元素邊緣上攀:c 若位於某透明元素之邊緣,該元素
@@ -248,10 +273,13 @@ export const jinzePass: StandalonePass = {
}); });
// 深者先處理:透明子先於可能將其切分之外層 parent,避免子之內部綁定被外層切分擾動。 // 深者先處理:透明子先於可能將其切分之外層 parent,避免子之內部綁定被外層切分擾動。
const ordered = Array.from(parents).sort((a, b) => depthOf(b) - depthOf(a)); const ordered = Array.from(parents).sort((a, b) => depthOf(b) - depthOf(a));
for (const parent of ordered) { processParent(parent, finder, anyCjk); } for (const parent of ordered)
{
processParent(parent, finder, anyCjk, ctx.ownedElements);
}
}, },
revert(ctx: RenderContext): void revert(ctx: RenderContext): void
{ {
revertPass(PASS, ctx.root); revertPass(PASS, ctx.root, ctx.ownedElements);
}, },
}; };
+17 -9
View File
@@ -16,7 +16,9 @@
import { bdClassOf, createJz, revertPass } from "../core/dom.js"; import { bdClassOf, createJz, revertPass } from "../core/dom.js";
import { classify, effectiveLang, resolveStyle } from "../core/locale.js"; import { classify, effectiveLang, resolveStyle } from "../core/locale.js";
import type { LangClass, PunctStyle } from "../core/locale.js"; import type { LangClass, PunctStyle } from "../core/locale.js";
import { blankSides, classifyBiaodian, inkBias } from "../core/unicode.js"; import {
blankSides, classifyBiaodian, codePointAtUtf16, inkBias, variationSequenceAtUtf16,
} from "../core/unicode.js";
import type { CharifyPass, RenderContext, StandalonePass, WrapRequest } from "../types.js"; import type { CharifyPass, RenderContext, StandalonePass, WrapRequest } from "../types.js";
const PASS = "jiya"; const PASS = "jiya";
@@ -69,11 +71,11 @@ export const jiyaPass: CharifyPass = {
const biasOverrides = ctx.options.jiyaConfig.bias; const biasOverrides = ctx.options.jiyaConfig.bias;
const biaodian = ctx.options.ruleset.biaodian; const biaodian = ctx.options.ruleset.biaodian;
for (let i = 0; i < data.length; i += 1) for (let i = 0; i < data.length;)
{ {
const ch = data.charAt(i); const ch = variationSequenceAtUtf16(data, i);
const bdClass = classifyBiaodian(ch, biaodian); const bdClass = classifyBiaodian(ch, biaodian);
if (!bdClass) { continue; } if (!bdClass) { i += ch.length; continue; }
if (style === null) { style = resolveStyle(node, ctx.options.locale); } if (style === null) { style = resolveStyle(node, ctx.options.locale); }
if (langClass === null) if (langClass === null)
{ {
@@ -83,26 +85,31 @@ export const jiyaPass: CharifyPass = {
// 字形偏側 class(jz-ink-*)— 供 margin 後備模式收對側半形空白;halt 模式 // 字形偏側 class(jz-ink-*)— 供 margin 後備模式收對側半形空白;halt 模式
// 之 CSS 不讀,無害。一律於 charify 標記(偏側為字形固有屬性,與何時擠壓 // 之 CSS 不讀,無害。一律於 charify 標記(偏側為字形固有屬性,與何時擠壓
// 無關),鄰接/行端 pass 後續加 jz-half 即可沿用。 // 無關),鄰接/行端 pass 後續加 jz-half 即可沿用。
const bias = inkBias(ch, langClass, biasOverrides); const bias = inkBias(codePointAtUtf16(ch, 0), langClass, biasOverrides);
if (bias) { classes.push("jz-ink-" + bias); } if (bias) { classes.push("jz-ink-" + bias); }
out.push({ out.push({
start: i, start: i,
end: i + 1, end: i + ch.length,
pass: PASS, pass: PASS,
build: (text) => build: (text) =>
{ {
const outer = createJz("jz-char", PASS, "wrap", { classes }); const outer = createJz(
const inner = createJz("jz-inner", PASS, "wrap", { text }); ctx.ownedElements, "jz-char", PASS, "wrap", { classes },
);
const inner = createJz(
ctx.ownedElements, "jz-inner", PASS, "wrap", { text },
);
outer.appendChild(inner); outer.appendChild(inner);
return outer; return outer;
}, },
}); });
i += ch.length;
} }
return out; return out;
}, },
revert(ctx: RenderContext): void revert(ctx: RenderContext): void
{ {
revertPass(PASS, ctx.root); revertPass(PASS, ctx.root, ctx.ownedElements);
}, },
}; };
@@ -162,6 +169,7 @@ export const jiyaAdjacencyPass: StandalonePass = {
const nm = el.nodeName; const nm = el.nodeName;
if (nm === "JZ-CHAR") if (nm === "JZ-CHAR")
{ {
if (!ctx.ownedElements.has(el)) { prev = null; continue; }
if (prev) { squeeze(prev, el); } if (prev) { squeeze(prev, el); }
prev = el; // jz-char 為原子,不下探(內含 jz-inner) prev = el; // jz-char 為原子,不下探(內含 jz-inner)
} }
+3 -4
View File
@@ -23,6 +23,7 @@
// order=10:standalone 群之首(於 charify 群後執行;charify 之 jz-* 包裹皆行內 // order=10:standalone 群之首(於 charify 群後執行;charify 之 jz-* 包裹皆行內
// 元素,不改變「文本之最近塊祖先」,故時點無影響)。 // 元素,不改變「文本之最近塊祖先」,故時點無影響)。
import { revertOwnedAttributes, setOwnedAttribute } from "../core/dom.js";
import type { RenderContext, StandalonePass } from "../types.js"; import type { RenderContext, StandalonePass } from "../types.js";
const ATTR = "data-jz-block-level"; const ATTR = "data-jz-block-level";
@@ -65,13 +66,11 @@ export const levelMarkPass: StandalonePass = {
const block = p ? nearestBlock(p) : null; const block = p ? nearestBlock(p) : null;
if (!block) { return; } if (!block) { return; }
const level = finder.levelFor(block); const level = finder.levelFor(block);
if (block.getAttribute(ATTR) !== level) { block.setAttribute(ATTR, level); } setOwnedAttribute(block, ATTR, level, ctx.ownerToken);
}); });
}, },
revert(ctx: RenderContext): void revert(ctx: RenderContext): void
{ {
if (ctx.root.hasAttribute(ATTR)) { ctx.root.removeAttribute(ATTR); } revertOwnedAttributes(ctx.root, ATTR, ctx.ownerToken);
ctx.root.querySelectorAll("[" + ATTR + "]")
.forEach((el) => el.removeAttribute(ATTR));
}, },
}; };
+62 -31
View File
@@ -13,6 +13,9 @@
// Chrome/Safari/Firefox 目視核對。 // Chrome/Safari/Firefox 目視核對。
import { bdClassOf } from "../core/dom.js"; import { bdClassOf } from "../core/dom.js";
import {
previousVariationSequenceStart, variationSequenceAtUtf16,
} from "../core/unicode.js";
import type { Finder } from "../core/finder.js"; import type { Finder } from "../core/finder.js";
import type { RenderContext, ResolvedOptions, StandalonePass } from "../types.js"; import type { RenderContext, ResolvedOptions, StandalonePass } from "../types.js";
@@ -33,7 +36,10 @@ const TAIL_CLASSES = new Set([ "bd-close" ]);
// 建,於每次 resize 皆先派發——確保 gapTrim 之行末去隙判定恆基於 lineEdge **已套用**之半形 // 建,於每次 resize 皆先派發——確保 gapTrim 之行末去隙判定恆基於 lineEdge **已套用**之半形
// 版面(非陳舊態)。**勿調換兩 pass 之 order 或創建序**,否則 gapTrim 可能以 resize 前之半形 // 版面(非陳舊態)。**勿調換兩 pass 之 order 或創建序**,否則 gapTrim 可能以 resize 前之半形
// 狀態誤判行末(且 ResizeObserver 只觀 root 尺寸、不因內部 class 變化重觸發,錯誤會持續)。 // 狀態誤判行末(且 ResizeObserver 只觀 root 尺寸、不因內部 class 變化重觸發,錯誤會持續)。
const observers = new WeakMap<Element, Map<string, ResizeObserver>>(); const observers = new WeakMap<
Element,
Map<object, Map<string, ResizeObserver>>
>();
function hasLayout(): boolean function hasLayout(): boolean
{ {
@@ -41,11 +47,18 @@ function hasLayout(): boolean
} }
/** 安裝一個 layout 重算:立即跑一次,掛 ResizeObserver,字型載入後再跑。 */ /** 安裝一個 layout 重算:立即跑一次,掛 ResizeObserver,字型載入後再跑。 */
function installLayout(root: Element, key: string, run: () => void): void function installLayout(
root: Element,
owner: object,
key: string,
run: () => void,
): void
{ {
run(); run();
let m = observers.get(root); let owners = observers.get(root);
if (!m) { m = new Map(); observers.set(root, m); } if (!owners) { owners = new Map(); observers.set(root, owners); }
let m = owners.get(owner);
if (!m) { m = new Map(); owners.set(owner, m); }
const existing = m.get(key); const existing = m.get(key);
if (existing) { existing.disconnect(); } if (existing) { existing.disconnect(); }
const ro = new ResizeObserver(() => run()); const ro = new ResizeObserver(() => run());
@@ -58,12 +71,14 @@ function installLayout(root: Element, key: string, run: () => void): void
} }
} }
function uninstallLayout(root: Element, key: string): void function uninstallLayout(root: Element, owner: object, key: string): void
{ {
const m = observers.get(root); const owners = observers.get(root);
const m = owners?.get(owner);
if (!m) { return; } if (!m) { return; }
const ro = m.get(key); const ro = m.get(key);
if (ro) { ro.disconnect(); m.delete(key); } if (ro) { ro.disconnect(); m.delete(key); }
if (m.size === 0) { owners!.delete(owner); }
} }
// 行內包裹集:edgeRect 攀爬時須跨越「不中斷行內流」之元素找相鄰內容(jz-char 可能被 // 行內包裹集:edgeRect 攀爬時須跨越「不中斷行內流」之元素找相鄰內容(jz-char 可能被
@@ -89,9 +104,10 @@ function rectOf(n: Node, side: "prev" | "next"): DOMRect | null
const doc = t.ownerDocument; const doc = t.ownerDocument;
if (!doc) { return null; } if (!doc) { return null; }
const range = doc.createRange(); const range = doc.createRange();
const at = side === "prev" ? t.data.length - 1 : 0; const at = side === "prev" ? previousVariationSequenceStart(t.data, t.data.length) : 0;
const length = variationSequenceAtUtf16(t.data, at).length;
range.setStart(t, at); range.setStart(t, at);
range.setEnd(t, at + 1); range.setEnd(t, at + length);
const rects = range.getClientRects(); const rects = range.getClientRects();
if (rects.length) { return rects[side === "prev" ? rects.length - 1 : 0]!; } if (rects.length) { return rects[side === "prev" ? rects.length - 1 : 0]!; }
} }
@@ -166,11 +182,14 @@ function lastRect(el: Element): DOMRect | null
return rects.length ? rects[rects.length - 1]! : null; return rects.length ? rects[rects.length - 1]! : null;
} }
function clearEdge(root: Element): void function clearEdge(ctx: RenderContext): void
{ {
for (const cls of ALL_EDGE) for (const cls of ALL_EDGE)
{ {
root.querySelectorAll("jz-char." + cls).forEach((e) => e.classList.remove(cls)); ctx.root.querySelectorAll("jz-char." + cls).forEach((e) =>
{
if (ctx.ownedElements.has(e)) { ctx.setOwnedClass(e, cls, false); }
});
} }
} }
@@ -184,21 +203,21 @@ interface EdgeDecision { el: Element; }
* 如此每次量測皆見無污染之自然佈局——不受自身或他者之 halt 收縮影響(避免迴圈內逐一 add * 如此每次量測皆見無污染之自然佈局——不受自身或他者之 halt 收縮影響(避免迴圈內逐一 add
* class 致下游量測偏移,同 gapTrim 之雙向污染教訓)。ResizeObserver 僅觀 root 尺寸、不因 * class 致下游量測偏移,同 gapTrim 之雙向污染教訓)。ResizeObserver 僅觀 root 尺寸、不因
* 套用後內部重排再觸發,故終態穩定。 */ * 套用後內部重排再觸發,故終態穩定。 */
function relayout(root: Element, finder: Finder, options: ResolvedOptions): void function relayout(ctx: RenderContext): void
{ {
clearEdge(root); clearEdge(ctx);
const wrapSet = buildWrapSet(options); const wrapSet = buildWrapSet(ctx.options);
const chars = Array.from(root.querySelectorAll("jz-char")).filter( const chars = Array.from(ctx.root.querySelectorAll("jz-char")).filter(
(c) => finder.inScope(c) && !finder.isAvoided(c), (c) => ctx.ownedElements.has(c) && ctx.finder.inScope(c) && !ctx.finder.isAvoided(c),
); );
const decisions: EdgeDecision[] = []; const decisions: EdgeDecision[] = [];
for (const el of chars) for (const el of chars)
{ {
// 行端為段落級(§6.5.0a):text-level 子樹(單行標題等)不處理行端。 // 行端為段落級(§6.5.0a):text-level 子樹(單行標題等)不處理行端。
if (!finder.levelAllows(el, "paragraph")) { continue; } if (!ctx.finder.levelAllows(el, "paragraph")) { continue; }
if (!finder.featureEnabledFor(el, "jiya")) { continue; } if (!ctx.finder.featureEnabledFor(el, "jiya")) { continue; }
const bd = bdClassOf(el); const bd = bdClassOf(el);
if (!bd) { continue; } if (!bd) { continue; }
const isHead = HEAD_CLASSES.has(bd); const isHead = HEAD_CLASSES.has(bd);
@@ -219,7 +238,7 @@ function relayout(root: Element, finder: Finder, options: ResolvedOptions): void
} }
} }
for (const d of decisions) { d.el.classList.add(LE); } for (const d of decisions) { ctx.setOwnedClass(d.el, LE, true); }
} }
export const lineEdgePass: StandalonePass = { export const lineEdgePass: StandalonePass = {
@@ -231,12 +250,15 @@ export const lineEdgePass: StandalonePass = {
render(ctx: RenderContext): void render(ctx: RenderContext): void
{ {
if (!hasLayout()) { return; } // jsdom 等無版面環境:跳過。 if (!hasLayout()) { return; } // jsdom 等無版面環境:跳過。
installLayout(ctx.root, "lineEdge", () => relayout(ctx.root, ctx.finder, ctx.options)); installLayout(ctx.root, ctx.ownerToken, "lineEdge", () =>
{
relayout(ctx);
});
}, },
revert(ctx: RenderContext): void revert(ctx: RenderContext): void
{ {
uninstallLayout(ctx.root, "lineEdge"); uninstallLayout(ctx.root, ctx.ownerToken, "lineEdge");
clearEdge(ctx.root); clearEdge(ctx);
}, },
}; };
@@ -275,13 +297,16 @@ function skippableAlign(g: Element, cache: Map<Element, boolean>): boolean
return result; return result;
} }
function trimGaps(root: Element, finder: Finder, wrapSet: Set<string>): void function trimGaps(
ctx: RenderContext,
wrapSet: Set<string>,
): void
{ {
const alignCache = new Map<Element, boolean>(); const alignCache = new Map<Element, boolean>();
const gaps = Array.from(root.querySelectorAll("jz-hws")).filter( const gaps = Array.from(ctx.root.querySelectorAll("jz-hws")).filter(
(g) => finder.inScope(g) && !finder.isAvoided(g) (g) => ctx.ownedElements.has(g) && ctx.finder.inScope(g) && !ctx.finder.isAvoided(g)
&& finder.levelAllows(g, "paragraph") && ctx.finder.levelAllows(g, "paragraph")
&& finder.featureEnabledFor(g, "spacing") && ctx.finder.featureEnabledFor(g, "spacing")
&& !skippableAlign(g, alignCache), && !skippableAlign(g, alignCache),
); );
if (!gaps.length) { return; } if (!gaps.length) { return; }
@@ -301,13 +326,13 @@ function trimGaps(root: Element, finder: Finder, wrapSet: Set<string>): void
// 行末 → 還原間隙。逐一(非一次量全部)必要:斷行為左→右因果,當前間隙之行起點取決 // 行末 → 還原間隙。逐一(非一次量全部)必要:斷行為左→右因果,當前間隙之行起點取決
// 於其前已定案內容;讀取 getClientRects 觸發同步重排,故每次量測皆為最新佈局。以末行 // 於其前已定案內容;讀取 getClientRects 觸發同步重排,故每次量測皆為最新佈局。以末行
// rect(lastRect,margin 實際落點)為準,兼顧跨行 pill(行尾 code 折行亦曾致隙誤消)。 // rect(lastRect,margin 實際落點)為準,兼顧跨行 pill(行尾 code 折行亦曾致隙誤消)。
gaps.forEach((g) => g.classList.add(TRIM)); gaps.forEach((g) => ctx.setOwnedClass(g, TRIM, true));
for (const g of gaps) for (const g of gaps)
{ {
const r = lastRect(g); const r = lastRect(g);
const next = r ? edgeRect(g.nextSibling, "next", g, wrapSet) : null; const next = r ? edgeRect(g.nextSibling, "next", g, wrapSet) : null;
const atLineEnd = !r || !next || next.top > r.top + EPS; const atLineEnd = !r || !next || next.top > r.top + EPS;
if (!atLineEnd) { g.classList.remove(TRIM); } if (!atLineEnd) { ctx.setOwnedClass(g, TRIM, false); }
} }
} }
@@ -321,11 +346,17 @@ export const gapTrimPass: StandalonePass = {
{ {
if (!hasLayout()) { return; } if (!hasLayout()) { return; }
const wrapSet = buildWrapSet(ctx.options); const wrapSet = buildWrapSet(ctx.options);
installLayout(ctx.root, "gapTrim", () => trimGaps(ctx.root, ctx.finder, wrapSet)); installLayout(ctx.root, ctx.ownerToken, "gapTrim", () =>
{
trimGaps(ctx, wrapSet);
});
}, },
revert(ctx: RenderContext): void revert(ctx: RenderContext): void
{ {
uninstallLayout(ctx.root, "gapTrim"); uninstallLayout(ctx.root, ctx.ownerToken, "gapTrim");
ctx.root.querySelectorAll("jz-hws." + TRIM).forEach((e) => e.classList.remove(TRIM)); ctx.root.querySelectorAll("jz-hws." + TRIM).forEach((e) =>
{
if (ctx.ownedElements.has(e)) { ctx.setOwnedClass(e, TRIM, false); }
});
}, },
}; };
+2 -1
View File
@@ -50,6 +50,7 @@ export const longWordPass: CharifyPass = {
span.setAttribute("data-jz", PASS); span.setAttribute("data-jz", PASS);
span.setAttribute("data-jz-kind", "wrap"); span.setAttribute("data-jz-kind", "wrap");
span.textContent = text; span.textContent = text;
ctx.ownedElements.add(span);
return span; return span;
}, },
}); });
@@ -58,6 +59,6 @@ export const longWordPass: CharifyPass = {
}, },
revert(ctx: RenderContext): void revert(ctx: RenderContext): void
{ {
revertPass(PASS, ctx.root); revertPass(PASS, ctx.root, ctx.ownedElements);
}, },
}; };
+23 -8
View File
@@ -15,7 +15,7 @@
import { createJz, revertPass } from "../core/dom.js"; import { createJz, revertPass } from "../core/dom.js";
import type { Finder } from "../core/finder.js"; import type { Finder } from "../core/finder.js";
import { splitElementAt } from "../core/split.js"; import { splitElementAt } from "../core/split.js";
import { classifyBiaodian } from "../core/unicode.js"; import { classifyBiaodian, variationSequenceAtUtf16 } from "../core/unicode.js";
import type { RenderContext, Ruleset, StandalonePass } from "../types.js"; import type { RenderContext, Ruleset, StandalonePass } from "../types.js";
type Biaodian = Ruleset["biaodian"]; type Biaodian = Ruleset["biaodian"];
@@ -57,14 +57,15 @@ function collectSubst(
{ {
const t = node as Text; const t = node as Text;
const d = t.data; const d = t.data;
for (let i = 0; i < d.length; i += 1) for (let i = 0; i < d.length;)
{ {
const ch = d.charAt(i); const ch = variationSequenceAtUtf16(d, i);
// 實義字:屬 CJK 字集且非標點(標點/符號不計)。 // 實義字:屬 CJK 字集且非標點(標點/符號不計)。
if (anyCjk.test(ch) && !classifyBiaodian(ch, biaodian)) if (anyCjk.test(ch) && !classifyBiaodian(ch, biaodian))
{ {
out.push({ directChild, textNode: t, offset: i }); out.push({ directChild, textNode: t, offset: i });
} }
i += ch.length;
} }
return; return;
} }
@@ -88,6 +89,7 @@ function processBlock(
anyCjk: RegExp, anyCjk: RegExp,
biaodian: Biaodian, biaodian: Biaodian,
chars: number, chars: number,
ownedElements: WeakSet<Element>,
): void ): void
{ {
if (hasOrphan(block)) { return; } if (hasOrphan(block)) { return; }
@@ -113,13 +115,18 @@ function processBlock(
else if (boundary.textNode && startChild.nodeType === 1) else if (boundary.textNode && startChild.nodeType === 1)
{ {
const clone = splitElementAt( const clone = splitElementAt(
startChild as Element, boundary.textNode, boundary.offset, PASS, finder, startChild as Element,
boundary.textNode,
boundary.offset,
PASS,
finder,
ownedElements,
); );
if (clone) { startChild = clone; } if (clone) { startChild = clone; }
} }
// 把 startChild 及其後全部直接子搬入 jz-orphan。 // 把 startChild 及其後全部直接子搬入 jz-orphan。
const orphan = createJz("jz-orphan", PASS, "wrap"); const orphan = createJz(ownedElements, "jz-orphan", PASS, "wrap");
block.insertBefore(orphan, startChild); block.insertBefore(orphan, startChild);
let n: Node | null = startChild; let n: Node | null = startChild;
while (n) while (n)
@@ -140,7 +147,7 @@ export const orphanPass: StandalonePass = {
{ {
const { finder, options } = ctx; const { finder, options } = ctx;
const blockSet = options.finder.blockTags; const blockSet = options.finder.blockTags;
const anyCjk = new RegExp("[" + options.ruleset.cjk + "]"); const anyCjk = new RegExp("[" + options.ruleset.cjk + "]", "u");
const biaodian = options.ruleset.biaodian; const biaodian = options.ruleset.biaodian;
const chars = options.orphan.chars; const chars = options.orphan.chars;
@@ -172,11 +179,19 @@ export const orphanPass: StandalonePass = {
if (finder.isAvoided(block)) { return; } if (finder.isAvoided(block)) { return; }
// 僅處理葉段落(無塊級後代):含巢狀塊之容器,其實義字屬內層段落,另行處理。 // 僅處理葉段落(無塊級後代):含巢狀塊之容器,其實義字屬內層段落,另行處理。
if (nonLeaf.has(block)) { return; } if (nonLeaf.has(block)) { return; }
processBlock(block, finder, blockSet, anyCjk, biaodian, chars); processBlock(
block,
finder,
blockSet,
anyCjk,
biaodian,
chars,
ctx.ownedElements,
);
}); });
}, },
revert(ctx: RenderContext): void revert(ctx: RenderContext): void
{ {
revertPass(PASS, ctx.root); revertPass(PASS, ctx.root, ctx.ownedElements);
}, },
}; };
+490 -74
View File
@@ -7,9 +7,12 @@
// 涵蓋 v1 Pass A(跨樣式邊界間隙)與 Pass B(pill code/kbd 邊界間隙)。 // 涵蓋 v1 Pass A(跨樣式邊界間隙)與 Pass B(pill code/kbd 邊界間隙)。
// 規則正則為間隙專用,故與本 pass 同處;共用字集自 unicode.ts。 // 規則正則為間隙專用,故與本 pass 同處;共用字集自 unicode.ts。
import { ALNUM, ALPHA } from "../core/unicode.js"; import {
ALNUM, ALPHA, VARIATION_SELECTORS,
previousVariationSequenceStart, splitVariationSequences, variationSequenceAtUtf16,
} from "../core/unicode.js";
import { createJz, revertPass } from "../core/dom.js"; import { createJz, revertPass } from "../core/dom.js";
import type { AdjacentChar, Finder } from "../core/finder.js"; import type { AdjacentChar, CharSpan, Finder, TextSpan } from "../core/finder.js";
import type { RenderContext, Ruleset, StandalonePass } from "../types.js"; import type { RenderContext, Ruleset, StandalonePass } from "../types.js";
const PASS = "spacing"; const PASS = "spacing";
@@ -64,38 +67,46 @@ function makeRules(rs: Ruleset): Rules
const CJK = rs.cjk; const CJK = rs.cjk;
const ANS_AFTER = rs.ansAfter; const ANS_AFTER = rs.ansAfter;
const ANS_BEFORE = rs.ansBefore; const ANS_BEFORE = rs.ansBefore;
// VS 只能隨 CJK base 被吞併:正則捕獲、marker 回放與 DOM 包裝皆以此 atom 為單位。
const CJK_ATOM = "(?:[" + CJK + "](?:[" + VARIATION_SELECTORS + "])?)";
const CJK_OR_ALNUM = "(?:" + CJK_ATOM + "|[" + ALNUM + "])";
return { return {
anyCjk: new RegExp("[" + CJK + "]"), anyCjk: new RegExp(CJK_ATOM, "u"),
pillNeighbor: new RegExp("[" + ALNUM + CJK + "]"), pillNeighbor: new RegExp("^(?:[" + ALNUM + "]|" + CJK_ATOM + ")$", "u"),
STRIP_CJK_SPACE_ANS: new RegExp("([" + CJK + "]) ([" + ANS_AFTER + "])", "g"), STRIP_CJK_SPACE_ANS: new RegExp("(" + CJK_ATOM + ") ([" + ANS_AFTER + "])", "gu"),
STRIP_ANS_SPACE_CJK: new RegExp("([" + ANS_BEFORE + "]) ([" + CJK + "])", "g"), STRIP_ANS_SPACE_CJK: new RegExp("([" + ANS_BEFORE + "]) (" + CJK_ATOM + ")", "gu"),
STRIP_CJK_SPACE_CJK: new RegExp("([" + CJK + "]) (?=[" + CJK + "])", "g"), STRIP_CJK_SPACE_CJK: new RegExp("(" + CJK_ATOM + ") (?=" + CJK_ATOM + ")", "gu"),
DOTS_CJK: new RegExp("([\\.]{2,})([" + CJK + "])", "g"), DOTS_CJK: new RegExp("([\\.]{2,})(" + CJK_ATOM + ")", "gu"),
CJK_PUNCTUATION: new RegExp("([" + CJK + "])([!;,\\?:]+)(?=[" + CJK + ALNUM + "])", "g"), CJK_PUNCTUATION: new RegExp("(" + CJK_ATOM + ")([!;,\\?:]+)(?=" + CJK_OR_ALNUM + ")", "gu"),
AN_PUNCTUATION_CJK: new RegExp("([" + ALNUM + "])([!;,\\?]+)([" + CJK + "])", "g"), AN_PUNCTUATION_CJK: new RegExp("([" + ALNUM + "])([!;,\\?]+)(" + CJK_ATOM + ")", "gu"),
CJK_TILDE: new RegExp("([" + CJK + "])(~+)(?!=)(?=[" + CJK + ALNUM + "])", "g"), CJK_TILDE: new RegExp("(" + CJK_ATOM + ")(~+)(?!=)(?=" + CJK_OR_ALNUM + ")", "gu"),
CJK_TILDE_EQUALS: new RegExp("([" + CJK + "])(~=)", "g"), CJK_TILDE_EQUALS: new RegExp("(" + CJK_ATOM + ")(~=)", "gu"),
CJK_PERIOD: new RegExp("([" + CJK + "])(\\.)(?![" + ALNUM + "\\./])(?=[" + CJK + ALNUM + "])", "g"), CJK_PERIOD: new RegExp("(" + CJK_ATOM + ")(\\.)(?![" + ALNUM + "\\./])(?=" + CJK_OR_ALNUM + ")", "gu"),
AN_PERIOD_CJK: new RegExp("([" + ALNUM + "])(\\.)([" + CJK + "])", "g"), AN_PERIOD_CJK: new RegExp("([" + ALNUM + "])(\\.)(" + CJK_ATOM + ")", "gu"),
AN_COLON_CJK: new RegExp("([" + ALNUM + "])(:)([" + CJK + "])", "g"), AN_COLON_CJK: new RegExp("([" + ALNUM + "])(:)(" + CJK_ATOM + ")", "gu"),
CJK_QUOTE: new RegExp("([" + CJK + "])([" + QUOTES_CLS + "])", "g"), CJK_QUOTE: new RegExp("(" + CJK_ATOM + ")([" + QUOTES_CLS + "])", "gu"),
QUOTE_CJK: new RegExp("([" + QUOTES_CLS + "])([" + CJK + "])", "g"), QUOTE_CJK: new RegExp("([" + QUOTES_CLS + "])(" + CJK_ATOM + ")", "gu"),
CJK_QUOTE_AN: new RegExp("([" + CJK + "])(\")([" + ALNUM + "])", "g"), CJK_QUOTE_AN: new RegExp("(" + CJK_ATOM + ")(\")([" + ALNUM + "])", "gu"),
CJK_HASH: new RegExp("([" + CJK + "])(#([^ ]))", "g"), CJK_HASH: new RegExp("(" + CJK_ATOM + ")(#([^ ]))", "gu"),
HASH_CJK: new RegExp("(([^ ])#)([" + CJK + "])", "g"), HASH_CJK: new RegExp("(([^ ])#)(" + CJK_ATOM + ")", "gu"),
SINGLE_LETTER_GRADE: new RegExp("\\b([" + ALPHA + "])([" + OPS_GRADE + "])([" + CJK + "])", "g"), SINGLE_LETTER_GRADE: new RegExp("\\b([" + ALPHA + "])([" + OPS_GRADE + "])(" + CJK_ATOM + ")", "gu"),
CJK_OPERATOR_ANS: new RegExp("([" + CJK + "])([" + OPS_HYPHEN + "])([" + ALNUM + "])", "g"), CJK_OPERATOR_ANS: new RegExp("(" + CJK_ATOM + ")([" + OPS_HYPHEN + "])([" + ALNUM + "])", "gu"),
ANS_OPERATOR_CJK: new RegExp("([" + ALNUM + "])([" + OPS_HYPHEN + "])([" + CJK + "])", "g"), ANS_OPERATOR_CJK: new RegExp("([" + ALNUM + "])([" + OPS_HYPHEN + "])(" + CJK_ATOM + ")", "gu"),
CJK_LESS_THAN: new RegExp("([" + CJK + "])(<)([" + ALNUM + "])", "g"), CJK_LESS_THAN: new RegExp("(" + CJK_ATOM + ")(<)([" + ALNUM + "])", "gu"),
LESS_THAN_CJK: new RegExp("([" + ALNUM + "])(<)([" + CJK + "])", "g"), LESS_THAN_CJK: new RegExp("([" + ALNUM + "])(<)(" + CJK_ATOM + ")", "gu"),
CJK_GREATER_THAN: new RegExp("([" + CJK + "])(>)([" + ALNUM + "])", "g"), CJK_GREATER_THAN: new RegExp("(" + CJK_ATOM + ")(>)([" + ALNUM + "])", "gu"),
GREATER_THAN_CJK: new RegExp("([" + ALNUM + "])(>)([" + CJK + "])", "g"), GREATER_THAN_CJK: new RegExp("([" + ALNUM + "])(>)(" + CJK_ATOM + ")", "gu"),
CJK_LEFT_BRACKET: new RegExp("([" + CJK + "])([" + LBR_EXT + "])", "g"), CJK_LEFT_BRACKET: new RegExp("(" + CJK_ATOM + ")([" + LBR_EXT + "])", "gu"),
RIGHT_BRACKET_CJK: new RegExp("([" + RBR_EXT + "])([" + CJK + "])", "g"), RIGHT_BRACKET_CJK: new RegExp("([" + RBR_EXT + "])(" + CJK_ATOM + ")", "gu"),
AN_LEFT_BRACKET: new RegExp("([" + ALNUM + "])(?<!\\.[" + ALNUM + "]*)([" + LBR_BASIC + "])", "g"), // 英數字串段前之字元一併匹配,才能在不用 lookbehind 下排除 `.foo(`。
// 括號只作 lookahead,避免 `a(b(` 之前一次匹配吞掉 `(` 而漏掉第二處。
AN_LEFT_BRACKET: new RegExp(
"(^|[^." + ALNUM + "])([" + ALNUM + "]+)(?=[" + LBR_BASIC + "])",
"g",
),
RIGHT_BRACKET_AN: new RegExp("([" + RBR_BASIC + "])([" + ALNUM + "])", "g"), RIGHT_BRACKET_AN: new RegExp("([" + RBR_BASIC + "])([" + ALNUM + "])", "g"),
CJK_ANS: new RegExp("([" + CJK + "])([" + ANS_AFTER + "])", "g"), CJK_ANS: new RegExp("(" + CJK_ATOM + ")([" + ANS_AFTER + "])", "gu"),
ANS_CJK: new RegExp("([" + ANS_BEFORE + "])([" + CJK + "])", "g"), ANS_CJK: new RegExp("([" + ANS_BEFORE + "])(" + CJK_ATOM + ")", "gu"),
S_A: new RegExp("(%)([" + ALPHA + "])", "g"), S_A: new RegExp("(%)([" + ALPHA + "])", "g"),
}; };
} }
@@ -104,10 +115,30 @@ function makeRules(rs: Ruleset): Rules
// 過後、括號 / CJK_ANS / ANS_CJK 過前還原。 // 過後、括號 / CJK_ANS / ANS_CJK 過前還原。
const COMPOUND_WORD = /\b(?:[A-Za-z0-9]*[a-z][A-Za-z0-9]*-[A-Za-z0-9]+|[A-Za-z0-9]+-[A-Za-z0-9]*[a-z][A-Za-z0-9]*|[A-Za-z]+-[0-9]+|[A-Za-z]+[0-9]+-[A-Za-z0-9]+)(?:-[A-Za-z0-9]+)*\b/g; const COMPOUND_WORD = /\b(?:[A-Za-z0-9]*[a-z][A-Za-z0-9]*-[A-Za-z0-9]+|[A-Za-z0-9]+-[A-Za-z0-9]*[a-z][A-Za-z0-9]*|[A-Za-z]+-[0-9]+|[A-Za-z]+[0-9]+-[A-Za-z0-9]+)(?:-[A-Za-z0-9]+)*\b/g;
const PH_PATTERN = new RegExp(PH_OPEN + "(\\d+)" + PH_CLOSE, "g"); const PH_PATTERN = new RegExp(PH_OPEN + "(\\d+)" + PH_CLOSE, "g");
const ASTRAL_CJK_SHADOW = "漢";
/**
* Safari 26.5.2 於實際 CJK 大字元類中,會漏掉非零 UTF-16 起點之補充平面
* CJK(global 與 sticky 皆同)。規則層不需原字,只需與 DOM map 同構之邏輯 atom
* 序列,故線性將「base > U+FFFF 且自身 index 0 命中 CJK」之整個 base+VS atom
* 替為固定 BMP CJK shadow。Pangu 輸出僅供 MARK/DEL 動作回放,不會寫回 shadow。
*/
function shadowAstralCjkAtoms(atoms: readonly string[], R: Rules): string
{
const shadowed = new Array<string>(atoms.length);
for (let i = 0; i < atoms.length; i += 1)
{
const atom = atoms[i]!;
const base = atom.codePointAt(0);
const match = base !== undefined && base > 0xFFFF ? R.anyCjk.exec(atom) : null;
shadowed[i] = match?.index === 0 ? ASTRAL_CJK_SHADOW : atom;
}
return shadowed.join("");
}
function panguSpace(text: string, R: Rules): string function panguSpace(text: string, R: Rules): string
{ {
if (text.length <= 1 || !R.anyCjk.test(text)) { return text; } if (splitVariationSequences(text).length <= 1 || !R.anyCjk.test(text)) { return text; }
let s = text; let s = text;
s = s.replace(R.STRIP_CJK_SPACE_ANS, "$1$2"); s = s.replace(R.STRIP_CJK_SPACE_ANS, "$1$2");
@@ -150,7 +181,7 @@ function panguSpace(text: string, R: Rules): string
s = s.replace(R.CJK_LEFT_BRACKET, "$1" + MARK + "$2"); s = s.replace(R.CJK_LEFT_BRACKET, "$1" + MARK + "$2");
s = s.replace(R.RIGHT_BRACKET_CJK, "$1" + MARK + "$2"); s = s.replace(R.RIGHT_BRACKET_CJK, "$1" + MARK + "$2");
s = s.replace(R.AN_LEFT_BRACKET, "$1" + MARK + "$2"); s = s.replace(R.AN_LEFT_BRACKET, "$1$2" + MARK);
s = s.replace(R.RIGHT_BRACKET_AN, "$1" + MARK + "$2"); s = s.replace(R.RIGHT_BRACKET_AN, "$1" + MARK + "$2");
s = s.replace(R.CJK_ANS, "$1" + MARK + "$2"); s = s.replace(R.CJK_ANS, "$1" + MARK + "$2");
@@ -162,59 +193,403 @@ function panguSpace(text: string, R: Rules): string
interface SpaceAction interface SpaceAction
{ {
before: { textNode: Text; offset: number } | undefined; before: CharSpan | undefined;
space: { textNode: Text; offset: number } | null; space: CharSpan | null;
} }
interface MovedPart
{
anchor: Comment;
gapAnchor: Comment;
gap: HTMLElement;
textNode: Text;
originalParent: Node;
originalPreviousSibling: Node | null;
originalNextSibling: Node | null;
gapOriginalParent: Node;
gapOriginalPreviousSibling: Node | null;
gapOriginalNextSibling: Node | null;
block: Element;
scopeRoot: Element;
scopeAncestors: Element[];
}
// 跨節點 variation sequence 需在排版期間聚合成單一行內載體;以 ownerToken
// 隔離各實例的原位錨點,revert 時恢復每個原始 TextSpan 的父子分佈。
const MOVED_PARTS = new WeakMap<object, Set<MovedPart>>();
const MOVED_ANCHOR = "jz-spacing-anchor";
const MOVED_GAP_ANCHOR = "jz-spacing-gap-anchor";
const CROSS_NODE_ATTR = "data-jz-cross-node-atom";
// 把一個節點(已隔離之單字文本,或元素如 pill)包進 jz-hws——間隙由 CSS 之 // 把一個節點(已隔離之單字文本,或元素如 pill)包進 jz-hws——間隙由 CSS 之
// margin-right 提供。不注入空白字元:故 justify 不把間隙當詞間空白放大(邊界 // margin-right 提供。不注入空白字元:故 justify 不把間隙當詞間空白放大(邊界
// 只吃均勻字間量),margin 尾隨左側內容(換行時落行尾、永不落行首),且 // 只吃均勻字間量),margin 尾隨左側內容(換行時落行尾、永不落行首),且
// textContent 完全不變(內容仍在)。冪等:已在 jz-hws 內則跳過。 // textContent 完全不變(內容仍在)。冪等:已在 jz-hws 內則跳過。
function wrapNodeInGap(node: Node): void function wrapNodeInGap(node: Node, ownedElements: WeakSet<Element>): void
{ {
const parent = node.parentNode; const parent = node.parentNode;
if (!parent || parent.nodeName === "JZ-HWS") { return; } if (!parent || parent.nodeName === "JZ-HWS") { return; }
const gap = createJz("jz-hws", PASS, "wrap"); const gap = createJz(ownedElements, "jz-hws", PASS, "wrap");
parent.insertBefore(gap, node); parent.insertBefore(gap, node);
gap.appendChild(node); gap.appendChild(node);
} }
// 隔離 pos 處之單一字元並包入 jz-hws(左側字 + margin-right 留隙)。 // 隔離 pos 處之 base+optional-VS 並包入 jz-hws(左側字 + margin-right 留隙)。
function wrapGapLeft(pos: { textNode: Text; offset: number }): void function isolateTextSpan(part: TextSpan): Text | null
{ {
let tn = pos.textNode; let tn = part.textNode;
if (!tn.parentNode || tn.parentNode.nodeName === "JZ-HWS") { return; } if (!tn.parentNode) { return null; }
if (pos.offset > 0) { tn = tn.splitText(pos.offset); } if (part.start > 0) { tn = tn.splitText(part.start); }
if (tn.data.length > 1) { tn.splitText(1); } const length = part.end - part.start;
wrapNodeInGap(tn); if (tn.data.length > length) { tn.splitText(length); }
return tn;
} }
function processBlock(block: Element, finder: Finder, R: Rules): void function wrapSingleSpanInGap(
part: TextSpan,
ownedElements: WeakSet<Element>,
): HTMLElement | null
{
const containingGap = part.textNode.parentElement?.closest("jz-hws") as HTMLElement | null;
if (containingGap)
{
return ownedElements.has(containingGap)
&& containingGap.getAttribute("data-jz") === PASS
&& containingGap.getAttribute("data-jz-kind") === "wrap"
? containingGap
: null;
}
const tn = isolateTextSpan(part);
if (!tn) { return null; }
const parent = tn.parentNode;
if (!parent) { return null; }
const gap = createJz(ownedElements, "jz-hws", PASS, "wrap");
try
{
parent.insertBefore(gap, tn);
gap.appendChild(tn);
return gap;
}
catch (error)
{
if (tn.parentNode === gap) { parent.insertBefore(tn, gap); }
if (gap.parentNode) { gap.parentNode.removeChild(gap); }
ownedElements.delete(gap);
(parent as Element).normalize?.();
throw error;
}
}
function isOwnedCrossGap(gap: HTMLElement, ownedElements: WeakSet<Element>): boolean
{
return ownedElements.has(gap)
&& gap.getAttribute("data-jz") === PASS
&& gap.getAttribute("data-jz-kind") === "wrap"
&& gap.hasAttribute(CROSS_NODE_ATTR);
}
function trustedInsertionPoint(record: MovedPart): Node | null | undefined
{
if (record.anchor.parentNode === record.originalParent) { return record.anchor; }
if (record.originalNextSibling?.parentNode === record.originalParent)
{
return record.originalNextSibling;
}
if (record.originalNextSibling !== null) { return undefined; }
if (record.originalPreviousSibling?.parentNode === record.originalParent)
{
return record.originalPreviousSibling === record.originalParent.lastChild ? null : undefined;
}
if (record.originalPreviousSibling === null && record.originalNextSibling === null
&& record.anchor.parentNode !== null && record.anchor.parentNode !== record.originalParent
&& !record.originalParent.hasChildNodes())
{
// selector 原本是原父唯一子節點:anchor 仍存在但被外部搬走,與
// anchor 已被作者刪除(parentNode === null)可明確區分。不跟隨外部父,
// 只回復到仍為空的 originalParent 末尾。
return null;
}
// anchor 與原前/後兄弟皆不存在時,空原父可能是作者明確刪除內容之結果;
// 不得以「目前為空」當作恢復授權,否則會復活舊 selector。
return undefined;
}
function restoreMovedText(record: MovedPart): boolean
{
if (record.textNode.parentNode === record.originalParent) { return true; }
if (record.textNode.parentNode !== record.gap) { return false; }
const before = trustedInsertionPoint(record);
if (before === undefined) { return false; }
record.originalParent.insertBefore(record.textNode, before);
return true;
}
function movePartIntoGap(
part: TextSpan,
gap: HTMLElement,
block: Element,
scopeRoot: Element,
): MovedPart | null
{
const textNode = isolateTextSpan(part);
const parent = textNode?.parentNode;
const gapParent = gap.parentNode;
if (!textNode || !parent || !gapParent) { return null; }
const anchor = textNode.ownerDocument.createComment(MOVED_ANCHOR);
const gapAnchor = textNode.ownerDocument.createComment(MOVED_GAP_ANCHOR);
const record: MovedPart = {
anchor,
gapAnchor,
gap,
textNode,
originalParent: parent,
originalPreviousSibling: textNode.previousSibling,
originalNextSibling: textNode.nextSibling,
gapOriginalParent: gapParent,
gapOriginalPreviousSibling: gap.previousSibling,
gapOriginalNextSibling: gap.nextSibling,
block,
scopeRoot,
scopeAncestors: (() =>
{
const ancestors: Element[] = [];
for (let parent = scopeRoot.parentElement; parent; parent = parent.parentElement)
{
ancestors.push(parent);
}
return ancestors;
})(),
};
try
{
parent.insertBefore(anchor, textNode);
gap.appendChild(gapAnchor);
gap.appendChild(textNode);
return record;
}
catch (error)
{
restoreMovedText(record);
if (anchor.parentNode) { anchor.parentNode.removeChild(anchor); }
if (gapAnchor.parentNode) { gapAnchor.parentNode.removeChild(gapAnchor); }
(parent as Element).normalize?.();
throw error;
}
}
// 單 Text 節點直接包裝完整 base+optional-VS。跨節點 atom 先包裝 base,
// 再只搬入後續 selector TextSpan(不搬動或複製作者 Element),使 WebKit 斷行器
// 將完整序列視為單一左側原子。每個搬移處的 Comment 錨點僅供精確還原。
function wrapGapLeft(
span: CharSpan,
ownedElements: WeakSet<Element>,
ownerToken: object,
block: Element,
scopeRoot: Element,
): void
{
const containingGaps = new Set<HTMLElement>();
for (const part of span.parts)
{
const containing = part.textNode.parentElement?.closest("jz-hws") as HTMLElement | null;
if (containing) { containingGaps.add(containing); }
}
if (containingGaps.size > 0)
{
if (containingGaps.size === 1)
{
const existing = Array.from(containingGaps)[0]!;
if (isOwnedCrossGap(existing, ownedElements)
&& span.parts.every((part) => existing.contains(part.textNode)))
{
return;
}
}
// 外來/偽造 gap 或不完整之已包裝狀態一律 fail closed,不搬文本、不登記。
return;
}
const gap = wrapSingleSpanInGap(span.parts[0]!, ownedElements);
if (!gap) { return; }
if (span.parts.length === 1) { return; }
const moved: MovedPart[] = [];
try
{
for (let i = 1; i < span.parts.length; i += 1)
{
const record = movePartIntoGap(span.parts[i]!, gap, block, scopeRoot);
if (!record) { throw new Error("juzhen: 跨節點字元搬移失敗。"); }
moved.push(record);
}
gap.setAttribute(CROSS_NODE_ATTR, "");
let records = MOVED_PARTS.get(ownerToken);
if (!records)
{
records = new Set();
MOVED_PARTS.set(ownerToken, records);
}
for (const record of moved) { records.add(record); }
}
catch (error)
{
for (let i = moved.length - 1; i >= 0; i -= 1)
{
const record = moved[i]!;
restoreMovedText(record);
if (record.anchor.parentNode) { record.anchor.parentNode.removeChild(record.anchor); }
if (record.gapAnchor.parentNode)
{
record.gapAnchor.parentNode.removeChild(record.gapAnchor);
}
(record.originalParent as Element).normalize?.();
}
gap.removeAttribute(CROSS_NODE_ATTR);
const gapParent = gap.parentNode;
if (gapParent)
{
while (gap.firstChild) { gapParent.insertBefore(gap.firstChild, gap); }
gapParent.removeChild(gap);
(gapParent as Element).normalize?.();
}
ownedElements.delete(gap);
throw error;
}
}
function recordWithinRoot(record: MovedPart, root: Element): boolean
{
const within = (node: Node): boolean => node === root || root.contains(node);
const detached = !record.anchor.isConnected && !record.gap.isConnected
&& !record.gapAnchor.isConnected && !record.originalParent.isConnected;
return root === record.scopeRoot || root.contains(record.scopeRoot)
|| record.scopeAncestors.some((ancestor) => root === ancestor || root.contains(ancestor))
|| within(record.gap) || within(record.anchor) || within(record.originalParent)
|| within(record.block)
|| (detached && root === root.ownerDocument.body);
}
function placeGapAtOriginalParent(record: MovedPart): boolean
{
if (record.gap.parentNode === record.gapOriginalParent) { return true; }
let before: Node | null = null;
if (record.gapOriginalNextSibling?.parentNode === record.gapOriginalParent)
{
before = record.gapOriginalNextSibling;
}
else if (record.gapOriginalNextSibling !== null)
{
return false;
}
else if (record.gapOriginalPreviousSibling?.parentNode === record.gapOriginalParent)
{
if (record.gapOriginalPreviousSibling !== record.gapOriginalParent.lastChild) { return false; }
before = null;
}
else if (record.gapOriginalParent.lastChild !== record.gapOriginalPreviousSibling
|| (record.gapOriginalPreviousSibling === null
&& record.gapOriginalNextSibling === null
&& record.gapOriginalParent.hasChildNodes()))
{
return false;
}
record.gapOriginalParent.insertBefore(record.gap, before);
return true;
}
function unwrapCrossGap(
record: MovedPart,
ownedElements: WeakSet<Element>,
touched: Set<Node>,
): void
{
const gap = record.gap;
if (!isOwnedCrossGap(gap, ownedElements)) { return; }
if (!placeGapAtOriginalParent(record))
{
if (!gap.isConnected) { ownedElements.delete(gap); }
return;
}
const parent = gap.parentNode;
if (!parent) { ownedElements.delete(gap); return; }
while (gap.firstChild) { parent.insertBefore(gap.firstChild, gap); }
parent.removeChild(gap);
ownedElements.delete(gap);
touched.add(parent);
}
function restoreMovedParts(ctx: RenderContext): Set<Node>
{
const touched = new Set<Node>();
const records = MOVED_PARTS.get(ctx.ownerToken);
if (!records) { return touched; }
const gaps = new Map<HTMLElement, MovedPart>();
for (const record of Array.from(records))
{
if (!recordWithinRoot(record, ctx.root)) { continue; }
const restored = restoreMovedText(record);
if (!restored && record.textNode.parentNode === record.gap)
{
// 原父已以 textContent/replaceChildren/innerHTML 取代:舊 selector
// 屬過時的本實例搬移產物,不得因 anchor 被搬往任意父節點而復活。
record.gap.removeChild(record.textNode);
}
if (restored) { touched.add(record.originalParent); }
if (record.anchor.parentNode) { record.anchor.parentNode.removeChild(record.anchor); }
if (record.gapAnchor.parentNode)
{
record.gapAnchor.parentNode.removeChild(record.gapAnchor);
}
gaps.set(record.gap, record);
records.delete(record);
}
for (const record of gaps.values()) { unwrapCrossGap(record, ctx.ownedElements, touched); }
if (records.size === 0) { MOVED_PARTS.delete(ctx.ownerToken); }
return touched;
}
function deleteSpan(span: CharSpan): void
{
for (let i = span.parts.length - 1; i >= 0; i -= 1)
{
const part = span.parts[i]!;
part.textNode.data = part.textNode.data.slice(0, part.start)
+ part.textNode.data.slice(part.end);
}
}
function processBlock(
block: Element,
scopeRoot: Element,
finder: Finder,
R: Rules,
ownedElements: WeakSet<Element>,
ownerToken: object,
): void
{ {
const runs = finder.collectRuns(block); const runs = finder.collectRuns(block);
for (const run of runs) for (const run of runs)
{ {
const { text, map } = run; const { map } = run;
const spaced = panguSpace(text, R); const originalChars = map.map((span) => span.text);
if (spaced === text) { continue; } const ruleText = shadowAstralCjkAtoms(originalChars, R);
const spaced = panguSpace(ruleText, R);
if (spaced === ruleText) { continue; }
const actions: SpaceAction[] = []; const actions: SpaceAction[] = [];
let origIdx = 0; let origIdx = 0;
let spacedIdx = 0; for (const sc of splitVariationSequences(spaced))
while (spacedIdx < spaced.length)
{ {
const sc = spaced.charAt(spacedIdx);
if (sc === DEL) if (sc === DEL)
{ {
// CJK↔CJK 贅餘空格:純刪除、不留隙(before 留空)。 // CJK↔CJK 贅餘空格:純刪除、不留隙(before 留空)。
actions.push({ before: undefined, space: map[origIdx]! }); actions.push({ before: undefined, space: map[origIdx]! });
origIdx += 1; origIdx += 1;
spacedIdx += 1;
continue; continue;
} }
if (sc === MARK) if (sc === MARK)
{ {
const oc = origIdx < text.length ? text.charAt(origIdx) : null; const oc = origIdx < originalChars.length ? originalChars[origIdx]! : null;
if (oc === " ") if (oc === " ")
{ {
actions.push({ before: map[origIdx - 1], space: map[origIdx]! }); actions.push({ before: map[origIdx - 1], space: map[origIdx]! });
@@ -224,11 +599,9 @@ function processBlock(block: Element, finder: Finder, R: Rules): void
{ {
actions.push({ before: map[origIdx - 1], space: null }); actions.push({ before: map[origIdx - 1], space: null });
} }
spacedIdx += 1;
continue; continue;
} }
origIdx += 1; origIdx += 1;
spacedIdx += 1;
} }
// 倒序執行,避免前面動作之 offset 被後面變更影響。先刪空格(若有)、再包左字 // 倒序執行,避免前面動作之 offset 被後面變更影響。先刪空格(若有)、再包左字
@@ -238,17 +611,26 @@ function processBlock(block: Element, finder: Finder, R: Rules): void
const act = actions[i]!; const act = actions[i]!;
if (act.space) if (act.space)
{ {
const tn = act.space.textNode; deleteSpan(act.space);
tn.data = tn.data.slice(0, act.space.offset) + tn.data.slice(act.space.offset + 1); }
if (act.before)
{
wrapGapLeft(act.before, ownedElements, ownerToken, block, scopeRoot);
} }
if (act.before) { wrapGapLeft(act.before); }
} }
} }
} }
// boundarySelector = pill(整體)∪ isolate(隔離):兩端補隙之口徑一致(§4.1 // boundarySelector = pill(整體)∪ isolate(隔離):兩端補隙之口徑一致(§4.1
// 三分類);差異僅在內部(pill 不可見、isolate 照常處理),不在此 pass。 // 三分類);差異僅在內部(pill 不可見、isolate 照常處理),不在此 pass。
function processPills(root: Element, finder: Finder, boundarySelector: string, pillNeighbor: RegExp): void function processPills(
root: Element,
finder: Finder,
boundarySelector: string,
pillNeighbor: RegExp,
ownedElements: WeakSet<Element>,
ownerToken: object,
): void
{ {
root.querySelectorAll(boundarySelector).forEach((pill) => root.querySelectorAll(boundarySelector).forEach((pill) =>
{ {
@@ -264,32 +646,57 @@ function processPills(root: Element, finder: Finder, boundarySelector: string, p
if (!pill.parentNode) { return; } if (!pill.parentNode) { return; }
const prev = finder.adjacentLogicalChar(pill, -1); const prev = finder.adjacentLogicalChar(pill, -1);
if (prev) { handlePillSide(prev, pill, -1, pillNeighbor); } if (prev)
{
handlePillSide(prev, pill, -1, pillNeighbor, ownedElements, ownerToken, root);
}
const next = finder.adjacentLogicalChar(pill, 1); const next = finder.adjacentLogicalChar(pill, 1);
if (next) { handlePillSide(next, pill, 1, pillNeighbor); } if (next)
{
handlePillSide(next, pill, 1, pillNeighbor, ownedElements, ownerToken, root);
}
}); });
} }
// dir -1:間隙在 adj 字(左)與 pill(右)之間 → margin 掛 adj 字(包裝該字)。 // dir -1:間隙在 adj 字(左)與 pill(右)之間 → margin 掛 adj 字(包裝該字)。
// dir +1:間隙在 pill(左)與 adj 字(右)之間 → margin 掛 pill(包裝 pill)。 // dir +1:間隙在 pill(左)與 adj 字(右)之間 → margin 掛 pill(包裝 pill)。
function handlePillSide(adj: AdjacentChar, pill: Element, dir: -1 | 1, pillNeighbor: RegExp): void function handlePillSide(
adj: AdjacentChar,
pill: Element,
dir: -1 | 1,
pillNeighbor: RegExp,
ownedElements: WeakSet<Element>,
ownerToken: object,
scopeRoot: Element,
): void
{ {
if (dir === -1) if (dir === -1)
{ {
if (pillNeighbor.test(adj.ch)) if (pillNeighbor.test(adj.ch))
{ {
wrapGapLeft({ textNode: adj.textNode, offset: adj.offset }); wrapGapLeft({
text: adj.ch,
parts: [ {
textNode: adj.textNode,
start: adj.offset,
end: adj.offset + adj.ch.length,
} ],
}, ownedElements, ownerToken, pill.parentElement ?? scopeRoot, scopeRoot);
return; return;
} }
if (adj.ch === " " && adj.offset >= 1) if (adj.ch === " " && adj.offset >= 1)
{ {
const tn = adj.textNode; const tn = adj.textNode;
const bc = tn.data.charAt(adj.offset - 1); const beforeOffset = previousVariationSequenceStart(tn.data, adj.offset);
const bc = variationSequenceAtUtf16(tn.data, beforeOffset);
if (bc !== " " && pillNeighbor.test(bc)) if (bc !== " " && pillNeighbor.test(bc))
{ {
tn.data = tn.data.slice(0, adj.offset) + tn.data.slice(adj.offset + 1); tn.data = tn.data.slice(0, adj.offset) + tn.data.slice(adj.offset + 1);
wrapGapLeft({ textNode: tn, offset: adj.offset - 1 }); wrapGapLeft({
text: bc,
parts: [ { textNode: tn, start: beforeOffset, end: beforeOffset + bc.length } ],
}, ownedElements, ownerToken, pill.parentElement ?? scopeRoot, scopeRoot);
} }
} }
} }
@@ -297,16 +704,16 @@ function handlePillSide(adj: AdjacentChar, pill: Element, dir: -1 | 1, pillNeigh
{ {
if (pillNeighbor.test(adj.ch)) if (pillNeighbor.test(adj.ch))
{ {
wrapNodeInGap(pill); wrapNodeInGap(pill, ownedElements);
return; return;
} }
if (adj.ch === " " && adj.textNode.data.length >= 2) if (adj.ch === " " && adj.textNode.data.length > 1)
{ {
const ac = adj.textNode.data.charAt(1); const ac = variationSequenceAtUtf16(adj.textNode.data, 1);
if (ac !== " " && pillNeighbor.test(ac)) if (ac !== " " && pillNeighbor.test(ac))
{ {
adj.textNode.data = adj.textNode.data.slice(1); adj.textNode.data = adj.textNode.data.slice(1);
wrapNodeInGap(pill); wrapNodeInGap(pill, ownedElements);
} }
} }
} }
@@ -327,7 +734,7 @@ export const spacingPass: StandalonePass = {
ctx.finder.eachBlock(ctx.root, (block) => ctx.finder.eachBlock(ctx.root, (block) =>
{ {
if (!finder.featureEnabledFor(block, PASS)) { return; } if (!finder.featureEnabledFor(block, PASS)) { return; }
processBlock(block, finder, R); processBlock(block, ctx.root, finder, R, ctx.ownedElements, ctx.ownerToken);
}); });
// 文本級功能須支持單元素(§6.5.0a):root 為行內/單一非塊元素(如消費端對 // 文本級功能須支持單元素(§6.5.0a):root 為行內/單一非塊元素(如消費端對
// <span> 標題呼叫 render)時 eachBlock 不含之,補處理 root 自身(collectRuns // <span> 標題呼叫 render)時 eachBlock 不含之,補處理 root 自身(collectRuns
@@ -336,7 +743,7 @@ export const spacingPass: StandalonePass = {
&& finder.featureEnabledFor(ctx.root, PASS) && finder.featureEnabledFor(ctx.root, PASS)
&& finder.inScope(ctx.root) && !finder.isAvoided(ctx.root)) && finder.inScope(ctx.root) && !finder.isAvoided(ctx.root))
{ {
processBlock(ctx.root, finder, R); processBlock(ctx.root, ctx.root, finder, R, ctx.ownedElements, ctx.ownerToken);
} }
// Pass B:pill/isolate 邊界。 // Pass B:pill/isolate 邊界。
const boundarySelector = [ options.finder.pillSelector, options.finder.isolateSelector ] const boundarySelector = [ options.finder.pillSelector, options.finder.isolateSelector ]
@@ -344,11 +751,20 @@ export const spacingPass: StandalonePass = {
.join(", "); .join(", ");
if (boundarySelector) if (boundarySelector)
{ {
processPills(ctx.root, finder, boundarySelector, R.pillNeighbor); processPills(
ctx.root,
finder,
boundarySelector,
R.pillNeighbor,
ctx.ownedElements,
ctx.ownerToken,
);
} }
}, },
revert(ctx: RenderContext): void revert(ctx: RenderContext): void
{ {
revertPass(PASS, ctx.root); const touched = restoreMovedParts(ctx);
revertPass(PASS, ctx.root, ctx.ownedElements);
for (const parent of touched) { (parent as Element).normalize?.(); }
}, },
}; };
+999
View File
@@ -0,0 +1,999 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { createJuzhen } from "../dist/juzhen.mjs";
import { countTag, setupDom } from "./helpers.mjs";
const TEXT_ONLY = {
jiya: false,
jinze: false,
longWord: false,
orphan: false,
};
function assertNoUnpairedSurrogates(text)
{
for (let i = 0; i < text.length; i += 1)
{
const unit = text.charCodeAt(i);
if (unit >= 0xD800 && unit <= 0xDBFF)
{
assert.ok(i + 1 < text.length, "high surrogate must have a following low surrogate");
const next = text.charCodeAt(i + 1);
assert.ok(next >= 0xDC00 && next <= 0xDFFF, "high surrogate must pair with low surrogate");
i += 1;
}
else
{
assert.ok(
unit < 0xDC00 || unit > 0xDFFF,
"low surrogate must have a preceding high surrogate",
);
}
}
}
test("astral classification: Extensions B-J and compatibility supplement are built-in CJK", () =>
{
const ranges = [
[ "ext-b", 0x20000, 0x2A6DF ],
[ "ext-c", 0x2A700, 0x2B73F ],
[ "ext-d", 0x2B740, 0x2B81F ],
[ "ext-e", 0x2B820, 0x2CEAF ],
[ "ext-f", 0x2CEB0, 0x2EBEF ],
[ "ext-i", 0x2EBF0, 0x2EE5F ],
[ "compat-supplement", 0x2F800, 0x2FA1F ],
[ "ext-g", 0x30000, 0x3134F ],
[ "ext-h", 0x31350, 0x323AF ],
[ "ext-j", 0x323B0, 0x3347F ],
];
const samples = ranges.flatMap(([ id, lower, upper ]) => [
[ id + "-lower", String.fromCodePoint(lower) ],
[ id + "-upper", String.fromCodePoint(upper) ],
]);
const gaps = [
[ "before-b", 0x1FFFF ],
[ "after-b", 0x2A6E0 ],
[ "after-i", 0x2EE60 ],
[ "after-compat", 0x2FA20 ],
[ "before-g", 0x2FFFF ],
[ "after-j", 0x33480 ],
].map(([ id, value ]) => [ id, String.fromCodePoint(value) ]);
const html = samples.map(([ id, ch ]) => `<p id="${id}">${ch}A</p>`).join("")
+ gaps.map(([ id, ch ]) => `<p id="${id}">${ch}A</p>`).join("")
+ '<p id="emoji">😀A</p>';
const { document } = setupDom(html);
createJuzhen(TEXT_ONLY).render(document.body);
for (const [ id, ch ] of samples)
{
const paragraph = document.getElementById(id);
assert.equal(countTag(paragraph, "jz-hws"), 1, `${id} must space against Latin`);
assert.equal(paragraph.textContent, ch + "A");
assertNoUnpairedSurrogates(paragraph.textContent);
}
for (const [ id ] of gaps)
{
assert.equal(countTag(document.getElementById(id), "jz-hws"), 0, `${id} is an intentional gap`);
}
assert.equal(countTag(document.getElementById("emoji"), "jz-hws"), 0, "emoji is not CJK");
});
test("Safari shadow: nonzero astral CJK regex misses do not suppress spacing actions", () =>
{
const ivs = "\u{20000}\u{E0100}";
const { document } = setupDom(
`<p id="cross">甲<strong>\u{20000}</strong>\u{E0100} A</p>`
+ `<p id="same">甲${ivs}A</p>`
+ `<p id="reverse">A甲${ivs}</p>`
+ `<p id="real-han">漢${ivs}A</p>`
+ '<p id="custom">甲<strong>😀</strong>\u{E0100}A</p>',
);
const replace = String.prototype.replace;
let simulatedMisses = 0;
String.prototype.replace = function (searchValue, replaceValue)
{
if (searchValue instanceof RegExp && searchValue.unicode
&& searchValue.source.includes("\\u{20000}"))
{
let offset = 0;
for (const ch of String(this))
{
const cp = ch.codePointAt(0);
if (offset > 0 && cp >= 0x20000 && cp <= 0x3347F)
{
simulatedMisses += 1;
return String(this);
}
offset += ch.length;
}
}
return replace.call(this, searchValue, replaceValue);
};
try
{
const representative = new RegExp("([\\u{20000}-\\u{2A6DF}])([A-Za-z])", "gu");
assert.equal("甲\u{20000}A".replace(representative, "$1$2"), "甲\u{20000}A");
const probeMisses = simulatedMisses;
createJuzhen(TEXT_ONLY).render(document.body);
createJuzhen({ ...TEXT_ONLY, charClass: { cjk: "😀" } })
.render(document.getElementById("custom"));
assert.equal(simulatedMisses, probeMisses, "rule inputs contain no nonzero astral CJK");
}
finally { String.prototype.replace = replace; }
assert.equal(countTag(document.getElementById("cross"), "jz-hws"), 1);
assert.equal(
document.querySelector("#cross jz-hws").textContent,
ivs,
"cross-node wraps the complete variation sequence",
);
assert.equal(countTag(document.getElementById("same"), "jz-hws"), 1);
assert.equal(document.querySelector("#same jz-hws").textContent, ivs, "same-node wraps real atom");
assert.equal(countTag(document.getElementById("reverse"), "jz-hws"), 1);
assert.equal(countTag(document.getElementById("real-han"), "jz-hws"), 1, "real 漢 is not confused");
assert.equal(countTag(document.getElementById("custom"), "jz-hws"), 1, "custom astral CJK shadows");
assertNoUnpairedSurrogates(document.body.textContent);
});
test("Safari shadow differential: representative and randomized Pangu actions stay aligned", () =>
{
const cjk = "\u{20000}\u{E0100}";
const shadow = "漢";
const representatives = [
`${cjk} A`, `A ${cjk}`, `${cjk} 甲`, `...${cjk}`,
`${cjk}!A`, `A!${cjk}`, `${cjk}~A`, `${cjk}~=A`,
`${cjk}.B`, `A.${cjk}`, `A:${cjk}`, `${cjk}\"A`, `\"${cjk}`,
`${cjk}#x`, `x#${cjk}`, `A+${cjk}`, `${cjk}+A`,
`${cjk}<A`, `A<${cjk}`, `${cjk}>A`, `A>${cjk}`,
`${cjk}(A`, `)${cjk}`, `A(B`, `)A`, `%A`,
`漢${cjk}A`, `甲乙${cjk}A`, `foo-bar${cjk}`, `${cjk}GPT-4`,
];
const tokens = [
"A", "b", "1", " ", "!", "?", ";", ":", ".", "~", "~=", "+", "-", "*",
"#", "(", ")", "<", ">", "\"", "...", "%", "foo-bar", "GPT-4", "甲", "漢",
];
let state = 0xC0FFEE;
const random = () =>
{
state = (state * 1664525 + 1013904223) >>> 0;
return state;
};
const cases = representatives.slice();
for (let i = 0; i < 256; i += 1)
{
const left = tokens[random() % tokens.length];
const right = tokens[random() % tokens.length];
const mode = random() % 4;
cases.push(mode === 0 ? left + cjk + right
: mode === 1 ? cjk + left + right
: mode === 2 ? left + right + cjk
: left + cjk + "漢" + right);
}
const escapeHtml = (text) => text.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
const html = cases.map((text, index) =>
{
const bmp = text.split(cjk).join(shadow);
return `<p id="a-${index}">${escapeHtml(text)}</p>`
+ `<p id="b-${index}">${escapeHtml(bmp)}</p>`;
}).join("");
const { document } = setupDom(html);
createJuzhen(TEXT_ONLY).render(document.body);
const boundaries = (paragraph) => Array.from(paragraph.querySelectorAll("jz-hws")).map((gap) =>
{
const range = document.createRange();
range.setStart(paragraph, 0);
range.setEndAfter(gap);
let atoms = 0;
for (const ch of range.toString())
{
const cp = ch.codePointAt(0);
if (!((cp >= 0xFE00 && cp <= 0xFE0F) || (cp >= 0xE0100 && cp <= 0xE01EF)))
{
atoms += 1;
}
}
return atoms;
});
for (let index = 0; index < cases.length; index += 1)
{
const astral = document.getElementById(`a-${index}`);
const bmp = document.getElementById(`b-${index}`);
assert.deepEqual(boundaries(astral), boundaries(bmp), `gap actions ${index}`);
assert.equal(
astral.textContent.split(cjk).join(shadow),
bmp.textContent,
`space deletion and atom order ${index}`,
);
assertNoUnpairedSurrogates(astral.textContent);
}
});
test("IVS spacing: both directions and transparent inline boundaries keep base plus VS atomic", () =>
{
const ivs = "\u{20000}\u{E0100}";
const bmpVs = "中\uFE00";
const { document } = setupDom(
`<p id="ivs-left">${ivs}A</p><p id="ivs-right">A${ivs}</p>`
+ `<p id="bmp-left">${bmpVs}A</p><p id="bmp-right">A${bmpVs}</p>`
+ `<p id="inline-left"><strong>${ivs}</strong>A</p>`
+ `<p id="inline-right">A<strong>${ivs}</strong></p>`,
);
const ids = [ "ivs-left", "ivs-right", "bmp-left", "bmp-right", "inline-left", "inline-right" ];
const originals = new Map(ids.map((id) => [ id, document.getElementById(id).innerHTML ]));
const jz = createJuzhen(TEXT_ONLY);
jz.render(document.body);
for (const id of ids) { assert.equal(countTag(document.getElementById(id), "jz-hws"), 1, id); }
assert.equal(document.querySelector("#ivs-left jz-hws").textContent, ivs);
assert.equal(document.querySelector("#bmp-left jz-hws").textContent, bmpVs);
assert.equal(document.querySelector("#inline-left jz-hws").textContent, ivs);
assert.equal(document.getElementById("ivs-left").textContent, ivs + "A");
assert.equal(document.getElementById("ivs-right").textContent, "A" + ivs);
assertNoUnpairedSurrogates(document.body.textContent);
jz.revert(document.body);
for (const id of ids) { assert.equal(document.getElementById(id).innerHTML, originals.get(id), id); }
assertNoUnpairedSurrogates(document.body.textContent);
});
test("IVS spacing: base and selector across transparent nodes share one DOM span", () =>
{
const samples = [
[
"bmp-left",
'<strong id="author-bmp-left">中</strong>\uFE00A',
'<strong id="author-bmp-left">中</strong>\uFE00A',
],
[
"bmp-left-space",
'<strong id="author-bmp-left-space">中</strong>\uFE00 A',
'<strong id="author-bmp-left-space">中</strong>\uFE00A',
],
[
"bmp-left-partial-start",
'<strong id="author-bmp-partial-start">甲中</strong>\uFE00 A',
'<strong id="author-bmp-partial-start">甲中</strong>\uFE00A',
],
[
"bmp-left-partial-end",
'<strong id="author-bmp-partial-end-base">中</strong>'
+ '<em id="author-bmp-partial-end-vs">\uFE00x</em>',
'<strong id="author-bmp-partial-end-base">中</strong>'
+ '<em id="author-bmp-partial-end-vs">\uFE00x</em>',
],
[
"bmp-right",
'A<strong id="author-bmp-right">中</strong>\uFE00',
'A<strong id="author-bmp-right">中</strong>\uFE00',
],
[
"bmp-right-space",
'A <strong id="author-bmp-right-space">中</strong>\uFE00',
'A<strong id="author-bmp-right-space">中</strong>\uFE00',
],
[
"ivs-left",
'<strong id="author-ivs-left">\u{20000}</strong>\u{E0100}A',
'<strong id="author-ivs-left">\u{20000}</strong>\u{E0100}A',
],
[
"ivs-left-space",
'<strong id="author-ivs-left-space">\u{20000}</strong>\u{E0100} A',
'<strong id="author-ivs-left-space">\u{20000}</strong>\u{E0100}A',
],
[
"ivs-right",
'A<strong id="author-ivs-right">\u{20000}</strong>\u{E0100}',
'A<strong id="author-ivs-right">\u{20000}</strong>\u{E0100}',
],
[
"ivs-right-space",
'A <strong id="author-ivs-right-space">\u{20000}</strong>\u{E0100}',
'A<strong id="author-ivs-right-space">\u{20000}</strong>\u{E0100}',
],
];
const { document } = setupDom(samples.map(([ id, html ]) => `<p id="${id}">${html}</p>`).join(""));
const jz = createJuzhen(TEXT_ONLY);
const authors = Array.from(document.querySelectorAll('[id^="author-"]'));
const clickCounts = new Map(authors.map((el) => [ el, 0 ]));
for (const author of authors)
{
author.addEventListener("click", () => clickCounts.set(author, clickCounts.get(author) + 1));
}
const assertAuthorState = (expectedClicks) =>
{
for (const author of authors)
{
assert.equal(document.getElementById(author.id), author, `${author.id} identity`);
assert.equal(document.querySelectorAll(`[id="${author.id}"]`).length, 1, `${author.id} unique`);
author.dispatchEvent(new document.defaultView.Event("click"));
assert.equal(clickCounts.get(author), expectedClicks, `${author.id} listener`);
}
};
jz.render(document.body);
for (const [ id, , normalized ] of samples)
{
const paragraph = document.getElementById(id);
assert.equal(countTag(paragraph, "jz-hws"), 1, id);
assert.equal(paragraph.textContent, paragraph.textContent.replace(" ", ""), id);
assertNoUnpairedSurrogates(paragraph.textContent);
const gap = paragraph.querySelector("jz-hws");
if (id.includes("left"))
{
const sequence = id.startsWith("bmp") ? "中\uFE00" : "\u{20000}\u{E0100}";
assert.equal(gap.textContent, sequence, `${id} wraps the complete sequence`);
assert.equal(gap.getAttribute("data-jz-kind"), "wrap", `${id} wrapper kind`);
assert.equal(
Array.from(gap.childNodes).filter((node) => node.nodeType === 3).length,
2,
`${id} wrapper retains both Text parts`,
);
assert.equal(gap.getAttribute("data-jz-cross-node-atom"), "", `${id} cross-node flag`);
}
else { assert.equal(gap.textContent, "A", `${id} Latin carrier remains unchanged`); }
assert.equal(paragraph.textContent, setupDom(`<p>${normalized}</p>`).document.body.textContent, id);
}
assertAuthorState(1);
jz.render(document.body);
for (const [ id ] of samples)
{
assert.equal(countTag(document.getElementById(id), "jz-hws"), 1, `${id} repeated render`);
}
assertAuthorState(2);
jz.revert(document.body);
for (const [ id, , normalized ] of samples)
{
const paragraph = document.getElementById(id);
assert.equal(paragraph.innerHTML, normalized, id);
assertNoUnpairedSurrogates(paragraph.textContent);
}
assertAuthorState(3);
});
test("moved IVS ownership: repeat and peer instances never claim an existing gap", async () =>
{
const countAnchors = (root) =>
{
const walker = root.ownerDocument.createTreeWalker(
root,
root.ownerDocument.defaultView.NodeFilter.SHOW_COMMENT,
);
let count = 0;
while (walker.nextNode())
{
if (walker.currentNode.data.startsWith("jz-spacing-")) { count += 1; }
}
return count;
};
const { document } = setupDom(
'<p><strong id="repeat-author">\u{20000}</strong><em>\u{E0100}A</em></p>',
);
const paragraph = document.querySelector("p");
const author = document.getElementById("repeat-author");
const originalHtml = paragraph.innerHTML;
let clicks = 0;
author.addEventListener("click", () => { clicks += 1; });
const first = createJuzhen(TEXT_ONLY);
first.render(paragraph);
const gap = paragraph.querySelector("jz-hws");
assert.equal(countAnchors(paragraph), 2, "one original-side and one gap-side anchor");
const mutations = [];
const observer = new document.defaultView.MutationObserver((records) => mutations.push(...records));
observer.observe(paragraph, { childList: true, subtree: true, attributes: true });
first.render(paragraph);
await new Promise((resolve) => setTimeout(resolve, 0));
observer.disconnect();
assert.equal(paragraph.querySelector("jz-hws"), gap, "repeat preserves exact gap object");
assert.equal(countAnchors(paragraph), 2, "repeat creates no anchor");
const gapMutations = mutations.filter((record) => record.target === gap || gap.contains(record.target)
|| Array.from(record.addedNodes).some(
(node) => node.nodeType === 8 && node.data.startsWith("jz-spacing-"),
));
assert.equal(gapMutations.length, 0, "repeat does not mutate the gap or add anchors");
const second = createJuzhen(TEXT_ONLY);
second.render(paragraph);
second.revert(paragraph);
assert.equal(paragraph.querySelector("jz-hws"), gap, "peer render/revert leaves first gap intact");
assert.equal(countAnchors(paragraph), 2, "peer registers no moved part");
first.revert(paragraph);
assert.equal(paragraph.innerHTML, originalHtml);
assert.equal(document.getElementById("repeat-author"), author);
author.dispatchEvent(new document.defaultView.Event("click"));
assert.equal(clicks, 1);
const reverseDom = setupDom(
'<p><strong id="reverse-author">\u{20000}</strong><em>\u{E0100}A</em></p>',
);
const reverse = reverseDom.document.querySelector("p");
const reverseHtml = reverse.innerHTML;
const a = createJuzhen(TEXT_ONLY);
const b = createJuzhen(TEXT_ONLY);
b.render(reverse);
a.render(reverse);
a.revert(reverse);
assert.equal(countTag(reverse, "jz-hws"), 1, "reverse order preserves first owner");
b.revert(reverse);
assert.equal(reverse.innerHTML, reverseHtml);
for (const attributes of [ "", ' data-jz="spacing" data-jz-kind="wrap"' ])
{
const foreignDom = setupDom(
`<p><jz-hws id="foreign"${attributes}>`
+ '<strong>\u{20000}</strong>\u{E0100}</jz-hws>A</p>',
);
const foreignParagraph = foreignDom.document.querySelector("p");
const foreignHtml = foreignParagraph.innerHTML;
createJuzhen(TEXT_ONLY).render(foreignParagraph);
assert.equal(foreignParagraph.innerHTML, foreignHtml, "foreign/forged gap is a strict no-op");
assert.equal(countAnchors(foreignParagraph), 0, "foreign/forged gap is never registered");
assert.equal(
foreignParagraph.querySelector("jz-hws").hasAttribute("data-jz-cross-node-atom"),
false,
);
}
});
test("moved IVS ownership: replaced selector parents discard stale text and rebuild", async () =>
{
const ivs = "\u{E0100}";
const methods = [ "textContent", "replaceChildren", "innerHTML" ];
for (const method of methods)
{
const { document } = setupDom(
'<p><strong id="replace-base">\u{20000}</strong>'
+ '<em id="replace-selector">\u{E0100}A</em></p>',
);
const paragraph = document.querySelector("p");
const base = document.getElementById("replace-base");
const selectorParent = document.getElementById("replace-selector");
const jz = createJuzhen(TEXT_ONLY);
jz.render(document.body);
const oldSelector = Array.from(paragraph.querySelector("jz-hws").childNodes)
.find((node) => node.nodeType === 3 && node.data === ivs);
assert.ok(oldSelector, `${method} captures moved selector`);
const replacement = ivs + method[0].toUpperCase();
if (method !== "innerHTML") { jz.observe(document.body); }
if (method === "textContent") { selectorParent.textContent = replacement; }
else if (method === "replaceChildren")
{
selectorParent.replaceChildren(document.createTextNode(replacement));
}
else
{
selectorParent.innerHTML = replacement;
jz.rerender(document.body);
}
if (method !== "innerHTML")
{
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
jz.disconnect();
}
assert.equal(document.getElementById("replace-base"), base, `${method} base identity`);
assert.equal(
document.getElementById("replace-selector"),
selectorParent,
`${method} selector-parent identity`,
);
assert.equal(paragraph.textContent, "\u{20000}" + replacement, `${method} exact author text`);
assert.equal(paragraph.textContent.split(ivs).length - 1, 1, `${method} has one selector`);
assert.equal(oldSelector.isConnected, false, `${method} stale selector is discarded`);
assert.equal(countTag(paragraph, "jz-hws"), 1, `${method} rebuilt gap`);
assert.equal(paragraph.querySelector("jz-hws").textContent, "\u{20000}" + ivs);
assertNoUnpairedSurrogates(paragraph.textContent);
jz.revert(document.body);
assert.equal(
paragraph.innerHTML,
'<strong id="replace-base">\u{20000}</strong>'
+ `<em id="replace-selector">${replacement}</em>`,
`${method} revert preserves only replacement`,
);
}
});
test("moved IVS ownership: empty selector parents never resurrect stale text", async () =>
{
for (const method of [ "textContent", "replaceChildren", "innerHTML" ])
{
const { document } = setupDom(
'<p><strong id="empty-base">\u{20000}</strong>'
+ '<em id="empty-selector">\u{E0100}</em>A</p>',
);
const paragraph = document.querySelector("p");
const base = document.getElementById("empty-base");
const selectorParent = document.getElementById("empty-selector");
const jz = createJuzhen(TEXT_ONLY);
jz.render(document.body);
const oldSelector = Array.from(paragraph.querySelector("jz-hws").childNodes)
.find((node) => node.nodeType === 3 && node.data === "\u{E0100}");
assert.ok(oldSelector, `${method} captures moved selector`);
if (method !== "innerHTML") { jz.observe(document.body); }
if (method === "textContent") { selectorParent.textContent = ""; }
else if (method === "replaceChildren") { selectorParent.replaceChildren(); }
else
{
selectorParent.innerHTML = "";
jz.rerender(document.body);
}
if (method !== "innerHTML")
{
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
jz.disconnect();
}
assert.equal(paragraph.textContent, "\u{20000}A", `${method} preserves author deletion`);
assert.equal(paragraph.textContent.includes("\u{E0100}"), false, `${method} has zero selectors`);
assert.equal(oldSelector.isConnected, false, `${method} stale selector stays discarded`);
assert.equal(document.getElementById("empty-base"), base, `${method} base identity`);
assert.equal(document.getElementById("empty-selector"), selectorParent, `${method} parent identity`);
assert.equal(selectorParent.childNodes.length, 0, `${method} original parent remains empty`);
const gap = paragraph.querySelector("jz-hws");
assert.ok(gap, `${method} rebuilds ordinary base-to-Latin gap`);
assert.equal(gap.textContent, "\u{20000}", `${method} ordinary gap wraps only base`);
assert.equal(
gap.hasAttribute("data-jz-cross-node-atom"),
false,
`${method} ordinary gap is not cross-node`,
);
assertNoUnpairedSurrogates(paragraph.textContent);
jz.revert(document.body);
assert.equal(
paragraph.innerHTML,
'<strong id="empty-base">\u{20000}</strong><em id="empty-selector"></em>A',
`${method} revert keeps selector deletion`,
);
}
});
test("moved IVS ownership: externally moved selector-only anchor restores to original parent", () =>
{
const original = '<strong id="anchor-base">\u{20000}</strong>'
+ '<em id="anchor-selector">\u{E0100}</em>A';
for (const side of [ "common", "base", "original-parent", "external-anchor" ])
{
const { document } = setupDom(`<div id="anchor-outside"></div><p>${original}</p>`);
const paragraph = document.querySelector("p");
const base = document.getElementById("anchor-base");
const selectorParent = document.getElementById("anchor-selector");
const outside = document.getElementById("anchor-outside");
let clicks = 0;
selectorParent.addEventListener("click", () => { clicks += 1; });
const jz = createJuzhen(TEXT_ONLY);
jz.render(document.body);
const walker = document.createTreeWalker(
paragraph,
document.defaultView.NodeFilter.SHOW_COMMENT,
);
let anchor = null;
while (walker.nextNode())
{
if (walker.currentNode.data === "jz-spacing-anchor")
{
anchor = walker.currentNode;
break;
}
}
assert.ok(anchor, `${side} original-side anchor`);
assert.equal(selectorParent.childNodes.length, 1, `${side} selector parent initially has anchor`);
outside.appendChild(anchor);
assert.equal(selectorParent.childNodes.length, 0, `${side} original parent is now empty`);
const root = side === "common" ? paragraph
: side === "base" ? base
: side === "original-parent" ? selectorParent
: outside;
jz.revert(root);
assert.equal(paragraph.innerHTML, original, `${side} exact author DOM`);
assert.equal(paragraph.querySelector("#anchor-base"), base, `${side} base identity`);
assert.equal(paragraph.querySelector("#anchor-selector"), selectorParent, `${side} parent identity`);
assert.equal(selectorParent.textContent, "\u{E0100}", `${side} selector preserved`);
assert.equal(countTag(paragraph, "jz-hws"), 0, `${side} linked gap removed`);
const outsideComments = Array.from(outside.childNodes)
.filter((node) => node.nodeType === 8 && node.data.startsWith("jz-spacing-"));
assert.equal(outsideComments.length, 0, `${side} external parent has no internal comment`);
selectorParent.dispatchEvent(new document.defaultView.Event("click"));
assert.equal(clicks, 1, `${side} listener`);
assertNoUnpairedSurrogates(paragraph.textContent);
}
});
test("moved IVS ownership: anchor and text-move throws roll back exactly", () =>
{
const fixture = '<p><strong id="throw-author">\u{20000}</strong><em>\u{E0100}A</em></p>';
const modes = [ "original-anchor", "gap-anchor", "selector" ];
for (const mode of modes)
{
for (const throwAfterNative of [ false, true ])
{
const { document } = setupDom(fixture);
const paragraph = document.querySelector("p");
const author = document.getElementById("throw-author");
const nodePrototype = document.defaultView.Node.prototype;
const insertBefore = nodePrototype.insertBefore;
const appendChild = nodePrototype.appendChild;
const error = new Error(`${mode}-${throwAfterNative ? "after" : "before"}`);
let thrown = false;
const shouldThrow = (node) => mode === "original-anchor"
? node.nodeType === 8 && node.data === "jz-spacing-anchor"
: mode === "gap-anchor"
? node.nodeType === 8 && node.data === "jz-spacing-gap-anchor"
: node.nodeType === 3 && node.data === "\u{E0100}";
nodePrototype.insertBefore = function (node, reference)
{
if (!thrown && shouldThrow(node) && mode === "original-anchor")
{
thrown = true;
if (!throwAfterNative) { throw error; }
const result = insertBefore.call(this, node, reference);
throw error;
return result;
}
return insertBefore.call(this, node, reference);
};
nodePrototype.appendChild = function (node)
{
if (!thrown && shouldThrow(node) && mode !== "original-anchor")
{
thrown = true;
if (!throwAfterNative) { throw error; }
const result = appendChild.call(this, node);
throw error;
return result;
}
return appendChild.call(this, node);
};
const jz = createJuzhen(TEXT_ONLY);
try { assert.throws(() => jz.render(paragraph), (got) => got === error); }
finally
{
nodePrototype.insertBefore = insertBefore;
nodePrototype.appendChild = appendChild;
}
assert.equal(thrown, true, `${mode} ${throwAfterNative} exercised`);
assert.equal(paragraph.innerHTML, fixture.slice(3, -4), `${mode} exact rollback`);
assert.equal(countTag(paragraph, "jz-hws"), 0);
assert.equal(
Array.from(paragraph.childNodes).filter((node) => node.nodeType === 8).length,
0,
`${mode} leaves no direct anchor`,
);
assert.equal(document.getElementById("throw-author"), author);
jz.render(paragraph);
assert.equal(countTag(paragraph, "jz-hws"), 1, `${mode} retry`);
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, fixture.slice(3, -4), `${mode} retry/revert`);
}
}
});
test("moved IVS ownership: partial roots and detached links restore as one unit", () =>
{
const original = '<strong id="partial-base">\u{20000}</strong>'
+ '<em id="partial-selector">\u{E0100}A</em>';
const cases = [ "common", "anchor-side", "base-side", "detached-gap", "moved-anchor", "detached" ];
for (const kind of cases)
{
const { document } = setupDom(
`<div id="outside"></div><div id="partial-container"><p>${original}</p></div>`,
);
const paragraph = document.querySelector("p");
const container = document.getElementById("partial-container");
const base = document.getElementById("partial-base");
const selectorParent = document.getElementById("partial-selector");
const outside = document.getElementById("outside");
let clicks = 0;
base.addEventListener("click", () => { clicks += 1; });
const jz = createJuzhen(TEXT_ONLY);
jz.render(kind === "detached" ? paragraph : document.body);
const gap = paragraph.querySelector("jz-hws");
const anchorWalker = document.createTreeWalker(
paragraph,
document.defaultView.NodeFilter.SHOW_COMMENT,
);
let anchor = null;
while (anchorWalker.nextNode())
{
if (anchorWalker.currentNode.data === "jz-spacing-anchor")
{
anchor = anchorWalker.currentNode;
break;
}
}
assert.ok(anchor, `${kind} anchor exists`);
let revertRoot = paragraph;
if (kind === "anchor-side") { revertRoot = selectorParent; }
else if (kind === "base-side") { revertRoot = base; }
else if (kind === "detached-gap")
{
gap.parentNode.removeChild(gap);
revertRoot = selectorParent;
}
else if (kind === "moved-anchor")
{
outside.appendChild(anchor);
revertRoot = base;
}
else if (kind === "detached")
{
paragraph.parentNode.removeChild(paragraph);
revertRoot = container;
}
jz.revert(revertRoot);
assert.equal(paragraph.innerHTML, original, `${kind} restores exact author DOM`);
assert.equal(paragraph.querySelector("#partial-base"), base, `${kind} base identity`);
assert.equal(paragraph.querySelector("#partial-selector"), selectorParent, `${kind} parent identity`);
assert.equal(selectorParent.textContent, "\u{E0100}A", `${kind} selector original parent`);
assert.equal(countTag(paragraph, "jz-hws"), 0, `${kind} removes linked gap`);
assert.equal(
Array.from(outside.childNodes).filter((node) => node.nodeType === 8).length,
0,
`${kind} never follows anchor to arbitrary parent`,
);
base.dispatchEvent(new document.defaultView.Event("click"));
assert.equal(clicks, 1, `${kind} listener`);
assertNoUnpairedSurrogates(paragraph.textContent);
if (kind === "detached")
{
container.appendChild(paragraph);
jz.render(paragraph);
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, original, "detached record was cleaned before reuse");
}
}
});
test("IVS spacing survives orphan reparenting without replacing author elements", () =>
{
const samples = [
[
"ivs-forward-space",
'<strong id="orphan-author-ivs-forward">甲\u{20000}</strong>\u{E0100} A',
'<strong id="orphan-author-ivs-forward">甲\u{20000}</strong>\u{E0100}A',
"\u{20000}\u{E0100}",
true,
],
[
"bmp-forward",
'<strong id="orphan-author-bmp-forward">甲中</strong>\uFE00A',
'<strong id="orphan-author-bmp-forward">甲中</strong>\uFE00A',
"中\uFE00",
true,
],
[
"bmp-forward-nested-carrier",
'<strong id="orphan-author-bmp-nested-base">甲中</strong>'
+ '<em id="orphan-author-bmp-nested-vs">\uFE00 A</em>',
'<strong id="orphan-author-bmp-nested-base">甲中</strong>'
+ '<em id="orphan-author-bmp-nested-vs">\uFE00A</em>',
"中\uFE00",
true,
],
[
"ivs-reverse-space",
'A <strong id="orphan-author-ivs-reverse">\u{20000}</strong>\u{E0100}乙',
'A<strong id="orphan-author-ivs-reverse">\u{20000}</strong>\u{E0100}乙',
"A",
false,
],
[
"bmp-reverse",
'A<strong id="orphan-author-bmp-reverse">中</strong>\uFE00乙',
'A<strong id="orphan-author-bmp-reverse">中</strong>\uFE00乙',
"A",
false,
],
];
const { document } = setupDom(samples.map(([ id, html ]) => `<p id="${id}">${html}</p>`).join(""));
const authors = Array.from(document.querySelectorAll('[id^="orphan-author-"]'));
const clickCounts = new Map(authors.map((el) => [ el, 0 ]));
for (const author of authors)
{
author.addEventListener("click", () => clickCounts.set(author, clickCounts.get(author) + 1));
}
const jz = createJuzhen({ jiya: false, jinze: false, longWord: false });
jz.render(document.body);
for (const [ id, , normalized, carrierText, insideOrphan ] of samples)
{
const paragraph = document.getElementById(id);
const gap = paragraph.querySelector("jz-hws");
const orphan = paragraph.querySelector("jz-orphan");
assert.ok(gap, `${id} gap survives orphan`);
assert.ok(orphan, `${id} orphan is active`);
assert.equal(gap.textContent, carrierText, `${id} carrier text`);
assert.equal(orphan.contains(gap), insideOrphan, `${id} carrier placement`);
if (insideOrphan)
{
assert.equal(gap.getAttribute("data-jz-kind"), "wrap", `${id} uses atomic wrapper`);
}
assert.equal(paragraph.textContent, setupDom(`<p>${normalized}</p>`).document.body.textContent, id);
assertNoUnpairedSurrogates(paragraph.textContent);
}
for (const author of authors)
{
assert.equal(document.getElementById(author.id), author, `${author.id} identity`);
assert.equal(document.querySelectorAll(`[id="${author.id}"]`).length, 1, `${author.id} unique`);
author.dispatchEvent(new document.defaultView.Event("click"));
assert.equal(clickCounts.get(author), 1, `${author.id} listener`);
}
jz.render(document.body);
for (const [ id ] of samples)
{
assert.equal(countTag(document.getElementById(id), "jz-hws"), 1, `${id} repeated render`);
}
jz.revert(document.body);
for (const [ id, , normalized ] of samples)
{
const paragraph = document.getElementById(id);
assert.equal(paragraph.innerHTML, normalized, id);
assertNoUnpairedSurrogates(paragraph.textContent);
}
for (const author of authors)
{
assert.equal(document.getElementById(author.id), author, `${author.id} revert identity`);
author.dispatchEvent(new document.defaultView.Event("click"));
assert.equal(clickCounts.get(author), 2, `${author.id} revert listener`);
}
});
test("IVS spacing: pill neighbors use complete sequences and UTF-16 offsets", () =>
{
const ivs = "\u{20000}\u{E0100}";
const { document } = setupDom(
`<p id="left">${ivs} <code>x</code></p>`
+ `<p id="right"><code>x</code> ${ivs}</p>`,
);
const left = document.getElementById("left");
const right = document.getElementById("right");
const jz = createJuzhen(TEXT_ONLY);
jz.render(document.body);
assert.equal(left.textContent, ivs + "x", "author space before pill is removed without splitting IVS");
assert.equal(right.textContent, "x" + ivs, "author space after pill is removed without splitting IVS");
assert.equal(left.querySelector("jz-hws").textContent, ivs);
assert.equal(countTag(left, "jz-hws"), 1);
assert.equal(countTag(right, "jz-hws"), 1);
assertNoUnpairedSurrogates(document.body.textContent);
jz.revert(document.body);
assert.equal(left.textContent, ivs + "x");
assert.equal(right.textContent, "x" + ivs);
assertNoUnpairedSurrogates(document.body.textContent);
});
test("IVS jinze: opening and closing punctuation bind one complete variation sequence", () =>
{
const ivs = "\u{20000}\u{E0100}";
const { document } = setupDom(
`<p id="open" lang="zh-Hant">「${ivs}A</p>`
+ `<p id="close" lang="zh-Hant">A${ivs}」</p>`,
);
const open = document.getElementById("open");
const close = document.getElementById("close");
const originalOpen = open.innerHTML;
const originalClose = close.innerHTML;
const jz = createJuzhen({ spacing: false, longWord: false, orphan: false });
jz.render(document.body);
assert.equal(open.querySelector("jz-jinze").textContent, "「" + ivs);
assert.equal(close.querySelector("jz-jinze").textContent, ivs + "」");
assert.equal(open.textContent, "「" + ivs + "A");
assert.equal(close.textContent, "A" + ivs + "」");
assertNoUnpairedSurrogates(document.body.textContent);
jz.revert(document.body);
assert.equal(open.innerHTML, originalOpen);
assert.equal(close.innerHTML, originalClose);
});
test("IVS splitoff: transparent inline splits after the full variation sequence", () =>
{
const ivs = "\u{20000}\u{E0100}";
const { document } = setupDom(`<p lang="zh-Hant">「<strong>${ivs}A</strong></p>`);
const paragraph = document.querySelector("p");
const originalHtml = paragraph.innerHTML;
const jz = createJuzhen({ spacing: false, longWord: false, orphan: false });
jz.render(paragraph);
assert.equal(paragraph.querySelector("jz-jinze").textContent, "「" + ivs);
const splitoff = paragraph.querySelector('strong[data-jz-kind="splitoff"]');
assert.ok(splitoff);
assert.equal(splitoff.textContent, "A");
assert.equal(paragraph.textContent, "「" + ivs + "A");
assertNoUnpairedSurrogates(paragraph.textContent);
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, originalHtml);
});
test("IVS orphan: base counts once, selector counts zero, and split keeps the sequence", () =>
{
const ivs = "\u{20000}\u{E0100}";
const { document } = setupDom(`<p id="enough">A${ivs}甲</p><p id="short">A${ivs}</p>`);
const enough = document.getElementById("enough");
const short = document.getElementById("short");
const enoughHtml = enough.innerHTML;
const shortHtml = short.innerHTML;
const jz = createJuzhen({ ...TEXT_ONLY, orphan: { chars: 2 }, spacing: false });
jz.render(document.body);
assert.equal(enough.querySelector("jz-orphan").textContent, ivs + "甲");
assert.equal(short.querySelector("jz-orphan"), null, "one base plus VS is only one substantive CJK");
assertNoUnpairedSurrogates(document.body.textContent);
jz.revert(document.body);
assert.equal(enough.innerHTML, enoughHtml);
assert.equal(short.innerHTML, shortHtml);
});
test("variation selectors: punctuation suffix is atomic and selectors without a base are not CJK", () =>
{
const punctuation = setupDom('<p lang="zh-Hant">,\uFE00A</p>');
const jz = createJuzhen({ spacing: false, jinze: false, longWord: false, orphan: false });
jz.render(punctuation.document.body);
const char = punctuation.document.querySelector("jz-char.bd-pause");
assert.ok(char);
assert.equal(char.textContent, ",\uFE00");
assert.equal(countTag(punctuation.document.body, "jz-char"), 1);
jz.revert(punctuation.document.body);
assert.equal(punctuation.document.body.textContent, ",\uFE00A");
const standalone = setupDom('<p id="bmp">\uFE00A</p><p id="supp">\u{E0100}A</p>');
createJuzhen(TEXT_ONLY).render(standalone.document.body);
assert.equal(countTag(standalone.document.getElementById("bmp"), "jz-hws"), 0);
assert.equal(countTag(standalone.document.getElementById("supp"), "jz-hws"), 0);
});
test("charClass append remains additive for BMP and astral variation sequences", () =>
{
const base = setupDom('<p id="bmp">〇A</p><p id="astral">😀\u{E0100}A</p>');
createJuzhen({ ...TEXT_ONLY, charClass: { cjk: "〇😀" } }).render(base.document.body);
assert.equal(countTag(base.document.getElementById("bmp"), "jz-hws"), 1);
assert.equal(countTag(base.document.getElementById("astral"), "jz-hws"), 1);
const punctuation = setupDom('<p lang="zh-Hant">A😀B</p>');
const jz = createJuzhen({
spacing: false,
jinze: false,
longWord: false,
orphan: false,
charClass: { biaodian: { pause: "😀" } },
});
jz.render(punctuation.document.body);
const char = punctuation.document.querySelector("jz-char.bd-pause");
assert.ok(char, "astral custom punctuation is charified once");
assert.equal(char.textContent, "😀");
assert.equal(countTag(punctuation.document.body, "jz-char"), 1);
assertNoUnpairedSurrogates(punctuation.document.body.textContent);
});
+732
View File
@@ -0,0 +1,732 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { createJuzhen } from "../dist/juzhen.mjs";
import { setupDom } from "./helpers.mjs";
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
function installCountingMutationObserver(
maxCallbacks = Number.POSITIVE_INFINITY,
{ deferDelivery = false, wrapRecords = (records) => records } = {},
)
{
const NativeMutationObserver = globalThis.MutationObserver;
const instances = [];
class CountingMutationObserver
{
constructor(callback)
{
this.callback = callback;
this.observeCount = 0;
this.disconnectCount = 0;
this.callbackCount = 0;
this.pendingRecords = [];
this.suppressed = false;
this.inner = new NativeMutationObserver((records) =>
{
if (deferDelivery) { this.pendingRecords.push(...records); }
else { this.deliver(records); }
});
instances.push(this);
}
deliver(records)
{
this.callbackCount += 1;
if (this.callbackCount > maxCallbacks)
{
this.suppressed = true;
this.inner.disconnect();
return;
}
this.callback(wrapRecords(records), this);
}
observe(target, options)
{
this.observeCount += 1;
if (!this.suppressed) { this.inner.observe(target, options); }
}
disconnect()
{
this.disconnectCount += 1;
this.inner.disconnect();
}
takeRecords()
{
return this.inner.takeRecords();
}
}
globalThis.MutationObserver = CountingMutationObserver;
return {
instances,
restore()
{
globalThis.MutationObserver = NativeMutationObserver;
},
};
}
function installExpectationShiftProbe()
{
const nativeShift = Array.prototype.shift;
let calls = 0;
let work = 0;
Array.prototype.shift = function ()
{
const head = this[0];
if (head && typeof head === "object" && "target" in head
&& "oldValue" in head && "newValue" in head)
{
calls += 1;
work += this.length;
}
return nativeShift.call(this);
};
return {
result: () => ({ calls, work }),
restore()
{
Array.prototype.shift = nativeShift;
},
};
}
function installExpectationSpliceProbe()
{
const nativeSplice = Array.prototype.splice;
let calls = 0;
let removed = 0;
Array.prototype.splice = function (...args)
{
const [ start, deleteCount ] = args;
const head = this[0];
if (start === 0 && head && typeof head === "object" && "target" in head
&& "oldValue" in head && "newValue" in head)
{
calls += 1;
removed += deleteCount;
}
return nativeSplice.apply(this, args);
};
return {
result: () => ({ calls, removed }),
restore()
{
Array.prototype.splice = nativeSplice;
},
};
}
function installLayoutHarness(document, withFonts = false)
{
const NativeResizeObserver = globalThis.ResizeObserver;
const originalGetClientRects = window.Element.prototype.getClientRects;
const instances = [];
let rectsEnabled = true;
class CapturingResizeObserver
{
constructor(callback)
{
this.callback = callback;
this.target = null;
this.disconnected = false;
instances.push(this);
}
observe(target)
{
this.target = target;
}
disconnect()
{
this.disconnected = true;
}
fire()
{
this.callback([ { target: this.target } ], this);
}
}
globalThis.ResizeObserver = CapturingResizeObserver;
window.Element.prototype.getClientRects = function ()
{
if (!rectsEnabled) { return []; }
return [ {
bottom: 16, height: 16, left: 0, right: 16,
top: 0, width: 16, x: 0, y: 0,
} ];
};
if (withFonts)
{
Object.defineProperty(document, "fonts", {
configurable: true,
value: { ready: Promise.resolve() },
});
}
return {
instances,
setRectsEnabled(value)
{
rectsEnabled = value;
},
restore()
{
window.Element.prototype.getClientRects = originalGetClientRects;
if (NativeResizeObserver === undefined) { delete globalThis.ResizeObserver; }
else { globalThis.ResizeObserver = NativeResizeObserver; }
},
};
}
test("observer recovery: synchronous render failure restores later observation", async () =>
{
const { document } = setupDom('<div id="root"><p lang="zh-Hant">原文,內容。</p></div>');
const root = document.getElementById("root");
const paragraph = root.querySelector("p");
const expected = new Error("isSkipped render failure");
let throwNext = false;
const jz = createJuzhen({
isSkipped()
{
if (!throwNext) { return false; }
throwNext = false;
throw expected;
},
});
jz.render(root);
jz.observe(root);
jz.invalidate(root);
throwNext = true;
assert.throws(() => jz.render(root), (error) => error === expected);
paragraph.textContent = "錯誤後,仍可繼續觀察。";
await tick();
await tick();
jz.disconnect();
assert.ok(paragraph.querySelector("jz-char"), "之後的 DOM 變化仍自動處理");
assert.equal(paragraph.textContent, "錯誤後,仍可繼續觀察。");
});
test("observer recovery: mutation callback failure restores a later mutation", async () =>
{
const { document } = setupDom('<div id="root"><p lang="zh-Hant">原文,內容。</p></div>');
const root = document.getElementById("root");
const paragraph = root.querySelector("p");
const expected = new Error("isSkipped mutation failure");
const reported = [];
let throwNext = false;
const onError = (event) =>
{
reported.push(event.error);
event.preventDefault();
};
window.addEventListener("error", onError);
const jz = createJuzhen({
isSkipped()
{
if (!throwNext) { return false; }
throwNext = false;
throw expected;
},
});
try
{
jz.render(root);
jz.observe(root);
jz.invalidate(root);
throwNext = true;
paragraph.textContent = "第一次,觸發錯誤。";
await tick();
assert.ok(reported.includes(expected), "MutationObserver 回調錯誤仍照常對外呈現");
paragraph.textContent = "第二次,恢復處理。";
await tick();
await tick();
assert.ok(paragraph.querySelector("jz-char"), "後續變化仍被觀察");
assert.equal(paragraph.textContent, "第二次,恢復處理。");
}
finally
{
jz.disconnect();
window.removeEventListener("error", onError);
}
});
test("observer depth: nested render does not resume or observe partial outer changes", async () =>
{
const { document } = setupDom('<div id="root"><p lang="zh-Hant">嵌套,渲染。</p></div>');
const counting = installCountingMutationObserver();
const root = document.getElementById("root");
const paragraph = root.querySelector("p");
let reenter = false;
let jz;
try
{
jz = createJuzhen({
isSkipped()
{
if (reenter)
{
reenter = false;
jz.render(paragraph);
}
return false;
},
});
jz.render(root);
jz.observe(root);
assert.equal(counting.instances.length, 1);
jz.invalidate(root);
reenter = true;
jz.rerender(root);
const observer = counting.instances[0];
assert.equal(observer.disconnectCount, 1, "只有最外層 0→1 暫停會 disconnect");
assert.equal(observer.observeCount, 2, "只有最外層 1→0 恢復會 observe");
await tick();
await tick();
assert.equal(observer.callbackCount, 0, "嵌套處理不會觀察半成品或自觸發");
assert.ok(paragraph.querySelector("jz-char"));
}
finally
{
if (jz) { jz.disconnect(); }
counting.restore();
}
});
test("observer depth: observe replacement inside a paused render is deferred coherently", async () =>
{
const { document } = setupDom('<div id="root"><p lang="zh-Hant">重建,觀察。</p></div>');
const counting = installCountingMutationObserver();
const root = document.getElementById("root");
const paragraph = root.querySelector("p");
let replaceObserver = false;
let observeCallsDuringPause = -1;
let jz;
try
{
jz = createJuzhen({
isSkipped()
{
if (replaceObserver)
{
replaceObserver = false;
jz.disconnect();
jz.observe(root);
observeCallsDuringPause = counting.instances.reduce(
(sum, observer) => sum + observer.observeCount, 0,
);
}
return false;
},
});
jz.render(root);
jz.observe(root);
jz.invalidate(root);
replaceObserver = true;
jz.rerender(root);
assert.equal(counting.instances.length, 2, "嵌套 observe 建立新觀察者");
assert.equal(observeCallsDuringPause, 1, "暫停期內新觀察者尚未 observe");
assert.equal(
counting.instances.reduce((sum, observer) => sum + observer.observeCount, 0),
2,
"最外層結束後新觀察者恰恢復一次",
);
paragraph.textContent = "重建後,仍能處理。";
await tick();
await tick();
assert.ok(paragraph.querySelector("jz-char"));
assert.equal(counting.instances[1].callbackCount, 1, "新觀察者接手後續變化");
}
finally
{
if (jz) { jz.disconnect(); }
counting.restore();
}
});
test("observer provenance: asynchronous internal layout classes do not self-trigger", async () =>
{
const { document } = setupDom('<div id="root"><p lang="zh-Hant">(版面回調</p></div>');
const counting = installCountingMutationObserver(8);
const layout = installLayoutHarness(document, true);
const root = document.getElementById("root");
const paragraph = root.querySelector("p");
let jz;
try
{
jz = createJuzhen({ spacing: false, orphan: false });
jz.render(root);
jz.observe(root);
await tick();
await tick();
const observer = counting.instances[0];
assert.equal(layout.instances.length, 1, "未因內部 class 記錄重建 layout observer");
assert.equal(observer.callbackCount, 1, "同批 remove/add provenance 被精確消費");
assert.equal(observer.suppressed, false, "防掛上限未被觸發");
assert.ok(paragraph.querySelector("jz-char.jz-half-le"), "異步行端結果保持");
}
finally
{
if (jz) { jz.disconnect(); }
layout.restore();
counting.restore();
}
});
test("observer provenance: author whitelist attributes still trigger rerender", async () =>
{
const { document } = setupDom('<div id="root"><p lang="zh-Hant">作者,屬性。</p></div>');
const counting = installCountingMutationObserver();
const root = document.getElementById("root");
const paragraph = root.querySelector("p");
const jz = createJuzhen();
try
{
jz.render(root);
jz.observe(root);
paragraph.classList.add("consumer-class");
await tick();
assert.equal(counting.instances[0].callbackCount, 1, "作者 class 觸發 observer");
assert.ok(paragraph.querySelector("jz-char"));
paragraph.setAttribute("data-jz-skip", "");
await tick();
assert.equal(counting.instances[0].callbackCount, 2, "作者非 class 白名單屬性亦觸發");
assert.equal(paragraph.querySelectorAll("[data-jz]").length, 0, "skip 重分類生效");
}
finally
{
jz.disconnect();
counting.restore();
}
});
test("observer provenance: external writes on owned products are not consumed", async () =>
{
const { document } = setupDom('<div id="root"><p lang="zh-Hans">前文<strong>粗体文字</strong>,后续</p></div>');
const counting = installCountingMutationObserver();
const root = document.getElementById("root");
const jz = createJuzhen({ spacing: false, longWord: false, orphan: false });
try
{
jz.render(root);
jz.observe(root);
const atom = root.querySelector("jz-char");
atom.classList.add("consumer-class");
await tick();
assert.equal(counting.instances[0].callbackCount, 1, "owned 元素外部 class 觸發");
assert.equal(atom.isConnected, false, "外部 class 變更進入 rerender");
const nextAtom = root.querySelector("jz-char");
nextAtom.setAttribute("lang", "en");
await tick();
assert.equal(counting.instances[0].callbackCount, 2, "owned 元素外部 lang 觸發");
assert.equal(nextAtom.isConnected, false);
const splitoff = root.querySelector('[data-jz-kind="splitoff"]');
splitoff.classList.add("consumer-splitoff");
await tick();
assert.equal(counting.instances[0].callbackCount, 3, "owned splitoff 外部 class 觸發");
assert.equal(splitoff.isConnected, false);
}
finally
{
jz.disconnect();
counting.restore();
}
});
test("observer provenance: mixed internal and external class records stay distinguishable", async () =>
{
const { document } = setupDom('<div id="root"><p lang="zh-Hant">(混合記錄</p></div>');
const counting = installCountingMutationObserver();
const layout = installLayoutHarness(document);
const root = document.getElementById("root");
const jz = createJuzhen({ spacing: false, orphan: false });
try
{
jz.render(root);
jz.observe(root);
const atom = root.querySelector("jz-char");
atom.classList.add("consumer-before-internal");
layout.instances[0].fire();
await tick();
assert.equal(counting.instances[0].callbackCount, 1, "同批記錄只派發一次");
assert.equal(atom.isConnected, false, "外部 mismatch 後之內部 transition 仍精確消費");
}
finally
{
jz.disconnect();
layout.restore();
counting.restore();
}
});
test(
"observer provenance: threshold compaction preserves a 1024-record tail across callbacks",
{ timeout: 20000 },
async () =>
{
const size = 2048;
const html = Array.from({ length: size }, (_, index) => `<p>(${index}</p>`).join("");
const { document } = setupDom(`<div id="root" lang="zh-Hant">${html}</div>`);
const counting = installCountingMutationObserver(4, { deferDelivery: true });
const layout = installLayoutHarness(document);
const spliceProbe = installExpectationSpliceProbe();
const root = document.getElementById("root");
const jz = createJuzhen({ spacing: false, longWord: false, orphan: false });
try
{
jz.render(root);
jz.observe(root);
layout.setRectsEnabled(false);
layout.instances[0].fire();
await tick();
const observer = counting.instances[0];
const records = observer.pendingRecords.splice(0);
assert.equal(records.length, size, "每個 target 各排入一條 internal remove");
observer.deliver(records.slice(0, 1024));
assert.deepEqual(spliceProbe.result(), { calls: 1, removed: 1024 });
assert.equal(layout.instances.length, 1, "首批觸發 threshold compaction 而不 rerender");
observer.deliver(records.slice(1024));
assert.equal(observer.callbackCount, 2, "第二批由壓縮後 deque 繼續消費");
assert.equal(layout.instances.length, 1, "尾批全消費後 head/reset 正確");
assert.equal(root.querySelectorAll("jz-char.jz-half-le").length, 0);
}
finally
{
jz.disconnect();
spliceProbe.restore();
layout.restore();
counting.restore();
}
},
);
test(
"observer provenance: callback failure compacts the consumed prefix and preserves its tail",
{ timeout: 15000 },
async () =>
{
const size = 1025;
const html = Array.from({ length: size }, (_, index) => `<p>(${index}</p>`).join("");
const { document } = setupDom(`<div id="root" lang="zh-Hant">${html}</div>`);
const counting = installCountingMutationObserver(4, { deferDelivery: true });
const layout = installLayoutHarness(document);
const spliceProbe = installExpectationSpliceProbe();
const root = document.getElementById("root");
const jz = createJuzhen({ spacing: false, longWord: false, orphan: false });
const expected = new Error("mid-callback classification failure");
const throwingRecord = {
type: "characterData",
get target()
{
throw expected;
},
};
try
{
jz.render(root);
jz.observe(root);
layout.setRectsEnabled(false);
layout.instances[0].fire();
await tick();
const observer = counting.instances[0];
const records = observer.pendingRecords.splice(0);
assert.equal(records.length, size);
assert.throws(
() => observer.deliver([ ...records.slice(0, 1024), throwingRecord ]),
(error) => error === expected,
);
assert.deepEqual(
spliceProbe.result(),
{ calls: 1, removed: 1024 },
"拋錯 callback 之 finally 立即壓縮已消費前綴",
);
assert.equal(layout.instances.length, 1, "finally 壓縮已消費前綴而不 rerender");
observer.deliver([ records[1024] ]);
assert.equal(layout.instances.length, 1, "下一 callback 精確消費保留之 tail");
const atom = root.querySelector("jz-char");
atom.classList.add("consumer-after-callback-error");
await tick();
assert.equal(observer.pendingRecords.length, 1, "外部 class 排入獨立 record");
observer.deliver(observer.pendingRecords.splice(0));
assert.equal(observer.callbackCount, 3, "錯誤、tail、外部 record 各交付一次");
assert.equal(atom.isConnected, false, "外部 record 未被殘留 expectation 誤吞");
}
finally
{
jz.disconnect();
spliceProbe.restore();
layout.restore();
counting.restore();
}
},
);
test("observer provenance: foreign-instance product changes still trigger", async () =>
{
const { document } = setupDom('<div id="root"><p lang="zh-Hant">外來,產物。</p></div>');
const foreign = createJuzhen();
const root = document.getElementById("root");
foreign.render(root);
const atom = root.querySelector("jz-char");
const counting = installCountingMutationObserver();
const local = createJuzhen();
try
{
local.observe(root);
atom.classList.add("foreign-change");
await tick();
assert.equal(counting.instances[0].callbackCount, 1, "外來實例無本地 provenance");
}
finally
{
local.disconnect();
counting.restore();
}
});
for (const mutationKind of [ "childList", "characterData" ])
{
test(`observer provenance: owned ${mutationKind} changes still trigger`, async () =>
{
const { document } = setupDom('<div id="root"><p lang="zh-Hant">內容,變更。</p></div>');
const counting = installCountingMutationObserver();
const root = document.getElementById("root");
const jz = createJuzhen();
try
{
jz.render(root);
jz.observe(root);
const atom = root.querySelector("jz-char");
if (mutationKind === "childList") { atom.append("外"); }
else
{
const text = document.createTreeWalker(atom, NodeFilter.SHOW_TEXT).nextNode();
text.data += "外";
}
await tick();
assert.equal(counting.instances[0].callbackCount, 1);
assert.equal(atom.isConnected, false, `${mutationKind} 進入 rerender`);
}
finally
{
jz.disconnect();
counting.restore();
}
});
}
for (const lifecycle of [ "pause", "replace", "disconnect" ])
{
test(`observer provenance: ${lifecycle} clears stale expectations`, async () =>
{
const { document } = setupDom('<div id="root"><p lang="zh-Hant">(生命週期</p></div>');
const counting = installCountingMutationObserver();
const layout = installLayoutHarness(document);
const root = document.getElementById("root");
const jz = createJuzhen({ spacing: false, orphan: false });
try
{
jz.render(root);
jz.observe(root);
layout.instances[0].fire();
if (lifecycle === "pause") { jz.rerender(root); }
else if (lifecycle === "replace") { jz.observe(root); }
else { jz.disconnect(); jz.observe(root); }
const activeObserver = counting.instances[counting.instances.length - 1];
root.querySelector("jz-char").classList.add("consumer-after-lifecycle");
await tick();
assert.equal(activeObserver.callbackCount, 1, "外部 class 未被陳舊 provenance 吞掉");
}
finally
{
jz.disconnect();
layout.restore();
counting.restore();
}
});
}
test("observer provenance: transition inference and queue consumption scale linearly", async (context) =>
{
const reports = [];
for (const size of [ 500, 1000, 2000 ])
{
const html = Array.from({ length: size }, (_, index) => `<p>(${index}</p>`).join("");
const { document } = setupDom(`<div id="root" lang="zh-Hant">${html}</div>`);
let recordAccesses = 0;
let recordCount = 0;
const counting = installCountingMutationObserver(Number.POSITIVE_INFINITY, {
wrapRecords(records)
{
recordCount += records.length;
return records.map((record) => new Proxy({}, {
get(_target, property)
{
recordAccesses += 1;
return record[property];
},
}));
},
});
const layout = installLayoutHarness(document);
const shiftProbe = installExpectationShiftProbe();
const root = document.getElementById("root");
const jz = createJuzhen({ spacing: false, longWord: false, orphan: false });
try
{
jz.render(root);
jz.observe(root);
assert.equal(root.querySelectorAll("jz-char.jz-half-le").length, size);
layout.instances[0].fire();
await tick();
const shift = shiftProbe.result();
assert.equal(recordCount, size * 2, "每 target 恰有 remove/add 兩條 transition");
const report = { size, recordCount, recordAccesses, queueShiftWork: shift.work };
context.diagnostic("linear probe " + JSON.stringify(report));
assert.ok(
recordAccesses <= recordCount * 12 && shift.calls === 0 && shift.work === 0,
"transition 推斷與 queue 消費保持線性:" + JSON.stringify(report),
);
assert.equal(layout.instances.length, 1, "規模回放沒有自觸發 rerender");
reports.push(report);
}
finally
{
jz.disconnect();
shiftProbe.restore();
layout.restore();
counting.restore();
}
}
context.diagnostic("linear probes complete " + JSON.stringify(reports));
});
+273
View File
@@ -0,0 +1,273 @@
// createJuzhen 實例所有權:revert 只得復原本實例產物。
import assert from "node:assert/strict";
import { test } from "node:test";
import { setupDom } from "./helpers.mjs";
import { createJuzhen } from "../dist/juzhen.mjs";
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
test("所有權:作者 data-jz 結構不可作為 revert 憑證", () =>
{
const html = '<section data-jz="spacing"><jz-hws data-jz="spacing" '
+ 'data-jz-kind="wrap">author</jz-hws></section>';
const { document } = setupDom(html);
const before = document.body.innerHTML;
createJuzhen().revert(document.body);
assert.equal(document.body.innerHTML, before);
});
test("所有權:實例 B 不得還原實例 A 之結構或 root 屬性", () =>
{
const { document } = setupDom('<p lang="zh-Hans">用Neovim寫,很好。</p>');
const before = document.body.innerHTML;
const a = createJuzhen({ jiya: { halfWidth: "margin" }, justifyAtoms: false });
const b = createJuzhen();
a.render(document.body);
const rendered = document.body.innerHTML;
assert.equal(document.body.getAttribute("data-jz-halfwidth"), "margin");
assert.equal(document.body.getAttribute("data-jz-atoms"), "inline");
b.revert(document.body);
assert.equal(document.body.innerHTML, rendered);
assert.equal(document.body.getAttribute("data-jz-halfwidth"), "margin");
assert.equal(document.body.getAttribute("data-jz-atoms"), "inline");
a.revert(document.body);
assert.equal(document.body.innerHTML, before);
});
test("所有權:兩實例同值屬性之早到者先 revert 不移除後到者聲明", () =>
{
const { document } = setupDom('<p lang="zh-Hans">純文字</p>');
const opts = {
spacing: false,
jiya: { halfWidth: "margin" },
jinze: false,
orphan: false,
longWord: false,
justifyAtoms: false,
};
const a = createJuzhen(opts);
const b = createJuzhen(opts);
a.render(document.body);
b.render(document.body);
a.revert(document.body);
assert.equal(document.body.getAttribute("data-jz-halfwidth"), "margin");
assert.equal(document.body.getAttribute("data-jz-atoms"), "inline");
assert.equal(document.querySelector("p").getAttribute("data-jz-block-level"), "paragraph");
b.revert(document.body);
assert.equal(document.body.hasAttribute("data-jz-halfwidth"), false);
assert.equal(document.body.hasAttribute("data-jz-atoms"), false);
assert.equal(document.querySelector("p").hasAttribute("data-jz-block-level"), false);
});
test("所有權:屬性寫者亂序 revert 還原最近活躍值或原值", () =>
{
const options = {
spacing: false, jiya: false, jinze: false, orphan: false, longWord: false,
};
const first = setupDom("<p>純文字</p>");
const a = createJuzhen(options);
const b = createJuzhen({ ...options, level: { text: "p" } });
a.render(first.document.body);
b.render(first.document.body);
a.revert(first.document.body);
assert.equal(first.document.querySelector("p").getAttribute("data-jz-block-level"), "text");
b.revert(first.document.body);
assert.equal(first.document.querySelector("p").hasAttribute("data-jz-block-level"), false);
const second = setupDom("<p>純文字</p>");
const c = createJuzhen(options);
const d = createJuzhen({ ...options, level: { text: "p" } });
c.render(second.document.body);
d.render(second.document.body);
d.revert(second.document.body);
assert.equal(second.document.querySelector("p").getAttribute("data-jz-block-level"), "paragraph");
c.revert(second.document.body);
assert.equal(second.document.querySelector("p").hasAttribute("data-jz-block-level"), false);
});
test("所有權:外部改寫屬性後 revert fail closed,新寫者可以當前值重建基線", () =>
{
const options = {
spacing: false, jiya: false, jinze: false, orphan: false, longWord: false,
justifyAtoms: false,
};
const { document } = setupDom("<p>純文字</p>");
const a = createJuzhen(options);
a.render(document.body);
document.body.setAttribute("data-jz-atoms", "external");
document.querySelector("p").removeAttribute("data-jz-block-level");
const b = createJuzhen(options);
b.render(document.body);
assert.equal(document.body.getAttribute("data-jz-atoms"), "inline");
assert.equal(document.querySelector("p").getAttribute("data-jz-block-level"), "paragraph");
a.revert(document.body);
assert.equal(document.body.getAttribute("data-jz-atoms"), "inline");
assert.equal(document.querySelector("p").getAttribute("data-jz-block-level"), "paragraph");
b.revert(document.body);
assert.equal(document.body.getAttribute("data-jz-atoms"), "external");
assert.equal(document.querySelector("p").hasAttribute("data-jz-block-level"), false);
});
test("所有權:子根 render 後對祖先 revert 完整清理", () =>
{
const { document } = setupDom('<div><p lang="zh-Hans">用Neovim寫,很好。</p></div>');
const before = document.body.innerHTML;
const p = document.querySelector("p");
const jz = createJuzhen({ jiya: { halfWidth: "margin" }, justifyAtoms: false });
jz.render(p);
assert.equal(p.getAttribute("data-jz-halfwidth"), "margin");
assert.ok(p.querySelector("[data-jz]"));
jz.revert(document.body);
assert.equal(document.body.innerHTML, before);
});
test("所有權:本實例完整還原 wrapper 與 splitoff", () =>
{
const html = '<p lang="zh-Hans">前文<strong>粗体文字</strong>,后续</p>';
const { document } = setupDom(html);
const before = document.body.innerHTML;
const jz = createJuzhen({ spacing: false, longWord: false, orphan: false });
jz.render(document.body);
assert.ok(document.querySelector('[data-jz-kind="wrap"]'));
assert.ok(document.querySelector('[data-jz-kind="splitoff"]'));
jz.revert(document.body);
assert.equal(document.body.innerHTML, before);
});
test("所有權:已有實例重複 render 仍冪等", () =>
{
const { document } = setupDom('<p lang="zh-Hans">用Neovim寫,很好。</p>');
const jz = createJuzhen();
jz.render(document.body);
const once = document.body.innerHTML;
jz.render(document.body);
assert.equal(document.body.innerHTML, once);
});
test("所有權:已還原 wrapper 之舊憑證即時撤銷", () =>
{
const { document } = setupDom("<p>中A文</p>");
const jz = createJuzhen({ jiya: false, longWord: false, jinze: false, orphan: false });
jz.render(document.body);
const gap = document.querySelector("jz-hws");
assert.ok(gap);
jz.revert(document.body);
document.querySelector("p").appendChild(gap);
const stale = gap.outerHTML;
jz.revert(document.body);
assert.ok(document.body.contains(gap));
assert.equal(gap.outerHTML, stale);
});
test("所有權:已知 tag 改成無生產者之 marker kind 亦 fail closed", () =>
{
const { document } = setupDom("<p>中A文</p>");
const jz = createJuzhen({ jiya: false, longWord: false, jinze: false, orphan: false });
jz.render(document.body);
const gap = document.querySelector("jz-hws");
assert.ok(gap);
gap.setAttribute("data-jz-kind", "marker");
const malformed = gap.outerHTML;
jz.revert(document.body);
assert.ok(document.body.contains(gap));
assert.equal(gap.outerHTML, malformed);
});
test("所有權:observe 移除獨立 render 根時清理脫離子樹與佈局觀察者", async () =>
{
const previousResizeObserver = globalThis.ResizeObserver;
const observers = [];
class MockResizeObserver
{
constructor()
{
this.targets = [];
this.disconnectCount = 0;
observers.push(this);
}
observe(target)
{
this.targets.push(target);
}
disconnect()
{
this.disconnectCount += 1;
}
}
globalThis.ResizeObserver = MockResizeObserver;
try
{
const { document } = setupDom('<div id="parent"></div>');
const parent = document.getElementById("parent");
const jz = createJuzhen({ jiya: { halfWidth: "margin" }, justifyAtoms: false });
jz.observe(parent);
const block = document.createElement("p");
block.lang = "zh-Hans";
block.innerHTML = "用Neovim寫,很好。";
const original = block.innerHTML;
parent.appendChild(block);
await tick();
const blockObservers = observers.filter((ro) => ro.targets.includes(block));
assert.equal(blockObservers.length, 2, "lineEdge 與 gapTrim 各持一觀察者");
assert.ok(block.querySelector("[data-jz]"));
assert.equal(block.getAttribute("data-jz-halfwidth"), "margin");
assert.equal(block.getAttribute("data-jz-atoms"), "inline");
block.setAttribute("data-jz-atoms", "external");
block.remove();
await tick();
assert.ok(blockObservers.every((ro) => ro.disconnectCount === 1));
assert.equal(block.querySelector("[data-jz]"), null);
assert.equal(block.innerHTML, original);
assert.equal(block.hasAttribute("data-jz-halfwidth"), false);
assert.equal(block.getAttribute("data-jz-atoms"), "external", "外部改寫 fail closed");
parent.appendChild(block);
await tick();
assert.ok(block.querySelector("[data-jz]"), "脫離根清理後可再插入重新 render");
jz.revert(parent);
jz.disconnect();
assert.equal(block.getAttribute("data-jz-atoms"), "external");
}
finally
{
if (previousResizeObserver === undefined) { delete globalThis.ResizeObserver; }
else { globalThis.ResizeObserver = previousResizeObserver; }
}
});
test("所有權:observe 移除非 render 根之行內內容不過度清理", async () =>
{
const { document } = setupDom('<div id="parent"><p>前<span>中A文</span>後</p></div>');
const parent = document.getElementById("parent");
const jz = createJuzhen({ jiya: false, jinze: false, orphan: false, longWord: false });
jz.render(parent);
jz.observe(parent);
const span = document.querySelector("span");
const rendered = span.innerHTML;
assert.ok(span.querySelector("jz-hws"));
span.remove();
await tick();
assert.equal(span.innerHTML, rendered, "非獨立 render 根之脫離內容保持原渲染結構");
assert.ok(span.querySelector("jz-hws"));
document.querySelector("p").appendChild(span);
await tick();
jz.revert(parent);
jz.disconnect();
});
+52
View File
@@ -0,0 +1,52 @@
import { spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
if (process.platform !== "darwin")
{
console.error("test:wkwebview requires macOS, Xcode, and an active GUI session.");
process.exit(1);
}
const testDir = dirname(fileURLToPath(import.meta.url));
const projectRoot = resolve(testDir, "..");
const temporaryRoot = mkdtempSync(join(tmpdir(), "cjk-autospace-wkwebview-"));
const moduleCache = join(temporaryRoot, "ModuleCache");
const binary = join(temporaryRoot, "wkwebview-e2e");
mkdirSync(moduleCache);
function run(command, args)
{
const result = spawnSync(command, args, {
cwd: projectRoot,
encoding: "utf8",
stdio: "inherit",
});
if (result.error) { throw result.error; }
if (result.status !== 0) { process.exitCode = result.status ?? 1; }
return result.status === 0;
}
try
{
const compiled = run("xcrun", [
"swiftc",
"-parse-as-library",
"-swift-version", "5",
"-module-cache-path", moduleCache,
"-framework", "AppKit",
"-framework", "WebKit",
join(testDir, "wkwebview-e2e.swift"),
"-o", binary,
]);
if (compiled)
{
run(binary, [ join(projectRoot, "demo/e2e.html"), projectRoot ]);
}
}
finally
{
rmSync(temporaryRoot, { recursive: true, force: true });
}
+498
View File
@@ -0,0 +1,498 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
import Ajv2020 from "ajv/dist/2020.js";
import { build } from "esbuild";
import { JSDOM } from "jsdom";
const TEST_DIR = dirname(fileURLToPath(import.meta.url));
const ROOT = dirname(TEST_DIR);
const RULES_DIR = join(ROOT, "rules");
async function readJson(path)
{
return JSON.parse(await readFile(path, "utf8"));
}
const [ rules, rulesSchema, vectorsDoc, vectorsSchema, packageJson, spacingCss ] = await Promise.all([
readJson(join(RULES_DIR, "juzhen-rules.json")),
readJson(join(RULES_DIR, "juzhen-rules.schema.json")),
readJson(join(RULES_DIR, "golden-vectors.json")),
readJson(join(RULES_DIR, "golden-vectors.schema.json")),
readJson(join(ROOT, "package.json")),
readFile(join(ROOT, "src/css/_spacing.css"), "utf8"),
]);
const ajv = new Ajv2020({ allErrors: true, strict: true });
const validateRules = ajv.compile(rulesSchema);
const validateVectors = ajv.compile(vectorsSchema);
async function bundleSourceAdapter()
{
const source = [
'export * from "./src/core/unicode.ts";',
'export * from "./src/core/locale.ts";',
'export { createJuzhen, normalizeOptions } from "./src/index.ts";',
'export { jiyaPass, jiyaAdjacencyPass } from "./src/typeset/jiya.ts";',
'export { longWordPass } from "./src/typeset/longword.ts";',
'export { levelMarkPass } from "./src/typeset/levelmark.ts";',
'export { jinzePass } from "./src/typeset/jinze.ts";',
'export { spacingPass } from "./src/typeset/spacing.ts";',
'export { orphanPass } from "./src/typeset/orphan.ts";',
'export { lineEdgePass, gapTrimPass } from "./src/typeset/lineedge.ts";',
].join("\n");
const result = await build({
stdin: {
contents: source,
loader: "ts",
resolveDir: ROOT,
sourcefile: "shared-rules-adapter.ts",
},
bundle: true,
format: "esm",
platform: "node",
target: "es2022",
write: false,
logLevel: "silent",
});
const bundled = result.outputFiles[0].text + "\n//# sourceURL=shared-rules-adapter.mjs\n";
const encoded = Buffer.from(bundled).toString("base64");
return import("data:text/javascript;base64," + encoded);
}
const adapter = await bundleSourceAdapter();
const vectors = vectorsDoc.vectors;
function vectorsOf(category)
{
return vectors.filter((vector) => vector.category === category);
}
function renderText(text, options = {})
{
const dom = new JSDOM("<!doctype html><body><p></p></body>");
const paragraph = dom.window.document.querySelector("p");
paragraph.textContent = text;
const instance = adapter.createJuzhen({
spacing: false,
jiya: false,
jinze: false,
orphan: false,
longWord: false,
...options,
});
const globals = {
document: globalThis.document,
NodeFilter: globalThis.NodeFilter,
};
globalThis.document = dom.window.document;
globalThis.NodeFilter = dom.window.NodeFilter;
try
{
instance.render(paragraph);
}
finally
{
if (globals.document === undefined) { delete globalThis.document; }
else { globalThis.document = globals.document; }
if (globals.NodeFilter === undefined) { delete globalThis.NodeFilter; }
else { globalThis.NodeFilter = globals.NodeFilter; }
}
return { dom, paragraph, instance };
}
function punctuationCharacter(punctuationClass)
{
return {
stop: "。",
pause: ",",
open: "「",
close: "」",
middle: "·",
liga: "…",
}[punctuationClass];
}
function entryBounds(entry)
{
const start = Number.parseInt(entry.codePoint || entry.start, 16);
const end = Number.parseInt(entry.codePoint || entry.end, 16);
return { start, end };
}
function assertOrderedCodePointSet(name, entries)
{
let previousEnd = -1;
for (const entry of entries)
{
const { start, end } = entryBounds(entry);
assert.ok(start >= 0 && end <= 0x10FFFF, name + " Unicode bounds");
assert.ok(start <= end, name + " closed range order");
assert.ok(start > previousEnd, name + " ranges must be sorted and non-overlapping");
previousEnd = end;
}
}
function webEscape(codePoint)
{
const hex = codePoint.toString(16).toUpperCase();
return codePoint <= 0xFFFF
? "\\u" + hex.padStart(4, "0")
: "\\u{" + hex + "}";
}
function rangesToWebFragment(entries)
{
return entries.map((entry) =>
{
const { start, end } = entryBounds(entry);
return start === end
? webEscape(start)
: webEscape(start) + "-" + webEscape(end);
}).join("");
}
function assertEquivalentMembership(name, entries, sourceFragment)
{
assertOrderedCodePointSet(name, entries);
const canonical = new RegExp("^[" + rangesToWebFragment(entries) + "]$", "u");
const source = new RegExp("^[" + sourceFragment + "]$", "u");
for (let codePoint = 0; codePoint <= 0x10FFFF; codePoint += 1)
{
const character = String.fromCodePoint(codePoint);
assert.equal(
canonical.test(character),
source.test(character),
name + " differs at U+" + codePoint.toString(16).toUpperCase(),
);
}
}
function canonicalCodePoints(entries)
{
const out = [];
for (const entry of entries)
{
const { start, end } = entryBounds(entry);
for (let codePoint = start; codePoint <= end; codePoint += 1) { out.push(codePoint); }
}
return out;
}
test("shared rules: Ajv 2020 strict validation, rejection cases, exports, and coverage", () =>
{
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.unicodeProfile.version, "17.0.0");
assert.equal(vectorsDoc.schemaVersion, 1);
assert.equal(vectorsDoc.rulesetVersion, rules.rulesetVersion);
assert.equal(validateRules(rules), true, ajv.errorsText(validateRules.errors));
assert.equal(validateVectors(vectorsDoc), true, ajv.errorsText(validateVectors.errors));
const extraRules = structuredClone(rules);
extraRules.unexpected = true;
assert.equal(validateRules(extraRules), false, "rules additionalProperties must be rejected");
const bogusJinze = structuredClone(vectorsDoc);
const jinzeIndex = bogusJinze.vectors.findIndex((vector) => vector.category === "jinze");
assert.notEqual(jinzeIndex, -1, "valid fixture must contain a jinze vector");
bogusJinze.vectors[jinzeIndex] = {
id: "bogus-jinze",
category: "jinze",
input: {},
expected: { reason: "bogus" },
};
assert.equal(validateVectors(bogusJinze), false, "malformed jinze vector must be rejected");
assert.equal(
validateVectors.errors?.some((error) => error.keyword === "minItems"),
false,
"bogus jinze rejection must not rely on document-wide vector count",
);
const extraVectorField = structuredClone(vectorsDoc);
extraVectorField.vectors[0].input.unexpected = true;
assert.equal(
validateVectors(extraVectorField),
false,
"category input additionalProperties must be rejected",
);
assert.ok(vectors.length >= 20, `expected at least 20 vectors, got ${vectors.length}`);
const ids = vectors.map((vector) => vector.id);
assert.equal(new Set(ids).size, ids.length, "golden vector ids must be unique");
for (const category of [ "jinze", "orphan" ])
{
assert.ok(
vectors.some((vector) => vector.category === category && vector.expected.atoms),
`${category} must retain at least one cross-feature atom vector`,
);
}
const requiredCategories = [
"language-classification",
"punctuation-classification",
"punctuation-style",
"punctuation-adjacency",
"spacing",
"ivs-atom",
"jinze",
"orphan",
];
for (const category of requiredCategories)
{
assert.ok(vectorsOf(category).length > 0, `missing vector category ${category}`);
}
assert.deepEqual(packageJson.exports["./rules"], "./rules/juzhen-rules.json");
assert.deepEqual(packageJson.exports["./rules/schema"], "./rules/juzhen-rules.schema.json");
assert.deepEqual(packageJson.exports["./rules/vectors"], "./rules/golden-vectors.json");
assert.deepEqual(
packageJson.exports["./rules/vectors-schema"],
"./rules/golden-vectors.schema.json",
);
assert.ok(packageJson.files.includes("rules"));
});
test("shared rules: canonical character classes equal the Web source constants", () =>
{
const canonical = rules.characterClasses;
assert.equal(canonical.representation, "unicode-code-point-ranges");
assertEquivalentMembership("cjk", canonical.cjk, adapter.CJK);
assertEquivalentMembership("ansAfterCjk", canonical.ansAfterCjk, adapter.ANS_AFTER_CJK);
assertEquivalentMembership("ansBeforeCjk", canonical.ansBeforeCjk, adapter.ANS_BEFORE_CJK);
assertEquivalentMembership(
"variationSelectors",
canonical.variationSelectors,
adapter.VARIATION_SELECTORS,
);
assert.deepEqual(canonical.biaodian.classificationOrder, [
"stop", "pause", "open", "close", "middle", "liga",
]);
for (const punctuationClass of canonical.biaodian.classificationOrder)
{
const entries = canonical.biaodian.classes[punctuationClass];
assertOrderedCodePointSet("biaodian." + punctuationClass, entries);
assert.deepEqual(
canonicalCodePoints(entries),
Array.from(adapter.DEFAULT_RULESET.biaodian[punctuationClass], (character) =>
character.codePointAt(0)).sort((a, b) => a - b),
"biaodian." + punctuationClass,
);
}
const extended = adapter.resolveRuleset({
cjk: "〇",
western: "★",
biaodian: { stop: "‼" },
});
assert.equal(extended.cjk, adapter.CJK + "〇");
assert.equal(extended.ansAfter, adapter.ANS_AFTER_CJK + "★");
assert.equal(extended.ansBefore, adapter.ANS_BEFORE_CJK + "★");
assert.equal(extended.biaodian.stop, adapter.DEFAULT_RULESET.biaodian.stop + "‼");
assert.equal(canonical.overrideSemantics.mode, "append");
assert.equal(canonical.overrideSemantics.removalAllowed, false);
assert.equal(canonical.overrideSemantics.reclassificationAllowed, false);
});
test("shared rules: locale defaults and all language vectors match the Web adapter", () =>
{
const config = { default: "zh-Hant", policy: {} };
for (const [ langClass, expectedStyle ] of Object.entries(rules.locales.defaultPolicies))
{
assert.equal(adapter.styleFor(langClass, config), expectedStyle, langClass);
}
for (const vector of vectorsOf("language-classification"))
{
assert.equal(
adapter.classify(vector.input.lang),
vector.expected.langClass,
vector.id,
);
}
assert.equal(adapter.classify("zhx"), "other", "BCP-47 boundary must reject zhx");
});
test("shared rules: punctuation classification and style vectors match Web behavior", () =>
{
for (const vector of vectorsOf("punctuation-classification"))
{
const actual = adapter.classifyBiaodian(vector.input.character);
const expected = vector.expected.punctuationClass;
assert.equal(actual, expected === null ? null : "bd-" + expected, vector.id);
}
for (const vector of vectorsOf("punctuation-style"))
{
const halfClasses = rules.punctuationStyles.baselineHalfWidthClasses[vector.input.style];
assert.equal(
halfClasses.includes(vector.input.punctuationClass),
vector.expected.halfWidth,
vector.id + " canonical",
);
const character = punctuationCharacter(vector.input.punctuationClass);
const { paragraph } = renderText(character, {
jiya: true,
lang: { default: "zh-Hant", style: vector.input.style },
});
const char = paragraph.querySelector("jz-char");
assert.ok(char, vector.id + " must be classified by Web adapter");
assert.equal(char.classList.contains("jz-half"), vector.expected.halfWidth, vector.id);
}
});
test("shared rules: punctuation adjacency vectors match Web behavior", () =>
{
for (const vector of vectorsOf("punctuation-adjacency"))
{
const leftCharacter = punctuationCharacter(vector.input.left);
const rightCharacter = punctuationCharacter(vector.input.right);
const { paragraph } = renderText(leftCharacter + rightCharacter, {
jiya: true,
lang: { default: "zh-Hant", style: "quanjiao" },
});
const chars = Array.from(paragraph.querySelectorAll("jz-char"));
assert.equal(chars.length, 2, vector.id);
assert.equal(
chars[0].classList.contains("jz-half"),
vector.expected.squeezeLeft,
vector.id + " left",
);
assert.equal(
chars[1].classList.contains("jz-half"),
vector.expected.squeezeRight,
vector.id + " right",
);
}
});
test("shared rules: spacing vectors match Web decisions and author-space normalization", () =>
{
assert.equal(rules.spacing.gapEm, 0.25);
const cssGap = /jz-hws\s*\{[^}]*margin-right:\s*([0-9.]+)em\s*;/s.exec(spacingCss);
assert.ok(cssGap, "Web adapter spacing CSS must declare jz-hws margin-right in em");
assert.equal(Number.parseFloat(cssGap[1]), rules.spacing.gapEm, "Web CSS gap drift");
for (const vector of vectorsOf("spacing"))
{
const { paragraph } = renderText(vector.input.text, { spacing: true });
assert.equal(
paragraph.querySelectorAll("jz-hws").length,
vector.expected.managedGaps,
vector.id + " gaps",
);
assert.equal(paragraph.textContent, vector.expected.normalizedText, vector.id + " text");
if (Object.hasOwn(vector.expected, "gapEm"))
{
assert.equal(vector.expected.gapEm, rules.spacing.gapEm, vector.id + " gap width");
}
}
});
test("shared rules: IVS vectors enforce base plus at most one selector", () =>
{
assert.equal(rules.atoms.maxVariationSelectors, 1);
for (const vector of vectorsOf("ivs-atom"))
{
assert.deepEqual(
adapter.splitVariationSequences(vector.input.text),
vector.expected.atoms,
vector.id,
);
}
});
test("Web adapter only: pass groups, orders, levels, and compatibility no-ops do not drift", () =>
{
const actual = {
charify: [ adapter.jiyaPass, adapter.longWordPass ],
standalone: [
adapter.levelMarkPass,
adapter.jiyaAdjacencyPass,
adapter.jinzePass,
adapter.spacingPass,
adapter.orphanPass,
adapter.lineEdgePass,
adapter.gapTrimPass,
],
};
assert.deepEqual(
actual.charify.map(({ name, order, level }) => ({ name, order, level })),
[
{ name: "jiya", order: 20, level: "text" },
{ name: "longWord", order: 60, level: "paragraph" },
],
"Web charify group",
);
assert.deepEqual(
actual.standalone.map(({ name, order, level }) => ({ name, order, level })),
[
{ name: "levelMark", order: 10, level: "text" },
{ name: "jiyaAdjacency", order: 25, level: "text" },
{ name: "jinze", order: 40, level: "paragraph" },
{ name: "spacing", order: 50, level: "text" },
{ name: "orphan", order: 70, level: "paragraph" },
{ name: "jiyaLineEdge", order: 90, level: "paragraph" },
{ name: "spacingGapTrim", order: 92, level: "paragraph" },
],
"Web standalone group",
);
const passNames = new Set([ ...actual.charify, ...actual.standalone ].map((pass) => pass.name));
for (const option of [ "hanging", "biaodian", "emphasis", "ruby" ])
{
assert.equal(passNames.has(option), false, option + " must remain unregistered");
}
});
test("shared rules: jinze and orphan semantic vectors match Web behavior", () =>
{
for (const vector of vectorsOf("jinze"))
{
if (vector.expected.atoms)
{
assert.deepEqual(
adapter.splitVariationSequences(vector.input.left + vector.input.right),
vector.expected.atoms,
vector.id + " atoms",
);
}
const { paragraph } = renderText(vector.input.left + vector.input.right, {
jiya: true,
jinze: true,
lang: { default: "zh-Hant", style: "quanjiao" },
});
assert.equal(
paragraph.querySelector("jz-jinze") !== null,
vector.expected.prohibitedBreak,
vector.id,
);
}
const anyCjk = new RegExp(
"[" + rangesToWebFragment(rules.characterClasses.cjk) + "]",
"u",
);
const punctuation = new Set(
Object.values(rules.characterClasses.biaodian.classes)
.flatMap((entries) => canonicalCodePoints(entries)),
);
for (const vector of vectorsOf("orphan"))
{
const atoms = adapter.splitVariationSequences(vector.input.text);
if (vector.expected.atoms)
{
assert.deepEqual(atoms, vector.expected.atoms, vector.id + " atoms");
}
const meaningful = atoms.filter((atom) =>
anyCjk.test(atom) && !punctuation.has(atom.codePointAt(0)));
assert.equal(meaningful.length, vector.expected.meaningfulCjkCount, vector.id + " count");
const { paragraph } = renderText(vector.input.text, {
orphan: { chars: vector.input.minimum },
});
const orphan = paragraph.querySelector("jz-orphan");
assert.equal(orphan ? orphan.textContent : null, vector.expected.protectedSuffix, vector.id);
}
});
+40
View File
@@ -2,6 +2,7 @@
// 在新引擎落地(回應使用者提問)。 // 在新引擎落地(回應使用者提問)。
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test"; import { test } from "node:test";
import { setupDom, countTag } from "./helpers.mjs"; import { setupDom, countTag } from "./helpers.mjs";
@@ -78,3 +79,42 @@ test("間隔號:姓名 · 兩側不插 jz-hws(· 屬 CJK 標點,非西文
createJuzhen(SPACING_ONLY).render(document.body); createJuzhen(SPACING_ONLY).render(document.body);
assert.equal(countTag(document.querySelector("p"), "jz-hws"), 0, "· 兩側皆不應有間隙"); assert.equal(countTag(document.querySelector("p"), "jz-hws"), 0, "· 兩側皆不應有間隙");
}); });
// ----- ASCII 左括號(Safari 16 無 lookbehind 路徑)-----
test("左括號:普通英數字後補間隙,連續樣式不漏匹配", () =>
{
const { document } = setupDom("<p>A(b[c{中</p>");
createJuzhen(SPACING_ONLY).render(document.body);
assert.equal(countTag(document.querySelector("p"), "jz-hws"), 3);
});
test("左括號:dot-prefixed 英數字串段不補間隙", () =>
{
const { document } = setupDom("<p>.foo(中</p><p>x.bar[文</p><p>.baz{字</p>");
createJuzhen(SPACING_ONLY).render(document.body);
for (const p of document.querySelectorAll("p"))
{
assert.equal(countTag(p, "jz-hws"), 0);
}
});
test("左括號:點號不在當前英數字 suffix 前時仍補間隙", () =>
{
const { document } = setupDom("<p>.foo-bar(中</p>");
createJuzhen(SPACING_ONLY).render(document.body);
assert.equal(countTag(document.querySelector("p"), "jz-hws"), 1);
});
test("Safari 16 產物:spacing 源碼與發佈 JS 不得含 lookbehind 語法", () =>
{
const lookbehindSyntax = /\(\?<([=!])/;
for (const path of [
new URL("../src/typeset/spacing.ts", import.meta.url),
new URL("../dist/juzhen.mjs", import.meta.url),
new URL("../dist/juzhen.iife.js", import.meta.url),
])
{
assert.doesNotMatch(readFileSync(path, "utf8"), lookbehindSyntax, path.pathname);
}
});
+196
View File
@@ -0,0 +1,196 @@
import AppKit
import Darwin
import Foundation
import WebKit
@MainActor
final class WkWebViewHarness: NSObject, WKNavigationDelegate
{
private let app: NSApplication
private let window: NSWindow
private let webView: WKWebView
private var finished = false
init(app: NSApplication, pagePath: String, readRoot: String)
{
self.app = app
let configuration = WKWebViewConfiguration()
configuration.websiteDataStore = .nonPersistent()
webView = WKWebView(
frame: NSRect(x: 0, y: 0, width: 1224, height: 768),
configuration: configuration
)
window = NSWindow(
contentRect: webView.frame,
styleMask: [ .titled, .closable, .resizable ],
backing: .buffered,
defer: false
)
super.init()
webView.navigationDelegate = self
window.title = "Juzhen WKWebView E2E"
window.contentView = webView
window.center()
window.makeKeyAndOrderFront(nil)
let pageUrl = URL(fileURLWithPath: pagePath)
let readUrl = URL(fileURLWithPath: readRoot, isDirectory: true)
webView.loadFileURL(pageUrl, allowingReadAccessTo: readUrl)
DispatchQueue.main.asyncAfter(deadline: .now() + 20)
{ [weak self] in
self?.finish(code: 2, reason: "timeout")
}
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!)
{
evaluate("navigator.userAgent")
{ value in
print("WKWEBVIEW_USER_AGENT=\(value)")
}
click(buttonId: "run-core")
pollTitle(expectedPrefix: "CORE PASS", timeoutAt: Date().addingTimeInterval(8))
{ [weak self] coreTitle in
guard let self else { return }
guard coreTitle.hasPrefix("CORE PASS") else
{
self.collectAndFinish(code: 3, reason: "core title: \(coreTitle)")
return
}
self.click(buttonId: "run-observer")
self.pollTitle(expectedPrefix: "PASS", timeoutAt: Date().addingTimeInterval(8))
{ [weak self] finalTitle in
guard let self else { return }
let success = finalTitle.hasPrefix("PASS")
self.collectAndFinish(
code: success ? 0 : 4,
reason: "final title: \(finalTitle)"
)
}
}
}
func webView(
_ webView: WKWebView,
didFail navigation: WKNavigation!,
withError error: Error
)
{
finish(code: 5, reason: "navigation failed: \(error)")
}
func webView(
_ webView: WKWebView,
didFailProvisionalNavigation navigation: WKNavigation!,
withError error: Error
)
{
finish(code: 6, reason: "provisional navigation failed: \(error)")
}
private func click(buttonId: String)
{
let script = "document.getElementById('\(buttonId)')?.click(); document.title;"
evaluate(script)
{ value in
print("WKWEBVIEW_CLICK_\(buttonId)=\(value)")
}
}
private func pollTitle(
expectedPrefix: String,
timeoutAt: Date,
completion: @escaping (String) -> Void
)
{
evaluate("document.title")
{ [weak self] value in
guard let self else { return }
if value.hasPrefix(expectedPrefix) || value.hasPrefix("FAIL") || Date() >= timeoutAt
{
completion(value)
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1)
{ [weak self] in
self?.pollTitle(
expectedPrefix: expectedPrefix,
timeoutAt: timeoutAt,
completion: completion
)
}
}
}
private func collectAndFinish(code: Int32, reason: String)
{
let script = """
JSON.stringify({
title: document.title,
summary: document.getElementById('summary-text')?.textContent || '',
failures: Array.from(document.querySelectorAll('#results li[data-state="fail"]'))
.map((item) => item.textContent),
results: Array.from(document.querySelectorAll('#results li'))
.map((item) => item.textContent)
})
"""
evaluate(script)
{ [weak self] value in
print("WKWEBVIEW_RESULT=\(value)")
self?.finish(code: code, reason: reason)
}
}
private func evaluate(_ script: String, completion: @escaping (String) -> Void)
{
webView.evaluateJavaScript(script)
{ value, error in
if let error
{
completion("EVALUATION_ERROR: \(error)")
return
}
completion(String(describing: value ?? "nil"))
}
}
private func finish(code: Int32, reason: String)
{
guard !finished else { return }
finished = true
print("WKWEBVIEW_EXIT_REASON=\(reason)")
fflush(stdout)
window.close()
app.stop(nil)
exit(code)
}
}
@main
struct Main
{
@MainActor
private static var harness: WkWebViewHarness?
@MainActor
static func main()
{
guard CommandLine.arguments.count == 3 else
{
fputs("usage: harness <e2e.html> <read-root>\n", stderr)
exit(64)
}
let app = NSApplication.shared
app.setActivationPolicy(.accessory)
harness = WkWebViewHarness(
app: app,
pagePath: CommandLine.arguments[1],
readRoot: CommandLine.arguments[2]
)
app.run()
}
}