feat(rules): 建立共享排版契约并收束 WebKit 与 Unicode 基线
This commit is contained in:
+158
-123
@@ -5,8 +5,8 @@
|
||||
> 單份間距、跨瀏覽器一致)作調整。
|
||||
>
|
||||
> 倉庫名 `cjk-autospace` 不變;公開 API 為 `createJuzhen` / 全域 `Juzhen`;
|
||||
> 自訂元素前綴 `jz-`。本文件先於任何程式碼,作為實作契約——後續每個功能點
|
||||
> 皆按此落地,以避免不一致與重大返工。
|
||||
> 自訂元素前綴 `jz-`。平台中立之排版語義以 `rules/juzhen-rules.json`、其 Schema 與
|
||||
> 黃金向量為唯一權威源;本文件描述 Web adapter 架構及設計理由,不得覆蓋或分叉共享規則。
|
||||
|
||||
---
|
||||
|
||||
@@ -14,28 +14,38 @@
|
||||
|
||||
1. **第一垂直切片 = v1(cjk-autospace)全部功能**,在新引擎上重寫:中西間隙
|
||||
(Pass A/B)、長英文詞斷詞包裝(Pass C)、開明式標點擠壓(Pass D)。
|
||||
2. **架構須預留完整復刻 Han.css 的能力**:標點修正、擠壓、懸掛、禁則、著重號、
|
||||
注音/ruby、樣式正常化、字體偵測、計數器等,皆能在不改動切片 1 既有程式碼
|
||||
的前提下,以「新增一個 pass」的方式加入。
|
||||
2. **架構保留擴充 Han.css 功能版圖的能力**:標點修正、著重號與注音/ruby 等目前
|
||||
**尚未實作**,若未來加入,須沿既有 pass、finder 與 CSS 契約擴充。標點懸掛之舊
|
||||
負 margin 實作則已移除;四個同名公開選項現階段僅為相容 no-op(見 §3.1)。
|
||||
3. **跨瀏覽器一致**(Chrome/Edge/Safari/WebKit/Firefox):一致性關鍵的版面
|
||||
行為,只用普遍支援的 CSS 原語;原生新特性僅作非必要增強。
|
||||
|
||||
非目標:不依賴 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 必須遵守)
|
||||
|
||||
這些不變量是「避免返工」的核心。任何新功能點都不得違反。
|
||||
|
||||
- **I1 複製乾淨(copy-clean)**:注入之間隙與標記一律 `user-select: none`,不改變邏輯
|
||||
`textContent` 的**語義**內容。**一項刻意之常態正規化例外**:`spacing` pass 剝除**贅餘之作者
|
||||
- **I1 複製乾淨(copy-clean)**:`spacing` 以 `jz-hws` 的 margin 留隙,**不插入空白字元**;
|
||||
唯跨節點 IVS 間隙以 `::after` 生成 `U+200B` 斷行機會,且只對此生成內容設
|
||||
`user-select:none`。該偽元素不進入 DOM/`textContent`。**一項刻意之常態正規化例外**:
|
||||
`spacing` pass 剝除**贅餘之作者
|
||||
空格**——CJK↔西文之作者空格吸收為受管 `margin` 間隙、CJK↔CJK(含跨 inline 標籤)之作者
|
||||
空格剝除不留隙。此為 CJK 排版正解(該空格屬裝飾性、間隙改由 margin 承載,語義內容不變),
|
||||
但**確實刪除空格字元、且此刪除不經 `revert` 還原**(見 I6)。除此常態正規化外,預設「只包裝、
|
||||
不改寫文字」;其餘真正會改寫文字之 pass(如標點替換 `biaodian`)須為顯式 opt-in 且可 `revert`
|
||||
還原。(實測 4 份真人撰寫之真實文檔:非空白字元多重集守恆、無任何字元改/增/漏/亂序,唯
|
||||
不改寫文字」。未來若新增會改寫文字之 pass,須為顯式 opt-in 且可 `revert` 還原;目前不存在
|
||||
`biaodian` 改寫 pass。(實測 4 份真人撰寫之真實文檔:非空白字元多重集守恆、無任何字元改/增/漏/亂序,唯
|
||||
贅餘作者空格依此契約規範化。)
|
||||
- **I2 JS/CSS 職責分離**:JS 只注入**結構**(`jz-*` 元素、class、`data-jz-*`
|
||||
屬性);**所有視覺尺寸(margin/letter-spacing/寬度/懸掛位移)一律寫在
|
||||
@@ -107,8 +117,6 @@ jz.disconnect(); // 停止觀察
|
||||
// 全域。style = 全域擠壓預設(quanjiao|kaiming|banjiao);policy = 逐 lang
|
||||
// 覆寫。優先序:policy[lang] > style > 內建預設(繁→全角、簡→開明)。
|
||||
lang: { default: "zh-Hant", style: "kaiming", policy: { "zh-Hant": "quanjiao" } },
|
||||
classPrefix: "jz",
|
||||
|
||||
// 作用域限定(§3.6)
|
||||
scope: {
|
||||
root: document.body, // 預設處理根;render(root) 可逐次覆寫
|
||||
@@ -122,86 +130,97 @@ jz.disconnect(); // 停止觀察
|
||||
longWord: { minLength: 6, lang: "en" },
|
||||
jiya: true, // 標點擠壓
|
||||
jinze: true, // 禁則(含 justify 綁定)
|
||||
hanging: false, // 標點懸掛(預設關,繁體橫排不宜)
|
||||
biaodian: false, // 標點修正(opt-in,可能改寫文字)
|
||||
emphasis: false,
|
||||
ruby: false,
|
||||
|
||||
orphan: { chars: 2 }, // 段末垂懸字避免
|
||||
level: { text: "h1,h2,h3,button,.ui" },
|
||||
// 分塊功能選擇(§3.6):選擇器 → 該塊內生效之功能集(覆寫全域)
|
||||
blocks: [
|
||||
{ selector: ".prose", features: ["spacing", "jiya", "jinze", "emphasis"] },
|
||||
{ selector: ".prose", features: ["spacing", "jiya", "jinze"] },
|
||||
{ selector: ".code-comment", features: ["spacing"] }, // 此塊只間隙、不擠壓
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
`hanging`/`biaodian`/`emphasis`/`ruby` 仍可作為頂層選項傳入,但目前全部為
|
||||
**相容 no-op**:沒有註冊 pass,也不產生 DOM 或 CSS 效果。`hanging` 的舊負 margin
|
||||
實作已移除;其餘三項僅保留為明確標記的未來設計。`charClass.biaodian` 則是已實作的
|
||||
標點字符分類擴充,與同名頂層 no-op 選項無關。
|
||||
|
||||
### 3.2 Pass 管線
|
||||
|
||||
每個功能點是一個 `Pass`:
|
||||
每個功能點是一個 `Pass`;目前型別是 `CharifyPass | StandalonePass`:
|
||||
|
||||
```
|
||||
interface Pass {
|
||||
interface BasePass {
|
||||
name: string;
|
||||
phase: "P0" | "P1" | "P2"; // 遍歷階段,供聚合(§3.4)
|
||||
order: number; // phase 內 apply 先後(charify→擠壓→間隙→…)
|
||||
enabled(options): boolean;
|
||||
// 聚合模式:collect 純判定(同階段共享單次走訪),apply 批次變更
|
||||
collect(node: Text, ctx: RenderContext): Target[];
|
||||
apply(targets: Target[], ctx: RenderContext): void;
|
||||
order: number;
|
||||
kind: "charify" | "standalone";
|
||||
level: "text" | "paragraph";
|
||||
enabled(options: ResolvedOptions): boolean;
|
||||
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)` 整段式而不參與
|
||||
collect 聚合;執行器兩種皆容納。
|
||||
`CharifyPass` 只收集文本包裝請求;`StandalonePass` 自行執行功能所需的掃描或 layout。
|
||||
兩類皆由同一註冊表管理,且均按 `level` 與功能閘判定是否生效。
|
||||
|
||||
`createJuzhen` 內建一個**有序、可開關**的 pass 註冊表。`render` 依 `order` 執行
|
||||
已啟用者。順序契約(避免互相破壞):
|
||||
|
||||
```
|
||||
10 biaodian 標點修正(若開,最先;可能改寫文字/包裝)
|
||||
20 charify 標點字元包裝(jz-char/jz-inner)——擠壓、禁則、著重號之共同前置
|
||||
30 jiya 標點擠壓(依 class + :lang 之 CSS)
|
||||
40 jinze 禁則 + justify 綁定
|
||||
50 spacing 中西間隙(jz-hws)
|
||||
Charify group(共享一次文本遍歷):
|
||||
20 jiya 標點字元包裝(jz-char/jz-inner)
|
||||
60 longWord 長英文詞 lang 包裝
|
||||
70 emphasis 著重號
|
||||
80 ruby 注音/ruby
|
||||
90 hanging 標點懸掛(需 layout,最後;掛 ResizeObserver)
|
||||
|
||||
Standalone group(各自 render):
|
||||
10 levelMark 寫回 resolved block level
|
||||
25 jiyaAdjacency 連續標點擠壓標記
|
||||
40 jinze 禁則 + justify 綁定
|
||||
50 spacing 中西間隙(jz-hws)
|
||||
70 orphan 垂懸字避免
|
||||
90 jiyaLineEdge 行端半形擠壓
|
||||
92 spacingGapTrim 行末中西間隙裁切
|
||||
```
|
||||
|
||||
> 順序理由:charify 先把標點變成可定址的 `jz-char`,擠壓/禁則/著重號才有對象;
|
||||
> 間隙在標點結構穩定後插入;懸掛依賴最終行盒,必須最後且 layout 後再算。
|
||||
執行器先跑完整個 charify group,再跑 standalone group;因此跨組不以數字互相比較。
|
||||
例如 `levelMark` 的 `order=10` 仍在 charify 完成後執行。`revert` 則把全部 pass 合併後
|
||||
按 `order` 逆序呼叫,產物的 ownership/kind 標記負責精確還原。
|
||||
|
||||
### 3.3 RenderContext
|
||||
|
||||
`render`/`revert` 收到統一上下文:`{ root, options, finder, locale }`。各 pass
|
||||
共用同一 `finder` 實例與 locale 解析,確保 avoid 名單、邊界規則、語言判定一致。
|
||||
`render`/`revert` 收到統一上下文:`{ root, options, finder, ownedElements,
|
||||
ownerToken, setOwnedClass }`。各 pass 共用同一 `finder` 與 resolved options;產物透過
|
||||
本實例的 ownership 集合/token 辨識,避免把外來或偽造的 `jz-*` 當成本實例產物。
|
||||
|
||||
### 3.4 遍歷聚合(減少 pass 遍歷次數,I9)
|
||||
|
||||
樸素做法是每個 pass 各跑一次 `TreeWalker`,9 個 pass 即 9 次全樹走訪。為此引入
|
||||
**遍歷階段(phase)**:相容之 pass 共享單次走訪。Pass 介面拆為
|
||||
`collect(node, ctx) → targets[]`(純判定、不變更 DOM)與 `apply(targets, ctx)`
|
||||
(批次變更),執行器:
|
||||
執行器先篩出已啟用的 pass,再按 `kind` 分組。所有 `CharifyPass` 共用一次
|
||||
`finder.eachTextNode(root)`:對每個文本節點依 `order` 呼叫各 pass 的 `collect`,
|
||||
先把節點與 `WrapRequest` 累積為 work,遍歷結束後才由管線執行器統一套用,避免走訪途中改 DOM。
|
||||
之後 `StandalonePass` 依 `order` 各自呼叫 `render(ctx)`:
|
||||
|
||||
```
|
||||
for phase in phases:
|
||||
walk(root) 一次:對每個文本節點,依序呼叫該 phase 內各 pass 的 collect()
|
||||
依 order 對收集到的 targets 做 apply()(兩階段:先收集後變更,避免遍歷中改 DOM)
|
||||
charify = enabled.filter(kind == "charify").sort(order)
|
||||
standalone = enabled.filter(kind == "standalone").sort(order)
|
||||
|
||||
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 次):
|
||||
|
||||
| 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 不相容(需獨立走訪),
|
||||
> 退化為自跑一次,不影響正確性。
|
||||
聚合僅是**執行優化**:邏輯上仍「一功能一 pass」、可獨立開關與 `revert`(I6)。
|
||||
需要自訂掃描、跨節點 run 或 layout 的功能採 standalone,不假裝參與 charify 聚合。
|
||||
|
||||
### 3.5 v1 向下相容層 — 已移除(I8 退役)
|
||||
|
||||
@@ -227,14 +246,14 @@ Nvim peek/Claude 模板等 v1 消費端之過渡期。**最後一批 v1 消費
|
||||
|
||||
- **宣告式(屬性,就近繼承)**:
|
||||
- `data-juzhen="spacing jiya"`——白名單:此塊內僅啟用列出之功能。
|
||||
- `data-juzhen-off="jiya hanging"`——黑名單:此塊停用列出者,其餘繼承。
|
||||
- `data-juzhen-off="jiya orphan"`——黑名單:此塊停用列出者,其餘繼承。
|
||||
- `lang`——驅動全角/開明(正交於功能開關)。
|
||||
- **程式式**:`options.blocks: [{ selector, features }]`,選擇器 → 功能集。
|
||||
- **解析(per-node 閘)**:對每個節點,向上找最近帶功能指令之祖先(屬性或命中
|
||||
`blocks` 選擇器者)決定其**有效功能集**,無則用全域 `options`;render 期間以
|
||||
元素為鍵快取。每個 pass 之 `collect(node)` 先問 `featureEnabledFor(node, name)`,
|
||||
否則跳過——**故分塊閘只是單次走訪內的 per-node 判定,不增遍歷、不破壞聚合
|
||||
(§3.4)**。
|
||||
元素為鍵快取。charify pass 在 `collect(node)`、standalone pass 在自身掃描時查詢
|
||||
`featureEnabledFor(node, name)`,未啟用即跳過。分塊閘不改變 §3.4 的兩類 pass
|
||||
執行模型。
|
||||
- **與 revert 之關係**:`revert` 依標記移除某功能產物,**不受當前閘狀態影響**,故
|
||||
「關閉某功能 + revert」恆能乾淨還原(I6)。
|
||||
|
||||
@@ -358,9 +377,9 @@ avoid」用組合入口 `isAvoided`(=自身或祖先,階段二歸一,免
|
||||
- `jinze`:`anyCjk = RegExp(ruleset.cjk)` 決定詞元邊界之 CJK 判定。
|
||||
|
||||
**設計取捨**:① **向下兼容**——不傳 `charClass` 則 `ruleset === DEFAULT_RULESET`,行為與
|
||||
重構前完全一致(現有測試全綠)。② **追加語義(v1)**——擴充內置類(把未分類字符如英文全角
|
||||
句號 `.`U+FF0E 歸入、或納新符號);不支援重分類/移除內置字符(範圍式集合難以挖洞、且需
|
||||
求罕見,列為後續)。③ 結構性 ASCII 不開放(非「字符類型」)。
|
||||
重構前完全一致(現有測試全綠)。② **追加語義(v1)**——擴充尚未內置的字符;全角句點
|
||||
`.`(U+FF0E)已是內置 `BD_STOP`,無須追加。規則不支援重分類/移除內置字符(範圍式集合
|
||||
難以挖洞、且需求罕見,列為後續)。③ 結構性 ASCII 不開放(非「字符類型」)。
|
||||
|
||||
> 借鑑 Han 之 `regex/typeset.js`、`regex/unicode.js` 與 Heti 之分類字集,重寫為
|
||||
> 現代 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-inner` | `jz-char` 內層字面容器,供 margin/letter-spacing 擠壓而不動外盒 |
|
||||
| `jz-cs` | 字間補償空白(連續/行端擠壓用,於行邊界折疊) |
|
||||
| `jz-jinze` | 禁則群組:綁定不可斷單元(亦為 justify 綁定載體) |
|
||||
| `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`、
|
||||
`jiya-consecutive`、`hangable`、`jinze-{tou,wei,touwei}` 等。
|
||||
`jz-cs`、`jz-em`、`jz-ruby`/`jz-rb`/`jz-rt` 只屬未實作之未來設計名稱,當前程式
|
||||
不會產生。
|
||||
|
||||
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)提供——**不注入任何空白字元**。
|
||||
WHERE 之規則沿用 v1 pangu 規則表(已驗證);作者原寫之 CJK↔西文空格一律移除
|
||||
(改由 margin 留隙)。
|
||||
- **跨節點 IVS**:當 base 與 variation selector 分居文本節點時,`jz-hws` 承載完整
|
||||
base+VS 序列;僅此類 gap 以 `::after` 生成 `U+200B` 斷行機會,並只對該偽元素設
|
||||
`user-select:none`。它不是 DOM 文本,不改變 `textContent` 或複製結果。
|
||||
- **為何不用空白字元/inline-block(justify 三難)**:純 CSS 下,CJK↔西文間隙在
|
||||
`justify` 必有一個邊緣瑕疵——①真實空白→被當詞間空白**暴增**;②inline-block 固定
|
||||
寬→罕見落**行首縮排**;③`margin-right`→**行末右緣內縮 0.25em**。選 ③(margin),
|
||||
@@ -628,7 +650,7 @@ class 約定:`bd`(標點)、`bd-open/close/cop/stop/sep/liga`、`cjk`、`j
|
||||
|
||||
#### 6.5.0a 功能分級:文本級 vs 段落級 + 元素標記(前置基礎設施)
|
||||
|
||||
**動機**:懸掛/垂懸字/禁則等為**段落級**功能(依賴行/段布局),不應作用於標題、按鈕、單行
|
||||
**動機**:垂懸字/禁則等為**段落級**功能(依賴行/段布局),不應作用於標題、按鈕、單行
|
||||
或單元素文本;而中西間隙、標點挤压為**文本級**,任意文本片段皆適用且想要。下游須能**方便標記**
|
||||
元素屬哪級。此為貫穿性基礎設施(已實作:`Finder.levelFor`/`levelAllows`、各 pass `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) | 僅字符鄰接,對單行/單元素文本即有意義 |
|
||||
| **段落級** | `jinze` 避頭尾、行端(半形/懸掛)、`gapTrim`、`orphan` 垂懸字、`longWord` 斷詞 | 需行/段布局(換行、行端、段末、justify)才有意義 |
|
||||
| **段落級** | `jinze` 避頭尾、行端半形、`gapTrim`、`orphan` 垂懸字、`longWord` 斷詞 | 需行/段布局(換行、行端、段末、justify)才有意義 |
|
||||
| 貫穿 | `charClass`、原子透明、normalize | 基礎設施,不分級 |
|
||||
|
||||
直覺:文本級=「字符級排版」(間隙、標點形態);段落級=「行/段級排版」(換行、行端、段末)。
|
||||
@@ -648,7 +670,7 @@ class 約定:`bd`(標點)、`bd-open/close/cop/stop/sep/liga`、`cjk`、`j
|
||||
- 顯式降級:`data-jz-level="text"` 於元素/祖先 → 該子樹**僅文本級**(段落級功能自動關);
|
||||
`data-jz-level="paragraph"`(或不標)→ 全功能。
|
||||
- 選項 `level?: { text?: string }`:選擇器一鍵標記(如 `level:{ text: "h1,h2,h3,button,.ui" }`)。
|
||||
- 典型:標題/按鈕/UI 文字標 **text-level**(要間隙與標點半形、不要懸掛/避孤字/避頭尾);
|
||||
- 典型:標題/按鈕/UI 文字標 **text-level**(要間隙與標點半形、不要避孤字/避頭尾);
|
||||
正文 `<p>` 預設全功能。
|
||||
|
||||
**實現**:
|
||||
@@ -659,8 +681,8 @@ class 約定:`bd`(標點)、`bd-open/close/cop/stop/sep/liga`、`cjk`、`j
|
||||
- **文本級功能須支持非塊/單元素**:`spacing` 現用 `eachBlock`,須確認 scope root 為 inline/
|
||||
單元素時亦能處理(或放寬走訪),使單行標題等之中西間隙、標點半形生效。
|
||||
|
||||
**與本批關係**:先落地分級(pass 標 level、`levelFor`、`data-jz-level`/選項)→ orphan/hanging
|
||||
註冊為段落級,自動只作用段落級元素、跳過 text-level 者。實作順序插於 6.5.5 之首。
|
||||
**與本批關係**:分級已落地(pass 標 level、`levelFor`、`data-jz-level`/選項);`orphan`
|
||||
等段落級 pass 只作用於段落級元素、跳過 text-level 者。`hanging` 未註冊為 pass。
|
||||
|
||||
**Resolved 分類之 CSS 鉤子(下游建議七,`typeset/levelmark.ts`)**:消費端之段落級 CSS(首要
|
||||
`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²);改以一趟
|
||||
上攀預計算「非葉塊」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 整體
|
||||
納入 jz-orphan,並處理邊界字定位**跨 jz-jinze 攀爬**(實作細節)。免重複包裹。
|
||||
- **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 是否在行首(開類)/行末(閉類),
|
||||
加 `jz-half-le`(CSS 同 halt)。兩階段免自我應驗(先 `clearEdge`、自然佈局量測全部、再一次套用);
|
||||
行歸屬以「垂直區間」`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
|
||||
|
||||
@@ -733,8 +756,9 @@ order `90 lineEdge`,`revert` 清 `jz-half-le`。純版面、jsdom 跳過、須
|
||||
|
||||
#### 6.5.4 統一 pass order
|
||||
|
||||
`20 charify → 25 jiyaAdj → 40 jinze → 50 spacing → 60 longWord → 70 orphan(垂懸字)→
|
||||
90 lineEdge(行端 squeeze 半形)→ 92 gapTrim`。
|
||||
實際分兩群且不跨群比較 order:charify 群為 `20 jiya → 60 longWord`,共用一次文本
|
||||
遍歷;其後 standalone 群為 `10 levelMark → 25 jiyaAdjacency → 40 jinze → 50 spacing →
|
||||
70 orphan → 90 jiyaLineEdge → 92 spacingGapTrim`。
|
||||
|
||||
#### 6.5.5 實作順序與結果
|
||||
|
||||
@@ -774,30 +798,33 @@ order `90 lineEdge`,`revert` 清 `jz-half-le`。純版面、jsdom 跳過、須
|
||||
擠壓 + `hanging-punctuation` 接管懸掛,按 `CSS.supports` 能力門控,整體退出 jz-char 機制。
|
||||
與已移除之負 margin 程式碼**沒有一行可複用**,故不保留休眠碼。
|
||||
|
||||
### 6.6 標點修正(Biaodian normalize)— 後續(opt-in)
|
||||
### 6.6 標點修正(Biaodian normalize)— 未實作之未來設計
|
||||
|
||||
- **Han**:`subst`/`biaodian` 修正標點變體、位置。
|
||||
- **我們**:opt-in pass,預設**只分類+打 class**(不改文字,I1);顯式開啟才做
|
||||
變體替換(簡↔繁標點、全半形修正),且可 `revert`。
|
||||
- **預留**:`order=10` 最先;產物為 `jz-char` 之 class/屬性,與 charify 銜接。
|
||||
- **擬議方向**:未來可設計 opt-in pass;顯式開啟才做變體替換(簡↔繁標點、全半形
|
||||
修正),且須可 `revert`。目前沒有 `biaodian` 模組、pass 或文字改寫。
|
||||
- **相容狀態**:頂層 `biaodian` 選項僅為 no-op;`charClass.biaodian` 是另一套已實作的
|
||||
字符分類擴充 API。
|
||||
|
||||
### 6.7 著重號(Emphasis)— 後續
|
||||
### 6.7 著重號(Emphasis)— 未實作之未來設計
|
||||
|
||||
- **Han**:逐字包裝加圈點。
|
||||
- **我們**:優先用原生 `text-emphasis`(Chrome/FF/Safari 支援良好);需精細
|
||||
控制時退回 `jz-em` 逐字包裝 + CSS 圓點。lang 感知(位置簡繁略異)。
|
||||
- **預留**:charify 能力直接複用。
|
||||
- **擬議方向**:可優先使用原生 `text-emphasis`;若未來需要精細控制,再評估逐字
|
||||
包裝與 lang 感知。`jz-em` 只是設計草案名稱,當前沒有對應模組、pass、DOM 或 CSS。
|
||||
- **相容狀態**:頂層 `emphasis` 選項僅為 no-op。
|
||||
|
||||
### 6.8 注音/Ruby — 後續(最複雜)
|
||||
### 6.8 注音/Ruby — 未實作之未來設計
|
||||
|
||||
- **我們**:基於原生 `<ruby>` + `jz-ruby/jz-rt` 處理注音符號直排與聲調定位。
|
||||
- **預留**:獨立 pass,`order=80`;不影響其餘。
|
||||
- **擬議方向**:未來可基於原生 `<ruby>` 評估注音符號直排與聲調定位;`jz-ruby`/
|
||||
`jz-rb`/`jz-rt` 只是設計草案名稱,當前沒有對應模組、pass、DOM 或 CSS。
|
||||
- **相容狀態**:頂層 `ruby` 選項僅為 no-op。
|
||||
|
||||
### 6.9 樣式正常化/字型歸屬 — 貫穿(CSS)
|
||||
|
||||
- **normalize**:`juzhen.css` 內以 `@supports`/`:where()` 提供 CJK normalize(關閉
|
||||
與託管擠壓重複作用之原生 `text-spacing-trim`/`text-autospace`、間隙 `user-select`
|
||||
等),限 `.juzhen` 域、非破壞。
|
||||
與託管擠壓重複作用之原生 `text-spacing-trim`/`text-autospace` 等),限 `.juzhen`
|
||||
域、非破壞。間隙本身不插入空白;只有跨節點 IVS 的 `U+200B` 偽元素設
|
||||
`user-select:none`。
|
||||
- **字型不歸聚珍管(設計決策,下游回饋)**:曾內建依 `:lang` 之 Noto 字型堆疊作「後備」,
|
||||
但字型屬消費端領域、內建即越權(且曾蓋掉消費端字型釀 bug)。故**移除 `_lang.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 B pill 邊界 | `spacing`(同引擎) | avoid + 鄰接判定 |
|
||||
| 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 變換正確性
|
||||
(包裝、class、邊界、冪等、copy-clean、lang 政策);justify/簡繁 demo 頁供瀏覽器
|
||||
@@ -907,9 +934,9 @@ inline-block,於 `_jiya.css` 補一條)。實測:halt/margin 兩模式 c
|
||||
支援。**注意**:標點半形預設不用負 `margin`(對居中字形會重疊),改用 `halt`(見
|
||||
§6.3 修訂)。**缺 `halt` 字型之後備**:`jiya.halfWidth:"margin"` 以 `width:0.5em`+
|
||||
字身依偏側(`jz-ink-*`)溢出定位,於 CSS 重現 halt、真半形 advance(見 §6.3)。
|
||||
- **漸進增強(非必要)**:`text-spacing-trim`(Blink)、`hanging-punctuation`
|
||||
(WebKit)、`text-emphasis`、`text-autospace`——僅在支援時錦上添花,缺席不影響
|
||||
基本排版(I4)。
|
||||
- **原生原語政策**:`text-spacing-trim`/`text-autospace` 在現行託管功能中須避免
|
||||
重複作用;`hanging-punctuation`/`text-emphasis` 只屬尚未實作之未來設計評估,
|
||||
不是當前 pass 或驗收項目。
|
||||
- **驗證矩陣**:Chrome(Blink,含 VSCode/Electron)、Safari/WebKit、Firefox
|
||||
(Gecko)。demo 頁含三引擎可開的測試案例。
|
||||
|
||||
@@ -929,8 +956,8 @@ inline-block,於 `_jiya.css` 補一條)。實測:halt/margin 兩模式 c
|
||||
```
|
||||
|
||||
政策:凡屬「一致性關鍵層」者(間隙、擠壓),一律由我們統一處理並禁用對應原生
|
||||
原語;凡屬「漸進增強」且不致衝突者(如 Safari 之 `hanging-punctuation`,在我們未
|
||||
託管懸掛時),方任其生效。此清單隨功能演進維護於 `css/_normalize.css`。
|
||||
原語。`hanging-punctuation` 等未來設計若要加入,須另立能力門控與三引擎驗收,不能
|
||||
從目前保留的 no-op 選項推定已實作。此清單隨功能演進維護於 `css/_normalize.css`。
|
||||
|
||||
---
|
||||
|
||||
@@ -938,31 +965,34 @@ inline-block,於 `_jiya.css` 補一條)。實測:halt/margin 兩模式 c
|
||||
|
||||
```
|
||||
src/
|
||||
index.ts createJuzhen / Juzhen / pass 註冊 / render / revert
|
||||
index.ts createJuzhen / Juzhen / pass 註冊 / observer / render / revert
|
||||
types.ts 公開選項、RenderContext、CharifyPass / StandalonePass 型別
|
||||
core/
|
||||
pass.ts Pass 介面 + 管線執行器
|
||||
finder.ts 遍歷/avoid/charify/邊界感知 replace
|
||||
pass.ts 兩類 pass 之分組、排序與管線執行器
|
||||
finder.ts 遍歷/avoid/charify/邊界感知與跨節點 run
|
||||
unicode.ts 字元類 + 標點子類 + 規則正則表
|
||||
locale.ts 有效 lang / 分類 / 全角開明政策
|
||||
dom.ts jz-* 工廠 / wrap / split / marker / revert
|
||||
dom.ts jz-* 工廠 / ownership / wrap / revert
|
||||
split.ts 禁則/垂懸字之行內元素邊界切分
|
||||
typeset/
|
||||
spacing.ts 中西間隙 [切片 1]
|
||||
longword.ts 長英文詞包裝 [切片 1]
|
||||
jiya.ts 標點擠壓 + 全角開明 [切片 1]
|
||||
jinze.ts 禁則 + justify 綁定 [切片 1 基礎]
|
||||
hanging.ts 標點懸掛 [後續]
|
||||
biaodian.ts 標點修正 [後續]
|
||||
emphasis.ts 著重號 [後續]
|
||||
ruby.ts 注音/ruby [後續]
|
||||
spacing.ts 中西間隙
|
||||
longword.ts 長英文詞包裝
|
||||
jiya.ts 標點擠壓 + 全角開明
|
||||
jinze.ts 禁則 + justify 綁定
|
||||
orphan.ts 段末垂懸字避免
|
||||
levelmark.ts resolved block level 寫回
|
||||
lineedge.ts 行端半形擠壓
|
||||
css/
|
||||
juzhen.css 入口(@import 各 partial)
|
||||
_normalize.css _spacing.css _jiya.css _jinze.css
|
||||
_hanging.css _emphasis.css _ruby.css (字型不內建,交消費端)
|
||||
_normalize.css _spacing.css _jiya.css _jinze.css _orphan.css
|
||||
test/ jsdom DOM 正確性測試
|
||||
demo/ 瀏覽器 layout/justify/簡繁 目視驗證頁
|
||||
build.mjs esbuild:ESM + IIFE + CSS;tsc:d.ts
|
||||
```
|
||||
|
||||
`biaodian.ts`/`emphasis.ts`/`ruby.ts`/`hanging.ts` 及同名 CSS partial 均不存在;
|
||||
前述章節是明確標記的未來設計或已移除機制,不是當前模組清單。
|
||||
|
||||
---
|
||||
|
||||
## 11. 構建與發佈
|
||||
@@ -973,7 +1003,7 @@ build.mjs esbuild:ESM + IIFE + CSS;tsc:d.ts
|
||||
- `dist/juzhen.iife.js`(IIFE,全域 `Juzhen`,原生 `<script>` 直跑——保留
|
||||
build.sh/Nvim peek 之單檔串接消費)
|
||||
- `dist/juzhen.css`(合併之樣式)
|
||||
- `tsc --emitDeclarationOnly` → `dist/juzhen.d.ts`
|
||||
- `tsc --emitDeclarationOnly` → `dist/index.d.ts`
|
||||
- **發佈物**:`dist/` 提交入庫(消費端可直接取單檔/CSS)。
|
||||
|
||||
---
|
||||
@@ -983,7 +1013,7 @@ build.mjs esbuild:ESM + IIFE + CSS;tsc:d.ts
|
||||
- **jsdom(自動)**:驗證 DOM 變換——包裝結構、`bd-*` class、邊界處理、冪等、
|
||||
`revert` 還原、copy-clean(`textContent` 不被注入污染)、lang 政策分支。
|
||||
- **瀏覽器(目視,demo 頁)**:layout 相關**無法**於 jsdom 驗證——justify 單份
|
||||
間距、擠壓視覺量、行端折疊、懸掛、inline-block 基線。demo 頁載 Noto CJK,簡繁
|
||||
間距、擠壓視覺量、行端折疊、inline-block 基線。demo 頁載 Noto CJK,簡繁
|
||||
並列、可切 justify、附選取/複製測試區,於 Chrome/Safari/FF 目視核對。
|
||||
- 文件明確標注哪些結論來自自動測試、哪些待瀏覽器目視。
|
||||
|
||||
@@ -994,7 +1024,8 @@ build.mjs esbuild:ESM + IIFE + CSS;tsc:d.ts
|
||||
新增任一 Han 功能點,標準流程:
|
||||
|
||||
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>` 開關。
|
||||
4. 在 `css/_<feature>.css` 寫視覺規則(I2:尺寸只在 CSS)。
|
||||
5. 補 jsdom 測試 + demo 案例。
|
||||
@@ -1012,15 +1043,19 @@ build.mjs esbuild:ESM + IIFE + CSS;tsc:d.ts
|
||||
- **R1 justify/inline-block 基線**(§8)。觀察:句末標點在三引擎 justify 下是否
|
||||
單份間距、inline-block 是否擾動基線/行高/行內換行。**預案**:改採 Han 路線之
|
||||
`jz-cs` 補償空白法(標點維持 inline,以行邊界可折疊之 `jz-cs` 控制伸縮側),
|
||||
結構已預留,切換僅換 CSS 機制不動 JS。
|
||||
- **R2 行端擠壓免量測**。觀察:純 CSS(`jz-cs` 折疊)能否在三引擎一致達成行首行尾
|
||||
擠壓。**預案**:退化為「連續+行內擠壓用 CSS(一致)+ 行端擠壓交由 layout pass
|
||||
量測或漸進增強原語」,行端與連續解耦,互不影響。
|
||||
- **R3 標點修正改寫文字 vs I1**。觀察:opt-in 開啟後複製/序列化是否仍可接受。
|
||||
**預案**:預設只分類不改寫;改寫模式強制可 `revert`,並提供「僅視覺替換(CSS
|
||||
但此為未實作設計;採用時須新增 JS 生產/`revert` 契約、CSS 與三引擎驗收,不能只換
|
||||
CSS。
|
||||
- **R2 行端擠壓免量測(未實作設計)**。觀察:純 CSS(未來若引入 `jz-cs` 折疊)能否
|
||||
在三引擎一致達成行首行尾擠壓。採用時同樣須新增 JS 生產/`revert` 契約、CSS 與三引擎
|
||||
驗收;否則維持現行 layout pass 量測或改用能力門控之漸進增強原語,令行端與連續機制
|
||||
解耦。
|
||||
- **R3 標點修正改寫文字 vs I1(未實作之未來設計)**。觀察:若未來加入 opt-in
|
||||
改寫,複製/序列化是否仍可接受。**擬議預案**:預設只分類不改寫;改寫模式強制可
|
||||
`revert`,並提供「僅視覺替換(CSS
|
||||
字符替身)不動文字」之次級模式作折衷。
|
||||
- **R4 注音/ruby 複雜度**。觀察:原生 `<ruby>` 對注音直排/聲調定位之不足。
|
||||
**預案**:獨立 pass、最後做;先以原生 `<ruby>` 覆蓋多數,注音特例再以 `jz-ruby`
|
||||
- **R4 注音/ruby 複雜度(未實作之未來設計)**。觀察:原生 `<ruby>` 對注音直排/
|
||||
聲調定位之不足。**擬議預案**:獨立 pass、最後做;先以原生 `<ruby>` 覆蓋多數,
|
||||
注音特例再評估擬議的 `jz-ruby`
|
||||
子結構補強,不影響其餘 pass。
|
||||
- **R5 DOM 重量**。觀察:長文 charify 之節點數與 render 耗時。**預案**:兩階段批次
|
||||
變更(§3.4)+ 僅包裝必要標點 + 可選分塊/`requestIdleCallback` 漸進渲染;demo
|
||||
|
||||
@@ -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.
|
||||
@@ -19,6 +19,8 @@
|
||||
npm install
|
||||
npm run build # esbuild → dist/juzhen.{mjs,iife.js,css};tsc → dist/index.d.ts
|
||||
npm test # jsdom DOM 正確性測試
|
||||
node --test test/shared-rules.test.mjs # 共享規則 Schema/黃金向量/Web 漂移守門
|
||||
npm run test:wkwebview # macOS 原生 WKWebView 版面/observer E2E(需 Xcode 與 GUI 會話)
|
||||
```
|
||||
|
||||
產物(`dist/`,已入庫):
|
||||
@@ -29,6 +31,29 @@ npm test # jsdom DOM 正確性測試
|
||||
| `dist/juzhen.iife.js` | IIFE,全域 `Juzhen`,原生 `<script>` 直跑 |
|
||||
| `dist/juzhen.css` | 合併樣式(**必須引入**,視覺尺寸全在此) |
|
||||
| `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 無連字符 → 複製不污染)。 |
|
||||
| **標點擠壓** `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`,絕不下潛肢解其內部。 |
|
||||
| **垂懸字避免** `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` 原子項,句末標點只貢獻一份對齊伸縮量。 |
|
||||
|
||||
後續(架構已預留,不返工):標點修正(簡↔繁)、著重號、注音/Ruby。
|
||||
相容選項 `hanging`/`biaodian`/`emphasis`/`ruby` 仍被接受,但當前均為
|
||||
**no-op**:沒有對應 pass、DOM 產物或 CSS。後三者僅屬明確標記的未來設計;
|
||||
`hanging` 之舊負 margin 實作已移除,僅保留 API 相容。
|
||||
|
||||
---
|
||||
|
||||
@@ -142,7 +168,6 @@ createJuzhen({
|
||||
jiya: true, // 或物件:{ halfWidth: "halt" | "margin", bias }
|
||||
jinze: true,
|
||||
orphan: true, // 垂懸字避免(預設開);或 { chars: 2 };false 關
|
||||
// hanging:已移除、無效果(見「標點懸掛」一節)
|
||||
level: { text: "h1,h2,h3,button,.ui" }, // 此選擇器子樹僅跑文本級功能(見下)
|
||||
pillSelector: "code, kbd, .katex", // 整體(pill)元素(見「行內元素三分類」)
|
||||
isolateSelector: "mark", // 內外隔離元素(預設 mark;"" 停用)
|
||||
@@ -214,8 +239,9 @@ createJuzhen({
|
||||
| `data-jz-skip` | (存在即可) | 標記元素為 avoid(內外皆不處理) |
|
||||
| `data-jz-halfwidth` | `halt|margin` | 逐子樹半形渲染模式 |
|
||||
|
||||
**預留、當前無效果**:`hanging`(已移除,見下);`biaodian`/`emphasis`/`ruby`(架構預留切片
|
||||
#7/#8)。注意頂層 `biaodian`(功能開關)與 `charClass.biaodian`(標點子類擴充表)**同名異義**。
|
||||
**相容 no-op 選項**:`hanging`/`biaodian`/`emphasis`/`ruby` 均可傳入,但當前不註冊
|
||||
pass,也不產生 DOM/CSS 效果。注意頂層 `biaodian` 與 `charClass.biaodian`(已實作的
|
||||
標點子類擴充表)**同名異義**。
|
||||
|
||||
### 逐元素標點樣式(`data-jz-style`/`stylePolicy`)
|
||||
|
||||
@@ -303,11 +329,11 @@ web component 等)採**嚴格白名單**、一律當**邊界**(語義同隔
|
||||
|
||||
- **文本級**(中西間隙 `spacing`、標點擠壓 `jiya`):僅依字符鄰接,**任意文本片段**(單行
|
||||
標題、按鈕、單一行內元素)皆適用且想要。
|
||||
- **段落級**(禁則 `jinze`、垂懸字 `orphan`、標點懸掛 `hanging`、長詞 `longWord`):依賴
|
||||
- **段落級**(禁則 `jinze`、垂懸字 `orphan`、長詞 `longWord`):依賴
|
||||
行/段布局(換行、行端、段末),對單行/非段落文本無意義甚至有害。
|
||||
|
||||
塊級元素(`p`/`div`/`li`/`h1`…)**預設段落級(全功能)**。把標題/按鈕/UI 文字標為
|
||||
**文本級**(要間隙與標點半形、不要懸掛/避孤字/避頭尾):
|
||||
**文本級**(要間隙與標點半形、不要避孤字/避頭尾):
|
||||
|
||||
```html
|
||||
<h1 data-jz-level="text">標題:只跑中西間隙與標點擠壓</h1>
|
||||
@@ -484,7 +510,8 @@ JS 只注入 `jz-*` 結構,**所有視覺尺寸在 `juzhen.css`**——故 CSS
|
||||
`jiya.bias` 逐字微調墨色偏側(見「字型適配工作流」)。
|
||||
7. **自動處理、無需消費端介入**:`juzhen.css` 已在 `.juzhen` 範圍內以 `@supports`
|
||||
關閉 Blink 原生 `text-spacing-trim`/`text-autospace`(避免與託管擠壓重複作用),
|
||||
並令注入間隙 `user-select: none`(複製乾淨)。
|
||||
間隙不注入空白字元;僅跨節點 IVS gap 之 `U+200B ::after`
|
||||
設 `user-select:none`,避免該斷行提示進入選取/複製。
|
||||
8. **段落屬性照常設、原子透明**:聚珍把標點拆成 inline-block 原子,但已確保這些原子對
|
||||
消費端段落樣式透明——可在段落正常設 `text-indent`(首行縮排)、`hanging-punctuation`、
|
||||
`text-decoration`(下劃線)等;庫內已歸零會被原子重複套用之繼承屬性、並令原子承接段落
|
||||
@@ -528,7 +555,7 @@ createJuzhen({
|
||||
cjk: "〇", // 追加為中文(CJK):與西文邊界補中西間隙、禁則/justify 視為單字
|
||||
western: "★☆", // 追加為西文(半寬標點亦歸此):與 CJK 邊界補間隙
|
||||
biaodian: { // 追加為全寬標點,按子類(jiya charify/擠壓、禁則避頭尾)
|
||||
stop: ".", // 句末(。!?類)
|
||||
stop: "‼", // 追加為句末點號(。.!?已內置)
|
||||
pause: "", // 句內(,、;:類)
|
||||
open: "", close: "", // 開/閉括號引號
|
||||
middle: "", liga: "", // 間隔號/省略破折
|
||||
@@ -537,10 +564,9 @@ createJuzhen({
|
||||
});
|
||||
```
|
||||
|
||||
**典型用例**:英文全角句號 `.`(U+FF0E,有別於中文句號 `。` U+3002)預設**未分類**
|
||||
——不被 charify、不挤壓、與 CJK 間不補隙。若內容用 `.` 作句末標點,以
|
||||
`charClass: { biaodian: { stop: "." } }` 歸為全寬句末標點,即比照 `。` 處理(開明式挤半形、
|
||||
全角式佔全形、行首避頭尾)。
|
||||
`.`(U+FF0E,全角句點)已是內置 `BD_STOP` 成員,無需再經
|
||||
`charClass` 追加;其政策與 `。!?` 一致。`charClass.biaodian` 用於追加尚未內置的
|
||||
符號(如上例 `‼`),不用來重複宣告內置字符。
|
||||
|
||||
要點:
|
||||
|
||||
@@ -566,6 +592,8 @@ v1 相容層(`createCjkAutospace` 導出、IIFE 之 `globalThis.createCjkAutos
|
||||
## 目視驗證
|
||||
|
||||
`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/resize-check.html` 為**可拖曳容器**檢查頁:拖動方框寬度,驗證中西間隙在
|
||||
各寬度下恆穩(含行末去隙之自我應驗、pill 跨行右隙、貨幣/拼音/西里爾外文補隙、全角省略號
|
||||
|
||||
+374
@@ -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">甲𠀀</strong>󠄀A</p>
|
||||
<p id="ivs-forward-no-orphan" data-juzhen-off="orphan"><strong>甲𠀀</strong>󠄀A</p>
|
||||
<p id="ivs-forward-same-node" data-juzhen-off="orphan">𠀀󠄀A</p>
|
||||
<p id="ivs-forward-same-node-prefix" data-juzhen-off="orphan">甲𠀀󠄀A</p>
|
||||
<p id="ivs-reverse">A<strong id="author-reverse">𠀀</strong>󠄀乙</p>
|
||||
<p id="ivs-line-break" data-juzhen-off="orphan" style="width:2.5em;text-align:justify"><strong>甲𠀀</strong>󠄀A</p>
|
||||
<p id="ivs-nowrap" data-juzhen-off="orphan" style="width:1em;white-space:nowrap"><strong>甲𠀀</strong>󠄀A</p>
|
||||
<p id="ivs-lineedge-open" lang="zh-Hant">「𠀀󠄀甲</p>
|
||||
<p id="ivs-lineedge-close" lang="zh-Hant">甲𠀀󠄀」</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>
|
||||
Vendored
+10
-4
@@ -1,6 +1,6 @@
|
||||
/** 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";
|
||||
type JzKind = "marker" | "wrap";
|
||||
type JzKind = "wrap";
|
||||
export interface CreateOptions {
|
||||
/** 追加之 class(空白分隔或陣列)。 */
|
||||
classes?: string | string[];
|
||||
@@ -8,16 +8,22 @@ export interface CreateOptions {
|
||||
text?: string;
|
||||
}
|
||||
/** 造一個帶 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 定址,逆文件序處理(先內層後
|
||||
* 外層),marker 移除、wrap 解包,最後 normalize 受影響父節點合併文本。
|
||||
* 外層),wrap 解包,最後 normalize 受影響父節點合併文本。
|
||||
*
|
||||
* splitoff(克隆切分之邊界詞元,見 core/split.ts)須在 wrap 全部解包**之後**才併回:
|
||||
* 否則其前兄弟可能仍是 wrap 殼(如 jz-jinze)而非原元素。故主迴圈跳過 splitoff,於
|
||||
* 解包後第二階段以文件序逐一併回前兄弟(同標籤元素),還原為單一元素(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)。 */
|
||||
export declare function bdClassOf(el: Element): string | null;
|
||||
/** 該元素是否為某/任一 pass 之 jz 產物。 */
|
||||
|
||||
Vendored
+15
-4
@@ -1,15 +1,26 @@
|
||||
import type { FeatureLevel, ResolvedOptions } from "../types.js";
|
||||
/** 邏輯 run 中之單一字元來源位置。 */
|
||||
/** 邏輯 run 中之單一 base+optional-VS 來源 UTF-16 位置。 */
|
||||
export interface CharPos {
|
||||
textNode: Text;
|
||||
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 {
|
||||
text: string;
|
||||
map: CharPos[];
|
||||
map: CharSpan[];
|
||||
}
|
||||
/** pill 鄰接之邏輯鄰字。 */
|
||||
/** pill 鄰接之邏輯鄰字(base+optional-VS)。 */
|
||||
export interface AdjacentChar {
|
||||
textNode: Text;
|
||||
offset: number;
|
||||
|
||||
Vendored
+2
-2
@@ -6,9 +6,9 @@ import type { Finder } from "./finder.js";
|
||||
* · 不可切/無法定位切點/整元素即該詞元 → 回 el 本身(綁整個元素,退化但安全)。
|
||||
* 切出之克隆標 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:自實義字綁至塊末)。
|
||||
* 不可切或切點在最起點 → 回 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;
|
||||
|
||||
Vendored
+17
@@ -1,6 +1,23 @@
|
||||
import type { LangClass } from "./locale.js";
|
||||
/** CJK 表意文字/假名/注音等範圍(沿用並擴充 v1 之 PANGU_CJK)。 */
|
||||
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";
|
||||
/** 西文字母。 */
|
||||
|
||||
Vendored
+6
@@ -24,6 +24,12 @@ jz-jinze {
|
||||
jz-hws {
|
||||
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 {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
Vendored
+1163
-462
File diff suppressed because it is too large
Load Diff
Vendored
+1163
-462
File diff suppressed because it is too large
Load Diff
Vendored
+6
@@ -146,6 +146,12 @@ export interface RenderContext {
|
||||
root: Element;
|
||||
options: ResolvedOptions;
|
||||
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() 造出之元素。
|
||||
|
||||
Generated
+186
-109
@@ -10,7 +10,8 @@
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"esbuild": "^0.24.0",
|
||||
"ajv": "^8.20.0",
|
||||
"esbuild": "^0.28.2",
|
||||
"jsdom": "^25.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
@@ -145,9 +146,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
|
||||
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -162,9 +163,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
|
||||
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
|
||||
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -179,9 +180,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -196,9 +197,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -213,9 +214,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -230,9 +231,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -247,9 +248,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -264,9 +265,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -281,9 +282,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
|
||||
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
|
||||
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -298,9 +299,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -315,9 +316,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
|
||||
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
|
||||
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -332,9 +333,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
|
||||
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
|
||||
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -349,9 +350,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
|
||||
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
|
||||
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
@@ -366,9 +367,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
|
||||
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
|
||||
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -383,9 +384,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
|
||||
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
|
||||
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -400,9 +401,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
|
||||
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
|
||||
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -417,9 +418,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -434,9 +435,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -451,9 +452,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -468,9 +469,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -485,9 +486,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -501,10 +502,27 @@
|
||||
"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": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -519,9 +537,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -536,9 +554,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
|
||||
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
|
||||
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -553,9 +571,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -608,6 +626,23 @@
|
||||
"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": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
@@ -790,9 +825,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
|
||||
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
@@ -803,45 +838,70 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.24.2",
|
||||
"@esbuild/android-arm": "0.24.2",
|
||||
"@esbuild/android-arm64": "0.24.2",
|
||||
"@esbuild/android-x64": "0.24.2",
|
||||
"@esbuild/darwin-arm64": "0.24.2",
|
||||
"@esbuild/darwin-x64": "0.24.2",
|
||||
"@esbuild/freebsd-arm64": "0.24.2",
|
||||
"@esbuild/freebsd-x64": "0.24.2",
|
||||
"@esbuild/linux-arm": "0.24.2",
|
||||
"@esbuild/linux-arm64": "0.24.2",
|
||||
"@esbuild/linux-ia32": "0.24.2",
|
||||
"@esbuild/linux-loong64": "0.24.2",
|
||||
"@esbuild/linux-mips64el": "0.24.2",
|
||||
"@esbuild/linux-ppc64": "0.24.2",
|
||||
"@esbuild/linux-riscv64": "0.24.2",
|
||||
"@esbuild/linux-s390x": "0.24.2",
|
||||
"@esbuild/linux-x64": "0.24.2",
|
||||
"@esbuild/netbsd-arm64": "0.24.2",
|
||||
"@esbuild/netbsd-x64": "0.24.2",
|
||||
"@esbuild/openbsd-arm64": "0.24.2",
|
||||
"@esbuild/openbsd-x64": "0.24.2",
|
||||
"@esbuild/sunos-x64": "0.24.2",
|
||||
"@esbuild/win32-arm64": "0.24.2",
|
||||
"@esbuild/win32-ia32": "0.24.2",
|
||||
"@esbuild/win32-x64": "0.24.2"
|
||||
"@esbuild/aix-ppc64": "0.28.2",
|
||||
"@esbuild/android-arm": "0.28.2",
|
||||
"@esbuild/android-arm64": "0.28.2",
|
||||
"@esbuild/android-x64": "0.28.2",
|
||||
"@esbuild/darwin-arm64": "0.28.2",
|
||||
"@esbuild/darwin-x64": "0.28.2",
|
||||
"@esbuild/freebsd-arm64": "0.28.2",
|
||||
"@esbuild/freebsd-x64": "0.28.2",
|
||||
"@esbuild/linux-arm": "0.28.2",
|
||||
"@esbuild/linux-arm64": "0.28.2",
|
||||
"@esbuild/linux-ia32": "0.28.2",
|
||||
"@esbuild/linux-loong64": "0.28.2",
|
||||
"@esbuild/linux-mips64el": "0.28.2",
|
||||
"@esbuild/linux-ppc64": "0.28.2",
|
||||
"@esbuild/linux-riscv64": "0.28.2",
|
||||
"@esbuild/linux-s390x": "0.28.2",
|
||||
"@esbuild/linux-x64": "0.28.2",
|
||||
"@esbuild/netbsd-arm64": "0.28.2",
|
||||
"@esbuild/netbsd-x64": "0.28.2",
|
||||
"@esbuild/openbsd-arm64": "0.28.2",
|
||||
"@esbuild/openbsd-x64": "0.28.2",
|
||||
"@esbuild/openharmony-arm64": "0.28.2",
|
||||
"@esbuild/sunos-x64": "0.28.2",
|
||||
"@esbuild/win32-arm64": "0.28.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": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
"hasown": "^2.0.4",
|
||||
"mime-types": "^2.1.35"
|
||||
},
|
||||
"engines": {
|
||||
"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": {
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||
@@ -1130,6 +1197,16 @@
|
||||
"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": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz",
|
||||
|
||||
+10
-3
@@ -12,17 +12,23 @@
|
||||
"import": "./dist/juzhen.mjs",
|
||||
"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": [
|
||||
"dist",
|
||||
"src",
|
||||
"rules",
|
||||
"ARCHITECTURE.md",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"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": [
|
||||
"cjk",
|
||||
@@ -36,7 +42,8 @@
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"esbuild": "^0.24.0",
|
||||
"ajv": "^8.20.0",
|
||||
"esbuild": "^0.28.2",
|
||||
"jsdom": "^25.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
|
||||
@@ -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 必須公開
|
||||
同名選項或未來功能介面已定案。
|
||||
@@ -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" ]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+151
-17
@@ -17,10 +17,10 @@ export type JzTag =
|
||||
| "jz-rb"
|
||||
| "jz-rt";
|
||||
|
||||
// createJz 之 kind:marker(純注入,還原時移除)/wrap(包裝原內容,還原時解包)。
|
||||
// createJz 目前僅有 wrap(包裝原內容,還原時解包)生產者。
|
||||
// data-jz-kind 另有第三種值 "splitoff"(克隆切分之邊界詞元),由 core/split.ts 直接以
|
||||
// setAttribute 設定、不經 createJz;revertPass 於 wrap 全解包後之第二階段併回(見下)。
|
||||
type JzKind = "marker" | "wrap";
|
||||
type JzKind = "wrap";
|
||||
|
||||
export interface CreateOptions
|
||||
{
|
||||
@@ -32,6 +32,50 @@ export interface CreateOptions
|
||||
|
||||
const KIND_ATTR = "data-jz-kind";
|
||||
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
|
||||
{
|
||||
@@ -44,6 +88,7 @@ function doc(): Document
|
||||
|
||||
/** 造一個帶 pass 標記之 jz-* 元素。 */
|
||||
export function createJz(
|
||||
ownedElements: WeakSet<Element>,
|
||||
tag: JzTag,
|
||||
pass: string,
|
||||
kind: JzKind,
|
||||
@@ -51,6 +96,7 @@ export function createJz(
|
||||
): HTMLElement
|
||||
{
|
||||
const el = doc().createElement(tag);
|
||||
ownedElements.add(el);
|
||||
el.setAttribute(PASS_ATTR, pass);
|
||||
el.setAttribute(KIND_ATTR, kind);
|
||||
if (opts.classes)
|
||||
@@ -62,19 +108,111 @@ export function createJz(
|
||||
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 定址,逆文件序處理(先內層後
|
||||
* 外層),marker 移除、wrap 解包,最後 normalize 受影響父節點合併文本。
|
||||
* 外層),wrap 解包,最後 normalize 受影響父節點合併文本。
|
||||
*
|
||||
* splitoff(克隆切分之邊界詞元,見 core/split.ts)須在 wrap 全部解包**之後**才併回:
|
||||
* 否則其前兄弟可能仍是 wrap 殼(如 jz-jinze)而非原元素。故主迴圈跳過 splitoff,於
|
||||
* 解包後第二階段以文件序逐一併回前兄弟(同標籤元素),還原為單一元素(I6 一致)。
|
||||
*/
|
||||
export function revertPass(pass: string, root: ParentNode): void
|
||||
export function revertPass(
|
||||
pass: string,
|
||||
root: ParentNode,
|
||||
ownedElements: WeakSet<Element>,
|
||||
): void
|
||||
{
|
||||
const nodes = Array.from(
|
||||
root.querySelectorAll<HTMLElement>('[' + PASS_ATTR + '="' + pass + '"]'),
|
||||
);
|
||||
).filter((el) => ownedElements.has(el));
|
||||
// 逆序:子元素先於父元素處理,避免解包父後子引用失效。
|
||||
const touched = new Set<Node>();
|
||||
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;
|
||||
if (!parent) { continue; }
|
||||
const kind = el.getAttribute(KIND_ATTR);
|
||||
if (!knownProduct(pass, kind, el)) { continue; }
|
||||
if (kind === "splitoff") { continue; } // 延後階段處理。
|
||||
touched.add(parent);
|
||||
if (kind === "marker")
|
||||
// wrap:把子節點搬出後移除外殼。
|
||||
while (el.firstChild)
|
||||
{
|
||||
parent.removeChild(el);
|
||||
}
|
||||
else
|
||||
{
|
||||
// wrap:把子節點搬出後移除外殼。
|
||||
while (el.firstChild)
|
||||
{
|
||||
parent.insertBefore(el.firstChild, el);
|
||||
}
|
||||
parent.removeChild(el);
|
||||
parent.insertBefore(el.firstChild, el);
|
||||
}
|
||||
parent.removeChild(el);
|
||||
ownedElements.delete(el);
|
||||
}
|
||||
// 第二階段:splitoff 併回(此時 wrap 已全解包,前兄弟即原元素)。文件序,使同一
|
||||
// 原元素之多個 splitoff 依序併入。
|
||||
@@ -105,7 +238,7 @@ export function revertPass(pass: string, root: ParentNode): void
|
||||
root.querySelectorAll<HTMLElement>(
|
||||
'[' + PASS_ATTR + '="' + pass + '"][' + KIND_ATTR + '="splitoff"]',
|
||||
),
|
||||
);
|
||||
).filter((el) => ownedElements.has(el) && knownProduct(pass, "splitoff", el));
|
||||
for (const so of splitoffs)
|
||||
{
|
||||
const parent = so.parentNode;
|
||||
@@ -124,6 +257,7 @@ export function revertPass(pass: string, root: ParentNode): void
|
||||
while (so.firstChild) { parent.insertBefore(so.firstChild, so); }
|
||||
parent.removeChild(so);
|
||||
}
|
||||
ownedElements.delete(so);
|
||||
}
|
||||
for (const p of touched)
|
||||
{
|
||||
|
||||
+52
-12
@@ -19,22 +19,41 @@
|
||||
// 與**物理版面**(typeset/lineedge——依 getClientRects 之物理相鄰,須跨 isolate 找真實鄰接)。
|
||||
|
||||
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
|
||||
{
|
||||
textNode: Text;
|
||||
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
|
||||
{
|
||||
text: string;
|
||||
map: CharPos[];
|
||||
map: CharSpan[];
|
||||
}
|
||||
|
||||
/** pill 鄰接之邏輯鄰字。 */
|
||||
/** pill 鄰接之邏輯鄰字(base+optional-VS)。 */
|
||||
export interface AdjacentChar
|
||||
{
|
||||
textNode: Text;
|
||||
@@ -329,11 +348,11 @@ export class Finder
|
||||
{
|
||||
const runs: LogicalRun[] = [];
|
||||
let curText = "";
|
||||
let curMap: CharPos[] = [];
|
||||
let curMap: CharSpan[] = [];
|
||||
|
||||
const flush = (): void =>
|
||||
{
|
||||
if (curText.length >= 2) { runs.push({ text: curText, map: curMap }); }
|
||||
if (curMap.length >= 2) { runs.push({ text: curText, map: curMap }); }
|
||||
curText = "";
|
||||
curMap = [];
|
||||
};
|
||||
@@ -346,10 +365,30 @@ export class Finder
|
||||
const t = node as Text;
|
||||
if (this.isAvoided(t) || !this.inScope(t)) { return; }
|
||||
const data = t.data;
|
||||
for (let i = 0; i < data.length; i += 1)
|
||||
for (let i = 0; i < data.length;)
|
||||
{
|
||||
curText += data.charAt(i);
|
||||
curMap.push({ textNode: t, offset: i });
|
||||
const ch = variationSequenceAtUtf16(data, 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;
|
||||
}
|
||||
@@ -404,13 +443,14 @@ export class Finder
|
||||
{
|
||||
if (direction === -1)
|
||||
{
|
||||
const offset = previousVariationSequenceStart(tn.data, tn.data.length);
|
||||
return {
|
||||
textNode: tn,
|
||||
offset: tn.data.length - 1,
|
||||
ch: tn.data.charAt(tn.data.length - 1),
|
||||
offset,
|
||||
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/
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ export function classify(lang: string): LangClass
|
||||
{
|
||||
const l = (lang || "").toLowerCase();
|
||||
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"; }
|
||||
|
||||
+9
-3
@@ -58,14 +58,18 @@ function runCharify(passes: CharifyPass[], ctx: RenderContext): void
|
||||
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 互不重疊;防禦性地丟棄與已採用段重疊者。
|
||||
*/
|
||||
function applyRequests(node: Text, requests: WrapRequest[]): void
|
||||
function applyRequests(
|
||||
node: Text,
|
||||
requests: WrapRequest[],
|
||||
ownedElements: WeakSet<Element>,
|
||||
): void
|
||||
{
|
||||
const parent = node.parentNode;
|
||||
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(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;
|
||||
}
|
||||
if (cursor === 0) { return; } // 無有效段。
|
||||
|
||||
+27
-10
@@ -24,6 +24,9 @@
|
||||
// <button>)會令中間 opaque 祖先被逐層克隆肢解——下游 A7 之 .selection-bar-message 病例。
|
||||
|
||||
import type { Finder } from "./finder.js";
|
||||
import {
|
||||
previousVariationSequenceStart, variationSequenceAtUtf16,
|
||||
} from "./unicode.js";
|
||||
|
||||
const PASS_ATTR = "data-jz";
|
||||
const KIND_ATTR = "data-jz-kind";
|
||||
@@ -91,22 +94,32 @@ function isTokenChar(ch: string, anyCjk: RegExp): boolean
|
||||
return !anyCjk.test(ch) && !/\s/.test(ch);
|
||||
}
|
||||
|
||||
/** 文本節點之首詞元結束位置(CJK 字=1;連續非 CJK 非空白=整詞)。 */
|
||||
/** 文本節點之首詞元結束 UTF-16 位置(CJK base+optional-VS=單一邏輯字)。 */
|
||||
function leadingTokenEnd(d: string, anyCjk: RegExp): number
|
||||
{
|
||||
if (anyCjk.test(d.charAt(0))) { return 1; }
|
||||
let e = 1;
|
||||
while (e < d.length && isTokenChar(d.charAt(e), anyCjk)) { e += 1; }
|
||||
const first = variationSequenceAtUtf16(d, 0);
|
||||
if (anyCjk.test(first)) { return first.length; }
|
||||
let e = first.length;
|
||||
while (e < d.length)
|
||||
{
|
||||
const ch = variationSequenceAtUtf16(d, e);
|
||||
if (!isTokenChar(ch, anyCjk)) { break; }
|
||||
e += ch.length;
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
/** 文本節點之末詞元起始位置。 */
|
||||
function trailingTokenStart(d: string, anyCjk: RegExp): number
|
||||
{
|
||||
const last = d.length - 1;
|
||||
if (anyCjk.test(d.charAt(last))) { return last; }
|
||||
let s = last;
|
||||
while (s > 0 && isTokenChar(d.charAt(s - 1), anyCjk)) { s -= 1; }
|
||||
let s = previousVariationSequenceStart(d, d.length);
|
||||
if (anyCjk.test(variationSequenceAtUtf16(d, s))) { return s; }
|
||||
while (s > 0)
|
||||
{
|
||||
const previous = previousVariationSequenceStart(d, s);
|
||||
if (!isTokenChar(variationSequenceAtUtf16(d, previous), anyCjk)) { break; }
|
||||
s = previous;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
@@ -125,6 +138,7 @@ function splitTreeAfter(
|
||||
offset: number,
|
||||
pass: string,
|
||||
finder: Finder,
|
||||
ownedElements: WeakSet<Element>,
|
||||
): Element | null
|
||||
{
|
||||
// 攀升對稱守衛:firstText/lastText 守下潛(遇非可切回 null),但 splitElementAt
|
||||
@@ -168,6 +182,7 @@ function splitTreeAfter(
|
||||
clone.removeAttribute("id");
|
||||
clone.setAttribute(PASS_ATTR, pass);
|
||||
clone.setAttribute(KIND_ATTR, "splitoff");
|
||||
ownedElements.add(clone);
|
||||
let n: Node | null = rightAtLevel;
|
||||
while (n)
|
||||
{
|
||||
@@ -210,6 +225,7 @@ export function isolateBoundaryToken(
|
||||
anyCjk: RegExp,
|
||||
pass: string,
|
||||
finder: Finder,
|
||||
ownedElements: WeakSet<Element>,
|
||||
): Element
|
||||
{
|
||||
if (!canSplit(el, finder)) { return el; }
|
||||
@@ -220,7 +236,7 @@ export function isolateBoundaryToken(
|
||||
? { textNode: text, offset: trailingTokenStart(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。
|
||||
return side === "tail" ? clone : el;
|
||||
}
|
||||
@@ -235,8 +251,9 @@ export function splitElementAt(
|
||||
offset: number,
|
||||
pass: string,
|
||||
finder: Finder,
|
||||
ownedElements: WeakSet<Element>,
|
||||
): Element | null
|
||||
{
|
||||
if (!canSplit(el, finder)) { return null; }
|
||||
return splitTreeAfter(el, textNode, offset, pass, finder);
|
||||
return splitTreeAfter(el, textNode, offset, pass, finder, ownedElements);
|
||||
}
|
||||
|
||||
+81
-2
@@ -5,7 +5,8 @@
|
||||
// 對應 CSS class(jz-char 之 bd-* 標記)。
|
||||
//
|
||||
// 這些常數以「regex 字元類片段」形式給出(已就 RegExp 字元類做必要轉義),
|
||||
// 故可直接 `new RegExp("[" + X + "]")`。
|
||||
// 故可直接 `new RegExp("[" + X + "]", "u")`;補充平面範圍使用 `\u{...}`,
|
||||
// 所有動態拼入 CJK 片段的正則必須保持 Unicode mode。
|
||||
|
||||
import type { LangClass } from "./locale.js";
|
||||
|
||||
@@ -15,8 +16,85 @@ export const CJK = (
|
||||
+ "-ゟ゠-ヺー-ヿ" // 平假名 + 片假名
|
||||
+ "-ㄯ㈀-㋿" // 注音符號 + 圈號 CJK
|
||||
+ "㐀-䶿一-鿿豈-" // 擴展 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";
|
||||
|
||||
@@ -158,9 +236,10 @@ export function classifyBiaodian(
|
||||
biaodian: Record<BdSubclass, string> = DEFAULT_RULESET.biaodian,
|
||||
): string | null
|
||||
{
|
||||
const base = codePointAtUtf16(ch, 0);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,18 @@ jz-hws
|
||||
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,
|
||||
使該行於 justify 下齊右、不內縮 0.25em。 */
|
||||
jz-hws.jz-hws-trim
|
||||
|
||||
+224
-52
@@ -8,6 +8,7 @@
|
||||
// v1 相容層(createCjkAutospace)已隨最後一批 v1 消費端遷移完成而移除。
|
||||
|
||||
import { Finder } from "./core/finder.js";
|
||||
import { revertOwnedAttribute, setOwnedAttribute } from "./core/dom.js";
|
||||
import { runPasses, revertPasses } from "./core/pass.js";
|
||||
import { jiyaAdjacencyPass, jiyaPass } from "./typeset/jiya.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",
|
||||
];
|
||||
|
||||
interface ExpectedClassMutation
|
||||
{
|
||||
target: Element;
|
||||
oldValue: string | null;
|
||||
newValue: string | null;
|
||||
}
|
||||
|
||||
/** 建立一個聚珍實例。 */
|
||||
export function createJuzhen(opts: JuzhenOptions = {}): Juzhen
|
||||
{
|
||||
@@ -196,32 +204,60 @@ export function createJuzhen(opts: JuzhenOptions = {}): Juzhen
|
||||
// 自動清其子樹快取(安全預設),顯式 invalidate 供「改屬性不 revert」之情形。移除之元素由
|
||||
// WeakMap 自動回收。
|
||||
const finder = new Finder(options);
|
||||
const ownedElements = new WeakSet<Element>();
|
||||
const ownerToken = {};
|
||||
const renderedRoots = new Set<Element>();
|
||||
|
||||
function makeCtx(root: Element): RenderContext
|
||||
{
|
||||
return { root, options, finder };
|
||||
return { root, options, finder, ownedElements, ownerToken, setOwnedClass };
|
||||
}
|
||||
|
||||
function renderImpl(el: Element): void
|
||||
{
|
||||
renderedRoots.add(el);
|
||||
runPasses(PASSES, makeCtx(el));
|
||||
// margin 後備模式以 root 屬性作 CSS 開關(見 css/_jiya.css);halt 模式不設。
|
||||
if (options.features.jiya && options.jiyaConfig.halfWidth === "margin")
|
||||
{
|
||||
el.setAttribute("data-jz-halfwidth", "margin");
|
||||
setOwnedAttribute(el, "data-jz-halfwidth", "margin", ownerToken);
|
||||
}
|
||||
// justifyAtoms:false → jz-char/jz-jinze 改 inline(分頁器相容,見 css)。
|
||||
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
|
||||
{
|
||||
revertPasses(PASSES, makeCtx(el));
|
||||
el.removeAttribute("data-jz-halfwidth");
|
||||
el.removeAttribute("data-jz-atoms");
|
||||
const roots = rootsWithin(el);
|
||||
if (roots.length === 0)
|
||||
{
|
||||
// 允許對已 render 根之子樹作局部還原。
|
||||
revertPasses(PASSES, makeCtx(el));
|
||||
}
|
||||
else { unwindRoots(roots); }
|
||||
// 失效(安全預設):清 el 子樹之元素鍵快取,使後續 render 對「屬性已變」之元素重新分類。
|
||||
// 內容變化雖不改分類(此清屬多餘),但廉價且無害,且 render 本就將重掃該子樹。
|
||||
finder.invalidate(el);
|
||||
@@ -230,22 +266,131 @@ export function createJuzhen(opts: JuzhenOptions = {}): Juzhen
|
||||
// ----- C2 觀察狀態 -----
|
||||
let observer: MutationObserver | null = null;
|
||||
let observeRoot: Element | null = null;
|
||||
let observerPauseDepth = 0;
|
||||
let observerActive = false;
|
||||
const expectedClassMutations: ExpectedClassMutation[] = [];
|
||||
let expectedClassMutationHead = 0;
|
||||
const observeConfig: MutationObserverInit = {
|
||||
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 變更不入觀察隊列、絕不自觸發。
|
||||
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
|
||||
{
|
||||
if (observer && observeRoot)
|
||||
if (observerPauseDepth <= 0) { return; }
|
||||
observerPauseDepth -= 1;
|
||||
if (observerPauseDepth === 0 && observer && observeRoot)
|
||||
{
|
||||
observer.takeRecords(); // 丟棄暫停期間之殘留(保險)
|
||||
clearExpectedClassMutations();
|
||||
observer.observe(observeRoot, observeConfig);
|
||||
observerActive = true;
|
||||
}
|
||||
}
|
||||
|
||||
function withObserverPaused<T>(run: () => T): T
|
||||
{
|
||||
pauseObserver();
|
||||
try { return run(); }
|
||||
finally { resumeObserver(); }
|
||||
}
|
||||
|
||||
/** node 之最近塊祖先(含自身若為塊);達 observeRoot 仍無塊 → 回 observeRoot。 */
|
||||
function nearestBlock(node: Node): Element | null
|
||||
{
|
||||
@@ -263,61 +408,80 @@ export function createJuzhen(opts: JuzhenOptions = {}): Juzhen
|
||||
{
|
||||
const rerenderBlocks = new Set<Element>(); // 既有塊:revert+render
|
||||
const renderNew: Element[] = []; // 新增之塊:直接 render(無須 revert)
|
||||
for (const m of records)
|
||||
const removedRoots = new Set<Element>(); // 已脫離之獨立 render 根:先就地清理
|
||||
const classNewValues = classMutationNewValues(records);
|
||||
try
|
||||
{
|
||||
if (m.type === "childList")
|
||||
for (let recordIndex = 0; recordIndex < records.length; recordIndex += 1)
|
||||
{
|
||||
m.addedNodes.forEach((n) =>
|
||||
const m = records[recordIndex]!;
|
||||
// 只消費由 setOwnedClass 預先登記、且 target/oldValue/newValue/順序皆精確吻合的
|
||||
// 內部 class transition。作者或其他實例對同一產物的變更仍走正常增量重排。
|
||||
if (consumeExpectedClassMutation(m, classNewValues[recordIndex])) { continue; }
|
||||
if (m.type === "childList")
|
||||
{
|
||||
if (n.nodeType === 1 && options.finder.blockTags.has(n.nodeName))
|
||||
m.addedNodes.forEach((n) =>
|
||||
{
|
||||
// 新增之塊:**通常**獨立 render(串流追加免全容器重掃)。例外:若其塊級
|
||||
// 父 P 原為**葉塊**(帶 `jz-orphan`——垂懸字為唯一「僅作用於葉塊」之 pass),
|
||||
// 追加塊令 P 轉非葉,全量路徑會撤除 P 之 `jz-orphan`;故此時改重處理 P
|
||||
// (涵蓋新塊),否則增量會殘留陳舊 orphan(IV-3 差分複驗發現)。純塊容器
|
||||
// (無直接內容、無 jz-orphan)不觸此例外、仍最小 render 新塊。
|
||||
const np = n as Element;
|
||||
const P = np.parentElement;
|
||||
const pIsBlock = !!P && options.finder.blockTags.has(P.nodeName);
|
||||
const pHadLeafOrphan = !!P && Array.prototype.some.call(
|
||||
P.children, (c) => (c as Element).nodeName === "JZ-ORPHAN");
|
||||
if (pIsBlock && pHadLeafOrphan) { rerenderBlocks.add(P as Element); }
|
||||
else if (!pIsBlock && P) { const b = nearestBlock(P); if (b) { rerenderBlocks.add(b); } }
|
||||
else { renderNew.push(np); }
|
||||
}
|
||||
else if (n.nodeType === 1 || n.nodeType === 3)
|
||||
if (n.nodeType === 1 && options.finder.blockTags.has(n.nodeName))
|
||||
{
|
||||
// 新增之塊:**通常**獨立 render(串流追加免全容器重掃)。例外:若其塊級
|
||||
// 父 P 原為**葉塊**(帶 `jz-orphan`——垂懸字為唯一「僅作用於葉塊」之 pass),
|
||||
// 追加塊令 P 轉非葉,全量路徑會撤除 P 之 `jz-orphan`;故此時改重處理 P
|
||||
// (涵蓋新塊),否則增量會殘留陳舊 orphan(IV-3 差分複驗發現)。純塊容器
|
||||
// (無直接內容、無 jz-orphan)不觸此例外、仍最小 render 新塊。
|
||||
const np = n as Element;
|
||||
const P = np.parentElement;
|
||||
const pIsBlock = !!P && options.finder.blockTags.has(P.nodeName);
|
||||
const pHadLeafOrphan = !!P && Array.prototype.some.call(
|
||||
P.children, (c) => (c as Element).nodeName === "JZ-ORPHAN");
|
||||
if (pIsBlock && pHadLeafOrphan) { rerenderBlocks.add(P as Element); }
|
||||
else if (!pIsBlock && P) { const b = nearestBlock(P); if (b) { rerenderBlocks.add(b); } }
|
||||
else { renderNew.push(np); }
|
||||
}
|
||||
else if (n.nodeType === 1 || n.nodeType === 3)
|
||||
{
|
||||
// 新增之行內元素/文本 → 所在塊之間隙/禁則邊界變,重處理最近塊。
|
||||
const b = nearestBlock(m.target);
|
||||
if (b) { rerenderBlocks.add(b); }
|
||||
}
|
||||
});
|
||||
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);
|
||||
if (b) { rerenderBlocks.add(b); }
|
||||
}
|
||||
});
|
||||
if (m.removedNodes.length)
|
||||
}
|
||||
else // attributes(白名單)/characterData(文本變化)
|
||||
{
|
||||
const b = nearestBlock(m.target);
|
||||
if (b) { rerenderBlocks.add(b); }
|
||||
}
|
||||
}
|
||||
else // attributes(白名單)/characterData(文本變化)
|
||||
{
|
||||
const b = nearestBlock(m.target);
|
||||
if (b) { rerenderBlocks.add(b); }
|
||||
}
|
||||
}
|
||||
if (!rerenderBlocks.size && !renderNew.length) { return; }
|
||||
finally { compactExpectedClassMutations(); }
|
||||
if (!rerenderBlocks.size && !renderNew.length && !removedRoots.size) { return; }
|
||||
|
||||
pauseObserver();
|
||||
// 去嵌套:只保留極大塊(移除為他者後代者),免重複重處理(重複亦正確、僅浪費)。
|
||||
const all = Array.from(rerenderBlocks).filter((b) => b.isConnected);
|
||||
const blocks = all.filter((b) => !all.some((o) => o !== b && o.contains(b)));
|
||||
for (const b of blocks) { revertImpl(b); renderImpl(b); }
|
||||
for (const el of renderNew)
|
||||
withObserverPaused(() =>
|
||||
{
|
||||
if (!el.isConnected) { continue; } // 同批又被移除
|
||||
if (blocks.some((b) => b.contains(el))) { continue; } // 已由塊重處理涵蓋
|
||||
renderImpl(el);
|
||||
}
|
||||
resumeObserver();
|
||||
// 去嵌套,並在父塊重排之前對脫離子樹做完結構/屬性/佈局清理。
|
||||
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 blocks = all.filter((b) => !all.some((o) => o !== b && o.contains(b)));
|
||||
for (const b of blocks) { revertImpl(b); renderImpl(b); }
|
||||
for (const el of renderNew)
|
||||
{
|
||||
if (!el.isConnected) { continue; } // 同批又被移除
|
||||
if (blocks.some((b) => b.contains(el))) { continue; } // 已由塊重處理涵蓋
|
||||
renderImpl(el);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -326,19 +490,19 @@ export function createJuzhen(opts: JuzhenOptions = {}): Juzhen
|
||||
{
|
||||
const el = resolveRoot(root, options);
|
||||
if (!el) { return; }
|
||||
pauseObserver(); renderImpl(el); resumeObserver();
|
||||
withObserverPaused(() => renderImpl(el));
|
||||
},
|
||||
revert(root?: Element | string): void
|
||||
{
|
||||
const el = resolveRoot(root, options);
|
||||
if (!el) { return; }
|
||||
pauseObserver(); revertImpl(el); resumeObserver();
|
||||
withObserverPaused(() => revertImpl(el));
|
||||
},
|
||||
rerender(root?: Element | string): void
|
||||
{
|
||||
const el = resolveRoot(root, options);
|
||||
if (!el) { return; }
|
||||
pauseObserver(); revertImpl(el); renderImpl(el); resumeObserver();
|
||||
withObserverPaused(() => { revertImpl(el); renderImpl(el); });
|
||||
},
|
||||
invalidate(root?: Element | string): void
|
||||
{
|
||||
@@ -350,14 +514,22 @@ export function createJuzhen(opts: JuzhenOptions = {}): Juzhen
|
||||
if (typeof MutationObserver === "undefined") { return; } // 無版面環境:no-op
|
||||
const el = resolveRoot(root, options);
|
||||
if (!el) { return; }
|
||||
observerActive = false;
|
||||
if (observer) { observer.disconnect(); }
|
||||
clearExpectedClassMutations();
|
||||
observeRoot = el;
|
||||
observer = new MutationObserver(onMutations);
|
||||
observer.observe(el, observeConfig);
|
||||
if (observerPauseDepth === 0)
|
||||
{
|
||||
observer.observe(el, observeConfig);
|
||||
observerActive = true;
|
||||
}
|
||||
},
|
||||
disconnect(): void
|
||||
{
|
||||
observerActive = false;
|
||||
if (observer) { observer.disconnect(); observer = null; observeRoot = null; }
|
||||
clearExpectedClassMutations();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -171,6 +171,12 @@ export interface RenderContext
|
||||
root: Element;
|
||||
options: ResolvedOptions;
|
||||
finder: Finder;
|
||||
/** 當前 createJuzhen 實例造出之元素;DOM 屬性不能作為所有權憑證。 */
|
||||
ownedElements: WeakSet<Element>;
|
||||
/** 實例不可偽造之屬性寫者身分;狀態由 core/dom 共享堆疊管理。 */
|
||||
ownerToken: object;
|
||||
/** 寫入本實例產物之內部 class,並在觀察啟用時留下可精確核對之 mutation provenance。 */
|
||||
setOwnedClass: (element: Element, className: string, present: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+42
-14
@@ -13,6 +13,9 @@
|
||||
import { bdClassOf, createJz, isJz, revertPass } from "../core/dom.js";
|
||||
import type { Finder } from "../core/finder.js";
|
||||
import { isolateBoundaryToken } from "../core/split.js";
|
||||
import {
|
||||
previousVariationSequenceStart, variationSequenceAtUtf16,
|
||||
} from "../core/unicode.js";
|
||||
import type { RenderContext, StandalonePass } from "../types.js";
|
||||
|
||||
const PASS = "jinze";
|
||||
@@ -111,18 +114,28 @@ function isTokenChar(ch: string, anyCjk: RegExp): boolean
|
||||
|
||||
function leadingTokenEnd(d: string, anyCjk: RegExp): number
|
||||
{
|
||||
if (anyCjk.test(d.charAt(0))) { return 1; }
|
||||
let e = 1;
|
||||
while (e < d.length && isTokenChar(d.charAt(e), anyCjk)) { e += 1; }
|
||||
const first = variationSequenceAtUtf16(d, 0);
|
||||
if (anyCjk.test(first)) { return first.length; }
|
||||
let e = first.length;
|
||||
while (e < d.length)
|
||||
{
|
||||
const ch = variationSequenceAtUtf16(d, e);
|
||||
if (!isTokenChar(ch, anyCjk)) { break; }
|
||||
e += ch.length;
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
function trailingTokenStart(d: string, anyCjk: RegExp): number
|
||||
{
|
||||
const last = d.length - 1;
|
||||
if (anyCjk.test(d.charAt(last))) { return last; }
|
||||
let s = last;
|
||||
while (s > 0 && isTokenChar(d.charAt(s - 1), anyCjk)) { s -= 1; }
|
||||
let s = previousVariationSequenceStart(d, d.length);
|
||||
if (anyCjk.test(variationSequenceAtUtf16(d, s))) { return s; }
|
||||
while (s > 0)
|
||||
{
|
||||
const previous = previousVariationSequenceStart(d, s);
|
||||
if (!isTokenChar(variationSequenceAtUtf16(d, previous), anyCjk)) { break; }
|
||||
s = previous;
|
||||
}
|
||||
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 之父
|
||||
// 正是上輪產生之 jz-jinze,若不擋則對已綁定內容再包一層、巢狀增殖(下游審計實測 3→6)。
|
||||
@@ -187,8 +205,14 @@ function processParent(parent: Node, finder: Finder, anyCjk: RegExp): void
|
||||
// isolate/avoid,由 canSplit 諮詢 finder 判定)退化整綁——pill 即「作為原子
|
||||
// 鄰字單位整體綁入 jz-jinze」之期望行為(下游問題六)。
|
||||
const el = u as Element;
|
||||
if (needLast) { isolateBoundaryToken(el, "tail", anyCjk, PASS, finder); }
|
||||
if (needFirst) { isolateBoundaryToken(el, "head", anyCjk, PASS, finder); }
|
||||
if (needLast)
|
||||
{
|
||||
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)
|
||||
{
|
||||
const group = createJz("jz-jinze", PASS, "wrap");
|
||||
const group = createJz(ownedElements, "jz-jinze", PASS, "wrap");
|
||||
parent.insertBefore(group, run[0]!);
|
||||
for (const node of run) { group.appendChild(node); }
|
||||
}
|
||||
@@ -225,10 +249,11 @@ export const jinzePass: StandalonePass = {
|
||||
{
|
||||
const { finder } = ctx;
|
||||
// 詞元邊界之 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>();
|
||||
ctx.root.querySelectorAll("jz-char").forEach((c) =>
|
||||
{
|
||||
if (!ctx.ownedElements.has(c)) { return; }
|
||||
// 功能分級閘(§6.5.0a):禁則為段落級,text-level 子樹(標題/按鈕)跳過。
|
||||
if (!finder.levelAllows(c, "paragraph")) { return; }
|
||||
// 收集 c 之直接父,並沿**透明**元素邊緣上攀:c 若位於某透明元素之邊緣,該元素
|
||||
@@ -248,10 +273,13 @@ export const jinzePass: StandalonePass = {
|
||||
});
|
||||
// 深者先處理:透明子先於可能將其切分之外層 parent,避免子之內部綁定被外層切分擾動。
|
||||
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
|
||||
{
|
||||
revertPass(PASS, ctx.root);
|
||||
revertPass(PASS, ctx.root, ctx.ownedElements);
|
||||
},
|
||||
};
|
||||
|
||||
+17
-9
@@ -16,7 +16,9 @@
|
||||
import { bdClassOf, createJz, revertPass } from "../core/dom.js";
|
||||
import { classify, effectiveLang, resolveStyle } from "../core/locale.js";
|
||||
import type { LangClass, PunctStyle } from "../core/locale.js";
|
||||
import { blankSides, classifyBiaodian, inkBias } from "../core/unicode.js";
|
||||
import {
|
||||
blankSides, classifyBiaodian, codePointAtUtf16, inkBias, variationSequenceAtUtf16,
|
||||
} from "../core/unicode.js";
|
||||
import type { CharifyPass, RenderContext, StandalonePass, WrapRequest } from "../types.js";
|
||||
|
||||
const PASS = "jiya";
|
||||
@@ -69,11 +71,11 @@ export const jiyaPass: CharifyPass = {
|
||||
const biasOverrides = ctx.options.jiyaConfig.bias;
|
||||
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);
|
||||
if (!bdClass) { continue; }
|
||||
if (!bdClass) { i += ch.length; continue; }
|
||||
if (style === null) { style = resolveStyle(node, ctx.options.locale); }
|
||||
if (langClass === null)
|
||||
{
|
||||
@@ -83,26 +85,31 @@ export const jiyaPass: CharifyPass = {
|
||||
// 字形偏側 class(jz-ink-*)— 供 margin 後備模式收對側半形空白;halt 模式
|
||||
// 之 CSS 不讀,無害。一律於 charify 標記(偏側為字形固有屬性,與何時擠壓
|
||||
// 無關),鄰接/行端 pass 後續加 jz-half 即可沿用。
|
||||
const bias = inkBias(ch, langClass, biasOverrides);
|
||||
const bias = inkBias(codePointAtUtf16(ch, 0), langClass, biasOverrides);
|
||||
if (bias) { classes.push("jz-ink-" + bias); }
|
||||
out.push({
|
||||
start: i,
|
||||
end: i + 1,
|
||||
end: i + ch.length,
|
||||
pass: PASS,
|
||||
build: (text) =>
|
||||
{
|
||||
const outer = createJz("jz-char", PASS, "wrap", { classes });
|
||||
const inner = createJz("jz-inner", PASS, "wrap", { text });
|
||||
const outer = createJz(
|
||||
ctx.ownedElements, "jz-char", PASS, "wrap", { classes },
|
||||
);
|
||||
const inner = createJz(
|
||||
ctx.ownedElements, "jz-inner", PASS, "wrap", { text },
|
||||
);
|
||||
outer.appendChild(inner);
|
||||
return outer;
|
||||
},
|
||||
});
|
||||
i += ch.length;
|
||||
}
|
||||
return out;
|
||||
},
|
||||
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;
|
||||
if (nm === "JZ-CHAR")
|
||||
{
|
||||
if (!ctx.ownedElements.has(el)) { prev = null; continue; }
|
||||
if (prev) { squeeze(prev, el); }
|
||||
prev = el; // jz-char 為原子,不下探(內含 jz-inner)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
// order=10:standalone 群之首(於 charify 群後執行;charify 之 jz-* 包裹皆行內
|
||||
// 元素,不改變「文本之最近塊祖先」,故時點無影響)。
|
||||
|
||||
import { revertOwnedAttributes, setOwnedAttribute } from "../core/dom.js";
|
||||
import type { RenderContext, StandalonePass } from "../types.js";
|
||||
|
||||
const ATTR = "data-jz-block-level";
|
||||
@@ -65,13 +66,11 @@ export const levelMarkPass: StandalonePass = {
|
||||
const block = p ? nearestBlock(p) : null;
|
||||
if (!block) { return; }
|
||||
const level = finder.levelFor(block);
|
||||
if (block.getAttribute(ATTR) !== level) { block.setAttribute(ATTR, level); }
|
||||
setOwnedAttribute(block, ATTR, level, ctx.ownerToken);
|
||||
});
|
||||
},
|
||||
revert(ctx: RenderContext): void
|
||||
{
|
||||
if (ctx.root.hasAttribute(ATTR)) { ctx.root.removeAttribute(ATTR); }
|
||||
ctx.root.querySelectorAll("[" + ATTR + "]")
|
||||
.forEach((el) => el.removeAttribute(ATTR));
|
||||
revertOwnedAttributes(ctx.root, ATTR, ctx.ownerToken);
|
||||
},
|
||||
};
|
||||
|
||||
+62
-31
@@ -13,6 +13,9 @@
|
||||
// Chrome/Safari/Firefox 目視核對。
|
||||
|
||||
import { bdClassOf } from "../core/dom.js";
|
||||
import {
|
||||
previousVariationSequenceStart, variationSequenceAtUtf16,
|
||||
} from "../core/unicode.js";
|
||||
import type { Finder } from "../core/finder.js";
|
||||
import type { RenderContext, ResolvedOptions, StandalonePass } from "../types.js";
|
||||
|
||||
@@ -33,7 +36,10 @@ const TAIL_CLASSES = new Set([ "bd-close" ]);
|
||||
// 建,於每次 resize 皆先派發——確保 gapTrim 之行末去隙判定恆基於 lineEdge **已套用**之半形
|
||||
// 版面(非陳舊態)。**勿調換兩 pass 之 order 或創建序**,否則 gapTrim 可能以 resize 前之半形
|
||||
// 狀態誤判行末(且 ResizeObserver 只觀 root 尺寸、不因內部 class 變化重觸發,錯誤會持續)。
|
||||
const observers = new WeakMap<Element, Map<string, ResizeObserver>>();
|
||||
const observers = new WeakMap<
|
||||
Element,
|
||||
Map<object, Map<string, ResizeObserver>>
|
||||
>();
|
||||
|
||||
function hasLayout(): boolean
|
||||
{
|
||||
@@ -41,11 +47,18 @@ function hasLayout(): boolean
|
||||
}
|
||||
|
||||
/** 安裝一個 layout 重算:立即跑一次,掛 ResizeObserver,字型載入後再跑。 */
|
||||
function installLayout(root: Element, key: string, run: () => void): void
|
||||
function installLayout(
|
||||
root: Element,
|
||||
owner: object,
|
||||
key: string,
|
||||
run: () => void,
|
||||
): void
|
||||
{
|
||||
run();
|
||||
let m = observers.get(root);
|
||||
if (!m) { m = new Map(); observers.set(root, m); }
|
||||
let owners = observers.get(root);
|
||||
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);
|
||||
if (existing) { existing.disconnect(); }
|
||||
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; }
|
||||
const ro = m.get(key);
|
||||
if (ro) { ro.disconnect(); m.delete(key); }
|
||||
if (m.size === 0) { owners!.delete(owner); }
|
||||
}
|
||||
|
||||
// 行內包裹集:edgeRect 攀爬時須跨越「不中斷行內流」之元素找相鄰內容(jz-char 可能被
|
||||
@@ -89,9 +104,10 @@ function rectOf(n: Node, side: "prev" | "next"): DOMRect | null
|
||||
const doc = t.ownerDocument;
|
||||
if (!doc) { return null; }
|
||||
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.setEnd(t, at + 1);
|
||||
range.setEnd(t, at + length);
|
||||
const rects = range.getClientRects();
|
||||
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;
|
||||
}
|
||||
|
||||
function clearEdge(root: Element): void
|
||||
function clearEdge(ctx: RenderContext): void
|
||||
{
|
||||
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
|
||||
* class 致下游量測偏移,同 gapTrim 之雙向污染教訓)。ResizeObserver 僅觀 root 尺寸、不因
|
||||
* 套用後內部重排再觸發,故終態穩定。 */
|
||||
function relayout(root: Element, finder: Finder, options: ResolvedOptions): void
|
||||
function relayout(ctx: RenderContext): void
|
||||
{
|
||||
clearEdge(root);
|
||||
const wrapSet = buildWrapSet(options);
|
||||
clearEdge(ctx);
|
||||
const wrapSet = buildWrapSet(ctx.options);
|
||||
|
||||
const chars = Array.from(root.querySelectorAll("jz-char")).filter(
|
||||
(c) => finder.inScope(c) && !finder.isAvoided(c),
|
||||
const chars = Array.from(ctx.root.querySelectorAll("jz-char")).filter(
|
||||
(c) => ctx.ownedElements.has(c) && ctx.finder.inScope(c) && !ctx.finder.isAvoided(c),
|
||||
);
|
||||
|
||||
const decisions: EdgeDecision[] = [];
|
||||
for (const el of chars)
|
||||
{
|
||||
// 行端為段落級(§6.5.0a):text-level 子樹(單行標題等)不處理行端。
|
||||
if (!finder.levelAllows(el, "paragraph")) { continue; }
|
||||
if (!finder.featureEnabledFor(el, "jiya")) { continue; }
|
||||
if (!ctx.finder.levelAllows(el, "paragraph")) { continue; }
|
||||
if (!ctx.finder.featureEnabledFor(el, "jiya")) { continue; }
|
||||
const bd = bdClassOf(el);
|
||||
if (!bd) { continue; }
|
||||
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 = {
|
||||
@@ -231,12 +250,15 @@ export const lineEdgePass: StandalonePass = {
|
||||
render(ctx: RenderContext): void
|
||||
{
|
||||
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
|
||||
{
|
||||
uninstallLayout(ctx.root, "lineEdge");
|
||||
clearEdge(ctx.root);
|
||||
uninstallLayout(ctx.root, ctx.ownerToken, "lineEdge");
|
||||
clearEdge(ctx);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -275,13 +297,16 @@ function skippableAlign(g: Element, cache: Map<Element, boolean>): boolean
|
||||
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 gaps = Array.from(root.querySelectorAll("jz-hws")).filter(
|
||||
(g) => finder.inScope(g) && !finder.isAvoided(g)
|
||||
&& finder.levelAllows(g, "paragraph")
|
||||
&& finder.featureEnabledFor(g, "spacing")
|
||||
const gaps = Array.from(ctx.root.querySelectorAll("jz-hws")).filter(
|
||||
(g) => ctx.ownedElements.has(g) && ctx.finder.inScope(g) && !ctx.finder.isAvoided(g)
|
||||
&& ctx.finder.levelAllows(g, "paragraph")
|
||||
&& ctx.finder.featureEnabledFor(g, "spacing")
|
||||
&& !skippableAlign(g, alignCache),
|
||||
);
|
||||
if (!gaps.length) { return; }
|
||||
@@ -301,13 +326,13 @@ function trimGaps(root: Element, finder: Finder, wrapSet: Set<string>): void
|
||||
// 行末 → 還原間隙。逐一(非一次量全部)必要:斷行為左→右因果,當前間隙之行起點取決
|
||||
// 於其前已定案內容;讀取 getClientRects 觸發同步重排,故每次量測皆為最新佈局。以末行
|
||||
// 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)
|
||||
{
|
||||
const r = lastRect(g);
|
||||
const next = r ? edgeRect(g.nextSibling, "next", g, wrapSet) : null;
|
||||
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; }
|
||||
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
|
||||
{
|
||||
uninstallLayout(ctx.root, "gapTrim");
|
||||
ctx.root.querySelectorAll("jz-hws." + TRIM).forEach((e) => e.classList.remove(TRIM));
|
||||
uninstallLayout(ctx.root, ctx.ownerToken, "gapTrim");
|
||||
ctx.root.querySelectorAll("jz-hws." + TRIM).forEach((e) =>
|
||||
{
|
||||
if (ctx.ownedElements.has(e)) { ctx.setOwnedClass(e, TRIM, false); }
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -50,6 +50,7 @@ export const longWordPass: CharifyPass = {
|
||||
span.setAttribute("data-jz", PASS);
|
||||
span.setAttribute("data-jz-kind", "wrap");
|
||||
span.textContent = text;
|
||||
ctx.ownedElements.add(span);
|
||||
return span;
|
||||
},
|
||||
});
|
||||
@@ -58,6 +59,6 @@ export const longWordPass: CharifyPass = {
|
||||
},
|
||||
revert(ctx: RenderContext): void
|
||||
{
|
||||
revertPass(PASS, ctx.root);
|
||||
revertPass(PASS, ctx.root, ctx.ownedElements);
|
||||
},
|
||||
};
|
||||
|
||||
+23
-8
@@ -15,7 +15,7 @@
|
||||
import { createJz, revertPass } from "../core/dom.js";
|
||||
import type { Finder } from "../core/finder.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";
|
||||
|
||||
type Biaodian = Ruleset["biaodian"];
|
||||
@@ -57,14 +57,15 @@ function collectSubst(
|
||||
{
|
||||
const t = node as Text;
|
||||
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 字集且非標點(標點/符號不計)。
|
||||
if (anyCjk.test(ch) && !classifyBiaodian(ch, biaodian))
|
||||
{
|
||||
out.push({ directChild, textNode: t, offset: i });
|
||||
}
|
||||
i += ch.length;
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -88,6 +89,7 @@ function processBlock(
|
||||
anyCjk: RegExp,
|
||||
biaodian: Biaodian,
|
||||
chars: number,
|
||||
ownedElements: WeakSet<Element>,
|
||||
): void
|
||||
{
|
||||
if (hasOrphan(block)) { return; }
|
||||
@@ -113,13 +115,18 @@ function processBlock(
|
||||
else if (boundary.textNode && startChild.nodeType === 1)
|
||||
{
|
||||
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; }
|
||||
}
|
||||
|
||||
// 把 startChild 及其後全部直接子搬入 jz-orphan。
|
||||
const orphan = createJz("jz-orphan", PASS, "wrap");
|
||||
const orphan = createJz(ownedElements, "jz-orphan", PASS, "wrap");
|
||||
block.insertBefore(orphan, startChild);
|
||||
let n: Node | null = startChild;
|
||||
while (n)
|
||||
@@ -140,7 +147,7 @@ export const orphanPass: StandalonePass = {
|
||||
{
|
||||
const { finder, options } = ctx;
|
||||
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 chars = options.orphan.chars;
|
||||
|
||||
@@ -172,11 +179,19 @@ export const orphanPass: StandalonePass = {
|
||||
if (finder.isAvoided(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
|
||||
{
|
||||
revertPass(PASS, ctx.root);
|
||||
revertPass(PASS, ctx.root, ctx.ownedElements);
|
||||
},
|
||||
};
|
||||
|
||||
+490
-74
@@ -7,9 +7,12 @@
|
||||
// 涵蓋 v1 Pass A(跨樣式邊界間隙)與 Pass B(pill code/kbd 邊界間隙)。
|
||||
// 規則正則為間隙專用,故與本 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 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";
|
||||
|
||||
const PASS = "spacing";
|
||||
@@ -64,38 +67,46 @@ function makeRules(rs: Ruleset): Rules
|
||||
const CJK = rs.cjk;
|
||||
const ANS_AFTER = rs.ansAfter;
|
||||
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 {
|
||||
anyCjk: new RegExp("[" + CJK + "]"),
|
||||
pillNeighbor: new RegExp("[" + ALNUM + CJK + "]"),
|
||||
STRIP_CJK_SPACE_ANS: new RegExp("([" + CJK + "]) ([" + ANS_AFTER + "])", "g"),
|
||||
STRIP_ANS_SPACE_CJK: new RegExp("([" + ANS_BEFORE + "]) ([" + CJK + "])", "g"),
|
||||
STRIP_CJK_SPACE_CJK: new RegExp("([" + CJK + "]) (?=[" + CJK + "])", "g"),
|
||||
DOTS_CJK: new RegExp("([\\.]{2,})([" + CJK + "])", "g"),
|
||||
CJK_PUNCTUATION: new RegExp("([" + CJK + "])([!;,\\?:]+)(?=[" + CJK + ALNUM + "])", "g"),
|
||||
AN_PUNCTUATION_CJK: new RegExp("([" + ALNUM + "])([!;,\\?]+)([" + CJK + "])", "g"),
|
||||
CJK_TILDE: new RegExp("([" + CJK + "])(~+)(?!=)(?=[" + CJK + ALNUM + "])", "g"),
|
||||
CJK_TILDE_EQUALS: new RegExp("([" + CJK + "])(~=)", "g"),
|
||||
CJK_PERIOD: new RegExp("([" + CJK + "])(\\.)(?![" + ALNUM + "\\./])(?=[" + CJK + ALNUM + "])", "g"),
|
||||
AN_PERIOD_CJK: new RegExp("([" + ALNUM + "])(\\.)([" + CJK + "])", "g"),
|
||||
AN_COLON_CJK: new RegExp("([" + ALNUM + "])(:)([" + CJK + "])", "g"),
|
||||
CJK_QUOTE: new RegExp("([" + CJK + "])([" + QUOTES_CLS + "])", "g"),
|
||||
QUOTE_CJK: new RegExp("([" + QUOTES_CLS + "])([" + CJK + "])", "g"),
|
||||
CJK_QUOTE_AN: new RegExp("([" + CJK + "])(\")([" + ALNUM + "])", "g"),
|
||||
CJK_HASH: new RegExp("([" + CJK + "])(#([^ ]))", "g"),
|
||||
HASH_CJK: new RegExp("(([^ ])#)([" + CJK + "])", "g"),
|
||||
SINGLE_LETTER_GRADE: new RegExp("\\b([" + ALPHA + "])([" + OPS_GRADE + "])([" + CJK + "])", "g"),
|
||||
CJK_OPERATOR_ANS: new RegExp("([" + CJK + "])([" + OPS_HYPHEN + "])([" + ALNUM + "])", "g"),
|
||||
ANS_OPERATOR_CJK: new RegExp("([" + ALNUM + "])([" + OPS_HYPHEN + "])([" + CJK + "])", "g"),
|
||||
CJK_LESS_THAN: new RegExp("([" + CJK + "])(<)([" + ALNUM + "])", "g"),
|
||||
LESS_THAN_CJK: new RegExp("([" + ALNUM + "])(<)([" + CJK + "])", "g"),
|
||||
CJK_GREATER_THAN: new RegExp("([" + CJK + "])(>)([" + ALNUM + "])", "g"),
|
||||
GREATER_THAN_CJK: new RegExp("([" + ALNUM + "])(>)([" + CJK + "])", "g"),
|
||||
CJK_LEFT_BRACKET: new RegExp("([" + CJK + "])([" + LBR_EXT + "])", "g"),
|
||||
RIGHT_BRACKET_CJK: new RegExp("([" + RBR_EXT + "])([" + CJK + "])", "g"),
|
||||
AN_LEFT_BRACKET: new RegExp("([" + ALNUM + "])(?<!\\.[" + ALNUM + "]*)([" + LBR_BASIC + "])", "g"),
|
||||
anyCjk: new RegExp(CJK_ATOM, "u"),
|
||||
pillNeighbor: new RegExp("^(?:[" + ALNUM + "]|" + CJK_ATOM + ")$", "u"),
|
||||
STRIP_CJK_SPACE_ANS: new RegExp("(" + CJK_ATOM + ") ([" + ANS_AFTER + "])", "gu"),
|
||||
STRIP_ANS_SPACE_CJK: new RegExp("([" + ANS_BEFORE + "]) (" + CJK_ATOM + ")", "gu"),
|
||||
STRIP_CJK_SPACE_CJK: new RegExp("(" + CJK_ATOM + ") (?=" + CJK_ATOM + ")", "gu"),
|
||||
DOTS_CJK: new RegExp("([\\.]{2,})(" + CJK_ATOM + ")", "gu"),
|
||||
CJK_PUNCTUATION: new RegExp("(" + CJK_ATOM + ")([!;,\\?:]+)(?=" + CJK_OR_ALNUM + ")", "gu"),
|
||||
AN_PUNCTUATION_CJK: new RegExp("([" + ALNUM + "])([!;,\\?]+)(" + CJK_ATOM + ")", "gu"),
|
||||
CJK_TILDE: new RegExp("(" + CJK_ATOM + ")(~+)(?!=)(?=" + CJK_OR_ALNUM + ")", "gu"),
|
||||
CJK_TILDE_EQUALS: new RegExp("(" + CJK_ATOM + ")(~=)", "gu"),
|
||||
CJK_PERIOD: new RegExp("(" + CJK_ATOM + ")(\\.)(?![" + ALNUM + "\\./])(?=" + CJK_OR_ALNUM + ")", "gu"),
|
||||
AN_PERIOD_CJK: new RegExp("([" + ALNUM + "])(\\.)(" + CJK_ATOM + ")", "gu"),
|
||||
AN_COLON_CJK: new RegExp("([" + ALNUM + "])(:)(" + CJK_ATOM + ")", "gu"),
|
||||
CJK_QUOTE: new RegExp("(" + CJK_ATOM + ")([" + QUOTES_CLS + "])", "gu"),
|
||||
QUOTE_CJK: new RegExp("([" + QUOTES_CLS + "])(" + CJK_ATOM + ")", "gu"),
|
||||
CJK_QUOTE_AN: new RegExp("(" + CJK_ATOM + ")(\")([" + ALNUM + "])", "gu"),
|
||||
CJK_HASH: new RegExp("(" + CJK_ATOM + ")(#([^ ]))", "gu"),
|
||||
HASH_CJK: new RegExp("(([^ ])#)(" + CJK_ATOM + ")", "gu"),
|
||||
SINGLE_LETTER_GRADE: new RegExp("\\b([" + ALPHA + "])([" + OPS_GRADE + "])(" + CJK_ATOM + ")", "gu"),
|
||||
CJK_OPERATOR_ANS: new RegExp("(" + CJK_ATOM + ")([" + OPS_HYPHEN + "])([" + ALNUM + "])", "gu"),
|
||||
ANS_OPERATOR_CJK: new RegExp("([" + ALNUM + "])([" + OPS_HYPHEN + "])(" + CJK_ATOM + ")", "gu"),
|
||||
CJK_LESS_THAN: new RegExp("(" + CJK_ATOM + ")(<)([" + ALNUM + "])", "gu"),
|
||||
LESS_THAN_CJK: new RegExp("([" + ALNUM + "])(<)(" + CJK_ATOM + ")", "gu"),
|
||||
CJK_GREATER_THAN: new RegExp("(" + CJK_ATOM + ")(>)([" + ALNUM + "])", "gu"),
|
||||
GREATER_THAN_CJK: new RegExp("([" + ALNUM + "])(>)(" + CJK_ATOM + ")", "gu"),
|
||||
CJK_LEFT_BRACKET: new RegExp("(" + CJK_ATOM + ")([" + LBR_EXT + "])", "gu"),
|
||||
RIGHT_BRACKET_CJK: new RegExp("([" + RBR_EXT + "])(" + CJK_ATOM + ")", "gu"),
|
||||
// 英數字串段前之字元一併匹配,才能在不用 lookbehind 下排除 `.foo(`。
|
||||
// 括號只作 lookahead,避免 `a(b(` 之前一次匹配吞掉 `(` 而漏掉第二處。
|
||||
AN_LEFT_BRACKET: new RegExp(
|
||||
"(^|[^." + ALNUM + "])([" + ALNUM + "]+)(?=[" + LBR_BASIC + "])",
|
||||
"g",
|
||||
),
|
||||
RIGHT_BRACKET_AN: new RegExp("([" + RBR_BASIC + "])([" + ALNUM + "])", "g"),
|
||||
CJK_ANS: new RegExp("([" + CJK + "])([" + ANS_AFTER + "])", "g"),
|
||||
ANS_CJK: new RegExp("([" + ANS_BEFORE + "])([" + CJK + "])", "g"),
|
||||
CJK_ANS: new RegExp("(" + CJK_ATOM + ")([" + ANS_AFTER + "])", "gu"),
|
||||
ANS_CJK: new RegExp("([" + ANS_BEFORE + "])(" + CJK_ATOM + ")", "gu"),
|
||||
S_A: new RegExp("(%)([" + ALPHA + "])", "g"),
|
||||
};
|
||||
}
|
||||
@@ -104,10 +115,30 @@ function makeRules(rs: Ruleset): Rules
|
||||
// 過後、括號 / 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 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
|
||||
{
|
||||
if (text.length <= 1 || !R.anyCjk.test(text)) { return text; }
|
||||
if (splitVariationSequences(text).length <= 1 || !R.anyCjk.test(text)) { return text; }
|
||||
|
||||
let s = text;
|
||||
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.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.CJK_ANS, "$1" + MARK + "$2");
|
||||
@@ -162,59 +193,403 @@ function panguSpace(text: string, R: Rules): string
|
||||
|
||||
interface SpaceAction
|
||||
{
|
||||
before: { textNode: Text; offset: number } | undefined;
|
||||
space: { textNode: Text; offset: number } | null;
|
||||
before: CharSpan | undefined;
|
||||
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 之
|
||||
// margin-right 提供。不注入空白字元:故 justify 不把間隙當詞間空白放大(邊界
|
||||
// 只吃均勻字間量),margin 尾隨左側內容(換行時落行尾、永不落行首),且
|
||||
// textContent 完全不變(內容仍在)。冪等:已在 jz-hws 內則跳過。
|
||||
function wrapNodeInGap(node: Node): void
|
||||
function wrapNodeInGap(node: Node, ownedElements: WeakSet<Element>): void
|
||||
{
|
||||
const parent = node.parentNode;
|
||||
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);
|
||||
gap.appendChild(node);
|
||||
}
|
||||
|
||||
// 隔離 pos 處之單一字元並包入 jz-hws(左側字 + margin-right 留隙)。
|
||||
function wrapGapLeft(pos: { textNode: Text; offset: number }): void
|
||||
// 隔離 pos 處之 base+optional-VS 並包入 jz-hws(左側字 + margin-right 留隙)。
|
||||
function isolateTextSpan(part: TextSpan): Text | null
|
||||
{
|
||||
let tn = pos.textNode;
|
||||
if (!tn.parentNode || tn.parentNode.nodeName === "JZ-HWS") { return; }
|
||||
if (pos.offset > 0) { tn = tn.splitText(pos.offset); }
|
||||
if (tn.data.length > 1) { tn.splitText(1); }
|
||||
wrapNodeInGap(tn);
|
||||
let tn = part.textNode;
|
||||
if (!tn.parentNode) { return null; }
|
||||
if (part.start > 0) { tn = tn.splitText(part.start); }
|
||||
const length = part.end - part.start;
|
||||
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);
|
||||
for (const run of runs)
|
||||
{
|
||||
const { text, map } = run;
|
||||
const spaced = panguSpace(text, R);
|
||||
if (spaced === text) { continue; }
|
||||
const { map } = run;
|
||||
const originalChars = map.map((span) => span.text);
|
||||
const ruleText = shadowAstralCjkAtoms(originalChars, R);
|
||||
const spaced = panguSpace(ruleText, R);
|
||||
if (spaced === ruleText) { continue; }
|
||||
|
||||
const actions: SpaceAction[] = [];
|
||||
let origIdx = 0;
|
||||
let spacedIdx = 0;
|
||||
while (spacedIdx < spaced.length)
|
||||
for (const sc of splitVariationSequences(spaced))
|
||||
{
|
||||
const sc = spaced.charAt(spacedIdx);
|
||||
if (sc === DEL)
|
||||
{
|
||||
// CJK↔CJK 贅餘空格:純刪除、不留隙(before 留空)。
|
||||
actions.push({ before: undefined, space: map[origIdx]! });
|
||||
origIdx += 1;
|
||||
spacedIdx += 1;
|
||||
continue;
|
||||
}
|
||||
if (sc === MARK)
|
||||
{
|
||||
const oc = origIdx < text.length ? text.charAt(origIdx) : null;
|
||||
const oc = origIdx < originalChars.length ? originalChars[origIdx]! : null;
|
||||
if (oc === " ")
|
||||
{
|
||||
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 });
|
||||
}
|
||||
spacedIdx += 1;
|
||||
continue;
|
||||
}
|
||||
origIdx += 1;
|
||||
spacedIdx += 1;
|
||||
}
|
||||
|
||||
// 倒序執行,避免前面動作之 offset 被後面變更影響。先刪空格(若有)、再包左字
|
||||
@@ -238,17 +611,26 @@ function processBlock(block: Element, finder: Finder, R: Rules): void
|
||||
const act = actions[i]!;
|
||||
if (act.space)
|
||||
{
|
||||
const tn = act.space.textNode;
|
||||
tn.data = tn.data.slice(0, act.space.offset) + tn.data.slice(act.space.offset + 1);
|
||||
deleteSpan(act.space);
|
||||
}
|
||||
if (act.before)
|
||||
{
|
||||
wrapGapLeft(act.before, ownedElements, ownerToken, block, scopeRoot);
|
||||
}
|
||||
if (act.before) { wrapGapLeft(act.before); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// boundarySelector = pill(整體)∪ isolate(隔離):兩端補隙之口徑一致(§4.1
|
||||
// 三分類);差異僅在內部(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) =>
|
||||
{
|
||||
@@ -264,32 +646,57 @@ function processPills(root: Element, finder: Finder, boundarySelector: string, p
|
||||
if (!pill.parentNode) { return; }
|
||||
|
||||
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);
|
||||
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:間隙在 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 (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;
|
||||
}
|
||||
if (adj.ch === " " && adj.offset >= 1)
|
||||
{
|
||||
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))
|
||||
{
|
||||
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))
|
||||
{
|
||||
wrapNodeInGap(pill);
|
||||
wrapNodeInGap(pill, ownedElements);
|
||||
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))
|
||||
{
|
||||
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) =>
|
||||
{
|
||||
if (!finder.featureEnabledFor(block, PASS)) { return; }
|
||||
processBlock(block, finder, R);
|
||||
processBlock(block, ctx.root, finder, R, ctx.ownedElements, ctx.ownerToken);
|
||||
});
|
||||
// 文本級功能須支持單元素(§6.5.0a):root 為行內/單一非塊元素(如消費端對
|
||||
// <span> 標題呼叫 render)時 eachBlock 不含之,補處理 root 自身(collectRuns
|
||||
@@ -336,7 +743,7 @@ export const spacingPass: StandalonePass = {
|
||||
&& finder.featureEnabledFor(ctx.root, PASS)
|
||||
&& 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 邊界。
|
||||
const boundarySelector = [ options.finder.pillSelector, options.finder.isolateSelector ]
|
||||
@@ -344,11 +751,20 @@ export const spacingPass: StandalonePass = {
|
||||
.join(", ");
|
||||
if (boundarySelector)
|
||||
{
|
||||
processPills(ctx.root, finder, boundarySelector, R.pillNeighbor);
|
||||
processPills(
|
||||
ctx.root,
|
||||
finder,
|
||||
boundarySelector,
|
||||
R.pillNeighbor,
|
||||
ctx.ownedElements,
|
||||
ctx.ownerToken,
|
||||
);
|
||||
}
|
||||
},
|
||||
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?.(); }
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
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);
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
// 在新引擎落地(回應使用者提問)。
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { setupDom, countTag } from "./helpers.mjs";
|
||||
@@ -78,3 +79,42 @@ test("間隔號:姓名 · 兩側不插 jz-hws(· 屬 CJK 標點,非西文
|
||||
createJuzhen(SPACING_ONLY).render(document.body);
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user