diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 16f57cd..a61fd33 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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**(要間隙與標點半形、不要避孤字/避頭尾); 正文 `

` 預設全功能。 **實現**: @@ -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 — 未實作之未來設計 -- **我們**:基於原生 `` + `jz-ruby/jz-rt` 處理注音符號直排與聲調定位。 -- **預留**:獨立 pass,`order=80`;不影響其餘。 +- **擬議方向**:未來可基於原生 `` 評估注音符號直排與聲調定位;`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` | `` + `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`,原生 ` + + + diff --git a/dist/core/dom.d.ts b/dist/core/dom.d.ts index 1ed9fab..af29240 100644 --- a/dist/core/dom.d.ts +++ b/dist/core/dom.d.ts @@ -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, 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): void; /** 讀取 jz-char 之 bd-* 子類(無則 null)。 */ export declare function bdClassOf(el: Element): string | null; /** 該元素是否為某/任一 pass 之 jz 產物。 */ diff --git a/dist/core/finder.d.ts b/dist/core/finder.d.ts index 454906f..21dae68 100644 --- a/dist/core/finder.d.ts +++ b/dist/core/finder.d.ts @@ -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; diff --git a/dist/core/split.d.ts b/dist/core/split.d.ts index 75dbd41..385108a 100644 --- a/dist/core/split.d.ts +++ b/dist/core/split.d.ts @@ -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; /** * 把 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 | null; diff --git a/dist/core/unicode.d.ts b/dist/core/unicode.d.ts index eb2f540..87aa1de 100644 --- a/dist/core/unicode.d.ts +++ b/dist/core/unicode.d.ts @@ -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"; /** 西文字母。 */ diff --git a/dist/juzhen.css b/dist/juzhen.css index d342cda..0d8e908 100644 --- a/dist/juzhen.css +++ b/dist/juzhen.css @@ -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; } diff --git a/dist/juzhen.iife.js b/dist/juzhen.iife.js index 4056358..1ba2747 100644 --- a/dist/juzhen.iife.js +++ b/dist/juzhen.iife.js @@ -25,6 +25,185 @@ var Juzhen = (() => { normalizeOptions: () => normalizeOptions }); + // src/core/unicode.ts + var CJK = "\u2E80-\u2EFF\u2F00-\u2FDF\u3040-\u309F\u30A0-\u30FA\u30FC-\u30FF\u3100-\u312F\u3200-\u32FF\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\\u{20000}-\\u{2A6DF}\\u{2A700}-\\u{2B73F}\\u{2B740}-\\u{2B81F}\\u{2B820}-\\u{2CEAF}\\u{2CEB0}-\\u{2EBEF}\\u{2EBF0}-\\u{2EE5F}\\u{2F800}-\\u{2FA1F}\\u{30000}-\\u{3134F}\\u{31350}-\\u{323AF}\\u{323B0}-\\u{3347F}"; + var VARIATION_SELECTORS = "\\uFE00-\\uFE0F\\u{E0100}-\\u{E01EF}"; + function codePointAtUtf16(text, offset) { + const value = text.codePointAt(offset); + return value === void 0 ? "" : String.fromCodePoint(value); + } + function previousCodePointStart(text, offset) { + if (offset <= 0) { + return 0; + } + const last = text.charCodeAt(offset - 1); + if (last >= 56320 && last <= 57343 && offset >= 2) { + const first = text.charCodeAt(offset - 2); + if (first >= 55296 && first <= 56319) { + return offset - 2; + } + } + return offset - 1; + } + function isVariationSelector(ch) { + const value = ch.codePointAt(0); + return value !== void 0 && (value >= 65024 && value <= 65039 || value >= 917760 && value <= 917999); + } + function variationSequenceAtUtf16(text, offset) { + const base = codePointAtUtf16(text, offset); + if (!base || isVariationSelector(base)) { + return base; + } + const selector = codePointAtUtf16(text, offset + base.length); + return isVariationSelector(selector) ? base + selector : base; + } + function previousVariationSequenceStart(text, offset) { + 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; + } + function splitVariationSequences(text) { + const out = []; + for (let offset = 0; offset < text.length; ) { + const sequence = variationSequenceAtUtf16(text, offset); + out.push(sequence); + offset += sequence.length; + } + return out; + } + var ALNUM = "A-Za-z0-9"; + var ALPHA = "A-Za-z"; + var WEST_SHARED = "0-9\\$%\\^&\\*\\-\\+\\\\=\\u0370-\\u03ff\\u0100-\\u024f\\u0400-\\u04ff\\u00a1-\\u00b6\\u00b8-\\u00ff\\u20a0-\\u20bf\\u2150-\\u21ff\\u2200-\\u22ff\\u2700-\\u27bf"; + var ANS_AFTER_CJK = ALPHA + "@" + WEST_SHARED; + var ANS_BEFORE_CJK = ALPHA + WEST_SHARED; + var BD_STOP = "\u3002\uFF0E\uFF01\uFF1F"; + var BD_PAUSE = "\uFF0C\u3001\uFF1B\uFF1A"; + var BD_OPEN = "\u300C\u300E\uFF08\u300A\u3008\u3010\u3016\u3014\uFF3B\uFF5B\u201C\u2018"; + var BD_CLOSE = "\u300D\u300F\uFF09\u300B\u3009\u3011\u3017\u3015\uFF3D\uFF5D\u201D\u2019"; + var BD_MIDDLE = "\xB7\u30FB\u2027"; + var BD_LIGA = "\u2026\u2014"; + var BD_ORDER = [ + "stop", + "pause", + "open", + "close", + "middle", + "liga" + ]; + var DEFAULT_RULESET = { + cjk: CJK, + ansAfter: ANS_AFTER_CJK, + ansBefore: ANS_BEFORE_CJK, + biaodian: { + stop: BD_STOP, + pause: BD_PAUSE, + open: BD_OPEN, + close: BD_CLOSE, + middle: BD_MIDDLE, + liga: BD_LIGA + } + }; + function resolveRuleset(override) { + if (!override) { + return DEFAULT_RULESET; + } + const west = override.western || ""; + const bd = { ...DEFAULT_RULESET.biaodian }; + if (override.biaodian) { + for (const sub of BD_ORDER) { + const extra = override.biaodian[sub]; + if (extra) { + bd[sub] = bd[sub] + extra; + } + } + } + return { + cjk: CJK + (override.cjk || ""), + ansAfter: ANS_AFTER_CJK + west, + ansBefore: ANS_BEFORE_CJK + west, + biaodian: bd + }; + } + function classifyBiaodian(ch, biaodian = DEFAULT_RULESET.biaodian) { + const base = codePointAtUtf16(ch, 0); + for (const sub of BD_ORDER) { + if (biaodian[sub].indexOf(base) >= 0) { + return "bd-" + sub; + } + } + return null; + } + function blankSides(bdClass) { + switch (bdClass) { + case "bd-open": { + return { left: true, right: false }; + } + case "bd-close": + case "bd-pause": + case "bd-stop": { + return { left: false, right: true }; + } + default: { + return { left: false, right: false }; + } + } + } + var BRACKET_OPEN_RIGHT = "\u300C\u3008\u201C\u2018"; + var BRACKET_CLOSE_LEFT = "\u300D\u3009\u201D\u2019"; + var BIAS_BRACKETS = (() => { + const t = {}; + for (const ch of BD_OPEN) { + t[ch] = BRACKET_OPEN_RIGHT.indexOf(ch) >= 0 ? "right" : "center"; + } + for (const ch of BD_CLOSE) { + t[ch] = BRACKET_CLOSE_LEFT.indexOf(ch) >= 0 ? "left" : "center"; + } + return t; + })(); + var BIAS_HANS = { + "\uFF0C": "left", + "\u3001": "left", + "\u3002": "left", + "\uFF0E": "left", + "\uFF1B": "left", + "\uFF1A": "left", + "\uFF01": "left", + "\uFF1F": "left" + }; + var BIAS_HANT = { + "\uFF0C": "center", + "\u3001": "center", + "\u3002": "center", + "\uFF0E": "center", + "\uFF1B": "center", + "\uFF1A": "center", + "\uFF01": "center", + "\uFF1F": "center" + }; + var BIAS_DEFAULT = { + "zh-Hans": BIAS_HANS, + "zh-Hant": BIAS_HANT, + "ja": BIAS_HANT, + // 日文點號慣例居中,沿用繁體表 + "other": BIAS_HANT + }; + function inkBias(ch, langClass, overrides) { + const ov = overrides ? overrides[langClass] : void 0; + if (ov && ov[ch]) { + return ov[ch]; + } + const bracket = BIAS_BRACKETS[ch]; + if (bracket) { + return bracket; + } + const t = BIAS_DEFAULT[langClass] || BIAS_DEFAULT.other; + return t[ch] || null; + } + // src/core/finder.ts var SVG_NS = "http://www.w3.org/2000/svg"; var Finder = class { @@ -291,7 +470,7 @@ var Juzhen = (() => { let curText = ""; let curMap = []; const flush = () => { - if (curText.length >= 2) { + if (curMap.length >= 2) { runs.push({ text: curText, map: curMap }); } curText = ""; @@ -307,9 +486,21 @@ var Juzhen = (() => { return; } const data = t.data; - for (let i = 0; i < data.length; i += 1) { - curText += data.charAt(i); - curMap.push({ textNode: t, offset: i }); + for (let i = 0; i < data.length; ) { + 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) + ))) { + previous.text += ch; + previous.parts.push(part); + } else { + curMap.push({ text: ch, parts: [part] }); + } + i += ch.length; } return; } @@ -358,13 +549,14 @@ var Juzhen = (() => { adjacentLogicalChar(el, direction) { const pickEnd = (tn) => { 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) }; }; const descend = (into) => { let n2 = direction === -1 ? into.lastChild : into.firstChild; @@ -512,6 +704,231 @@ var Juzhen = (() => { } }; + // src/core/dom.ts + var KIND_ATTR = "data-jz-kind"; + var PASS_ATTR = "data-jz"; + var WRAP_TAGS = /* @__PURE__ */ new Map([ + ["spacing", /* @__PURE__ */ new Set(["JZ-HWS"])], + ["jiya", /* @__PURE__ */ new Set(["JZ-CHAR", "JZ-INNER"])], + ["jinze", /* @__PURE__ */ new Set(["JZ-JINZE"])], + ["longWord", /* @__PURE__ */ new Set(["SPAN"])], + ["orphan", /* @__PURE__ */ new Set(["JZ-ORPHAN"])] + ]); + var SPLITOFF_TAGS = /* @__PURE__ */ new Set([ + "STRONG", + "EM", + "B", + "I", + "U", + "S", + "SPAN", + "SMALL", + "INS", + "DEL", + "SUB", + "SUP" + ]); + var ATTRIBUTE_WRITERS = /* @__PURE__ */ new WeakMap(); + var OWNER_ATTRIBUTES = /* @__PURE__ */ new WeakMap(); + function trackOwnedAttribute(owner, name, el) { + let attrs = OWNER_ATTRIBUTES.get(owner); + if (!attrs) { + attrs = /* @__PURE__ */ new Map(); + OWNER_ATTRIBUTES.set(owner, attrs); + } + let elements = attrs.get(name); + if (!elements) { + elements = /* @__PURE__ */ new Set(); + attrs.set(name, elements); + } + elements.add(el); + } + function untrackOwnedAttribute(owner, name, el) { + 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() { + if (typeof document === "undefined") { + throw new Error("juzhen: \u7121 document\uFF08\u9700\u65BC\u700F\u89BD\u5668\u6216 jsdom \u74B0\u5883\u57F7\u884C\uFF09\u3002"); + } + return document; + } + function createJz(ownedElements, tag, pass, kind, opts = {}) { + const el = doc().createElement(tag); + ownedElements.add(el); + el.setAttribute(PASS_ATTR, pass); + el.setAttribute(KIND_ATTR, kind); + if (opts.classes) { + const list = Array.isArray(opts.classes) ? opts.classes : [opts.classes]; + for (const c of list) { + if (c) { + el.classList.add(c); + } + } + } + if (opts.text !== void 0) { + el.textContent = opts.text; + } + return el; + } + function setOwnedAttribute(el, name, value, owner) { + let attrs = ATTRIBUTE_WRITERS.get(el); + if (!attrs) { + attrs = /* @__PURE__ */ 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) { + 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); + } + function revertOwnedAttribute(el, name, owner) { + 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); + } + } + function revertOwnedAttributes(root, name, owner) { + 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, kind, el) { + 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; + } + function revertPass(pass, root, ownedElements) { + const nodes = Array.from( + root.querySelectorAll("[" + PASS_ATTR + '="' + pass + '"]') + ).filter((el) => ownedElements.has(el)); + const touched = /* @__PURE__ */ new Set(); + for (let i = nodes.length - 1; i >= 0; i -= 1) { + const el = nodes[i]; + 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); + while (el.firstChild) { + parent.insertBefore(el.firstChild, el); + } + parent.removeChild(el); + ownedElements.delete(el); + } + const splitoffs = Array.from( + root.querySelectorAll( + "[" + PASS_ATTR + '="' + pass + '"][' + KIND_ATTR + '="splitoff"]' + ) + ).filter((el) => ownedElements.has(el) && knownProduct(pass, "splitoff", el)); + for (const so of splitoffs) { + const parent = so.parentNode; + if (!parent) { + continue; + } + touched.add(parent); + const prev = so.previousSibling; + if (prev && prev.nodeType === 1 && prev.nodeName === so.nodeName) { + while (so.firstChild) { + prev.appendChild(so.firstChild); + } + parent.removeChild(so); + } else { + while (so.firstChild) { + parent.insertBefore(so.firstChild, so); + } + parent.removeChild(so); + } + ownedElements.delete(so); + } + for (const p of touched) { + if (p.normalize) { + p.normalize(); + } + } + } + var BD_CLASSES = ["bd-open", "bd-close", "bd-pause", "bd-stop", "bd-middle", "bd-liga"]; + function bdClassOf(el) { + for (const c of BD_CLASSES) { + if (el.classList.contains(c)) { + return c; + } + } + return null; + } + function isJz(node, pass) { + if (!node || node.nodeType !== 1) { + return false; + } + const el = node; + if (!el.hasAttribute(PASS_ATTR)) { + return false; + } + return pass === void 0 || el.getAttribute(PASS_ATTR) === pass; + } + // src/core/pass.ts function runPasses(passes, ctx) { const enabled = passes.filter((p) => p.enabled(ctx.options)); @@ -548,10 +965,10 @@ var Juzhen = (() => { } }); for (const item of work) { - applyRequests(item.node, item.requests); + applyRequests(item.node, item.requests, ctx.ownedElements); } } - function applyRequests(node, requests) { + function applyRequests(node, requests, ownedElements) { const parent = node.parentNode; if (!parent) { return; @@ -574,7 +991,9 @@ var Juzhen = (() => { if (req.start > cursor) { 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) { @@ -586,107 +1005,6 @@ var Juzhen = (() => { parent.replaceChild(frag, node); } - // src/core/dom.ts - var KIND_ATTR = "data-jz-kind"; - var PASS_ATTR = "data-jz"; - function doc() { - if (typeof document === "undefined") { - throw new Error("juzhen: \u7121 document\uFF08\u9700\u65BC\u700F\u89BD\u5668\u6216 jsdom \u74B0\u5883\u57F7\u884C\uFF09\u3002"); - } - return document; - } - function createJz(tag, pass, kind, opts = {}) { - const el = doc().createElement(tag); - el.setAttribute(PASS_ATTR, pass); - el.setAttribute(KIND_ATTR, kind); - if (opts.classes) { - const list = Array.isArray(opts.classes) ? opts.classes : [opts.classes]; - for (const c of list) { - if (c) { - el.classList.add(c); - } - } - } - if (opts.text !== void 0) { - el.textContent = opts.text; - } - return el; - } - function revertPass(pass, root) { - const nodes = Array.from( - root.querySelectorAll("[" + PASS_ATTR + '="' + pass + '"]') - ); - const touched = /* @__PURE__ */ new Set(); - for (let i = nodes.length - 1; i >= 0; i -= 1) { - const el = nodes[i]; - const parent = el.parentNode; - if (!parent) { - continue; - } - const kind = el.getAttribute(KIND_ATTR); - if (kind === "splitoff") { - continue; - } - touched.add(parent); - if (kind === "marker") { - parent.removeChild(el); - } else { - while (el.firstChild) { - parent.insertBefore(el.firstChild, el); - } - parent.removeChild(el); - } - } - const splitoffs = Array.from( - root.querySelectorAll( - "[" + PASS_ATTR + '="' + pass + '"][' + KIND_ATTR + '="splitoff"]' - ) - ); - for (const so of splitoffs) { - const parent = so.parentNode; - if (!parent) { - continue; - } - touched.add(parent); - const prev = so.previousSibling; - if (prev && prev.nodeType === 1 && prev.nodeName === so.nodeName) { - while (so.firstChild) { - prev.appendChild(so.firstChild); - } - parent.removeChild(so); - } else { - while (so.firstChild) { - parent.insertBefore(so.firstChild, so); - } - parent.removeChild(so); - } - } - for (const p of touched) { - if (p.normalize) { - p.normalize(); - } - } - } - var BD_CLASSES = ["bd-open", "bd-close", "bd-pause", "bd-stop", "bd-middle", "bd-liga"]; - function bdClassOf(el) { - for (const c of BD_CLASSES) { - if (el.classList.contains(c)) { - return c; - } - } - return null; - } - function isJz(node, pass) { - if (!node || node.nodeType !== 1) { - return false; - } - const el = node; - if (!el.hasAttribute(PASS_ATTR)) { - return false; - } - return pass === void 0 || el.getAttribute(PASS_ATTR) === pass; - } - // src/core/locale.ts var DEFAULT_POLICY = { "zh-Hant": "quanjiao", @@ -711,7 +1029,7 @@ var Juzhen = (() => { 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"; } @@ -768,136 +1086,6 @@ var Juzhen = (() => { return styleFor(classify(lang), config); } - // src/core/unicode.ts - var CJK = "\u2E80-\u2EFF\u2F00-\u2FDF\u3040-\u309F\u30A0-\u30FA\u30FC-\u30FF\u3100-\u312F\u3200-\u32FF\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF"; - var ALNUM = "A-Za-z0-9"; - var ALPHA = "A-Za-z"; - var WEST_SHARED = "0-9\\$%\\^&\\*\\-\\+\\\\=\\u0370-\\u03ff\\u0100-\\u024f\\u0400-\\u04ff\\u00a1-\\u00b6\\u00b8-\\u00ff\\u20a0-\\u20bf\\u2150-\\u21ff\\u2200-\\u22ff\\u2700-\\u27bf"; - var ANS_AFTER_CJK = ALPHA + "@" + WEST_SHARED; - var ANS_BEFORE_CJK = ALPHA + WEST_SHARED; - var BD_STOP = "\u3002\uFF0E\uFF01\uFF1F"; - var BD_PAUSE = "\uFF0C\u3001\uFF1B\uFF1A"; - var BD_OPEN = "\u300C\u300E\uFF08\u300A\u3008\u3010\u3016\u3014\uFF3B\uFF5B\u201C\u2018"; - var BD_CLOSE = "\u300D\u300F\uFF09\u300B\u3009\u3011\u3017\u3015\uFF3D\uFF5D\u201D\u2019"; - var BD_MIDDLE = "\xB7\u30FB\u2027"; - var BD_LIGA = "\u2026\u2014"; - var BD_ORDER = [ - "stop", - "pause", - "open", - "close", - "middle", - "liga" - ]; - var DEFAULT_RULESET = { - cjk: CJK, - ansAfter: ANS_AFTER_CJK, - ansBefore: ANS_BEFORE_CJK, - biaodian: { - stop: BD_STOP, - pause: BD_PAUSE, - open: BD_OPEN, - close: BD_CLOSE, - middle: BD_MIDDLE, - liga: BD_LIGA - } - }; - function resolveRuleset(override) { - if (!override) { - return DEFAULT_RULESET; - } - const west = override.western || ""; - const bd = { ...DEFAULT_RULESET.biaodian }; - if (override.biaodian) { - for (const sub of BD_ORDER) { - const extra = override.biaodian[sub]; - if (extra) { - bd[sub] = bd[sub] + extra; - } - } - } - return { - cjk: CJK + (override.cjk || ""), - ansAfter: ANS_AFTER_CJK + west, - ansBefore: ANS_BEFORE_CJK + west, - biaodian: bd - }; - } - function classifyBiaodian(ch, biaodian = DEFAULT_RULESET.biaodian) { - for (const sub of BD_ORDER) { - if (biaodian[sub].indexOf(ch) >= 0) { - return "bd-" + sub; - } - } - return null; - } - function blankSides(bdClass) { - switch (bdClass) { - case "bd-open": { - return { left: true, right: false }; - } - case "bd-close": - case "bd-pause": - case "bd-stop": { - return { left: false, right: true }; - } - default: { - return { left: false, right: false }; - } - } - } - var BRACKET_OPEN_RIGHT = "\u300C\u3008\u201C\u2018"; - var BRACKET_CLOSE_LEFT = "\u300D\u3009\u201D\u2019"; - var BIAS_BRACKETS = (() => { - const t = {}; - for (const ch of BD_OPEN) { - t[ch] = BRACKET_OPEN_RIGHT.indexOf(ch) >= 0 ? "right" : "center"; - } - for (const ch of BD_CLOSE) { - t[ch] = BRACKET_CLOSE_LEFT.indexOf(ch) >= 0 ? "left" : "center"; - } - return t; - })(); - var BIAS_HANS = { - "\uFF0C": "left", - "\u3001": "left", - "\u3002": "left", - "\uFF0E": "left", - "\uFF1B": "left", - "\uFF1A": "left", - "\uFF01": "left", - "\uFF1F": "left" - }; - var BIAS_HANT = { - "\uFF0C": "center", - "\u3001": "center", - "\u3002": "center", - "\uFF0E": "center", - "\uFF1B": "center", - "\uFF1A": "center", - "\uFF01": "center", - "\uFF1F": "center" - }; - var BIAS_DEFAULT = { - "zh-Hans": BIAS_HANS, - "zh-Hant": BIAS_HANT, - "ja": BIAS_HANT, - // 日文點號慣例居中,沿用繁體表 - "other": BIAS_HANT - }; - function inkBias(ch, langClass, overrides) { - const ov = overrides ? overrides[langClass] : void 0; - if (ov && ov[ch]) { - return ov[ch]; - } - const bracket = BIAS_BRACKETS[ch]; - if (bracket) { - return bracket; - } - const t = BIAS_DEFAULT[langClass] || BIAS_DEFAULT.other; - return t[ch] || null; - } - // src/typeset/jiya.ts var PASS = "jiya"; function baselineHalf(style, bdClass) { @@ -939,10 +1127,11 @@ var Juzhen = (() => { let langClass = null; const biasOverrides = ctx.options.jiyaConfig.bias; const biaodian = ctx.options.ruleset.biaodian; - for (let i = 0; i < data.length; i += 1) { - const ch = data.charAt(i); + for (let i = 0; i < data.length; ) { + const ch = variationSequenceAtUtf16(data, i); const bdClass = classifyBiaodian(ch, biaodian); if (!bdClass) { + i += ch.length; continue; } if (style === null) { @@ -952,26 +1141,39 @@ var Juzhen = (() => { langClass = classify(effectiveLang(node, ctx.options.locale.default)); } const classes = baselineHalf(style, bdClass) ? [bdClass, "jz-half"] : [bdClass]; - 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) { - revertPass(PASS, ctx.root); + revertPass(PASS, ctx.root, ctx.ownedElements); } }; var jiyaAdjacencyPass = { @@ -1013,6 +1215,10 @@ var Juzhen = (() => { const el = c; const nm = el.nodeName; if (nm === "JZ-CHAR") { + if (!ctx.ownedElements.has(el)) { + prev = null; + continue; + } if (prev) { squeeze(prev, el); } @@ -1102,27 +1308,35 @@ var Juzhen = (() => { return !anyCjk.test(ch) && !/\s/.test(ch); } function leadingTokenEnd(d, anyCjk) { - if (anyCjk.test(d.charAt(0))) { - return 1; + const first = variationSequenceAtUtf16(d, 0); + if (anyCjk.test(first)) { + return first.length; } - let e = 1; - while (e < d.length && isTokenChar(d.charAt(e), anyCjk)) { - e += 1; + let e = first.length; + while (e < d.length) { + const ch = variationSequenceAtUtf16(d, e); + if (!isTokenChar(ch, anyCjk)) { + break; + } + e += ch.length; } return e; } function trailingTokenStart(d, anyCjk) { - const last = d.length - 1; - if (anyCjk.test(d.charAt(last))) { - return last; + let s = previousVariationSequenceStart(d, d.length); + if (anyCjk.test(variationSequenceAtUtf16(d, s))) { + return s; } - let s = last; - while (s > 0 && isTokenChar(d.charAt(s - 1), anyCjk)) { - s -= 1; + while (s > 0) { + const previous = previousVariationSequenceStart(d, s); + if (!isTokenChar(variationSequenceAtUtf16(d, previous), anyCjk)) { + break; + } + s = previous; } return s; } - function splitTreeAfter(top, textNode, offset, pass, finder) { + function splitTreeAfter(top, textNode, offset, pass, finder, ownedElements) { for (let a = textNode.parentNode; a && a.nodeType === 1; a = a.parentNode) { if (!canSplit(a, finder)) { return null; @@ -1152,6 +1366,7 @@ var Juzhen = (() => { clone.removeAttribute("id"); clone.setAttribute(PASS_ATTR2, pass); clone.setAttribute(KIND_ATTR2, "splitoff"); + ownedElements.add(clone); let n = rightAtLevel; while (n) { const next = n.nextSibling; @@ -1181,7 +1396,7 @@ var Juzhen = (() => { } return topClone; } - function isolateBoundaryToken(el, side, anyCjk, pass, finder) { + function isolateBoundaryToken(el, side, anyCjk, pass, finder, ownedElements) { if (!canSplit(el, finder)) { return el; } @@ -1190,17 +1405,17 @@ var Juzhen = (() => { return el; } const point = side === "tail" ? { 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; } return side === "tail" ? clone : el; } - function splitElementAt(el, textNode, offset, pass, finder) { + function splitElementAt(el, textNode, offset, pass, finder, ownedElements) { if (!canSplit(el, finder)) { return null; } - return splitTreeAfter(el, textNode, offset, pass, finder); + return splitTreeAfter(el, textNode, offset, pass, finder, ownedElements); } // src/typeset/jinze.ts @@ -1274,23 +1489,31 @@ var Juzhen = (() => { return !anyCjk.test(ch) && !/\s/.test(ch); } function leadingTokenEnd2(d, anyCjk) { - if (anyCjk.test(d.charAt(0))) { - return 1; + const first = variationSequenceAtUtf16(d, 0); + if (anyCjk.test(first)) { + return first.length; } - let e = 1; - while (e < d.length && isTokenChar2(d.charAt(e), anyCjk)) { - e += 1; + let e = first.length; + while (e < d.length) { + const ch = variationSequenceAtUtf16(d, e); + if (!isTokenChar2(ch, anyCjk)) { + break; + } + e += ch.length; } return e; } function trailingTokenStart2(d, anyCjk) { - const last = d.length - 1; - if (anyCjk.test(d.charAt(last))) { - return last; + let s = previousVariationSequenceStart(d, d.length); + if (anyCjk.test(variationSequenceAtUtf16(d, s))) { + return s; } - let s = last; - while (s > 0 && isTokenChar2(d.charAt(s - 1), anyCjk)) { - s -= 1; + while (s > 0) { + const previous = previousVariationSequenceStart(d, s); + if (!isTokenChar2(variationSequenceAtUtf16(d, previous), anyCjk)) { + break; + } + s = previous; } return s; } @@ -1309,7 +1532,7 @@ var Juzhen = (() => { } } } - function processParent(parent, finder, anyCjk) { + function processParent(parent, finder, anyCjk, ownedElements) { if (isJz(parent, PASS2)) { return; } @@ -1346,10 +1569,10 @@ var Juzhen = (() => { } else if (u.nodeType === 1) { const el = u; if (needLast) { - isolateBoundaryToken(el, "tail", anyCjk, PASS2, finder); + isolateBoundaryToken(el, "tail", anyCjk, PASS2, finder, ownedElements); } if (needFirst) { - isolateBoundaryToken(el, "head", anyCjk, PASS2, finder); + isolateBoundaryToken(el, "head", anyCjk, PASS2, finder, ownedElements); } } } @@ -1365,7 +1588,7 @@ var Juzhen = (() => { i += 1; } if (run.length >= 2) { - const group = createJz("jz-jinze", PASS2, "wrap"); + const group = createJz(ownedElements, "jz-jinze", PASS2, "wrap"); parent.insertBefore(group, run[0]); for (const node of run) { group.appendChild(node); @@ -1382,9 +1605,12 @@ var Juzhen = (() => { enabled: (o) => o.features.jinze, render(ctx) { const { finder } = ctx; - const anyCjk = new RegExp("[" + ctx.options.ruleset.cjk + "]"); + const anyCjk = new RegExp("[" + ctx.options.ruleset.cjk + "]", "u"); const parents = /* @__PURE__ */ new Set(); ctx.root.querySelectorAll("jz-char").forEach((c) => { + if (!ctx.ownedElements.has(c)) { + return; + } if (!finder.levelAllows(c, "paragraph")) { return; } @@ -1405,11 +1631,11 @@ var Juzhen = (() => { }); const ordered = Array.from(parents).sort((a, b) => depthOf(b) - depthOf(a)); for (const parent of ordered) { - processParent(parent, finder, anyCjk); + processParent(parent, finder, anyCjk, ctx.ownedElements); } }, revert(ctx) { - revertPass(PASS2, ctx.root); + revertPass(PASS2, ctx.root, ctx.ownedElements); } }; @@ -1427,7 +1653,7 @@ var Juzhen = (() => { const { finder, options } = ctx; const blockSet = options.finder.blockTags; const nearest = /* @__PURE__ */ new Map(); - const nearestBlock = (p) => { + const nearestBlock = ((p) => { const cached = nearest.get(p); if (cached !== void 0) { return cached; @@ -1446,7 +1672,7 @@ var Juzhen = (() => { } nearest.set(p, block); return block; - }; + }); finder.eachTextNode(ctx.root, (t) => { if (!/\S/.test(t.data)) { return; @@ -1457,16 +1683,11 @@ var Juzhen = (() => { return; } const level = finder.levelFor(block); - if (block.getAttribute(ATTR) !== level) { - block.setAttribute(ATTR, level); - } + setOwnedAttribute(block, ATTR, level, ctx.ownerToken); }); }, revert(ctx) { - if (ctx.root.hasAttribute(ATTR)) { - ctx.root.removeAttribute(ATTR); - } - ctx.root.querySelectorAll("[" + ATTR + "]").forEach((el) => el.removeAttribute(ATTR)); + revertOwnedAttributes(ctx.root, ATTR, ctx.ownerToken); } }; @@ -1480,12 +1701,17 @@ var Juzhen = (() => { function hasLayout() { return typeof ResizeObserver !== "undefined"; } - function installLayout(root, key, run) { + function installLayout(root, owner, key, run) { run(); - let m = observers.get(root); + let owners = observers.get(root); + if (!owners) { + owners = /* @__PURE__ */ new Map(); + observers.set(root, owners); + } + let m = owners.get(owner); if (!m) { m = /* @__PURE__ */ new Map(); - observers.set(root, m); + owners.set(owner, m); } const existing = m.get(key); if (existing) { @@ -1499,8 +1725,9 @@ var Juzhen = (() => { fonts.ready.then(run, () => void 0); } } - function uninstallLayout(root, key) { - const m = observers.get(root); + function uninstallLayout(root, owner, key) { + const owners = observers.get(root); + const m = owners?.get(owner); if (!m) { return; } @@ -1509,6 +1736,9 @@ var Juzhen = (() => { ro.disconnect(); m.delete(key); } + if (m.size === 0) { + owners.delete(owner); + } } var JZ_WRAP_TAGS = ["JZ-JINZE", "JZ-CHAR", "JZ-INNER", "JZ-HWS"]; function buildWrapSet(options) { @@ -1526,9 +1756,10 @@ var Juzhen = (() => { return null; } const range = doc2.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]; @@ -1576,23 +1807,27 @@ var Juzhen = (() => { const rects = el.getClientRects(); return rects.length ? rects[rects.length - 1] : null; } - function clearEdge(root) { + function clearEdge(ctx) { 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); + } + }); } } - function relayout(root, finder, options) { - clearEdge(root); - const wrapSet = buildWrapSet(options); - const chars = Array.from(root.querySelectorAll("jz-char")).filter( - (c) => finder.inScope(c) && !finder.isAvoided(c) + function relayout(ctx) { + clearEdge(ctx); + const wrapSet = buildWrapSet(ctx.options); + 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 = []; for (const el of chars) { - if (!finder.levelAllows(el, "paragraph")) { + if (!ctx.finder.levelAllows(el, "paragraph")) { continue; } - if (!finder.featureEnabledFor(el, "jiya")) { + if (!ctx.finder.featureEnabledFor(el, "jiya")) { continue; } const bd = bdClassOf(el); @@ -1621,7 +1856,7 @@ var Juzhen = (() => { } } for (const d of decisions) { - d.el.classList.add(LE); + ctx.setOwnedClass(d.el, LE, true); } } var lineEdgePass = { @@ -1634,11 +1869,13 @@ var Juzhen = (() => { if (!hasLayout()) { return; } - installLayout(ctx.root, "lineEdge", () => relayout(ctx.root, ctx.finder, ctx.options)); + installLayout(ctx.root, ctx.ownerToken, "lineEdge", () => { + relayout(ctx); + }); }, revert(ctx) { - uninstallLayout(ctx.root, "lineEdge"); - clearEdge(ctx.root); + uninstallLayout(ctx.root, ctx.ownerToken, "lineEdge"); + clearEdge(ctx); } }; var TRIM = "jz-hws-trim"; @@ -1661,21 +1898,21 @@ var Juzhen = (() => { cache.set(key, result); return result; } - function trimGaps(root, finder, wrapSet) { + function trimGaps(ctx, wrapSet) { const alignCache = /* @__PURE__ */ new Map(); - const gaps = Array.from(root.querySelectorAll("jz-hws")).filter( - (g) => finder.inScope(g) && !finder.isAvoided(g) && finder.levelAllows(g, "paragraph") && finder.featureEnabledFor(g, "spacing") && !skippableAlign(g, alignCache) + 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; } - 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); + ctx.setOwnedClass(g, TRIM, false); } } } @@ -1690,11 +1927,17 @@ var Juzhen = (() => { 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) { - 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); + } + }); } }; @@ -1742,6 +1985,7 @@ var Juzhen = (() => { span.setAttribute("data-jz", PASS3); span.setAttribute("data-jz-kind", "wrap"); span.textContent = text; + ctx.ownedElements.add(span); return span; } }); @@ -1749,7 +1993,7 @@ var Juzhen = (() => { return out; }, revert(ctx) { - revertPass(PASS3, ctx.root); + revertPass(PASS3, ctx.root, ctx.ownedElements); } }; @@ -1769,11 +2013,12 @@ var Juzhen = (() => { if (node.nodeType === 3) { const t = node; const d = t.data; - for (let i = 0; i < d.length; i += 1) { - const ch = d.charAt(i); + for (let i = 0; i < d.length; ) { + const ch = variationSequenceAtUtf16(d, i); if (anyCjk.test(ch) && !classifyBiaodian(ch, biaodian)) { out.push({ directChild, textNode: t, offset: i }); } + i += ch.length; } return; } @@ -1796,7 +2041,7 @@ var Juzhen = (() => { } return out; } - function processBlock(block, finder, blockSet, anyCjk, biaodian, chars) { + function processBlock(block, finder, blockSet, anyCjk, biaodian, chars, ownedElements) { if (hasOrphan(block)) { return; } @@ -1816,13 +2061,14 @@ var Juzhen = (() => { boundary.textNode, boundary.offset, PASS4, - finder + finder, + ownedElements ); if (clone) { startChild = clone; } } - const orphan = createJz("jz-orphan", PASS4, "wrap"); + const orphan = createJz(ownedElements, "jz-orphan", PASS4, "wrap"); block.insertBefore(orphan, startChild); let n = startChild; while (n) { @@ -1840,7 +2086,7 @@ var Juzhen = (() => { render(ctx) { 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; const sel = Array.from(blockSet).map((t) => t.toLowerCase()).join(","); @@ -1870,11 +2116,19 @@ var Juzhen = (() => { if (nonLeaf.has(block)) { return; } - processBlock(block, finder, blockSet, anyCjk, biaodian, chars); + processBlock( + block, + finder, + blockSet, + anyCjk, + biaodian, + chars, + ctx.ownedElements + ); }); }, revert(ctx) { - revertPass(PASS4, ctx.root); + revertPass(PASS4, ctx.root, ctx.ownedElements); } }; @@ -1895,45 +2149,63 @@ var Juzhen = (() => { const CJK2 = rs.cjk; const ANS_AFTER = rs.ansAfter; const ANS_BEFORE = rs.ansBefore; + const CJK_ATOM = "(?:[" + CJK2 + "](?:[" + VARIATION_SELECTORS + "])?)"; + const CJK_OR_ALNUM = "(?:" + CJK_ATOM + "|[" + ALNUM + "])"; return { - anyCjk: new RegExp("[" + CJK2 + "]"), - pillNeighbor: new RegExp("[" + ALNUM + CJK2 + "]"), - STRIP_CJK_SPACE_ANS: new RegExp("([" + CJK2 + "]) ([" + ANS_AFTER + "])", "g"), - STRIP_ANS_SPACE_CJK: new RegExp("([" + ANS_BEFORE + "]) ([" + CJK2 + "])", "g"), - STRIP_CJK_SPACE_CJK: new RegExp("([" + CJK2 + "]) (?=[" + CJK2 + "])", "g"), - DOTS_CJK: new RegExp("([\\.]{2,})([" + CJK2 + "])", "g"), - CJK_PUNCTUATION: new RegExp("([" + CJK2 + "])([!;,\\?:]+)(?=[" + CJK2 + ALNUM + "])", "g"), - AN_PUNCTUATION_CJK: new RegExp("([" + ALNUM + "])([!;,\\?]+)([" + CJK2 + "])", "g"), - CJK_TILDE: new RegExp("([" + CJK2 + "])(~+)(?!=)(?=[" + CJK2 + ALNUM + "])", "g"), - CJK_TILDE_EQUALS: new RegExp("([" + CJK2 + "])(~=)", "g"), - CJK_PERIOD: new RegExp("([" + CJK2 + "])(\\.)(?![" + ALNUM + "\\./])(?=[" + CJK2 + ALNUM + "])", "g"), - AN_PERIOD_CJK: new RegExp("([" + ALNUM + "])(\\.)([" + CJK2 + "])", "g"), - AN_COLON_CJK: new RegExp("([" + ALNUM + "])(:)([" + CJK2 + "])", "g"), - CJK_QUOTE: new RegExp("([" + CJK2 + "])([" + QUOTES_CLS + "])", "g"), - QUOTE_CJK: new RegExp("([" + QUOTES_CLS + "])([" + CJK2 + "])", "g"), - CJK_QUOTE_AN: new RegExp("([" + CJK2 + '])(")([' + ALNUM + "])", "g"), - CJK_HASH: new RegExp("([" + CJK2 + "])(#([^ ]))", "g"), - HASH_CJK: new RegExp("(([^ ])#)([" + CJK2 + "])", "g"), - SINGLE_LETTER_GRADE: new RegExp("\\b([" + ALPHA + "])([" + OPS_GRADE + "])([" + CJK2 + "])", "g"), - CJK_OPERATOR_ANS: new RegExp("([" + CJK2 + "])([" + OPS_HYPHEN + "])([" + ALNUM + "])", "g"), - ANS_OPERATOR_CJK: new RegExp("([" + ALNUM + "])([" + OPS_HYPHEN + "])([" + CJK2 + "])", "g"), - CJK_LESS_THAN: new RegExp("([" + CJK2 + "])(<)([" + ALNUM + "])", "g"), - LESS_THAN_CJK: new RegExp("([" + ALNUM + "])(<)([" + CJK2 + "])", "g"), - CJK_GREATER_THAN: new RegExp("([" + CJK2 + "])(>)([" + ALNUM + "])", "g"), - GREATER_THAN_CJK: new RegExp("([" + ALNUM + "])(>)([" + CJK2 + "])", "g"), - CJK_LEFT_BRACKET: new RegExp("([" + CJK2 + "])([" + LBR_EXT + "])", "g"), - RIGHT_BRACKET_CJK: new RegExp("([" + RBR_EXT + "])([" + CJK2 + "])", "g"), - AN_LEFT_BRACKET: new RegExp("([" + ALNUM + "])(?)([" + 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("([" + CJK2 + "])([" + ANS_AFTER + "])", "g"), - ANS_CJK: new RegExp("([" + ANS_BEFORE + "])([" + CJK2 + "])", "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") }; } var 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; var PH_PATTERN = new RegExp(PH_OPEN + "(\\d+)" + PH_CLOSE, "g"); + var ASTRAL_CJK_SHADOW = "\u6F22"; + function shadowAstralCjkAtoms(atoms, R) { + const shadowed = new Array(atoms.length); + for (let i = 0; i < atoms.length; i += 1) { + const atom = atoms[i]; + const base = atom.codePointAt(0); + const match = base !== void 0 && base > 65535 ? R.anyCjk.exec(atom) : null; + shadowed[i] = match?.index === 0 ? ASTRAL_CJK_SHADOW : atom; + } + return shadowed.join(""); + } function panguSpace(text, R) { - if (text.length <= 1 || !R.anyCjk.test(text)) { + if (splitVariationSequences(text).length <= 1 || !R.anyCjk.test(text)) { return text; } let s = text; @@ -1968,81 +2240,350 @@ var Juzhen = (() => { s = s.replace(PH_PATTERN, (_m, idx) => compounds[Number(idx)] || ""); 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"); s = s.replace(R.ANS_CJK, "$1" + MARK + "$2"); s = s.replace(R.S_A, "$1" + MARK + "$2"); return s; } - function wrapNodeInGap(node) { + var MOVED_PARTS = /* @__PURE__ */ new WeakMap(); + var MOVED_ANCHOR = "jz-spacing-anchor"; + var MOVED_GAP_ANCHOR = "jz-spacing-gap-anchor"; + var CROSS_NODE_ATTR = "data-jz-cross-node-atom"; + function wrapNodeInGap(node, ownedElements) { const parent = node.parentNode; if (!parent || parent.nodeName === "JZ-HWS") { return; } - const gap = createJz("jz-hws", PASS5, "wrap"); + const gap = createJz(ownedElements, "jz-hws", PASS5, "wrap"); parent.insertBefore(gap, node); gap.appendChild(node); } - function wrapGapLeft(pos) { - let tn = pos.textNode; - if (!tn.parentNode || tn.parentNode.nodeName === "JZ-HWS") { + function isolateTextSpan(part) { + 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 wrapSingleSpanInGap(part, ownedElements) { + const containingGap = part.textNode.parentElement?.closest("jz-hws"); + if (containingGap) { + return ownedElements.has(containingGap) && containingGap.getAttribute("data-jz") === PASS5 && 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", PASS5, "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.normalize?.(); + throw error; + } + } + function isOwnedCrossGap(gap, ownedElements) { + return ownedElements.has(gap) && gap.getAttribute("data-jz") === PASS5 && gap.getAttribute("data-jz-kind") === "wrap" && gap.hasAttribute(CROSS_NODE_ATTR); + } + function trustedInsertionPoint(record) { + if (record.anchor.parentNode === record.originalParent) { + return record.anchor; + } + if (record.originalNextSibling?.parentNode === record.originalParent) { + return record.originalNextSibling; + } + if (record.originalNextSibling !== null) { + return void 0; + } + if (record.originalPreviousSibling?.parentNode === record.originalParent) { + return record.originalPreviousSibling === record.originalParent.lastChild ? null : void 0; + } + if (record.originalPreviousSibling === null && record.originalNextSibling === null && record.anchor.parentNode !== null && record.anchor.parentNode !== record.originalParent && !record.originalParent.hasChildNodes()) { + return null; + } + return void 0; + } + function restoreMovedText(record) { + if (record.textNode.parentNode === record.originalParent) { + return true; + } + if (record.textNode.parentNode !== record.gap) { + return false; + } + const before = trustedInsertionPoint(record); + if (before === void 0) { + return false; + } + record.originalParent.insertBefore(record.textNode, before); + return true; + } + function movePartIntoGap(part, gap, block, scopeRoot) { + 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 = { + 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 = []; + for (let parent2 = scopeRoot.parentElement; parent2; parent2 = parent2.parentElement) { + ancestors.push(parent2); + } + 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.normalize?.(); + throw error; + } + } + function wrapGapLeft(span, ownedElements, ownerToken, block, scopeRoot) { + const containingGaps = /* @__PURE__ */ new Set(); + for (const part of span.parts) { + const containing = part.textNode.parentElement?.closest("jz-hws"); + 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; + } + } return; } - if (pos.offset > 0) { - tn = tn.splitText(pos.offset); + const gap = wrapSingleSpanInGap(span.parts[0], ownedElements); + if (!gap) { + return; } - if (tn.data.length > 1) { - tn.splitText(1); + if (span.parts.length === 1) { + return; + } + const moved = []; + 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: \u8DE8\u7BC0\u9EDE\u5B57\u5143\u642C\u79FB\u5931\u6557\u3002"); + } + moved.push(record); + } + gap.setAttribute(CROSS_NODE_ATTR, ""); + let records = MOVED_PARTS.get(ownerToken); + if (!records) { + records = /* @__PURE__ */ 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.normalize?.(); + } + gap.removeAttribute(CROSS_NODE_ATTR); + const gapParent = gap.parentNode; + if (gapParent) { + while (gap.firstChild) { + gapParent.insertBefore(gap.firstChild, gap); + } + gapParent.removeChild(gap); + gapParent.normalize?.(); + } + ownedElements.delete(gap); + throw error; } - wrapNodeInGap(tn); } - function processBlock2(block, finder, R) { + function recordWithinRoot(record, root) { + const within = (node) => 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) { + if (record.gap.parentNode === record.gapOriginalParent) { + return true; + } + let before = 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, ownedElements, touched) { + 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) { + const touched = /* @__PURE__ */ new Set(); + const records = MOVED_PARTS.get(ctx.ownerToken); + if (!records) { + return touched; + } + const gaps = /* @__PURE__ */ new Map(); + for (const record of Array.from(records)) { + if (!recordWithinRoot(record, ctx.root)) { + continue; + } + const restored = restoreMovedText(record); + if (!restored && record.textNode.parentNode === record.gap) { + 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) { + 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 processBlock2(block, scopeRoot, finder, R, ownedElements, ownerToken) { const runs = finder.collectRuns(block); for (const run of runs) { - const { text, map } = run; - const spaced = panguSpace(text, R); - if (spaced === text) { + 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 = []; let origIdx = 0; - let spacedIdx = 0; - while (spacedIdx < spaced.length) { - const sc = spaced.charAt(spacedIdx); + for (const sc of splitVariationSequences(spaced)) { if (sc === DEL) { actions.push({ before: void 0, space: map[origIdx] }); origIdx += 1; - spacedIdx += 1; continue; } if (sc === MARK) { - const oc = origIdx < text.length ? text.charAt(origIdx) : null; + const oc = origIdx < originalChars.length ? originalChars[origIdx] : null; if (oc === " ") { actions.push({ before: map[origIdx - 1], space: map[origIdx] }); origIdx += 1; } else { actions.push({ before: map[origIdx - 1], space: null }); } - spacedIdx += 1; continue; } origIdx += 1; - spacedIdx += 1; } for (let i = actions.length - 1; i >= 0; i -= 1) { 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); + wrapGapLeft(act.before, ownedElements, ownerToken, block, scopeRoot); } } } } - function processPills(root, finder, boundarySelector, pillNeighbor) { + function processPills(root, finder, boundarySelector, pillNeighbor, ownedElements, ownerToken) { root.querySelectorAll(boundarySelector).forEach((pill) => { if (finder.isAvoided(pill) || finder.insidePill(pill) || !finder.inScope(pill)) { return; @@ -2055,38 +2596,49 @@ var Juzhen = (() => { } const prev = finder.adjacentLogicalChar(pill, -1); if (prev) { - handlePillSide(prev, pill, -1, pillNeighbor); + handlePillSide(prev, pill, -1, pillNeighbor, ownedElements, ownerToken, root); } const next = finder.adjacentLogicalChar(pill, 1); if (next) { - handlePillSide(next, pill, 1, pillNeighbor); + handlePillSide(next, pill, 1, pillNeighbor, ownedElements, ownerToken, root); } }); } - function handlePillSide(adj, pill, dir, pillNeighbor) { + function handlePillSide(adj, pill, dir, pillNeighbor, ownedElements, ownerToken, scopeRoot) { 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); } } } else { if (pillNeighbor.test(adj.ch)) { - wrapNodeInGap(pill); + wrapNodeInGap(pill, ownedElements); return; } - if (adj.ch === " " && adj.textNode.data.length >= 2) { - const ac = adj.textNode.data.charAt(1); + if (adj.ch === " " && adj.textNode.data.length > 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); } } } @@ -2104,18 +2656,29 @@ var Juzhen = (() => { if (!finder.featureEnabledFor(block, PASS5)) { return; } - processBlock2(block, finder, R); + processBlock2(block, ctx.root, finder, R, ctx.ownedElements, ctx.ownerToken); }); if (!options.finder.blockTags.has(ctx.root.nodeName) && finder.featureEnabledFor(ctx.root, PASS5) && finder.inScope(ctx.root) && !finder.isAvoided(ctx.root)) { - processBlock2(ctx.root, finder, R); + processBlock2(ctx.root, ctx.root, finder, R, ctx.ownedElements, ctx.ownerToken); } const boundarySelector = [options.finder.pillSelector, options.finder.isolateSelector].filter(Boolean).join(", "); if (boundarySelector) { - processPills(ctx.root, finder, boundarySelector, R.pillNeighbor); + processPills( + ctx.root, + finder, + boundarySelector, + R.pillNeighbor, + ctx.ownedElements, + ctx.ownerToken + ); } }, revert(ctx) { - revertPass(PASS5, ctx.root); + const touched = restoreMovedParts(ctx); + revertPass(PASS5, ctx.root, ctx.ownedElements); + for (const parent of touched) { + parent.normalize?.(); + } } }; @@ -2319,42 +2882,158 @@ var Juzhen = (() => { function createJuzhen(opts = {}) { const options = normalizeOptions(opts); const finder = new Finder(options); + const ownedElements = /* @__PURE__ */ new WeakSet(); + const ownerToken = {}; + const renderedRoots = /* @__PURE__ */ new Set(); function makeCtx(root) { - return { root, options, finder }; + return { root, options, finder, ownedElements, ownerToken, setOwnedClass }; } function renderImpl(el) { + renderedRoots.add(el); runPasses(PASSES, makeCtx(el)); if (options.features.jiya && options.jiyaConfig.halfWidth === "margin") { - el.setAttribute("data-jz-halfwidth", "margin"); + setOwnedAttribute(el, "data-jz-halfwidth", "margin", ownerToken); } if (!options.justifyAtoms) { - el.setAttribute("data-jz-atoms", "inline"); + setOwnedAttribute(el, "data-jz-atoms", "inline", ownerToken); + } + } + function rootsWithin(el) { + return Array.from(renderedRoots).filter((root) => root === el || el.contains(root)).sort((a, b) => a.contains(b) ? 1 : b.contains(a) ? -1 : 0); + } + function unwindRoots(roots) { + 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) { - revertPasses(PASSES, makeCtx(el)); - el.removeAttribute("data-jz-halfwidth"); - el.removeAttribute("data-jz-atoms"); + const roots = rootsWithin(el); + if (roots.length === 0) { + revertPasses(PASSES, makeCtx(el)); + } else { + unwindRoots(roots); + } finder.invalidate(el); } let observer = null; let observeRoot = null; + let observerPauseDepth = 0; + let observerActive = false; + const expectedClassMutations = []; + let expectedClassMutationHead = 0; const observeConfig = { subtree: true, childList: true, characterData: true, attributes: true, + attributeOldValue: true, attributeFilter: OBSERVE_ATTRS.slice() }; - function pauseObserver() { - if (observer) { - observer.disconnect(); + function clearExpectedClassMutations() { + expectedClassMutations.length = 0; + expectedClassMutationHead = 0; + } + function setOwnedClass(element, className, present) { + 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) { + const values = new Array(records.length); + const nextValues = /* @__PURE__ */ new Map(); + 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; + values[i] = nextValues.has(target) ? nextValues.get(target) : target.getAttribute("class"); + nextValues.set(target, record.oldValue); + } + return values; + } + function consumeExpectedClassMutation(record, newValue) { + 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() { + if (expectedClassMutationHead === 0) { + return; + } + if (expectedClassMutationHead === expectedClassMutations.length) { + clearExpectedClassMutations(); + return; + } + if (expectedClassMutationHead >= 1024 && expectedClassMutationHead * 2 >= expectedClassMutations.length) { + expectedClassMutations.splice(0, expectedClassMutationHead); + expectedClassMutationHead = 0; + } + } + function pauseObserver() { + if (observerPauseDepth === 0) { + observerActive = false; + if (observer) { + observer.disconnect(); + } + clearExpectedClassMutations(); + } + observerPauseDepth += 1; + } function resumeObserver() { - 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(run) { + pauseObserver(); + try { + return run(); + } finally { + resumeObserver(); } } function nearestBlock(node) { @@ -2373,67 +3052,86 @@ var Juzhen = (() => { function onMutations(records) { const rerenderBlocks = /* @__PURE__ */ new Set(); const renderNew = []; - for (const m of records) { - if (m.type === "childList") { - m.addedNodes.forEach((n) => { - if (n.nodeType === 1 && options.finder.blockTags.has(n.nodeName)) { - const np = n; - const P = np.parentElement; - const pIsBlock = !!P && options.finder.blockTags.has(P.nodeName); - const pHadLeafOrphan = !!P && Array.prototype.some.call( - P.children, - (c) => c.nodeName === "JZ-ORPHAN" - ); - if (pIsBlock && pHadLeafOrphan) { - rerenderBlocks.add(P); - } else if (!pIsBlock && P) { - const b = nearestBlock(P); + const removedRoots = /* @__PURE__ */ new Set(); + const classNewValues = classMutationNewValues(records); + try { + for (let recordIndex = 0; recordIndex < records.length; recordIndex += 1) { + const m = records[recordIndex]; + if (consumeExpectedClassMutation(m, classNewValues[recordIndex])) { + continue; + } + if (m.type === "childList") { + m.addedNodes.forEach((n) => { + if (n.nodeType === 1 && options.finder.blockTags.has(n.nodeName)) { + const np = n; + const P = np.parentElement; + const pIsBlock = !!P && options.finder.blockTags.has(P.nodeName); + const pHadLeafOrphan = !!P && Array.prototype.some.call( + P.children, + (c) => c.nodeName === "JZ-ORPHAN" + ); + if (pIsBlock && pHadLeafOrphan) { + rerenderBlocks.add(P); + } 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); } - } else { - renderNew.push(np); } - } else if (n.nodeType === 1 || n.nodeType === 3) { + }); + if (m.removedNodes.length) { + m.removedNodes.forEach((node) => { + if (node.nodeType !== 1) { + return; + } + for (const root of rootsWithin(node)) { + removedRoots.add(root); + } + }); const b = nearestBlock(m.target); if (b) { rerenderBlocks.add(b); } } - }); - if (m.removedNodes.length) { + } else { const b = nearestBlock(m.target); if (b) { rerenderBlocks.add(b); } } - } else { - const b = nearestBlock(m.target); - if (b) { - rerenderBlocks.add(b); - } } + } finally { + compactExpectedClassMutations(); } - if (!rerenderBlocks.size && !renderNew.length) { + 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) { - if (!el.isConnected) { - continue; + withObserverPaused(() => { + unwindRoots(Array.from(removedRoots).sort((a, b) => a.contains(b) ? 1 : b.contains(a) ? -1 : 0)); + const all = Array.from(rerenderBlocks).filter((b) => b.isConnected); + const blocks = all.filter((b) => !all.some((o) => o !== b && o.contains(b))); + for (const b of blocks) { + revertImpl(b); + renderImpl(b); } - if (blocks.some((b) => b.contains(el))) { - continue; + for (const el of renderNew) { + if (!el.isConnected) { + continue; + } + if (blocks.some((b) => b.contains(el))) { + continue; + } + renderImpl(el); } - renderImpl(el); - } - resumeObserver(); + }); } return { options, @@ -2442,28 +3140,24 @@ var Juzhen = (() => { if (!el) { return; } - pauseObserver(); - renderImpl(el); - resumeObserver(); + withObserverPaused(() => renderImpl(el)); }, revert(root) { const el = resolveRoot(root, options); if (!el) { return; } - pauseObserver(); - revertImpl(el); - resumeObserver(); + withObserverPaused(() => revertImpl(el)); }, rerender(root) { const el = resolveRoot(root, options); if (!el) { return; } - pauseObserver(); - revertImpl(el); - renderImpl(el); - resumeObserver(); + withObserverPaused(() => { + revertImpl(el); + renderImpl(el); + }); }, invalidate(root) { const el = resolveRoot(root, options); @@ -2479,19 +3173,26 @@ var Juzhen = (() => { 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() { + observerActive = false; if (observer) { observer.disconnect(); observer = null; observeRoot = null; } + clearExpectedClassMutations(); } }; } diff --git a/dist/juzhen.mjs b/dist/juzhen.mjs index 0c7145f..c6f9ae8 100644 --- a/dist/juzhen.mjs +++ b/dist/juzhen.mjs @@ -1,3 +1,182 @@ +// src/core/unicode.ts +var CJK = "\u2E80-\u2EFF\u2F00-\u2FDF\u3040-\u309F\u30A0-\u30FA\u30FC-\u30FF\u3100-\u312F\u3200-\u32FF\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\\u{20000}-\\u{2A6DF}\\u{2A700}-\\u{2B73F}\\u{2B740}-\\u{2B81F}\\u{2B820}-\\u{2CEAF}\\u{2CEB0}-\\u{2EBEF}\\u{2EBF0}-\\u{2EE5F}\\u{2F800}-\\u{2FA1F}\\u{30000}-\\u{3134F}\\u{31350}-\\u{323AF}\\u{323B0}-\\u{3347F}"; +var VARIATION_SELECTORS = "\\uFE00-\\uFE0F\\u{E0100}-\\u{E01EF}"; +function codePointAtUtf16(text, offset) { + const value = text.codePointAt(offset); + return value === void 0 ? "" : String.fromCodePoint(value); +} +function previousCodePointStart(text, offset) { + if (offset <= 0) { + return 0; + } + const last = text.charCodeAt(offset - 1); + if (last >= 56320 && last <= 57343 && offset >= 2) { + const first = text.charCodeAt(offset - 2); + if (first >= 55296 && first <= 56319) { + return offset - 2; + } + } + return offset - 1; +} +function isVariationSelector(ch) { + const value = ch.codePointAt(0); + return value !== void 0 && (value >= 65024 && value <= 65039 || value >= 917760 && value <= 917999); +} +function variationSequenceAtUtf16(text, offset) { + const base = codePointAtUtf16(text, offset); + if (!base || isVariationSelector(base)) { + return base; + } + const selector = codePointAtUtf16(text, offset + base.length); + return isVariationSelector(selector) ? base + selector : base; +} +function previousVariationSequenceStart(text, offset) { + 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; +} +function splitVariationSequences(text) { + const out = []; + for (let offset = 0; offset < text.length; ) { + const sequence = variationSequenceAtUtf16(text, offset); + out.push(sequence); + offset += sequence.length; + } + return out; +} +var ALNUM = "A-Za-z0-9"; +var ALPHA = "A-Za-z"; +var WEST_SHARED = "0-9\\$%\\^&\\*\\-\\+\\\\=\\u0370-\\u03ff\\u0100-\\u024f\\u0400-\\u04ff\\u00a1-\\u00b6\\u00b8-\\u00ff\\u20a0-\\u20bf\\u2150-\\u21ff\\u2200-\\u22ff\\u2700-\\u27bf"; +var ANS_AFTER_CJK = ALPHA + "@" + WEST_SHARED; +var ANS_BEFORE_CJK = ALPHA + WEST_SHARED; +var BD_STOP = "\u3002\uFF0E\uFF01\uFF1F"; +var BD_PAUSE = "\uFF0C\u3001\uFF1B\uFF1A"; +var BD_OPEN = "\u300C\u300E\uFF08\u300A\u3008\u3010\u3016\u3014\uFF3B\uFF5B\u201C\u2018"; +var BD_CLOSE = "\u300D\u300F\uFF09\u300B\u3009\u3011\u3017\u3015\uFF3D\uFF5D\u201D\u2019"; +var BD_MIDDLE = "\xB7\u30FB\u2027"; +var BD_LIGA = "\u2026\u2014"; +var BD_ORDER = [ + "stop", + "pause", + "open", + "close", + "middle", + "liga" +]; +var DEFAULT_RULESET = { + cjk: CJK, + ansAfter: ANS_AFTER_CJK, + ansBefore: ANS_BEFORE_CJK, + biaodian: { + stop: BD_STOP, + pause: BD_PAUSE, + open: BD_OPEN, + close: BD_CLOSE, + middle: BD_MIDDLE, + liga: BD_LIGA + } +}; +function resolveRuleset(override) { + if (!override) { + return DEFAULT_RULESET; + } + const west = override.western || ""; + const bd = { ...DEFAULT_RULESET.biaodian }; + if (override.biaodian) { + for (const sub of BD_ORDER) { + const extra = override.biaodian[sub]; + if (extra) { + bd[sub] = bd[sub] + extra; + } + } + } + return { + cjk: CJK + (override.cjk || ""), + ansAfter: ANS_AFTER_CJK + west, + ansBefore: ANS_BEFORE_CJK + west, + biaodian: bd + }; +} +function classifyBiaodian(ch, biaodian = DEFAULT_RULESET.biaodian) { + const base = codePointAtUtf16(ch, 0); + for (const sub of BD_ORDER) { + if (biaodian[sub].indexOf(base) >= 0) { + return "bd-" + sub; + } + } + return null; +} +function blankSides(bdClass) { + switch (bdClass) { + case "bd-open": { + return { left: true, right: false }; + } + case "bd-close": + case "bd-pause": + case "bd-stop": { + return { left: false, right: true }; + } + default: { + return { left: false, right: false }; + } + } +} +var BRACKET_OPEN_RIGHT = "\u300C\u3008\u201C\u2018"; +var BRACKET_CLOSE_LEFT = "\u300D\u3009\u201D\u2019"; +var BIAS_BRACKETS = (() => { + const t = {}; + for (const ch of BD_OPEN) { + t[ch] = BRACKET_OPEN_RIGHT.indexOf(ch) >= 0 ? "right" : "center"; + } + for (const ch of BD_CLOSE) { + t[ch] = BRACKET_CLOSE_LEFT.indexOf(ch) >= 0 ? "left" : "center"; + } + return t; +})(); +var BIAS_HANS = { + "\uFF0C": "left", + "\u3001": "left", + "\u3002": "left", + "\uFF0E": "left", + "\uFF1B": "left", + "\uFF1A": "left", + "\uFF01": "left", + "\uFF1F": "left" +}; +var BIAS_HANT = { + "\uFF0C": "center", + "\u3001": "center", + "\u3002": "center", + "\uFF0E": "center", + "\uFF1B": "center", + "\uFF1A": "center", + "\uFF01": "center", + "\uFF1F": "center" +}; +var BIAS_DEFAULT = { + "zh-Hans": BIAS_HANS, + "zh-Hant": BIAS_HANT, + "ja": BIAS_HANT, + // 日文點號慣例居中,沿用繁體表 + "other": BIAS_HANT +}; +function inkBias(ch, langClass, overrides) { + const ov = overrides ? overrides[langClass] : void 0; + if (ov && ov[ch]) { + return ov[ch]; + } + const bracket = BIAS_BRACKETS[ch]; + if (bracket) { + return bracket; + } + const t = BIAS_DEFAULT[langClass] || BIAS_DEFAULT.other; + return t[ch] || null; +} + // src/core/finder.ts var SVG_NS = "http://www.w3.org/2000/svg"; var Finder = class { @@ -264,7 +443,7 @@ var Finder = class { let curText = ""; let curMap = []; const flush = () => { - if (curText.length >= 2) { + if (curMap.length >= 2) { runs.push({ text: curText, map: curMap }); } curText = ""; @@ -280,9 +459,21 @@ var Finder = class { return; } const data = t.data; - for (let i = 0; i < data.length; i += 1) { - curText += data.charAt(i); - curMap.push({ textNode: t, offset: i }); + for (let i = 0; i < data.length; ) { + 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) + ))) { + previous.text += ch; + previous.parts.push(part); + } else { + curMap.push({ text: ch, parts: [part] }); + } + i += ch.length; } return; } @@ -331,13 +522,14 @@ var Finder = class { adjacentLogicalChar(el, direction) { const pickEnd = (tn) => { 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) }; }; const descend = (into) => { let n2 = direction === -1 ? into.lastChild : into.firstChild; @@ -485,6 +677,231 @@ var Finder = class { } }; +// src/core/dom.ts +var KIND_ATTR = "data-jz-kind"; +var PASS_ATTR = "data-jz"; +var WRAP_TAGS = /* @__PURE__ */ new Map([ + ["spacing", /* @__PURE__ */ new Set(["JZ-HWS"])], + ["jiya", /* @__PURE__ */ new Set(["JZ-CHAR", "JZ-INNER"])], + ["jinze", /* @__PURE__ */ new Set(["JZ-JINZE"])], + ["longWord", /* @__PURE__ */ new Set(["SPAN"])], + ["orphan", /* @__PURE__ */ new Set(["JZ-ORPHAN"])] +]); +var SPLITOFF_TAGS = /* @__PURE__ */ new Set([ + "STRONG", + "EM", + "B", + "I", + "U", + "S", + "SPAN", + "SMALL", + "INS", + "DEL", + "SUB", + "SUP" +]); +var ATTRIBUTE_WRITERS = /* @__PURE__ */ new WeakMap(); +var OWNER_ATTRIBUTES = /* @__PURE__ */ new WeakMap(); +function trackOwnedAttribute(owner, name, el) { + let attrs = OWNER_ATTRIBUTES.get(owner); + if (!attrs) { + attrs = /* @__PURE__ */ new Map(); + OWNER_ATTRIBUTES.set(owner, attrs); + } + let elements = attrs.get(name); + if (!elements) { + elements = /* @__PURE__ */ new Set(); + attrs.set(name, elements); + } + elements.add(el); +} +function untrackOwnedAttribute(owner, name, el) { + 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() { + if (typeof document === "undefined") { + throw new Error("juzhen: \u7121 document\uFF08\u9700\u65BC\u700F\u89BD\u5668\u6216 jsdom \u74B0\u5883\u57F7\u884C\uFF09\u3002"); + } + return document; +} +function createJz(ownedElements, tag, pass, kind, opts = {}) { + const el = doc().createElement(tag); + ownedElements.add(el); + el.setAttribute(PASS_ATTR, pass); + el.setAttribute(KIND_ATTR, kind); + if (opts.classes) { + const list = Array.isArray(opts.classes) ? opts.classes : [opts.classes]; + for (const c of list) { + if (c) { + el.classList.add(c); + } + } + } + if (opts.text !== void 0) { + el.textContent = opts.text; + } + return el; +} +function setOwnedAttribute(el, name, value, owner) { + let attrs = ATTRIBUTE_WRITERS.get(el); + if (!attrs) { + attrs = /* @__PURE__ */ 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) { + 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); +} +function revertOwnedAttribute(el, name, owner) { + 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); + } +} +function revertOwnedAttributes(root, name, owner) { + 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, kind, el) { + 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; +} +function revertPass(pass, root, ownedElements) { + const nodes = Array.from( + root.querySelectorAll("[" + PASS_ATTR + '="' + pass + '"]') + ).filter((el) => ownedElements.has(el)); + const touched = /* @__PURE__ */ new Set(); + for (let i = nodes.length - 1; i >= 0; i -= 1) { + const el = nodes[i]; + 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); + while (el.firstChild) { + parent.insertBefore(el.firstChild, el); + } + parent.removeChild(el); + ownedElements.delete(el); + } + const splitoffs = Array.from( + root.querySelectorAll( + "[" + PASS_ATTR + '="' + pass + '"][' + KIND_ATTR + '="splitoff"]' + ) + ).filter((el) => ownedElements.has(el) && knownProduct(pass, "splitoff", el)); + for (const so of splitoffs) { + const parent = so.parentNode; + if (!parent) { + continue; + } + touched.add(parent); + const prev = so.previousSibling; + if (prev && prev.nodeType === 1 && prev.nodeName === so.nodeName) { + while (so.firstChild) { + prev.appendChild(so.firstChild); + } + parent.removeChild(so); + } else { + while (so.firstChild) { + parent.insertBefore(so.firstChild, so); + } + parent.removeChild(so); + } + ownedElements.delete(so); + } + for (const p of touched) { + if (p.normalize) { + p.normalize(); + } + } +} +var BD_CLASSES = ["bd-open", "bd-close", "bd-pause", "bd-stop", "bd-middle", "bd-liga"]; +function bdClassOf(el) { + for (const c of BD_CLASSES) { + if (el.classList.contains(c)) { + return c; + } + } + return null; +} +function isJz(node, pass) { + if (!node || node.nodeType !== 1) { + return false; + } + const el = node; + if (!el.hasAttribute(PASS_ATTR)) { + return false; + } + return pass === void 0 || el.getAttribute(PASS_ATTR) === pass; +} + // src/core/pass.ts function runPasses(passes, ctx) { const enabled = passes.filter((p) => p.enabled(ctx.options)); @@ -521,10 +938,10 @@ function runCharify(passes, ctx) { } }); for (const item of work) { - applyRequests(item.node, item.requests); + applyRequests(item.node, item.requests, ctx.ownedElements); } } -function applyRequests(node, requests) { +function applyRequests(node, requests, ownedElements) { const parent = node.parentNode; if (!parent) { return; @@ -547,7 +964,9 @@ function applyRequests(node, requests) { if (req.start > cursor) { 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) { @@ -559,107 +978,6 @@ function applyRequests(node, requests) { parent.replaceChild(frag, node); } -// src/core/dom.ts -var KIND_ATTR = "data-jz-kind"; -var PASS_ATTR = "data-jz"; -function doc() { - if (typeof document === "undefined") { - throw new Error("juzhen: \u7121 document\uFF08\u9700\u65BC\u700F\u89BD\u5668\u6216 jsdom \u74B0\u5883\u57F7\u884C\uFF09\u3002"); - } - return document; -} -function createJz(tag, pass, kind, opts = {}) { - const el = doc().createElement(tag); - el.setAttribute(PASS_ATTR, pass); - el.setAttribute(KIND_ATTR, kind); - if (opts.classes) { - const list = Array.isArray(opts.classes) ? opts.classes : [opts.classes]; - for (const c of list) { - if (c) { - el.classList.add(c); - } - } - } - if (opts.text !== void 0) { - el.textContent = opts.text; - } - return el; -} -function revertPass(pass, root) { - const nodes = Array.from( - root.querySelectorAll("[" + PASS_ATTR + '="' + pass + '"]') - ); - const touched = /* @__PURE__ */ new Set(); - for (let i = nodes.length - 1; i >= 0; i -= 1) { - const el = nodes[i]; - const parent = el.parentNode; - if (!parent) { - continue; - } - const kind = el.getAttribute(KIND_ATTR); - if (kind === "splitoff") { - continue; - } - touched.add(parent); - if (kind === "marker") { - parent.removeChild(el); - } else { - while (el.firstChild) { - parent.insertBefore(el.firstChild, el); - } - parent.removeChild(el); - } - } - const splitoffs = Array.from( - root.querySelectorAll( - "[" + PASS_ATTR + '="' + pass + '"][' + KIND_ATTR + '="splitoff"]' - ) - ); - for (const so of splitoffs) { - const parent = so.parentNode; - if (!parent) { - continue; - } - touched.add(parent); - const prev = so.previousSibling; - if (prev && prev.nodeType === 1 && prev.nodeName === so.nodeName) { - while (so.firstChild) { - prev.appendChild(so.firstChild); - } - parent.removeChild(so); - } else { - while (so.firstChild) { - parent.insertBefore(so.firstChild, so); - } - parent.removeChild(so); - } - } - for (const p of touched) { - if (p.normalize) { - p.normalize(); - } - } -} -var BD_CLASSES = ["bd-open", "bd-close", "bd-pause", "bd-stop", "bd-middle", "bd-liga"]; -function bdClassOf(el) { - for (const c of BD_CLASSES) { - if (el.classList.contains(c)) { - return c; - } - } - return null; -} -function isJz(node, pass) { - if (!node || node.nodeType !== 1) { - return false; - } - const el = node; - if (!el.hasAttribute(PASS_ATTR)) { - return false; - } - return pass === void 0 || el.getAttribute(PASS_ATTR) === pass; -} - // src/core/locale.ts var DEFAULT_POLICY = { "zh-Hant": "quanjiao", @@ -684,7 +1002,7 @@ function classify(lang) { 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"; } @@ -741,136 +1059,6 @@ function resolveStyle(node, config) { return styleFor(classify(lang), config); } -// src/core/unicode.ts -var CJK = "\u2E80-\u2EFF\u2F00-\u2FDF\u3040-\u309F\u30A0-\u30FA\u30FC-\u30FF\u3100-\u312F\u3200-\u32FF\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF"; -var ALNUM = "A-Za-z0-9"; -var ALPHA = "A-Za-z"; -var WEST_SHARED = "0-9\\$%\\^&\\*\\-\\+\\\\=\\u0370-\\u03ff\\u0100-\\u024f\\u0400-\\u04ff\\u00a1-\\u00b6\\u00b8-\\u00ff\\u20a0-\\u20bf\\u2150-\\u21ff\\u2200-\\u22ff\\u2700-\\u27bf"; -var ANS_AFTER_CJK = ALPHA + "@" + WEST_SHARED; -var ANS_BEFORE_CJK = ALPHA + WEST_SHARED; -var BD_STOP = "\u3002\uFF0E\uFF01\uFF1F"; -var BD_PAUSE = "\uFF0C\u3001\uFF1B\uFF1A"; -var BD_OPEN = "\u300C\u300E\uFF08\u300A\u3008\u3010\u3016\u3014\uFF3B\uFF5B\u201C\u2018"; -var BD_CLOSE = "\u300D\u300F\uFF09\u300B\u3009\u3011\u3017\u3015\uFF3D\uFF5D\u201D\u2019"; -var BD_MIDDLE = "\xB7\u30FB\u2027"; -var BD_LIGA = "\u2026\u2014"; -var BD_ORDER = [ - "stop", - "pause", - "open", - "close", - "middle", - "liga" -]; -var DEFAULT_RULESET = { - cjk: CJK, - ansAfter: ANS_AFTER_CJK, - ansBefore: ANS_BEFORE_CJK, - biaodian: { - stop: BD_STOP, - pause: BD_PAUSE, - open: BD_OPEN, - close: BD_CLOSE, - middle: BD_MIDDLE, - liga: BD_LIGA - } -}; -function resolveRuleset(override) { - if (!override) { - return DEFAULT_RULESET; - } - const west = override.western || ""; - const bd = { ...DEFAULT_RULESET.biaodian }; - if (override.biaodian) { - for (const sub of BD_ORDER) { - const extra = override.biaodian[sub]; - if (extra) { - bd[sub] = bd[sub] + extra; - } - } - } - return { - cjk: CJK + (override.cjk || ""), - ansAfter: ANS_AFTER_CJK + west, - ansBefore: ANS_BEFORE_CJK + west, - biaodian: bd - }; -} -function classifyBiaodian(ch, biaodian = DEFAULT_RULESET.biaodian) { - for (const sub of BD_ORDER) { - if (biaodian[sub].indexOf(ch) >= 0) { - return "bd-" + sub; - } - } - return null; -} -function blankSides(bdClass) { - switch (bdClass) { - case "bd-open": { - return { left: true, right: false }; - } - case "bd-close": - case "bd-pause": - case "bd-stop": { - return { left: false, right: true }; - } - default: { - return { left: false, right: false }; - } - } -} -var BRACKET_OPEN_RIGHT = "\u300C\u3008\u201C\u2018"; -var BRACKET_CLOSE_LEFT = "\u300D\u3009\u201D\u2019"; -var BIAS_BRACKETS = (() => { - const t = {}; - for (const ch of BD_OPEN) { - t[ch] = BRACKET_OPEN_RIGHT.indexOf(ch) >= 0 ? "right" : "center"; - } - for (const ch of BD_CLOSE) { - t[ch] = BRACKET_CLOSE_LEFT.indexOf(ch) >= 0 ? "left" : "center"; - } - return t; -})(); -var BIAS_HANS = { - "\uFF0C": "left", - "\u3001": "left", - "\u3002": "left", - "\uFF0E": "left", - "\uFF1B": "left", - "\uFF1A": "left", - "\uFF01": "left", - "\uFF1F": "left" -}; -var BIAS_HANT = { - "\uFF0C": "center", - "\u3001": "center", - "\u3002": "center", - "\uFF0E": "center", - "\uFF1B": "center", - "\uFF1A": "center", - "\uFF01": "center", - "\uFF1F": "center" -}; -var BIAS_DEFAULT = { - "zh-Hans": BIAS_HANS, - "zh-Hant": BIAS_HANT, - "ja": BIAS_HANT, - // 日文點號慣例居中,沿用繁體表 - "other": BIAS_HANT -}; -function inkBias(ch, langClass, overrides) { - const ov = overrides ? overrides[langClass] : void 0; - if (ov && ov[ch]) { - return ov[ch]; - } - const bracket = BIAS_BRACKETS[ch]; - if (bracket) { - return bracket; - } - const t = BIAS_DEFAULT[langClass] || BIAS_DEFAULT.other; - return t[ch] || null; -} - // src/typeset/jiya.ts var PASS = "jiya"; function baselineHalf(style, bdClass) { @@ -912,10 +1100,11 @@ var jiyaPass = { let langClass = null; const biasOverrides = ctx.options.jiyaConfig.bias; const biaodian = ctx.options.ruleset.biaodian; - for (let i = 0; i < data.length; i += 1) { - const ch = data.charAt(i); + for (let i = 0; i < data.length; ) { + const ch = variationSequenceAtUtf16(data, i); const bdClass = classifyBiaodian(ch, biaodian); if (!bdClass) { + i += ch.length; continue; } if (style === null) { @@ -925,26 +1114,39 @@ var jiyaPass = { langClass = classify(effectiveLang(node, ctx.options.locale.default)); } const classes = baselineHalf(style, bdClass) ? [bdClass, "jz-half"] : [bdClass]; - 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) { - revertPass(PASS, ctx.root); + revertPass(PASS, ctx.root, ctx.ownedElements); } }; var jiyaAdjacencyPass = { @@ -986,6 +1188,10 @@ var jiyaAdjacencyPass = { const el = c; const nm = el.nodeName; if (nm === "JZ-CHAR") { + if (!ctx.ownedElements.has(el)) { + prev = null; + continue; + } if (prev) { squeeze(prev, el); } @@ -1075,27 +1281,35 @@ function isTokenChar(ch, anyCjk) { return !anyCjk.test(ch) && !/\s/.test(ch); } function leadingTokenEnd(d, anyCjk) { - if (anyCjk.test(d.charAt(0))) { - return 1; + const first = variationSequenceAtUtf16(d, 0); + if (anyCjk.test(first)) { + return first.length; } - let e = 1; - while (e < d.length && isTokenChar(d.charAt(e), anyCjk)) { - e += 1; + let e = first.length; + while (e < d.length) { + const ch = variationSequenceAtUtf16(d, e); + if (!isTokenChar(ch, anyCjk)) { + break; + } + e += ch.length; } return e; } function trailingTokenStart(d, anyCjk) { - const last = d.length - 1; - if (anyCjk.test(d.charAt(last))) { - return last; + let s = previousVariationSequenceStart(d, d.length); + if (anyCjk.test(variationSequenceAtUtf16(d, s))) { + return s; } - let s = last; - while (s > 0 && isTokenChar(d.charAt(s - 1), anyCjk)) { - s -= 1; + while (s > 0) { + const previous = previousVariationSequenceStart(d, s); + if (!isTokenChar(variationSequenceAtUtf16(d, previous), anyCjk)) { + break; + } + s = previous; } return s; } -function splitTreeAfter(top, textNode, offset, pass, finder) { +function splitTreeAfter(top, textNode, offset, pass, finder, ownedElements) { for (let a = textNode.parentNode; a && a.nodeType === 1; a = a.parentNode) { if (!canSplit(a, finder)) { return null; @@ -1125,6 +1339,7 @@ function splitTreeAfter(top, textNode, offset, pass, finder) { clone.removeAttribute("id"); clone.setAttribute(PASS_ATTR2, pass); clone.setAttribute(KIND_ATTR2, "splitoff"); + ownedElements.add(clone); let n = rightAtLevel; while (n) { const next = n.nextSibling; @@ -1154,7 +1369,7 @@ function splitTreeAfter(top, textNode, offset, pass, finder) { } return topClone; } -function isolateBoundaryToken(el, side, anyCjk, pass, finder) { +function isolateBoundaryToken(el, side, anyCjk, pass, finder, ownedElements) { if (!canSplit(el, finder)) { return el; } @@ -1163,17 +1378,17 @@ function isolateBoundaryToken(el, side, anyCjk, pass, finder) { return el; } const point = side === "tail" ? { 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; } return side === "tail" ? clone : el; } -function splitElementAt(el, textNode, offset, pass, finder) { +function splitElementAt(el, textNode, offset, pass, finder, ownedElements) { if (!canSplit(el, finder)) { return null; } - return splitTreeAfter(el, textNode, offset, pass, finder); + return splitTreeAfter(el, textNode, offset, pass, finder, ownedElements); } // src/typeset/jinze.ts @@ -1247,23 +1462,31 @@ function isTokenChar2(ch, anyCjk) { return !anyCjk.test(ch) && !/\s/.test(ch); } function leadingTokenEnd2(d, anyCjk) { - if (anyCjk.test(d.charAt(0))) { - return 1; + const first = variationSequenceAtUtf16(d, 0); + if (anyCjk.test(first)) { + return first.length; } - let e = 1; - while (e < d.length && isTokenChar2(d.charAt(e), anyCjk)) { - e += 1; + let e = first.length; + while (e < d.length) { + const ch = variationSequenceAtUtf16(d, e); + if (!isTokenChar2(ch, anyCjk)) { + break; + } + e += ch.length; } return e; } function trailingTokenStart2(d, anyCjk) { - const last = d.length - 1; - if (anyCjk.test(d.charAt(last))) { - return last; + let s = previousVariationSequenceStart(d, d.length); + if (anyCjk.test(variationSequenceAtUtf16(d, s))) { + return s; } - let s = last; - while (s > 0 && isTokenChar2(d.charAt(s - 1), anyCjk)) { - s -= 1; + while (s > 0) { + const previous = previousVariationSequenceStart(d, s); + if (!isTokenChar2(variationSequenceAtUtf16(d, previous), anyCjk)) { + break; + } + s = previous; } return s; } @@ -1282,7 +1505,7 @@ function splitBoundaries(t, needFirst, needLast, anyCjk) { } } } -function processParent(parent, finder, anyCjk) { +function processParent(parent, finder, anyCjk, ownedElements) { if (isJz(parent, PASS2)) { return; } @@ -1319,10 +1542,10 @@ function processParent(parent, finder, anyCjk) { } else if (u.nodeType === 1) { const el = u; if (needLast) { - isolateBoundaryToken(el, "tail", anyCjk, PASS2, finder); + isolateBoundaryToken(el, "tail", anyCjk, PASS2, finder, ownedElements); } if (needFirst) { - isolateBoundaryToken(el, "head", anyCjk, PASS2, finder); + isolateBoundaryToken(el, "head", anyCjk, PASS2, finder, ownedElements); } } } @@ -1338,7 +1561,7 @@ function processParent(parent, finder, anyCjk) { i += 1; } if (run.length >= 2) { - const group = createJz("jz-jinze", PASS2, "wrap"); + const group = createJz(ownedElements, "jz-jinze", PASS2, "wrap"); parent.insertBefore(group, run[0]); for (const node of run) { group.appendChild(node); @@ -1355,9 +1578,12 @@ var jinzePass = { enabled: (o) => o.features.jinze, render(ctx) { const { finder } = ctx; - const anyCjk = new RegExp("[" + ctx.options.ruleset.cjk + "]"); + const anyCjk = new RegExp("[" + ctx.options.ruleset.cjk + "]", "u"); const parents = /* @__PURE__ */ new Set(); ctx.root.querySelectorAll("jz-char").forEach((c) => { + if (!ctx.ownedElements.has(c)) { + return; + } if (!finder.levelAllows(c, "paragraph")) { return; } @@ -1378,11 +1604,11 @@ var jinzePass = { }); const ordered = Array.from(parents).sort((a, b) => depthOf(b) - depthOf(a)); for (const parent of ordered) { - processParent(parent, finder, anyCjk); + processParent(parent, finder, anyCjk, ctx.ownedElements); } }, revert(ctx) { - revertPass(PASS2, ctx.root); + revertPass(PASS2, ctx.root, ctx.ownedElements); } }; @@ -1400,7 +1626,7 @@ var levelMarkPass = { const { finder, options } = ctx; const blockSet = options.finder.blockTags; const nearest = /* @__PURE__ */ new Map(); - const nearestBlock = (p) => { + const nearestBlock = ((p) => { const cached = nearest.get(p); if (cached !== void 0) { return cached; @@ -1419,7 +1645,7 @@ var levelMarkPass = { } nearest.set(p, block); return block; - }; + }); finder.eachTextNode(ctx.root, (t) => { if (!/\S/.test(t.data)) { return; @@ -1430,16 +1656,11 @@ var levelMarkPass = { return; } const level = finder.levelFor(block); - if (block.getAttribute(ATTR) !== level) { - block.setAttribute(ATTR, level); - } + setOwnedAttribute(block, ATTR, level, ctx.ownerToken); }); }, revert(ctx) { - if (ctx.root.hasAttribute(ATTR)) { - ctx.root.removeAttribute(ATTR); - } - ctx.root.querySelectorAll("[" + ATTR + "]").forEach((el) => el.removeAttribute(ATTR)); + revertOwnedAttributes(ctx.root, ATTR, ctx.ownerToken); } }; @@ -1453,12 +1674,17 @@ var observers = /* @__PURE__ */ new WeakMap(); function hasLayout() { return typeof ResizeObserver !== "undefined"; } -function installLayout(root, key, run) { +function installLayout(root, owner, key, run) { run(); - let m = observers.get(root); + let owners = observers.get(root); + if (!owners) { + owners = /* @__PURE__ */ new Map(); + observers.set(root, owners); + } + let m = owners.get(owner); if (!m) { m = /* @__PURE__ */ new Map(); - observers.set(root, m); + owners.set(owner, m); } const existing = m.get(key); if (existing) { @@ -1472,8 +1698,9 @@ function installLayout(root, key, run) { fonts.ready.then(run, () => void 0); } } -function uninstallLayout(root, key) { - const m = observers.get(root); +function uninstallLayout(root, owner, key) { + const owners = observers.get(root); + const m = owners?.get(owner); if (!m) { return; } @@ -1482,6 +1709,9 @@ function uninstallLayout(root, key) { ro.disconnect(); m.delete(key); } + if (m.size === 0) { + owners.delete(owner); + } } var JZ_WRAP_TAGS = ["JZ-JINZE", "JZ-CHAR", "JZ-INNER", "JZ-HWS"]; function buildWrapSet(options) { @@ -1499,9 +1729,10 @@ function rectOf(n, side) { return null; } const range = doc2.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]; @@ -1549,23 +1780,27 @@ function lastRect(el) { const rects = el.getClientRects(); return rects.length ? rects[rects.length - 1] : null; } -function clearEdge(root) { +function clearEdge(ctx) { 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); + } + }); } } -function relayout(root, finder, options) { - clearEdge(root); - const wrapSet = buildWrapSet(options); - const chars = Array.from(root.querySelectorAll("jz-char")).filter( - (c) => finder.inScope(c) && !finder.isAvoided(c) +function relayout(ctx) { + clearEdge(ctx); + const wrapSet = buildWrapSet(ctx.options); + 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 = []; for (const el of chars) { - if (!finder.levelAllows(el, "paragraph")) { + if (!ctx.finder.levelAllows(el, "paragraph")) { continue; } - if (!finder.featureEnabledFor(el, "jiya")) { + if (!ctx.finder.featureEnabledFor(el, "jiya")) { continue; } const bd = bdClassOf(el); @@ -1594,7 +1829,7 @@ function relayout(root, finder, options) { } } for (const d of decisions) { - d.el.classList.add(LE); + ctx.setOwnedClass(d.el, LE, true); } } var lineEdgePass = { @@ -1607,11 +1842,13 @@ var lineEdgePass = { if (!hasLayout()) { return; } - installLayout(ctx.root, "lineEdge", () => relayout(ctx.root, ctx.finder, ctx.options)); + installLayout(ctx.root, ctx.ownerToken, "lineEdge", () => { + relayout(ctx); + }); }, revert(ctx) { - uninstallLayout(ctx.root, "lineEdge"); - clearEdge(ctx.root); + uninstallLayout(ctx.root, ctx.ownerToken, "lineEdge"); + clearEdge(ctx); } }; var TRIM = "jz-hws-trim"; @@ -1634,21 +1871,21 @@ function skippableAlign(g, cache) { cache.set(key, result); return result; } -function trimGaps(root, finder, wrapSet) { +function trimGaps(ctx, wrapSet) { const alignCache = /* @__PURE__ */ new Map(); - const gaps = Array.from(root.querySelectorAll("jz-hws")).filter( - (g) => finder.inScope(g) && !finder.isAvoided(g) && finder.levelAllows(g, "paragraph") && finder.featureEnabledFor(g, "spacing") && !skippableAlign(g, alignCache) + 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; } - 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); + ctx.setOwnedClass(g, TRIM, false); } } } @@ -1663,11 +1900,17 @@ var gapTrimPass = { 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) { - 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); + } + }); } }; @@ -1715,6 +1958,7 @@ var longWordPass = { span.setAttribute("data-jz", PASS3); span.setAttribute("data-jz-kind", "wrap"); span.textContent = text; + ctx.ownedElements.add(span); return span; } }); @@ -1722,7 +1966,7 @@ var longWordPass = { return out; }, revert(ctx) { - revertPass(PASS3, ctx.root); + revertPass(PASS3, ctx.root, ctx.ownedElements); } }; @@ -1742,11 +1986,12 @@ function collectSubst(block, finder, blockSet, anyCjk, biaodian) { if (node.nodeType === 3) { const t = node; const d = t.data; - for (let i = 0; i < d.length; i += 1) { - const ch = d.charAt(i); + for (let i = 0; i < d.length; ) { + const ch = variationSequenceAtUtf16(d, i); if (anyCjk.test(ch) && !classifyBiaodian(ch, biaodian)) { out.push({ directChild, textNode: t, offset: i }); } + i += ch.length; } return; } @@ -1769,7 +2014,7 @@ function collectSubst(block, finder, blockSet, anyCjk, biaodian) { } return out; } -function processBlock(block, finder, blockSet, anyCjk, biaodian, chars) { +function processBlock(block, finder, blockSet, anyCjk, biaodian, chars, ownedElements) { if (hasOrphan(block)) { return; } @@ -1789,13 +2034,14 @@ function processBlock(block, finder, blockSet, anyCjk, biaodian, chars) { boundary.textNode, boundary.offset, PASS4, - finder + finder, + ownedElements ); if (clone) { startChild = clone; } } - const orphan = createJz("jz-orphan", PASS4, "wrap"); + const orphan = createJz(ownedElements, "jz-orphan", PASS4, "wrap"); block.insertBefore(orphan, startChild); let n = startChild; while (n) { @@ -1813,7 +2059,7 @@ var orphanPass = { render(ctx) { 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; const sel = Array.from(blockSet).map((t) => t.toLowerCase()).join(","); @@ -1843,11 +2089,19 @@ var orphanPass = { if (nonLeaf.has(block)) { return; } - processBlock(block, finder, blockSet, anyCjk, biaodian, chars); + processBlock( + block, + finder, + blockSet, + anyCjk, + biaodian, + chars, + ctx.ownedElements + ); }); }, revert(ctx) { - revertPass(PASS4, ctx.root); + revertPass(PASS4, ctx.root, ctx.ownedElements); } }; @@ -1868,45 +2122,63 @@ function makeRules(rs) { const CJK2 = rs.cjk; const ANS_AFTER = rs.ansAfter; const ANS_BEFORE = rs.ansBefore; + const CJK_ATOM = "(?:[" + CJK2 + "](?:[" + VARIATION_SELECTORS + "])?)"; + const CJK_OR_ALNUM = "(?:" + CJK_ATOM + "|[" + ALNUM + "])"; return { - anyCjk: new RegExp("[" + CJK2 + "]"), - pillNeighbor: new RegExp("[" + ALNUM + CJK2 + "]"), - STRIP_CJK_SPACE_ANS: new RegExp("([" + CJK2 + "]) ([" + ANS_AFTER + "])", "g"), - STRIP_ANS_SPACE_CJK: new RegExp("([" + ANS_BEFORE + "]) ([" + CJK2 + "])", "g"), - STRIP_CJK_SPACE_CJK: new RegExp("([" + CJK2 + "]) (?=[" + CJK2 + "])", "g"), - DOTS_CJK: new RegExp("([\\.]{2,})([" + CJK2 + "])", "g"), - CJK_PUNCTUATION: new RegExp("([" + CJK2 + "])([!;,\\?:]+)(?=[" + CJK2 + ALNUM + "])", "g"), - AN_PUNCTUATION_CJK: new RegExp("([" + ALNUM + "])([!;,\\?]+)([" + CJK2 + "])", "g"), - CJK_TILDE: new RegExp("([" + CJK2 + "])(~+)(?!=)(?=[" + CJK2 + ALNUM + "])", "g"), - CJK_TILDE_EQUALS: new RegExp("([" + CJK2 + "])(~=)", "g"), - CJK_PERIOD: new RegExp("([" + CJK2 + "])(\\.)(?![" + ALNUM + "\\./])(?=[" + CJK2 + ALNUM + "])", "g"), - AN_PERIOD_CJK: new RegExp("([" + ALNUM + "])(\\.)([" + CJK2 + "])", "g"), - AN_COLON_CJK: new RegExp("([" + ALNUM + "])(:)([" + CJK2 + "])", "g"), - CJK_QUOTE: new RegExp("([" + CJK2 + "])([" + QUOTES_CLS + "])", "g"), - QUOTE_CJK: new RegExp("([" + QUOTES_CLS + "])([" + CJK2 + "])", "g"), - CJK_QUOTE_AN: new RegExp("([" + CJK2 + '])(")([' + ALNUM + "])", "g"), - CJK_HASH: new RegExp("([" + CJK2 + "])(#([^ ]))", "g"), - HASH_CJK: new RegExp("(([^ ])#)([" + CJK2 + "])", "g"), - SINGLE_LETTER_GRADE: new RegExp("\\b([" + ALPHA + "])([" + OPS_GRADE + "])([" + CJK2 + "])", "g"), - CJK_OPERATOR_ANS: new RegExp("([" + CJK2 + "])([" + OPS_HYPHEN + "])([" + ALNUM + "])", "g"), - ANS_OPERATOR_CJK: new RegExp("([" + ALNUM + "])([" + OPS_HYPHEN + "])([" + CJK2 + "])", "g"), - CJK_LESS_THAN: new RegExp("([" + CJK2 + "])(<)([" + ALNUM + "])", "g"), - LESS_THAN_CJK: new RegExp("([" + ALNUM + "])(<)([" + CJK2 + "])", "g"), - CJK_GREATER_THAN: new RegExp("([" + CJK2 + "])(>)([" + ALNUM + "])", "g"), - GREATER_THAN_CJK: new RegExp("([" + ALNUM + "])(>)([" + CJK2 + "])", "g"), - CJK_LEFT_BRACKET: new RegExp("([" + CJK2 + "])([" + LBR_EXT + "])", "g"), - RIGHT_BRACKET_CJK: new RegExp("([" + RBR_EXT + "])([" + CJK2 + "])", "g"), - AN_LEFT_BRACKET: new RegExp("([" + ALNUM + "])(?)([" + 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("([" + CJK2 + "])([" + ANS_AFTER + "])", "g"), - ANS_CJK: new RegExp("([" + ANS_BEFORE + "])([" + CJK2 + "])", "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") }; } var 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; var PH_PATTERN = new RegExp(PH_OPEN + "(\\d+)" + PH_CLOSE, "g"); +var ASTRAL_CJK_SHADOW = "\u6F22"; +function shadowAstralCjkAtoms(atoms, R) { + const shadowed = new Array(atoms.length); + for (let i = 0; i < atoms.length; i += 1) { + const atom = atoms[i]; + const base = atom.codePointAt(0); + const match = base !== void 0 && base > 65535 ? R.anyCjk.exec(atom) : null; + shadowed[i] = match?.index === 0 ? ASTRAL_CJK_SHADOW : atom; + } + return shadowed.join(""); +} function panguSpace(text, R) { - if (text.length <= 1 || !R.anyCjk.test(text)) { + if (splitVariationSequences(text).length <= 1 || !R.anyCjk.test(text)) { return text; } let s = text; @@ -1941,81 +2213,350 @@ function panguSpace(text, R) { s = s.replace(PH_PATTERN, (_m, idx) => compounds[Number(idx)] || ""); 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"); s = s.replace(R.ANS_CJK, "$1" + MARK + "$2"); s = s.replace(R.S_A, "$1" + MARK + "$2"); return s; } -function wrapNodeInGap(node) { +var MOVED_PARTS = /* @__PURE__ */ new WeakMap(); +var MOVED_ANCHOR = "jz-spacing-anchor"; +var MOVED_GAP_ANCHOR = "jz-spacing-gap-anchor"; +var CROSS_NODE_ATTR = "data-jz-cross-node-atom"; +function wrapNodeInGap(node, ownedElements) { const parent = node.parentNode; if (!parent || parent.nodeName === "JZ-HWS") { return; } - const gap = createJz("jz-hws", PASS5, "wrap"); + const gap = createJz(ownedElements, "jz-hws", PASS5, "wrap"); parent.insertBefore(gap, node); gap.appendChild(node); } -function wrapGapLeft(pos) { - let tn = pos.textNode; - if (!tn.parentNode || tn.parentNode.nodeName === "JZ-HWS") { +function isolateTextSpan(part) { + 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 wrapSingleSpanInGap(part, ownedElements) { + const containingGap = part.textNode.parentElement?.closest("jz-hws"); + if (containingGap) { + return ownedElements.has(containingGap) && containingGap.getAttribute("data-jz") === PASS5 && 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", PASS5, "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.normalize?.(); + throw error; + } +} +function isOwnedCrossGap(gap, ownedElements) { + return ownedElements.has(gap) && gap.getAttribute("data-jz") === PASS5 && gap.getAttribute("data-jz-kind") === "wrap" && gap.hasAttribute(CROSS_NODE_ATTR); +} +function trustedInsertionPoint(record) { + if (record.anchor.parentNode === record.originalParent) { + return record.anchor; + } + if (record.originalNextSibling?.parentNode === record.originalParent) { + return record.originalNextSibling; + } + if (record.originalNextSibling !== null) { + return void 0; + } + if (record.originalPreviousSibling?.parentNode === record.originalParent) { + return record.originalPreviousSibling === record.originalParent.lastChild ? null : void 0; + } + if (record.originalPreviousSibling === null && record.originalNextSibling === null && record.anchor.parentNode !== null && record.anchor.parentNode !== record.originalParent && !record.originalParent.hasChildNodes()) { + return null; + } + return void 0; +} +function restoreMovedText(record) { + if (record.textNode.parentNode === record.originalParent) { + return true; + } + if (record.textNode.parentNode !== record.gap) { + return false; + } + const before = trustedInsertionPoint(record); + if (before === void 0) { + return false; + } + record.originalParent.insertBefore(record.textNode, before); + return true; +} +function movePartIntoGap(part, gap, block, scopeRoot) { + 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 = { + 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 = []; + for (let parent2 = scopeRoot.parentElement; parent2; parent2 = parent2.parentElement) { + ancestors.push(parent2); + } + 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.normalize?.(); + throw error; + } +} +function wrapGapLeft(span, ownedElements, ownerToken, block, scopeRoot) { + const containingGaps = /* @__PURE__ */ new Set(); + for (const part of span.parts) { + const containing = part.textNode.parentElement?.closest("jz-hws"); + 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; + } + } return; } - if (pos.offset > 0) { - tn = tn.splitText(pos.offset); + const gap = wrapSingleSpanInGap(span.parts[0], ownedElements); + if (!gap) { + return; } - if (tn.data.length > 1) { - tn.splitText(1); + if (span.parts.length === 1) { + return; + } + const moved = []; + 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: \u8DE8\u7BC0\u9EDE\u5B57\u5143\u642C\u79FB\u5931\u6557\u3002"); + } + moved.push(record); + } + gap.setAttribute(CROSS_NODE_ATTR, ""); + let records = MOVED_PARTS.get(ownerToken); + if (!records) { + records = /* @__PURE__ */ 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.normalize?.(); + } + gap.removeAttribute(CROSS_NODE_ATTR); + const gapParent = gap.parentNode; + if (gapParent) { + while (gap.firstChild) { + gapParent.insertBefore(gap.firstChild, gap); + } + gapParent.removeChild(gap); + gapParent.normalize?.(); + } + ownedElements.delete(gap); + throw error; } - wrapNodeInGap(tn); } -function processBlock2(block, finder, R) { +function recordWithinRoot(record, root) { + const within = (node) => 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) { + if (record.gap.parentNode === record.gapOriginalParent) { + return true; + } + let before = 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, ownedElements, touched) { + 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) { + const touched = /* @__PURE__ */ new Set(); + const records = MOVED_PARTS.get(ctx.ownerToken); + if (!records) { + return touched; + } + const gaps = /* @__PURE__ */ new Map(); + for (const record of Array.from(records)) { + if (!recordWithinRoot(record, ctx.root)) { + continue; + } + const restored = restoreMovedText(record); + if (!restored && record.textNode.parentNode === record.gap) { + 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) { + 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 processBlock2(block, scopeRoot, finder, R, ownedElements, ownerToken) { const runs = finder.collectRuns(block); for (const run of runs) { - const { text, map } = run; - const spaced = panguSpace(text, R); - if (spaced === text) { + 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 = []; let origIdx = 0; - let spacedIdx = 0; - while (spacedIdx < spaced.length) { - const sc = spaced.charAt(spacedIdx); + for (const sc of splitVariationSequences(spaced)) { if (sc === DEL) { actions.push({ before: void 0, space: map[origIdx] }); origIdx += 1; - spacedIdx += 1; continue; } if (sc === MARK) { - const oc = origIdx < text.length ? text.charAt(origIdx) : null; + const oc = origIdx < originalChars.length ? originalChars[origIdx] : null; if (oc === " ") { actions.push({ before: map[origIdx - 1], space: map[origIdx] }); origIdx += 1; } else { actions.push({ before: map[origIdx - 1], space: null }); } - spacedIdx += 1; continue; } origIdx += 1; - spacedIdx += 1; } for (let i = actions.length - 1; i >= 0; i -= 1) { 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); + wrapGapLeft(act.before, ownedElements, ownerToken, block, scopeRoot); } } } } -function processPills(root, finder, boundarySelector, pillNeighbor) { +function processPills(root, finder, boundarySelector, pillNeighbor, ownedElements, ownerToken) { root.querySelectorAll(boundarySelector).forEach((pill) => { if (finder.isAvoided(pill) || finder.insidePill(pill) || !finder.inScope(pill)) { return; @@ -2028,38 +2569,49 @@ function processPills(root, finder, boundarySelector, pillNeighbor) { } const prev = finder.adjacentLogicalChar(pill, -1); if (prev) { - handlePillSide(prev, pill, -1, pillNeighbor); + handlePillSide(prev, pill, -1, pillNeighbor, ownedElements, ownerToken, root); } const next = finder.adjacentLogicalChar(pill, 1); if (next) { - handlePillSide(next, pill, 1, pillNeighbor); + handlePillSide(next, pill, 1, pillNeighbor, ownedElements, ownerToken, root); } }); } -function handlePillSide(adj, pill, dir, pillNeighbor) { +function handlePillSide(adj, pill, dir, pillNeighbor, ownedElements, ownerToken, scopeRoot) { 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); } } } else { if (pillNeighbor.test(adj.ch)) { - wrapNodeInGap(pill); + wrapNodeInGap(pill, ownedElements); return; } - if (adj.ch === " " && adj.textNode.data.length >= 2) { - const ac = adj.textNode.data.charAt(1); + if (adj.ch === " " && adj.textNode.data.length > 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); } } } @@ -2077,18 +2629,29 @@ var spacingPass = { if (!finder.featureEnabledFor(block, PASS5)) { return; } - processBlock2(block, finder, R); + processBlock2(block, ctx.root, finder, R, ctx.ownedElements, ctx.ownerToken); }); if (!options.finder.blockTags.has(ctx.root.nodeName) && finder.featureEnabledFor(ctx.root, PASS5) && finder.inScope(ctx.root) && !finder.isAvoided(ctx.root)) { - processBlock2(ctx.root, finder, R); + processBlock2(ctx.root, ctx.root, finder, R, ctx.ownedElements, ctx.ownerToken); } const boundarySelector = [options.finder.pillSelector, options.finder.isolateSelector].filter(Boolean).join(", "); if (boundarySelector) { - processPills(ctx.root, finder, boundarySelector, R.pillNeighbor); + processPills( + ctx.root, + finder, + boundarySelector, + R.pillNeighbor, + ctx.ownedElements, + ctx.ownerToken + ); } }, revert(ctx) { - revertPass(PASS5, ctx.root); + const touched = restoreMovedParts(ctx); + revertPass(PASS5, ctx.root, ctx.ownedElements); + for (const parent of touched) { + parent.normalize?.(); + } } }; @@ -2292,42 +2855,158 @@ var OBSERVE_ATTRS = [ function createJuzhen(opts = {}) { const options = normalizeOptions(opts); const finder = new Finder(options); + const ownedElements = /* @__PURE__ */ new WeakSet(); + const ownerToken = {}; + const renderedRoots = /* @__PURE__ */ new Set(); function makeCtx(root) { - return { root, options, finder }; + return { root, options, finder, ownedElements, ownerToken, setOwnedClass }; } function renderImpl(el) { + renderedRoots.add(el); runPasses(PASSES, makeCtx(el)); if (options.features.jiya && options.jiyaConfig.halfWidth === "margin") { - el.setAttribute("data-jz-halfwidth", "margin"); + setOwnedAttribute(el, "data-jz-halfwidth", "margin", ownerToken); } if (!options.justifyAtoms) { - el.setAttribute("data-jz-atoms", "inline"); + setOwnedAttribute(el, "data-jz-atoms", "inline", ownerToken); + } + } + function rootsWithin(el) { + return Array.from(renderedRoots).filter((root) => root === el || el.contains(root)).sort((a, b) => a.contains(b) ? 1 : b.contains(a) ? -1 : 0); + } + function unwindRoots(roots) { + 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) { - revertPasses(PASSES, makeCtx(el)); - el.removeAttribute("data-jz-halfwidth"); - el.removeAttribute("data-jz-atoms"); + const roots = rootsWithin(el); + if (roots.length === 0) { + revertPasses(PASSES, makeCtx(el)); + } else { + unwindRoots(roots); + } finder.invalidate(el); } let observer = null; let observeRoot = null; + let observerPauseDepth = 0; + let observerActive = false; + const expectedClassMutations = []; + let expectedClassMutationHead = 0; const observeConfig = { subtree: true, childList: true, characterData: true, attributes: true, + attributeOldValue: true, attributeFilter: OBSERVE_ATTRS.slice() }; - function pauseObserver() { - if (observer) { - observer.disconnect(); + function clearExpectedClassMutations() { + expectedClassMutations.length = 0; + expectedClassMutationHead = 0; + } + function setOwnedClass(element, className, present) { + 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) { + const values = new Array(records.length); + const nextValues = /* @__PURE__ */ new Map(); + 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; + values[i] = nextValues.has(target) ? nextValues.get(target) : target.getAttribute("class"); + nextValues.set(target, record.oldValue); + } + return values; + } + function consumeExpectedClassMutation(record, newValue) { + 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() { + if (expectedClassMutationHead === 0) { + return; + } + if (expectedClassMutationHead === expectedClassMutations.length) { + clearExpectedClassMutations(); + return; + } + if (expectedClassMutationHead >= 1024 && expectedClassMutationHead * 2 >= expectedClassMutations.length) { + expectedClassMutations.splice(0, expectedClassMutationHead); + expectedClassMutationHead = 0; + } + } + function pauseObserver() { + if (observerPauseDepth === 0) { + observerActive = false; + if (observer) { + observer.disconnect(); + } + clearExpectedClassMutations(); + } + observerPauseDepth += 1; + } function resumeObserver() { - 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(run) { + pauseObserver(); + try { + return run(); + } finally { + resumeObserver(); } } function nearestBlock(node) { @@ -2346,67 +3025,86 @@ function createJuzhen(opts = {}) { function onMutations(records) { const rerenderBlocks = /* @__PURE__ */ new Set(); const renderNew = []; - for (const m of records) { - if (m.type === "childList") { - m.addedNodes.forEach((n) => { - if (n.nodeType === 1 && options.finder.blockTags.has(n.nodeName)) { - const np = n; - const P = np.parentElement; - const pIsBlock = !!P && options.finder.blockTags.has(P.nodeName); - const pHadLeafOrphan = !!P && Array.prototype.some.call( - P.children, - (c) => c.nodeName === "JZ-ORPHAN" - ); - if (pIsBlock && pHadLeafOrphan) { - rerenderBlocks.add(P); - } else if (!pIsBlock && P) { - const b = nearestBlock(P); + const removedRoots = /* @__PURE__ */ new Set(); + const classNewValues = classMutationNewValues(records); + try { + for (let recordIndex = 0; recordIndex < records.length; recordIndex += 1) { + const m = records[recordIndex]; + if (consumeExpectedClassMutation(m, classNewValues[recordIndex])) { + continue; + } + if (m.type === "childList") { + m.addedNodes.forEach((n) => { + if (n.nodeType === 1 && options.finder.blockTags.has(n.nodeName)) { + const np = n; + const P = np.parentElement; + const pIsBlock = !!P && options.finder.blockTags.has(P.nodeName); + const pHadLeafOrphan = !!P && Array.prototype.some.call( + P.children, + (c) => c.nodeName === "JZ-ORPHAN" + ); + if (pIsBlock && pHadLeafOrphan) { + rerenderBlocks.add(P); + } 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); } - } else { - renderNew.push(np); } - } else if (n.nodeType === 1 || n.nodeType === 3) { + }); + if (m.removedNodes.length) { + m.removedNodes.forEach((node) => { + if (node.nodeType !== 1) { + return; + } + for (const root of rootsWithin(node)) { + removedRoots.add(root); + } + }); const b = nearestBlock(m.target); if (b) { rerenderBlocks.add(b); } } - }); - if (m.removedNodes.length) { + } else { const b = nearestBlock(m.target); if (b) { rerenderBlocks.add(b); } } - } else { - const b = nearestBlock(m.target); - if (b) { - rerenderBlocks.add(b); - } } + } finally { + compactExpectedClassMutations(); } - if (!rerenderBlocks.size && !renderNew.length) { + 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) { - if (!el.isConnected) { - continue; + withObserverPaused(() => { + unwindRoots(Array.from(removedRoots).sort((a, b) => a.contains(b) ? 1 : b.contains(a) ? -1 : 0)); + const all = Array.from(rerenderBlocks).filter((b) => b.isConnected); + const blocks = all.filter((b) => !all.some((o) => o !== b && o.contains(b))); + for (const b of blocks) { + revertImpl(b); + renderImpl(b); } - if (blocks.some((b) => b.contains(el))) { - continue; + for (const el of renderNew) { + if (!el.isConnected) { + continue; + } + if (blocks.some((b) => b.contains(el))) { + continue; + } + renderImpl(el); } - renderImpl(el); - } - resumeObserver(); + }); } return { options, @@ -2415,28 +3113,24 @@ function createJuzhen(opts = {}) { if (!el) { return; } - pauseObserver(); - renderImpl(el); - resumeObserver(); + withObserverPaused(() => renderImpl(el)); }, revert(root) { const el = resolveRoot(root, options); if (!el) { return; } - pauseObserver(); - revertImpl(el); - resumeObserver(); + withObserverPaused(() => revertImpl(el)); }, rerender(root) { const el = resolveRoot(root, options); if (!el) { return; } - pauseObserver(); - revertImpl(el); - renderImpl(el); - resumeObserver(); + withObserverPaused(() => { + revertImpl(el); + renderImpl(el); + }); }, invalidate(root) { const el = resolveRoot(root, options); @@ -2452,19 +3146,26 @@ function createJuzhen(opts = {}) { 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() { + observerActive = false; if (observer) { observer.disconnect(); observer = null; observeRoot = null; } + clearExpectedClassMutations(); } }; } diff --git a/dist/types.d.ts b/dist/types.d.ts index 5456a44..ec212be 100644 --- a/dist/types.d.ts +++ b/dist/types.d.ts @@ -146,6 +146,12 @@ export interface RenderContext { root: Element; options: ResolvedOptions; finder: Finder; + /** 當前 createJuzhen 實例造出之元素;DOM 屬性不能作為所有權憑證。 */ + ownedElements: WeakSet; + /** 實例不可偽造之屬性寫者身分;狀態由 core/dom 共享堆疊管理。 */ + ownerToken: object; + /** 寫入本實例產物之內部 class,並在觀察啟用時留下可精確核對之 mutation provenance。 */ + setOwnedClass: (element: Element, className: string, present: boolean) => void; } /** * charify 階段之包裝請求:把 node.data[start, end) 包進 build() 造出之元素。 diff --git a/package-lock.json b/package-lock.json index 460b3c9..25a1543 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 028418e..86f8025 100644 --- a/package.json +++ b/package.json @@ -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" } diff --git a/rules/README.md b/rules/README.md new file mode 100644 index 0000000..2b4f89c --- /dev/null +++ b/rules/README.md @@ -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 必須公開 +同名選項或未來功能介面已定案。 diff --git a/rules/golden-vectors.json b/rules/golden-vectors.json new file mode 100644 index 0000000..0b66707 --- /dev/null +++ b/rules/golden-vectors.json @@ -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" ] + } + } + ] +} diff --git a/rules/golden-vectors.schema.json b/rules/golden-vectors.schema.json new file mode 100644 index 0000000..926d20e --- /dev/null +++ b/rules/golden-vectors.schema.json @@ -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" } + } + } + } + } + } +} diff --git a/rules/juzhen-rules.json b/rules/juzhen-rules.json new file mode 100644 index 0000000..66c0238 --- /dev/null +++ b/rules/juzhen-rules.json @@ -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" + ] + } +} diff --git a/rules/juzhen-rules.schema.json b/rules/juzhen-rules.schema.json new file mode 100644 index 0000000..9c8a733 --- /dev/null +++ b/rules/juzhen-rules.schema.json @@ -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" } + } + } + } + } + } +} diff --git a/src/core/dom.ts b/src/core/dom.ts index 8fc913a..da59dde 100644 --- a/src/core/dom.ts +++ b/src/core/dom.ts @@ -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>([ + [ "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>(); +const OWNER_ATTRIBUTES = new WeakMap>>(); + +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, 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, +): void { const nodes = Array.from( root.querySelectorAll('[' + PASS_ATTR + '="' + pass + '"]'), - ); + ).filter((el) => ownedElements.has(el)); // 逆序:子元素先於父元素處理,避免解包父後子引用失效。 const touched = new Set(); 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( '[' + 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) { diff --git a/src/core/finder.ts b/src/core/finder.ts index 5df84e0..d94238b 100644 --- a/src/core/finder.ts +++ b/src/core/finder.ts @@ -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/ diff --git a/src/core/locale.ts b/src/core/locale.ts index 81c0a48..98418de 100644 --- a/src/core/locale.ts +++ b/src/core/locale.ts @@ -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"; } diff --git a/src/core/pass.ts b/src/core/pass.ts index dc6aff1..048bb9c 100644 --- a/src/core/pass.ts +++ b/src/core/pass.ts @@ -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, +): 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; } // 無有效段。 diff --git a/src/core/split.ts b/src/core/split.ts index 60a05d3..90c5d21 100644 --- a/src/core/split.ts +++ b/src/core/split.ts @@ -24,6 +24,9 @@ //