4 Commits

Author SHA1 Message Date
admin 69f34413c9 CHANGELOG:補 ESM export
對應 main 之 a5faec5。檔尾加 `export { createCjkAutospace }` 讓
Vite / Rollup / TS 之 ESM 消費端可 named import;IIFE 串接端
sed 剝最後一行即可。
2026-05-26 17:26:30 +08:00
admin b5ac89b687 docs: Pass C —— 長英文段 lang 包裝
CHANGELOG 記新項:Pass C 預設啟用之動機(避開原模板 wbr 每 6 字元亂
插之兩個觀感問題:斷點位置與音節無關、斷處不顯連字符),實作要點,
與字型陷阱(CJK serif 把 U+2010 設計為全形 1 em,須消費端加
hyphenate-character: "-" 強制 U+002D)。

index 加 Pass C 完整章節(演算法、消費端 CSS、選取潔淨性、不在範圍
之識別符 / URL);lede 與 attribution 段同步更新雙 pass → 三 pass。
2026-05-26 16:54:02 +08:00
admin bda7795c85 docs: 起步 Changelog 2026-05-22 23:25:04 +08:00
admin 3e5e45fb89 docs: 詳細 HTML 文檔(API、雙 pass、配置、整合範例) 2026-05-22 22:44:49 +08:00
88 changed files with 9647 additions and 27280 deletions
-8
View File
@@ -1,8 +0,0 @@
.DS_Store
node_modules/
*.log
.scratch/
# 文檔(HTML 文檔+changelog)只存於 docs 分支;此處為 main 上之未追蹤本地鏡像,
# 唯讀,請勿在此編輯——所有文檔操作須於 docs 分支進行。
.docs/
-1151
View File
File diff suppressed because it is too large Load Diff
+4475
View File
File diff suppressed because it is too large Load Diff
+123
View File
@@ -0,0 +1,123 @@
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>cjk-autospace — Changelog</title>
<meta name="generated-at" content="2026-05-22">
<meta name="source" content="cjk-autospace · docs branch">
<!-- RUNTIME_CSS -->
</head>
<body>
<header class="page">
<div class="meta">
<h1>cjk-autospace — Changelog</h1>
<p class="subtitle">時間倒序。架構細節見同分支 <code>index.html</code>。</p>
<p class="timestamp"><time>2026-05-22</time> · <span>docs branch</span></p>
</div>
<div class="actions">
<!-- ACTIONS -->
</div>
</header>
<main>
<h2>2026-05-26 — 補 ESM <code>export</code>(四種消費形式並存)</h2>
<p>下游 Vite / Rollup / TypeScript 之靜態 ESM 解析需要真正之 <code>export</code> 關鍵字;原 UMD-ish(<code>module.exports</code> + <code>globalThis</code>)對 ESM 消費端仍須走「副作用導入 + 從 global 取 factory」之 workaround。檔尾在既有 CJS / globalThis 兩塊後加 <code>export { createCjkAutospace }</code>,順序刻意如此 —— 讓 IIFE 串接端(Claude 模板 <code>build.sh</code> / Nvim peek build hook)只需以 <code>sed '/^export {/d'</code> 剝最後一行。</p>
<p>四種消費形式並存:ESM <code>import</code> / CommonJS <code>require</code> / browser <code>globalThis</code> / IIFE 串接(後者需剝 <code>export</code> 行)。</p>
<h2>2026-05-26 — Pass C:長英文段 lang 包裝(預設啟用)</h2>
<p>解決 CJK 兩端對齊正文中行末長英文詞之斷行問題。原模板於 runtime 端用 <code>&lt;wbr&gt;</code> 每 6 字元亂插斷詞機會,產生兩個觀感問題:(1)位置與音節邊界無關,斷在怪位;(2)<code>&lt;wbr&gt;</code> 為 zero-width,斷處不顯連字符,讀者無法辨認「上行末是被斷開的詞」。</p>
<p>Pass C 走另一條路:把 <code>[A-Za-z]{N,}</code>(預設 <code>N=6</code>)之長英文段包進 <code>&lt;span lang="en"&gt;</code>,配合消費端 CSS:</p>
<pre><code>[lang|="en"] {
hyphens: auto;
-webkit-hyphens: auto;
hyphenate-limit-chars: 6 3 3;
hyphenate-character: "-"; /* 強制 U+002D 而非預設 U+2010 */
-webkit-hyphenate-character: "-";
}</code></pre>
<ul>
<li>瀏覽器原生 <code>hyphens: auto</code> 用 en 字典做斷詞 + 渲染連字符;連字符純由 CSS 渲染,<strong>DOM 內不存在</strong> → 選取 / 複製返回原文無污染(符合「斷字符僅供渲染」之核心約束)</li>
<li>冪等:parent 為 <code>&lt;span lang&gt;</code> 直接跳過,重跑或 MutationObserver 觸發不會重複包裝</li>
<li>skip 鏈與 Pass A / B 共用 <code>isSkipped</code>:CODE / KBD / PRE / SAMP / TT / VAR / SCRIPT / STYLE / TEXTAREA / INPUT / SVG / contentEditable / <code>data-md-key</code> 皆跳過</li>
<li><strong>預設啟用</strong>。<code>longWordWrap: false</code> 顯式關閉;<code>{ minLength, lang }</code> 自訂粒度與標語</li>
</ul>
<h3>字型陷阱:U+2010 之全形 CJK 設計</h3>
<p>瀏覽器 <code>hyphens: auto</code> 預設在斷處插 U+2010 HYPHEN。Noto Serif CJK SC 等 CJK serif 字型把 U+2010 設計為 <strong>全形 1 em</strong>(與一個漢字同寬),以服務 CJK 範圍表達(<code>1月—3月</code>);而 U+002D HYPHEN-MINUS 在同字型內為窄拉丁(約 0.35 em)。直接吃預設會看到「全形連字符」突兀地接在英文詞末。</p>
<p>解法是消費端 CSS 加 <code>hyphenate-character: "-"</code>,強制使用 U+002D;仍為純視覺渲染,DOM 與剪貼簿不受影響。lib 不涉入字型決策,僅在文檔中提示。</p>
<h2>2026-05-22 — 初始發布</h2>
<ul>
<li>工廠函式 <code>createCjkAutospace(options)</code>,回傳 <code>{ apply(root?) }</code>。Port 自 Pangu.js v7.2.1 規則表(MIT 授權),覆寫架構為雙 pass:
<ul>
<li><strong>Pass A — per-block Pangu on tag-stripped text</strong>:對每個 BLOCK 走 DFS 收集邏輯文本(樣式標籤透明、PILL 與子 BLOCK 中斷 run),整段套 <code>panguSpace</code>,把 MARK 反映射回 textNode/offset 並插入 <code>.cjk-autospace</code> marker span。</li>
<li><strong>Pass B — pill boundary, style-inline aware</strong>:對每個 PILL 透過樣式鏈遞迴找邏輯鄰字(<code>adjacentLogicalChar</code>),按 0/1/多+特殊規則處理空白。</li>
</ul>
</li>
<li>標籤三分類:STYLE_INLINES / PILL / BLOCK,皆可由消費端配置。</li>
<li>UMD-ish export:同時支援 ESM <code>import</code>、CommonJS <code>require</code>、browser global <code>globalThis.createCjkAutospace</code> 與 inline <code>&lt;script&gt;</code> 函式宣告形式。</li>
<li>TS 宣告:<code>cjk-autospace.d.ts</code> 提供完整 <code>CjkAutospaceOptions</code> 與 <code>CjkAutospaceInstance</code> 型別。</li>
<li>初始消費者:
<ul>
<li>Claude HTML 模板(<code>Base/templates/html/runtime/lib/cjk-autospace/</code>,pillSelector <code>"code, kbd"</code>)</li>
<li>Peek.nvim Markdown 預覽 shim(<code>Nvim/lua/user/lib/cjk-autospace/</code>,pillSelector <code>"code, kbd, mark, samp"</code>)</li>
<li>cdrop(<code>cdrop/web/src/lib/cjk-autospace/</code>,pillSelector <code>"code, kbd, .cjk-pill"</code>)</li>
</ul>
</li>
<li>docs 分支:<code>index.html</code>(架構詳細文檔)+ <code>CHANGELOG.html</code>(本檔)。</li>
</ul>
<div class="callout info">
<span class="label">設計來源</span> 雙 pass 架構是原創設計,解決 Pangu.js 原版「text-node walker 看不到跨元素邊界」之問題(典型癥狀:<code>&lt;strong&gt;是&lt;/strong&gt; Neovim</code> 此類邊界無法被識別)。
</div>
</main>
<footer class="page">
<p>cjk-autospace · docs branch · CHANGELOG</p>
</footer>
<!-- RUNTIME_SPRITE -->
<script type="text/markdown" id="md-source">
# cjk-autospace — Changelog
## 2026-05-26 — 補 ESM `export`(四種消費形式並存)
- 檔尾加 `export { createCjkAutospace }`,順序在 CJS / globalThis 兩塊之後 → IIFE 串接端只需 `sed '/^export {/d'` 剝最後一行
- 四種消費形式並存:ESM `import` / CommonJS `require` / browser `globalThis` / IIFE 串接
## 2026-05-26 — Pass C:長英文段 lang 包裝(預設啟用)
- 把 [A-Za-z]{N,} 之長英文段包進 `<span lang="en">`,配消費端 CSS `[lang|="en"] { hyphens: auto; ... }` 用瀏覽器原生 en 字典斷詞 + 渲染連字符
- 連字符純由 CSS 渲染,**DOM 內不存在**,選取 / 複製返回原文無污染
- 預設啟用;`longWordWrap: false` 顯式關閉
- 字型陷阱:CJK serif 字型把 U+2010 設計為全形(1 em),須加 `hyphenate-character: "-"` 強制 U+002D 才不會看到全形連字符
## 2026-05-22 — 初始發布
- 工廠函式 createCjkAutospace(options)。Port 自 Pangu.js v7.2.1 規則表。
- 雙 pass 架構:
- Pass A — per-block Pangu on tag-stripped text
- Pass B — pill boundary, style-inline aware
- 標籤三分類:STYLE_INLINES / PILL / BLOCK,皆可配置。
- UMD-ish export + TS 宣告。
- 初始消費者:Claude HTML 模板、Peek.nvim、cdrop。
- docs 分支:index.html(架構)+ CHANGELOG.html(本檔)。
</script>
<!-- RUNTIME_JS -->
</body>
</html>
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 cjk-autospace contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-718
View File
@@ -1,718 +0,0 @@
# 聚珍(Juzhen)
> 現代簡/繁中文網頁排版套件。Han.css 之現代化重寫,沿其功能版圖,並針對
> 數種情況作調整:**複製乾淨**(中西間隙不入剪貼板)、**依 `lang` 全角/開明
> 自動切換**、**`justify` 下句末標點只佔一份間距**、**跨瀏覽器一致的設計目標**
> (Chrome/Edge/Safari/WebKit/Firefox;實際驗收範圍見 UPDATES,Firefox 暫緩)。
倉庫名仍為 `cjk-autospace`;公開 API 為 `createJuzhen` / 全域 `Juzhen`;注入之
自訂元素前綴 `jz-`。消費端契約見本文的[用法](#用法)與[標記說明](#行內元素三分類透明隔離整體),
升級影響見 [`UPDATES.md`](./UPDATES.md)。完整設計見 [`ARCHITECTURE.md`](./ARCHITECTURE.md)。
2026-10-02 的 Web/CSS 邊界與生命週期修復已推送至 `main`;
[修復提交 `b1a16c4`](https://git.commilitia.net/admin/cjk-autospace/commit/b1a16c49698dc418aa733e41a04bdbc9922927be)
包含配套構建產物。2026-10-03 的全專案掃描修復亦已提交並推送至 `main`,
共享規則 `1.0.1` 補充既有語言契約的反例向量,詳見 [UPDATES](./UPDATES.md)。
套件版本仍為 `2.0.0`,未進行 npm 發佈。
使用倉庫產物時須一併更新 JS 與 CSS,具體升級影響見上述消費端更新記錄。
> 本版(2.x)為從零重寫。v1 之 `createCjkAutospace` 相容層**已移除**(最後一批
> v1 消費端已遷移 v2 API);v1 原始碼保存於 `v1-autospace` 分支。
---
## 安裝與構建
```sh
npm install
npm run build # esbuild → dist/juzhen.{mjs,iife.js,css};tsc → dist/index.d.ts
npm test # jsdom DOM 正確性測試
node --test test/shared-rules.test.mjs # 共享規則 Schema/黃金向量/Web 漂移守門
npm run test:wkwebview # macOS 原生 WKWebView 版面/observer E2E(需 Xcode 與 GUI 會話)
```
產物(`dist/`,已入庫):
| 檔 | 用途 |
|---|---|
| `dist/juzhen.mjs` | ESM,供打包器/TypeScript |
| `dist/juzhen.iife.js` | IIFE,全域 `Juzhen`,原生 `<script>` 直跑 |
| `dist/juzhen.css` | 合併樣式(**必須引入**,視覺尺寸全在此) |
| `dist/index.d.ts` | TypeScript 型別宣告 |
| `rules/juzhen-rules.json` | 平台中立之共享排版規則(Web/XeLaTeX 共用) |
| `rules/golden-vectors.json` | 跨實作語義黃金向量 |
---
## 共享規則契約
`rules/` 是 Web/CSS 與並行 XeLaTeX 實作之唯一規則權威源;初版規則版本為 `1.0.0`,目前為 `1.0.1`(契約澄清與新增反例向量)。
規則以 Unicode code-point 區間、語言政策、標點寬度、間隙、禁斷與段末約束描述「應發生
什麼」,不規定 DOM/CSS、TeX node/glue/penalty 或其他 adapter 的實作機制。
套件提供四個唯讀子路徑:
```js
import rules from "cjk-autospace/rules" with { type: "json" };
import schema from "cjk-autospace/rules/schema" with { type: "json" };
import vectors from "cjk-autospace/rules/vectors" with { type: "json" };
import vectorsSchema from "cjk-autospace/rules/vectors-schema" with { type: "json" };
```
規則版本、Schema 版本、變更程序與 adapter 邊界詳見 [`rules/README.md`](./rules/README.md)。
任何規則變更必須同步更新 canonical JSON、Schema、黃金向量,並讓 Web 與 XeLaTeX adapter
同時通過相同語義向量;任一實作不得單邊分叉規則。
---
## 用法
呼叫端接入時須做兩個獨立選擇:
1. **元件如何參與排版**:依「行內元素三分類」選 pill(保護內部、處理交界)、
isolate(內部也排版、保持盒子完整)或 avoid(完全排除)。
2. **文字需要哪些功能**:依「功能分級」選 text(標題/UI 文字)或 paragraph(正文)。
完整操作範例與衝突規則見下方兩節;不要用其中一種分類代替另一種。
### 原生 `<script>`
```html
<link rel="stylesheet" href="dist/juzhen.css">
<main class="prose juzhen" lang="zh-Hant">需要排版的正文。</main>
<script src="dist/juzhen.iife.js"></script>
<script>
const jz = Juzhen.createJuzhen({ scope: { include: ".prose" } });
jz.render(); // 對 document.body 套用(僅命中 .prose 子樹)
// jz.revert(); // 還原
</script>
```
### ESM/TypeScript
```ts
import { createJuzhen } from "cjk-autospace";
import "cjk-autospace/css";
const jz = createJuzhen({ lang: { default: "zh-Hant" } });
const main = document.querySelector("main");
if (main) { jz.render(main); }
```
**JS 與 CSS 缺一不可**:JS 只注入 `jz-*` 結構,所有視覺尺寸(間隙寬度、擠壓量、
`justify` 原子化)都在 `juzhen.css`;字型由呼叫端 CSS 設定。
### 動態頁面:增量恢復(局部變化免全樹重掃)
`render(root)`/`revert(root)` **皆可傳入局部 Element**——頁面局部變化時,只重處理受影響
之子樹即可,成本 O(該塊) 而非 O(全文)(實測編輯 400 段中之一段,局部恢復較全樹快數百倍)。
```ts
const jz = createJuzhen();
jz.render(document.body); // 初次全樹
// 局部變化後(手動):
jz.rerender(changedBlock); // = revert(changedBlock) + render(changedBlock)
jz.render(newlyAppendedBlock); // 新增之塊:直接 render(無須 revert)
// 自動(opt-in):觀察 DOM,內容/分類屬性變化時自動只重處理受影響之最近塊。
jz.observe(document.body); // 之後 DOM 變動即自動增量恢復
// jz.disconnect(); // 停止觀察
```
- **`rerender(root?)`**:`revert` 後 `render` 同一(子)樹(便利方法)。
- **`observe(root?)`/`disconnect()`**:以 `MutationObserver` 自動增量。juzhen 自身之注入
於處理期間暫停觀察、**絕不自觸發**;新增之塊直接 render(免全容器重掃)。需 `MutationObserver`
(瀏覽器/jsdom 有;無則 no-op)。
觀察根可尚未掛入 document;更新按目前是否仍在該根內判定。同批新增後移出根的節點,
或已離開根的舊變更目標,不會被此 observer 排版。
搬入的既有塊先撤回本實例於舊父環境的產物,再依新祖先的級別、功能及語言重排;
純新增塊仍直接 render。插入子樹的祖先快取也會失效。
手動 `render`/`revert`/`rerender` 暫停觀察前會先結算已排隊的變更(可能觸發其他區塊
的排版),再執行本次手動操作;因此局部回退仍最後生效,也不會丟失兄弟區塊更新。
結算中的 observer 錯誤仍由宿主非同步報告,手動操作自己的錯誤則照常同步拋出。
- **`invalidate(root?)`**:清該子樹之內部快取。**`revert` 已自動失效其子樹**,故僅在「改了既有
元素之分類相關屬性(`class`/`data-jz-*`/`lang` 等)**但不走 `revert`**」時才需顯式調用,以
免下次 render 沿用陳舊分類。內容(子節點/文本)變化不改分類、無需 `invalidate`(但須
`revert`+`render` 該塊方能重處理)。
> **元素變化之正確性**:實例內部對「元素分類(透明/pill/隔離/avoid)/功能級別/功能開關」
> 快取以加速跨 render 復用(快取隨實例持久、非每次 render 重建)。**改變既有元素之上列屬性後,
> 須經 `revert`/`rerender`/`invalidate`/`observe` 任一使該子樹快取失效**,否則沿用舊分類。
> 新增/移除元素與純內容編輯無此顧慮(新元素重新計算、移除者自動回收)。
> **增量之粒度為「塊」**:換塊內容(`textContent`/`innerHTML`)、增刪塊、改分類屬性——皆與
> 全量重繪一致。**不支持**對聚珍已切分之文本節點做外科式 `characterData` 直改(如持有 render
> **前**之 `p.firstChild` 引用、事後直改其 `.data`):render 後原文本節點已被 `jz-*` 切分/
> 替換,該引用已非原整段,直改單一碎片會令 `revert` 合併出之內容偏離作者意圖。此為後處理排版
> 器之固有限制(任何外科式文本 patch 之框架皆會與注入後之 DOM 失步)、非缺陷——聚珍對當前
> DOM 之處理始終自洽。欲改文字,以塊為單位替換內容即可。
> **`revert` 之範圍**:撤回本實例仍持有所有權、且符合產物與來源完整性檢查的結構及屬性,
> 保留作者標記與其他實例的產物。沒有外部修改時,DOM 回到處理前的**邏輯等價**態;但
> **不還原** `spacing` 依契約規範化之贅餘作者空格(CJK↔西文吸收為 margin 間隙、
> CJK↔CJK 剝除;見後面的「中西間隙」功能表)。此差異穩定、不隨反覆 render/revert 累積。
> 外部克隆、替換或刪除後,以作者當前內容為準,不保證恢復歷史快照或清空所有 `jz-*`。
> 若消費端需保留原始作者空格以供編輯,應自存原文、勿以 `revert` 當「還原到未處理源碼」。
外部克隆/替換/刪除跨節點載體後,`revert` 保留作者當前內容,不補回已刪文字;
克隆不繼承本實例所有權。欲修改原文,先 `revert` 再修改,或直接替換整塊內容。
其他聚珍實例合法撤回包裹時,來源記錄會沿受控解包鏈更新,任意回退次序不丟 base/VS。
---
## 切片 1 已實作功能
| 功能 | 說明 |
|---|---|
| **中西間隙** `spacing` | CJK↔西文邊界以 copy-clean 之 `jz-hws` 與 `margin-right` 留隙;**不插入空白字元**,故剪貼板不多出空格。唯跨節點 IVS 產物以 `::after` 產生的 `U+200B` 斷行機會設 `user-select:none`;它不進入 DOM/`textContent`。「西文」涵蓋拉丁(含擴充 A/B,拼音聲調 `ā ǎ ē`)、希臘、西里爾、數字、貨幣(`€ ₩ ₿`)、數學運算符等;CJK 標點(`。,「」()……` 等)不誤判為西文、不生隙。含跨樣式行內邊界與 pill/隔離元素邊界(`code`/`kbd`/`.katex` 與 `mark`,兩端補隙口徑一致;pill 緊鄰標點時穿越 `jz-jinze` 仍補間隙;pill 跨行右隙、間隙落行末之自我應驗皆已修)。**pill 之邏輯鄰字為標點時(如 `漢字。<code>`)不誤加間隙**(標點非 pill 鄰字;`jz-char`/`jz-inner` 透明穿越,與 `collectRuns` 一致)。贅餘作者空格一律正規化:CJK↔西文吸收為受管間隙、CJK↔CJK(含跨 inline 標籤)剝除不留隙。 |
| **長英文詞** `longWord` | `[A-Za-z]{6,}` 包入 `<span lang="en">`,配合 CSS `hyphens:auto` 原生斷詞(DOM 無連字符 → 複製不污染)。 |
| **標點擠壓** `jiya` | 標點 charify 為 `jz-char`+`bd-*` class。三機制:①baseline 政策(全角式不擠/開明式擠句內點號+括號引號/半角式全擠);②行內連續擠壓(相鄰標點**雙側獨立**收——各標點若其有空白之側緊鄰另一標點即收半,故 `,「` 全角式與開明式皆 1em、`。」` 全角式 1.5em/開明式 1em;**唯句末點號接開類為句界**——`。「`/`?「` 保留句末點號右空白、僅收開類,全角式與開明式皆 1.5em;透明穿越 `strong`/`em` 等行內邊界);③行端擠壓(`lineedge` layout pass)。半形預設以字型 `halt` 渲染(按字形正確定位,居中字形如 `!?`/繁體 `。,` 不重疊;不依賴 `text-spacing-trim`)。**字型缺 `halt` 時可切 `margin` 後備模式**(見下節)。 |
| **禁則** `jinze` | 行首行末避頭尾:閉類/點號綁前一字(不置行首)、開類綁後一字(不置行末),只隔離邊界字故長引文中段仍可斷。亦為 `justify` 綁定載體。邊界字落於**透明**行內樣式元素(`strong`/`em` 等)內時**克隆切分**只綁邊界詞元(避免整個元素入 `nowrap` 致行末整體掉行、上行留巨大空隙),切出之克隆標 `data-jz-kind="splitoff"`、`revert` 時驗來源身份及屬性後併回原元素;若外部插入兄弟、替換來源或改樣式而無法安全合併,保留片段元素與文字位置並撤銷聚珍標記;不可切元素(`a`/`abbr`/`ruby`/帶 `id`,以及 **pill/隔離**——見「行內元素三分類」)退化為**整元素綁定**——pill(如行內公式 `.katex`)即作為原子鄰字單位整體綁入 `jz-jinze`,絕不下潛肢解其內部。兩側須符合作用域、paragraph 級別與 `jinze` 功能閘;不可切時也不把 avoid/text/off 後代整綁帶入。 |
| **垂懸字避免** `orphan` | 段末可處理的連續區域 CJK 實義字 ≥ N(預設 2;標點/符號不計,完整碼點及 base+VS 算一字)。方案 C(斷行層、無測量):把段末「第 N 個實義字起至段末」包入 `jz-orphan`(`white-space:nowrap`),整體不可斷 → 末行恒含之 ⇒ 末行實義字 ≥ N,任意寬度/字型成立。單行段無害。邊界字落於行內元素內時同樣**克隆切分**(只綁末 N 字、避免整個長元素入 `nowrap` 溢出容器;機制同禁則)。**預設開**(`orphan:false` 關,`{chars:n}` 調)。段落級功能。avoid/text/off/`isSkipped` 區域中斷計數,只綁其後的合法尾部;不足 N 字或不可切容器含排除內容時不綁。 |
| **功能分級** `level` | 功能分文本級(中西間隙、標點擠壓——任意文本片段適用)與段落級(禁則、垂懸字、長詞——需行/段布局)。塊級元素預設全功能;`data-jz-level="text"` 子樹或選項 `level:{text:"選擇器"}`(如標題/按鈕)僅跑文本級。 |
| **justify 單份間距** | 每個 `jz-char` 為 `inline-block` 原子項,句末標點只貢獻一份對齊伸縮量。 |
相容選項 `hanging`/`biaodian`/`emphasis`/`ruby` 仍被接受,但當前均為
**no-op**:沒有對應 pass、DOM 產物或 CSS。後三者僅屬明確標記的未來設計;
`hanging` 之舊負 margin 實作已移除,僅保留 API 相容。
---
## 選項
```ts
createJuzhen({
lang: {
default: "zh-Hant", // 無 lang 祖先時之預設
style: "kaiming", // 全域政策一鍵覆寫(quanjiao|kaiming|banjiao)
policy: { "zh-Hant": "quanjiao", "zh-Hans": "kaiming" }, // 逐 lang(低於逐元素覆寫)
},
scope: {
root: document.body, // 處理根;render(root) 可逐次覆寫
include: ".prose", // 啟用選擇器(opt-in):僅處理命中子樹
avoid: "blockquote.raw", // 追加排除選擇器
},
spacing: true,
longWord: { minLength: 6, lang: "en" },
jiya: true, // 或物件:{ halfWidth: "halt" | "margin", bias }
jinze: true,
orphan: true, // 垂懸字避免(預設開);或 { chars: 2 };false 關
level: { text: "h1,h2,h3,button,.ui" }, // 此選擇器子樹僅跑文本級功能(見下)
pillSelector: "code, kbd, .katex", // 整體(pill)元素(見「行內元素三分類」)
isolateSelector: "mark", // 內外隔離元素(預設 mark;"" 停用)
justifyAtoms: true, // jz-char/jz-jinze 以 inline-block 作對齊原子;
// 分頁器(Paged.js)下設 false 改 inline(見下)
blocks: [ // 分塊功能選擇
{ selector: ".code-comment", features: [ "spacing" ] },
],
});
```
### 全部選項速查(含少見但強力者)
完整選項一覽。**「標籤名」型別只吃大寫標籤名陣列**(精確比對 `nodeName`),與
`pillSelector`/`isolateSelector`/`scope.*`(**CSS 選擇器**,可用 class/屬性)**不同機制**,
勿混。斜體「詳見」指向本文檔展開該項之小節。
**語言與擠壓政策**(優先序:**逐元素** `data-jz-style`/`stylePolicy` > `policy` > `style` > 逐語言內建)
| 選項 | 型別 | 預設 | 作用 |
|---|---|---|---|
| `lang.default` | string | `"zh-Hant"` | 自身及祖先皆無 `lang` 時之預設;空字串表示未知 |
| `lang.style` | `quanjiao|kaiming|banjiao` | 逐語言內建(繁→全角、簡→開明) | **全域政策一鍵覆寫** |
| `lang.policy` | `Partial<Record<LangClass, PunctStyle>>` | `{}` | 按語言分類覆寫:鍵僅為 `zh-Hans`、`zh-Hant`、`ja`、`other`;如 `zh-TW` 先歸入 `zh-Hant`,不直接作為鍵 |
| `lang.stylePolicy` | `{selector,style}[]` | `[]` | **逐元素樣式覆寫**(選擇器命中之子樹用指定樣式;如 `[{selector:"h1,button,.ui",style:"banjiao"}]`);配合 `data-jz-style` 屬性,優先於 lang |
**作用域**
| 選項 | 型別 | 預設 | 作用 |
|---|---|---|---|
| `scope.root` | Element|選擇器 | `document.body` | 處理根(`render(root)` 可逐次覆寫) |
| `scope.include` | 選擇器 | `null` | opt-in:僅處理命中此選擇器之子樹 |
| `scope.avoid` | 選擇器 | `null` | 追加排除子樹 |
**功能開關 / 對齊 / 分級 / 分塊**(詳見「已實作功能」表及各節)
| 選項 | 型別 | 預設 | 作用 |
|---|---|---|---|
| `spacing` | bool | `true` | 中西間隙 |
| `longWord` | bool|`{minLength,lang}` | `true`(`{6,"en"}`) | 長英文詞斷字;`minLength` 須為 ≥2 的有限安全整數,無效值回退 6 |
| `jiya` | bool|`{halfWidth,bias}` | `true` | 標點擠壓(*詳見「標點半形」*) |
| `jinze` | bool | `true` | 禁則避頭尾 |
| `orphan` | bool|`{chars}` | `true`(`{chars:2}`) | 垂懸字避免;`chars` 須有限且 ≥1,向下取整,無效值回退 2 |
| `justifyAtoms` | bool | `true` | inline-block 對齊原子(分頁器設 `false`) |
| `level.text` | 選擇器 | `null` | 命中子樹僅跑文本級(*詳見「功能分級」*) |
| `blocks` | `{selector,features}[]` | `[]` | 分塊功能選擇 |
| `charClass` | `{cjk,western,biaodian}` | — | 追加字符分類(*詳見「自定義字符分類」*) |
**行內三分類與排除**(*詳見「行內元素三分類」*)
| 選項 | 型別 | 預設 | 作用 |
|---|---|---|---|
| `pillSelector` | CSS 選擇器 | `"code, kbd"` | 整體元素(內部不可見、兩端補隙) |
| `isolateSelector` | CSS 選擇器 | `"mark"`(`""` 停用) | 隔離元素(內部照常、兩端補隙) |
| `styleInlines` | 標籤名[] | 28 個標準行內標籤 | **透明穿越白名單**(未列之行內標籤=邊界) |
| `blockTags` | 標籤名[] | 43 個塊標籤 | 塊級邊界集 |
| `skipTags` | 標籤名[] | `PRE,SAMP,TT,VAR,SCRIPT,STYLE,TEXTAREA,INPUT` | avoid 標籤集(**不含 CODE/KBD**,由 pill 涵蓋) |
| `skipAttribute` | string | `null` | 自訂「跳過」屬性名(命中即 avoid) |
| `isSkipped` | `(node)=>bool` | `null` | 自訂跳過謂詞(函數級排除鉤子) |
**HTML 屬性**(各屬性之繼承/衝突規則見對應小節)
| 屬性 | 值 | 作用 |
|---|---|---|
| `data-jz-level` | `text|paragraph` | 逐子樹功能分級 |
| `data-jz-style` | `quanjiao|kaiming|banjiao` | 逐子樹標點樣式覆寫(近祖先勝、同層優先於選擇器,整體優先於 lang) |
| `data-juzhen` | 功能名(空格分隔) | 白名單:僅列出之功能生效 |
| `data-juzhen-off` | 功能名 | 黑名單:停用列出之功能 |
| `data-jz-skip` | (存在即可) | 標記元素為 avoid(內外皆不處理) |
| `data-jz-halfwidth` | `halt|margin` | 逐子樹半形渲染模式 |
**相容 no-op 選項**:`hanging`/`biaodian`/`emphasis`/`ruby` 均可傳入,但當前不註冊
pass,也不產生 DOM/CSS 效果。注意頂層 `biaodian` 與 `charClass.biaodian`(已實作的
標點子類擴充表)**同名異義**。
### 逐元素標點樣式(`data-jz-style`/`stylePolicy`)
標點樣式(`quanjiao` 全角式/`kaiming` 開明式/`banjiao` 半角式)預設由 `lang` 決定,但可
**逐元素/逐範圍覆寫**——典型:**非段落/UI 元素(標題、按鈕)用半角式,段落用全角/開明**。
```ts
// 選擇器批量:h1/h2/button 及 .ui 子樹用半角式,其餘(段落)走 lang
createJuzhen({
lang: {
default: "zh-Hant", // 段落 → 全角式(繁)
stylePolicy: [ { selector: "h1, h2, button, .ui", style: "banjiao" } ],
},
});
```
```html
<!-- 或以 HTML 屬性逐元素/逐範圍標記(標於元素或任一祖先,該子樹生效) -->
<h1 data-jz-style="banjiao">標題:半角標點更緊湊</h1>
<p>段落沿用 lang 之全角/開明式。</p>
```
優先序:**逐元素覆寫**(`data-jz-style` 屬性/`stylePolicy` 選擇器,**近祖先勝、同層屬性優先於
選擇器**)> `lang.policy` > `lang.style` > 內建逐語言預設。屬性值非法(拼錯)只忽略該信號,
仍查同層 `stylePolicy` 選擇器與祖先;沒有任何逐元素信號時才回退語言政策。
半角式渲染沿用既有 `halt`/`margin` 機制(見下節),本功能只做「按元素選樣式」、無新增
渲染邏輯。
### 行內元素三分類:透明/隔離/整體
**先決定哪些內容交給聚珍處理,再決定它與前後文字如何銜接。** 呼叫端須主動選擇
pill/isolate/avoid;不要把「保護內部內容」一律寫成跳過,也不要只依元素外觀猜分類。
普通 `strong`/`em`/`span` 等只改文字樣式的元素通常保持透明,無須額外標記。
| 你的需求 | 選擇 | 元素內部 | 與外部文字的交界 | 如何啟用 |
|---|---|---|---|---|
| 內部由程式碼/公式等元件自己管理,但仍要接入正文排版 | **pill(整體)** | 不進入、不改寫內部文字、不拆分元素 | 視為整體;依相鄰字符規則補間隙,禁則需要時連同整個元素綁定 | `pillSelector` |
| 內部是普通文字,仍要補間隙、處理標點,但盒子不能被拆開 | **isolate(隔離)** | 照常排版,內部自成文字脈絡 | 與外部隔開;依相鄰字符規則補間隙,禁則需要時整體綁定 | `isolateSelector` |
| 這一區完全不交給聚珍,連交界間隙也自行管理 | **avoid(排除)** | 不處理整個子樹 | 停止跨界排版,不替它建立 pill/isolate 式交界間隙 | `data-jz-skip` 或 `scope.avoid` |
| 只是粗體、斜體等樣式,文字仍屬同一句 | **透明(一般文字)** | 照常排版 | 前後文字連續判斷;安全的樣式元素可能被拆分後於回退時合併 | 預設 `styleInlines` 中的標籤 |
**pill 與 avoid 都保護內部,差別在外部:pill 仍參與正文交界排版;avoid 完全退出。**
**pill 與 isolate 都保持元素完整,差別在內部:pill 不處理;isolate 照常處理。**
這裡的「完整」指聚珍不拆分元素,不代表替任意元件提供 CSS `white-space:nowrap`。
交界也不是無條件各加一格:須符合相鄰字符規則,例如句號緊接 pill 不會因此多補間隙。
#### 主動指定你的元件
以下 class 名稱由呼叫端自行命名,透過選擇器接入;它們不是聚珍內建標記。
`data-jz-skip` 則是內建屬性,存在即排除,寫成 `data-jz-skip="false"` 仍會排除。
```html
<article class="juzhen" lang="zh-Hant">
<p>執行<code>npm test</code>後查看結果。</p>
<p>公式<span class="formula">x+y</span>由公式元件管理。</p>
<p>狀態<mark class="badge">版本API,已更新</mark>仍要處理內部中西間隙與標點。</p>
<p>外部元件<span data-jz-skip>原樣保留 API ,</span>的交界由呼叫端自行處理。</p>
<div class="raw-widget">這個子樹也完全排除。</div>
</article>
```
```js
const jz = Juzhen.createJuzhen({
scope: { include: ".juzhen", avoid: ".raw-widget" },
pillSelector: "code, kbd, .formula, .cite-ref",
isolateSelector: "mark, .badge",
});
jz.render();
```
- **pill 預設只有 `code, kbd`**。公式 `.katex`、引用標 `.cite-ref` 等須自行加入。
自訂 `pillSelector` 是取代預設選擇器;要保留 code/kbd,須像上例一併列出。
- **isolate 預設為 `mark`**。自訂 `isolateSelector` 同樣取代預設;傳 `""` 停用此選擇器。
只有背景色的 `span` 不會因外觀自動變成 isolate,應加 class 並列入選擇器。
- **avoid** 可用 `data-jz-skip` 或 `scope.avoid` 主動指定;另外 `skipTags`、SVG、可編輯內容、
`skipAttribute` 和 `isSkipped` 也可能使元素被排除。子元素不能以 pill/isolate 或
`data-jz-level="paragraph"` 重新打開已被排除的祖先子樹。
- **同一元素多重命中時:avoid > pill > isolate**。例如同時命中 `.formula` 與 `.badge`
的元素採 pill;再加 `data-jz-skip` 則採 avoid。請避免互相矛盾的選擇器。
- 從 `pillSelector` 移除 `code`/`kbd` 不等於排除它們:預設 `skipTags` 不含二者。
若要完全跳過,須另列入 `scope.avoid`/`skipTags`。
- 未列在 `styleInlines` 的未知行內標籤會形成隔離式邊界,不會自動當透明樣式。
`styleInlines` 接受**大寫標籤名陣列**,與 pill/isolate 的 CSS 選擇器不同。
內部結構由第三方管理的元件宜明確指定 pill 或 avoid,不要為了穿越它而加入透明名單。
分類不等於功能級別。isolate 內部是否執行禁則、避孤字等,仍由下節的 text/paragraph
及功能開關決定;pill/avoid 內部則根本不進入文字處理。
### 分頁器(Paged.js 等)相容:`justifyAtoms`
預設 `jz-char`/`jz-jinze` 為 `inline-block`,作 `justify` 之單一對齊原子(句末標點
單份間距)。但**分頁器**(如 Paged.js)之對齊引擎會把每個 inline-block 邊界當伸縮點、
與 CJK inter-ideograph 疊加,於窄欄混排時字距失控、整段散架。此時設
`justifyAtoms: false`——`jz-char`/`jz-jinze` 改 `display:inline`(半形 `halt` 與禁則
`nowrap` 綁定均保留)。常規流式版面無需設定。
> 注意:`margin` 後備半形依賴 `inline-block`(`width:0.5em`),故
> `justifyAtoms:false` 與 `margin` 模式不可兼得——分頁器場景請用含 `halt` 之字型。
### 分塊功能選擇(HTML 屬性,就近繼承)
先按下一節選擇 text/paragraph,再用功能白名單或黑名單微調。
它們不改變 pill/isolate/avoid 分類,也不會繞過級別限制。
```html
<p data-juzhen="spacing jiya">只選中西間隙與標點擠壓。</p>
<p data-juzhen-off="orphan longWord">保留其他功能,停用避孤字與長詞斷字。</p>
```
功能名區分大小寫,以空白分隔。一般呼叫端使用 `spacing`、`jiya`、`jinze`、`orphan`、
`longWord`;前述相容 no-op 名稱不會產生效果。
對每個功能,從元素自身向上找:同一元素先取 `data-juzhen` 白名單,再檢查
`data-juzhen-off` 黑名單,接著取第一條命中的 `blocks` 規則。白名單未列入的功能停用;
黑名單未列入的功能繼續查找;皆無決定時沿祖先向上,最後回退全域開關。
因此同一元素同時寫白名單與黑名單時,**白名單優先**;`data-juzhen=""` 是空白名單,
不是恢復預設。全域停用的功能不會被局部白名單重新啟用。
`spacing` 與 `jiya` 的門控也作用於普通行內內容;禁用區域不刪作者空格、不參與
外部間隙/標點鄰接,空禁用元素同樣中斷邊界。子白名單可在自身子樹重新啟用。
`scope.include` 可選行內子樹;文本級間隙不要求其父塊也命中選擇器。
### 功能分級:文本級 vs 段落級(`level`/`data-jz-level`)
**text/paragraph 決定允許哪些排版功能,不是 HTML 標籤分類,也不設定 CSS display。**
呼叫端應按內容用途選擇,不能把「現在只有一行」當作 text 的唯一判準。
| 內容用途 | 選擇 | 允許的功能(仍受功能開關控制) | 不會替你做什麼 |
|---|---|---|---|
| 標題、按鈕、選單、短標籤等 UI 文字 | **text(文本級)** | `spacing` 中西間隙、`jiya` 標點擠壓 | 不做 `jinze` 禁則、`orphan` 避孤字、`longWord` 長詞斷字;不保證單行 |
| 正文、說明段落、完整句子的列表項等連續閱讀內容 | **paragraph(段落級)** | 上述文本級功能,加上禁則、避孤字、長詞斷字 | 不自動兩端對齊,不設定字型或容器寬度 |
**沒有任何級別訊號時預設 paragraph。** `h1`/`button` 不會因名稱自動變成 text;
`p` 也可以明確標成 text。即使一段正文在寬螢幕恰好只有一行,仍可選 paragraph;
即使標題在窄螢幕換行,也可保留 text。需不需段落約束由呼叫端決定。
#### HTML 標記與選擇器
```html
<article class="juzhen" lang="zh-Hant">
<h1 data-jz-level="text">聚珍API:使用說明</h1>
<p data-jz-level="paragraph">正文採段落級,允許禁則與段末避孤字。</p>
<div class="ui" data-jz-level="text">
<button>開啟API</button>
<p data-jz-level="paragraph">這段較長的說明重新採用段落級。</p>
</div>
</article>
```
也可在建立實例時用 `level: { text: "h1,h2,h3,button,.ui" }` 批量選定文本級子樹,
不必逐個寫屬性;已有 pill/isolate/avoid 設定時,將 `level` 加入**同一個**選項物件。
解析順序如下:
1. 從當前元素(文字則從父元素)開始往上找,包含自身。
2. 每一層先讀合法的 `data-jz-level="text|paragraph"`,再看是否命中 `level.text`。
3. 找到第一個訊號即停止;都沒有才用 paragraph。非法屬性值不構成訊號,繼續查找。
所以**距離優先、同層屬性優先**:近處選擇器可以勝過遠處祖先屬性;同一元素標
`paragraph` 則可以蓋過該元素命中的 `level.text`。在 text 子樹中寫
`data-juzhen="orphan"` 不能開啟避孤字,須先明確切回 paragraph,且該功能全域已啟用。
#### 與 pill/isolate/avoid 搭配
| 組合 | 實際意思 |
|---|---|
| paragraph 正文中的 pill 公式 | 正文有段落約束;公式內部由元件管理,公式整體仍參與正文交界排版 |
| text 標題中的 isolate 標籤 | 標籤內部仍補間隙、處理標點;不因 isolate 就開啟段落功能 |
| paragraph 容器中的 avoid 元件 | 元件子樹仍完全排除;paragraph 不會取消 avoid |
不要用 avoid 代替 text:若只是不想讓按鈕跑避孤字,應標 text,這樣仍保留間隙與標點處理。
也不要用 text 代替 pill:text 仍會改寫內部文字排版,不能用來保護公式或程式碼元件。
#### CSS 可查之 resolved 分類:`data-jz-block-level`(輸出屬性)
作者只寫 **`data-jz-level`**。`render` 後聚珍會對承載可處理非空白文字的塊級元素產生
**`data-jz-block-level`**,供呼叫端 CSS 使用;不要把輸出屬性當作設定值手寫。
```css
/* 依聚珍輸出分類選擇對齊;這是呼叫端的樣式決定。 */
.juzhen [data-jz-block-level="paragraph"] { text-align: justify; }
.juzhen .marginalia[data-jz-block-level="paragraph"] { text-align: start; }
```
純容器(例如只含子段落的 `div`/`ul`)不因包住正文就取得此輸出;域外文字、pill 內部與
avoid 子樹不計入。屬性描述**所屬塊的級別**,不是每個行內子元素的級別;同一段落內個別
`span` 標 text,不會把整個段落輸出改成 text。若需要段落 CSS 跟著切換,應標在該塊本身。
`revert` 會撤回本實例寫入的輸出,依所有權規則保留/恢復作者或其他實例的屬性。
輸入 `data-jz-level` 不由聚珍改寫;`data-jz-block-level` 不參與級別判定,也不觸發分類重算。
#### 標記改動後如何更新
在第一次 `render` **之前**準備好 class、排除與級別標記。已渲染後改動時,手動呼叫
`jz.rerender(changedBlock)`;若開啟 `jz.observe(root)`,受觀察範圍內的 `class`、
`data-jz-skip`、`data-jz-level`、`data-juzhen`/`data-juzhen-off` 變化會觸發增量更新。
自訂選擇器若依賴其他屬性(例如 `data-widget`),不要假設 observer 會追蹤該屬性,請手動
重排受影響區塊。更新時以塊為單位,詳見前面的「動態頁面」。
### 標點懸掛(`hanging`)— 已移除
原計劃之標點懸掛(行端標點凸出版心)經實測為**機制級死路、已移除**(負 margin 程式碼與
CSS 皆刪)。`hanging` 選項仍被接受(API 相容)但**無任何效果**——`hanging:true`/
`data-juzhen`/`blocks` 任一要求皆被靜默忽略,行端僅做半形擠壓。
**為何機制級不可行**(純 CSS 之寬度守恆矛盾):行端標點盒若保持非零寬,則「墨色出去多少、
版心內就空出多少」(行末實義字無法齊版心右緣);若收為零寬,則釋放一整字寬、斷行器把下一字
拉上本行與標點重疊(且 `justifyAtoms` 預設之 inline-block 下負 margin 被 justify 吸收)。
**墨色出去多少版心內就空多少;想不空就得讓斷行器重排這塊寬度,而 CSS hack 接觸不到斷行器**。
補償墊片、兩階段試錯都改變不了此恒等式。
原生 CSS `hanging-punctuation` 是未來可評估路徑,須另行核對各引擎能力及字型行為,且
本庫之 `jz-char` 為 inline-block 原子、非文本,原生懸掛不作用——未來若做,須是繞開 `jz-char`
的「原生管線」新功能(`text-spacing-trim` + `hanging-punctuation`,按能力門控),與已移除之
程式碼無共享。詳見 `ARCHITECTURE.md §6.5.6`。
---
## 標點半形:`halt` 預設與 `margin` 後備
**缺半形特性字型的逐字相容方案仍保留**:`margin` 後備搭配 `jiya.bias`,可按語言分類、
逐標點指定 `left`/`center`/`right`/`full`。它與字型自帶的 `halt` 是兩條渲染路徑;
偏側覆寫、量測工具與目視微調流程見本節,不因移除舊負 letter-spacing 方案而取消。
開明/半角式把標點收成「半形」,有兩種渲染機制:
| 機制 | 設定 | 原理 | 適用 |
|---|---|---|---|
| **`halt`**(預設) | `jiya: true` | 字型 OpenType `halt`,由字型按字形正確定位、不重疊 | 字型含 `halt`(Noto CJK/思源系列) |
| **`margin`**(後備) | `jiya: { halfWidth: "margin" }` | `width:0.5em`+字身依墨色偏側溢出定位(以 CSS 重現 halt) | 字型**缺** `halt`(多數系統字型、GBK 老字型如方正書宋 GBK、PingFang) |
> 為何需要後備:`halt` 缺席時開明式擠壓「完全無效」(標點仍佔全形)。實測
> **方正書宋 GBK 僅含 `vert`、無 `halt`** → halt 模式不擠;切 `margin` 後備後,
> (開明式 zh-Hans)同句行寬 `24.0em → 19.7em`、單一句內點號 `1.0em → 0.5em`,半形生效。
> **句末標點含全角句點 `.`**:句末點號為 `。.!?`——除表意句號 `。`(U+3002)外納入
> 全角句點 `.`(U+FF0E,實測 Noto CJK 有 `halt`)。政策行為與 `。!?` 同:全角式與開明式
> 句末**保持全角 1em**;唯 banjiao(或與鄰標點連續被收)時 `halt` 收為半形 advance 0.5em。
> **`halt` 即字型之「挤壓意願」**:字型不提供某標點之 `halt`(其字形占滿字框、不宜挤半,
> 如某些字型之全角 `?`、`〈〉`)時,聚珍**信任字型、保持全角不強挤**(優雅降級);
> `margin` 後備僅用於**整體無 `halt`** 之字型,逐字型以 `jiya.bias` 調教。
### 激活與粒度
模式由**最近的有效 `data-jz-halfwidth="halt|margin"` 標記**決定;CSS 繼承模式參數,
不依標記距離競爭選擇器。未標記時用 halt;非法值忽略並繼承祖先模式。
- **全域**:`jiya: { halfWidth: "margin" }` → `render()` 於處理 root 自動掛屬性。
- **逐區塊/逐語言**:在 HTML 任一祖先手寫 `data-jz-halfwidth="margin"`(或
`"halt"`),即令該子樹用對應機制——無需另建實例。可任意交替巢狀 halt/margin;
切回 halt 時同時撤銷 margin 盒寬、內盒與墨色偏移。未壓縮的全形標點不套偏側位移。
模式切換是純 CSS,變更此屬性即可,無須 rerender。
```html
<!-- 全頁 halt(預設),此塊改用無 halt 字型 → 局部 margin 後備 -->
<section data-jz-halfwidth="margin" style="font-family: '方正书宋_GBK'">…</section>
```
### 機制與字形偏側表(`margin` 模式專用,可覆寫)
`margin` 模式把標點 `jz-char` 之 inline-block 寬收為 **0.5em(真半形 advance)**,字身
(`jz-inner`)以 `overflow` 溢出該盒,再依**墨色偏側**左移、使墨色落於 0.5em 盒內、
空白溢向外側——即以 CSS 盒模型重現 `halt` 之「收字身寬+按字形定位」。相鄰兩標點各
0.5em、合佔 1em,無鄰接疊加或單側重疊問題。
偏側須知每字之墨色中心。聚珍內建**參考表**(依方正書宋 GBK 實測之 glyph ink 位):
簡體點號 `,、。;:!?` 偏左;繁體點號居中;彎引號 `“”‘’`、角括號 `「」`、尖括號
`〈〉` 墨色強偏一側(開→`right`、閉→`left`);圓/方/書名等括號 `()【】《》…` 墨色
近中→`center`。於 charify 標為 `jz-ink-{left,center,right}`。因字型字身分布各異,可逐
`lang` 逐字覆寫:
```ts
createJuzhen({
jiya: {
halfWidth: "margin",
bias: {
"zh-Hans": { ",": "center", "(": "right" }, // 覆寫參考預設
"zh-Hant": { "。": "center" },
},
},
});
// 偏側值(決定字身於 0.5em 盒內之左移):
// left 墨偏左 → 不移(字身左對齊)
// center 墨居中 → 左移 0.25em
// right 墨偏右 → 左移 0.5em(右半入盒)
// full 無可壓縮空白 → 不收、維持全形
```
> `halt` 模式不讀偏側表(由字型負責定位)。偏側表僅在 `margin` 後備模式生效。
> `demo/fallback.html` 用方正書宋 GBK 對照 halt 失效 vs margin 生效。配字工作流見下節。
### 字型適配工作流(margin 模式配字)
換用新字型時,依下列步驟確認半形渲染:
1. **驗 `halt`**:`python3 tools/measure-bias.py <font.ttf>`(需 `pip install fonttools`)。
首行報該字型是否含 OpenType `halt`;特性存在不保證所有標點/語言字形均覆蓋。
- **有 `halt`** → 先在目標瀏覽器、字型及 `lang` 下逐標點驗證預設 halt 模式;
覆蓋及定位符合要求者可免配字,未覆蓋者改用局部 `margin`。
- **無 `halt`** → 用 `margin` 後備,續下。
2. **取偏側建議**:同一工具同時量各標點 glyph 之墨色中心、印出建議偏側表
(`left|center|right|full`)與可貼入 `jiya.bias` 之 JSON。工具只量預設 cmap glyph,
不做 shaping/`locl`;第二個語言參數僅選 JSON 分類鍵,不會改變所量字形。泛 CJK 地域字形須
以瀏覽器實際輸出微調,不能把各語言同值建議當成逐語言字形驗收。
3. **套用**:`createJuzhen({ jiya: { halfWidth: "margin", bias: <貼入 JSON> } })`;
或全用內建參考表(多數宋體系 GBK 字型與參考表相符,未必需覆寫)。
4. **目視微調**:以 `demo/fallback.html`(或自有頁面)對照原文,逐字檢查——
- 標點與相鄰字**重疊** → 該字墨色比量測更偏該側,偏側往「不移」方向調
(`right`→`center`→`left`)。
- 標點與內容**仍有空隙、不夠半形** → 往「多移」方向調(`left`→`center`→`right`)。
- 某標點根本無可壓空白(字面已滿全形)→ 設 `full`,不收。
5. **記錄**:把該字型之 `bias` 覆寫存入專案設定,作為該字型之適配結果。
> 偏側是「字身於 0.5em 盒內左移多少」的三檔量化(0/0.25/0.5em),量測值近門檻
> (如 0.6 介於 center/right)時,以目視為準。`halt` 字型走 OpenType 由字型精確
> 定位;是否完整覆蓋仍須核對——`margin` 配字是缺 `halt` 或覆蓋不足字型之務實近似。
---
## CSS 基礎設定(消費端須知)
JS 只注入 `jz-*` 結構,**所有視覺尺寸在 `juzhen.css`**——故 CSS 不可或缺。消費端
須備妥下列幾項:
1. **引入樣式(必須)**:`<link rel="stylesheet" href="dist/juzhen.css">`(或 ESM
`import "cjk-autospace/css"`)。缺此則間隙/擠壓/justify 原子化全部失效。
2. **設定 `lang`(強烈建議)**:在內容容器標 `lang="zh-Hans"`/`"zh-Hant"`/`"ja"`,
驅動全角/開明政策與字形 `locl`。自身及祖先均無 `lang` 時回退 `options.lang.default`;顯式 `lang=""` 為未知語言
(`other`),不繼承外層語言。簡繁分類只讀 BCP 47 的 script/region 位置,忽略
extension/private-use 內的同名字串(如 `zh-Hans-x-hant` 仍為簡體)。
3. **作用域類**:受管內容容器須加 `class="juzhen"`,以啟用內建原生間距禁用樣式。
`scope.include` 只控制 JS 處理範圍,不會改寫 CSS 選擇器;若使用 `.prose` 等自訂
選擇器,請同時加類別(如 `class="prose juzhen"`)。
4. **長英文詞斷字**:`longWord` 包出 `<span lang="en">` 後,須由消費端 CSS 提供
`hyphens: auto`(含 `-webkit-hyphens`)才會原生斷詞顯示連字符。
5. **`justify`(選用)**:欲兩端對齊則自行設 `text-align: justify`;句末標點單份間距
與行末去隙由聚珍處理,無需額外設定。
6. **字型由消費端負責(聚珍不管字型)**:聚珍**不**內建任何 `font-family`/字型堆疊
——逐語言字型完全由消費端 CSS 決定。聚珍只確保原子(`jz-*`)以 `font: inherit`
繼承消費端字型、絕不強加,並顯式保留 `locl`(見下「字型與 locl」一節)。半形擠壓
依字型 OpenType `halt`:含 `halt` 之字型(Noto CJK/思源)預設即可;**缺 `halt`
之字型**(如方正書宋 GBK)須改用 `jiya.halfWidth:"margin"` 後備,並按字型以
`jiya.bias` 逐字微調墨色偏側(見「字型適配工作流」)。
7. **自動處理、無需消費端介入**:`juzhen.css` 已在 `.juzhen` 範圍內以 `@supports`
關閉原生 `text-autospace`(避免與受管間隙重複作用);不設定 `text-spacing-trim`,
間隙不注入空白字元;僅跨節點 IVS gap 之 `U+200B ::after`
設 `user-select:none`,避免該斷行提示進入選取/複製。
8. **段落縮排與裝飾**:聚珍把標點拆成 inline-block 原子,於原子上歸零 `text-indent`,
避免段落首行縮排被每個原子重複套用;原子也承接 `text-decoration` 裝飾。
可在段落設定縮排與下劃線(機制見 ARCHITECTURE「原子透明原則」)。
`hanging-punctuation` 在原子上設為 `none`;當前管線不提供標點懸掛,不能由此推定
段落設定該屬性便會使受管標點出版心。
---
## 字型與 locl(消費端設定)
聚珍**不管字型**;逐語言字型由你以 CSS 設定。推薦範式——逐 `lang` 設 `font-family`,
掛在 `.juzhen` 或帶 `lang` 之容器(**勿**直接設 `jz-inner`/`jz-char`,它們靠繼承取字型):
```css
.juzhen:lang(zh-Hans), .juzhen [lang]:lang(zh-Hans) { font-family: "Noto Serif SC", "Source Han Serif SC", serif; }
.juzhen:lang(zh-Hant), .juzhen [lang]:lang(zh-Hant) { font-family: "Noto Serif TC", "Source Han Serif TC", serif; }
.juzhen:lang(ja), .juzhen [lang]:lang(ja) { font-family: "Noto Serif JP", serif; }
```
要點:
- **字型須含 OpenType `halt`** 才能半形擠壓;否則用 `jiya.halfWidth:"margin"` + 按字型
以 `jiya.bias` 逐字微調墨色偏側(見「字型適配工作流」)。
- **locl(地域字形)**:泛 CJK 字型(思源/Noto CJK)以 `locl` 依 `lang` 選地域字形
(如「直/海/骨」之簡繁日差異)。聚珍**不改寫 `lang`、不關閉 locl**,故只要你 ①
正確標 `lang`、② 用支援 locl 之字型,locl 即正常。body 文本之 locl 全由你的字型+
`lang` 決定;標點(`jz-inner`)之 `halt` 已顯式併入 `locl`,地域標點字形不致失。
- 若只想全語言同一字型,直接 `.juzhen { font-family: … }` 即可(無需逐 `lang`)。
---
## 自定義字符分類(`charClass`)
`cjk`/`western` 是 regex 字元類片段,特殊字元須轉義;`biaodian` 各子類則是
逐碼點字串,不解析範圍。追加三個圈碼應寫 `"①②③"`;`"①-③"` 會追加 `①`、`-`、`③`。
每個字符的分類(中文/西文、全寬標點各子類)抽象為**規則集**:內置默認規則集即聚珍
的分類表,消費端可經 `options.charClass` **擴充**,以覆寫 `spacing`/`jiya`/`jinze`
等規則對特定字符之判定。語義為**追加**——把當前未分類之字符、或新符號歸入對應類。
```js
createJuzhen({
charClass: {
cjk: "〇", // 追加為中文(CJK):與西文邊界補中西間隙、禁則/justify 視為單字
western: "★☆", // 追加為西文(半寬標點亦歸此):與 CJK 邊界補間隙
biaodian: { // 追加為全寬標點,按子類(jiya charify/擠壓、禁則避頭尾)
stop: "‼", // 追加為句末點號(。.!?已內置)
pause: "", // 句內(,、;:類)
open: "", close: "", // 開/閉括號引號
middle: "", liga: "", // 間隔號/省略破折
},
},
});
```
`.`(U+FF0E,全角句點)已是內置 `BD_STOP` 成員,無需再經
`charClass` 追加;其政策與 `。!?` 一致。`charClass.biaodian` 用於追加尚未內置的
符號(如上例 `‼`),不用來重複宣告內置字符。
要點:
- **向下兼容**:不傳 `charClass` 時,分類與行為與內置默認**完全一致**。
- **追加語義(v1)**:擴充內置類;**不**支援把內置字符「重分類/移除」(屬罕見需求,後續再議)。
勿把同一字符同時加入相衝之兩類。
- **結構性 ASCII**(字母數字 `A-Za-z0-9`、運算符 `+-*/` 等)為固定集、不開放覆寫——它們
服務於運算符/複合詞等內部規則,非「字符類型」。
- `margin` 後備模式下若新增開/閉括號,其墨色偏側未必命中內建參考表,可一併以
`jiya.bias` 指定(見「字型適配工作流」)。
---
## v1 向下相容 — 已移除
v1 相容層(`createCjkAutospace` 導出、IIFE 之 `globalThis.createCjkAutospace`
別名、`autospaceClass` 選項)**已隨最後一批 v1 消費端遷移完成而移除**。既有消費端
請改用 `createJuzhen(options).render(root)` v2 API;v1 原始碼保存於 `v1-autospace`
分支。
---
## 目視驗證
`demo/index.html` 載入 `dist/` 產物,簡繁並列、可切 `justify`、附 copy-clean 測試區。
`demo/e2e.html` 是可機讀的核心版面與 MutationObserver 驗收頁;macOS 上可以
`npm run test:wkwebview` 將同一頁面載入原生 `WKWebView`,自動執行全部斷言。
`demo/fallback.html` 以**方正書宋 GBK**(無 `halt`)對照 `halt` 模式失效 vs `margin`
後備生效。`demo/resize-check.html` 為**可拖曳容器**檢查頁:拖動方框寬度,驗證中西間隙在
各寬度下恆穩(含行末去隙之自我應驗、pill 跨行右隙、貨幣/拼音/西里爾外文補隙、全角省略號
與各式中文標點無誤隙),可切 `justify` 與「顯示間隙」描邊。版面相關結論(justify 單份間距、
擠壓視覺量、行端、`inline-block` 基線、間隙隨寬度之穩定性)須於 Chrome/Safari/Firefox
三引擎目視核對——jsdom 僅驗證 DOM 結構正確性。
---
## 授權
MIT。借鑑 Han.css/Heti 之思路與分類表(MIT),成品自含、不依賴其執行期程式碼。
-171
View File
@@ -1,171 +0,0 @@
# 聚珍消費端更新
目前用法與標記契約以 [README](./README.md#用法) 為準;本檔只記錄升級影響與所需動作。
## 2026-10-03:全專案掃描後的行緣、回退與語言修復
- **基線/範圍**:以 `024ad5d`(套件 2.0.0、ruleset 1.0.0)為掃描基線,修復
Web adapter、demo、量測工具及文件。套件版本仍為 2.0.0。
- **交付狀態**:本次修復已提交並推送至 `main`,倉庫含配套 JS/CSS 與型別宣告。
未進行 npm 發佈、下游更新或部署;既有安裝及網站不會自動更新。
### 可見行為與升級影響
1. **物理行緣**:`jz-orphan` 包裹不再把同行開括號誤當行首;自訂 isolate/opaque
的實際 inline/inline-block/contents 盒可攀出,block 仍是邊界。可見 `br` 作為
強制行緣,隱藏 `br` 可穿越。修正影響行端半形及同行/行末間隙判斷,邏輯隔離政策不變。
2. **切分回退**:splitoff 不再只憑前兄弟標籤合併。私有切分/合併鏈證明同源、仍相鄰
且作者屬性一致時才併回,支援多實例再次切分;外部插入、替換來源或改樣式時保留
片段元素、樣式及字序並撤銷本庫標記/憑證。受外部干預後不承諾恢復歷史 DOM 快照。
3. **語言**:自身及祖先最近的 `lang=""` 表示未知(`other`),僅屬性缺失才繼承/回退;
`lang.default: ""` 亦表示未知。script/region 只按 BCP 47 的位置判讀,私用或擴展
內容不再冒充腳本,如 `zh-Hans-x-hant` 為簡體、`zh-TW-x-hans` 為繁體。四個分類鍵、
裸 `zh` 預設繁體及逐元素樣式優先序不變。依賴舊錯誤行為者須改成明確語言或樣式。
4. **demo 控制**:主 demo 對齊/寬度切換顯式重排,revert 後控制保持停用;拖曳頁
八個框各自作為 render 根,寬度觀察及對齊刷新不再只依賴 body 尺寸變化。
5. **字型後備**:`margin`+逐語言分類/逐字 `jiya.bias`(left/center/right/full)
完整保留。工具補量 `.`(U+FF0E),並明示只量預設 cmap、未做 shaping/`locl`;
語言參數僅選 JSON 分類鍵,`halt` 特性存在不保證逐字覆蓋。目標字型與語言下仍須目視微調。
共享規則升為 **1.0.1 patch**:澄清既有 explicit-script/region 契約並新增七個反例向量,
原有 55 個向量結果、字符集合、政策、schema 及 adapter 義務均不變。Web 修正違反既有
契約的解析錯誤,不另立 Web 專用分類政策;其他 adapter 須核對全部 62 個向量。
本次未驗收 XeLaTeX 或其他 adapter。版本依據見 [共享規則](./rules/README.md#101-語言契約澄清)。
取用本次倉庫修改時,一起更新配套 JS/CSS/型別及共享規則;公開入口與選項名稱不變。
文件另修正 Finder 快取生命週期、no-op 懸掛說明、功能級別預設、樣式優先序及
`charClass` 輸入格式:cjk/western 是 regex 字元類片段,biaodian 是逐碼點字串,
`"①-③"` 不代表三個圈碼,應寫 `"①②③"`。
### 本地驗證
- `npm run build`:JS/CSS 與 TypeScript 型別宣告完整構建通過。
- `npm test`:**376/376 通過,0 失敗、0 跳過**;含 strict schema/常數對照、
全部 62 個語義向量、外部插入/替換/改樣式、observer 回退及多實例切分 36 個組合。
- `npm run test:wkwebview`:最終構建 **64/64 通過**,failures 為空;新增行緣、
語言、外部同標籤兄弟及多實例切分用例均在真實 WKWebView 執行。
- 最終產物另以原生宿主載入兩個 demo 副本:主頁 4 項、拖曳頁 5 項通過,核對
對齊後與手動重排一致、revert 停用,以及框寬變化實際改變行端決策。
以上是本地 DOM/原生幾何及控制驗證,不代表三引擎目視、所有字型地域字形或真實下游
頁面驗收。本次未重新驗收 Chrome/Edge;**Firefox 依用戶要求暫緩**,不安裝或作相容性結論。
## 2026-10-02:Web 模組邊界、半形模式與局部生命週期修復
- **基線/範圍**:以 `1c2fb0e`(套件 2.0.0、ruleset 1.0.0)為修復前基線;本次為
Web/CSS adapter 修復,公開入口、選項與共享規則版本不變。
- **交付狀態**:修復已以 [`b1a16c4`](https://git.commilitia.net/admin/cjk-autospace/commit/b1a16c49698dc418aa733e41a04bdbc9922927be)
提交並推送至 `main`,可從倉庫取得配套 JS、CSS 與型別宣告。未進行 npm 發佈;
既有 npm 安裝或任何下游網站不會因此自動更新。
### 半形模式
舊版以後代選擇器切換 `data-jz-halfwidth`,外層 halt 會蓋過內層 margin;從 margin
切回 halt 也會殘留 inline-block 內盒。現在由最近有效標記決定模式,可交替巢狀,
切回 halt 同時復原內盒與偏移。margin 的偏側位移僅作用於已壓縮標點;全形標點不再偏移。
既有缺半形特性字型的逐字相容方案仍保留:`margin` 後備搭配 `jiya.bias`,可按語言分類、
逐標點選 `left`/`center`/`right`/`full`;字型量測與目視微調流程見 README。
**受影響者**:混用含 halt 與缺 halt 字型、巢狀半形模式,或 margin 區域含全形標點的消費端。
標記與呼叫方式無需更換。若曾加 CSS workaround 抵銷舊版行為,更新後須確認其是否仍需要。
### 局部回退與觀察更新
舊版局部 `revert` 子樹含獨立 render 根時,可能只還原子根而漏掉由祖根處理的兄弟內容。
現在撤回完整請求子樹中本實例的產物,保留作者及其他實例的所有權。
舊版手動 `render`/`revert`/`rerender` 暫停 observer 時會丟掉未交付的變更。
現在先結算待交付更新,再執行手動操作;兄弟區塊仍更新,手動回退不被舊記錄重新打開。
**時機注意**:手動操作可能先處理其他已變動區塊,因此消費端的 `isSkipped` 等鉤子也可能
在該呼叫期間執行。observer 結算失敗仍走宿主非同步錯誤通道,不成為手動 API 的同步例外。
增量更新按目前觀察根的包含關係判定,未掛入 document 的根仍可更新;同批新增後
移到根外,或舊記錄目標已離開根時不再排版。舊版分別會漏掉前者、誤處理後者。
### 元素參與、Unicode 原子與源文本
舊版 standalone 遍歷可能進入 pill 內部的塊;禁則與避孤字也可能把 avoid、text 或
關閉該功能的內容帶入 `nowrap`。現在 pill 內部保持原樣,禁則同時檢查兩側及整綁
範圍,行內 render 根不向外綁定。避孤字只計算排除邊界之後的合法段末區域;不足
N 字,或不可切容器還含排除內容時不綁。pill 仍可作完整單元參與外部交界。
跨透明文本節點的 surrogate pair 及 base+VS 現在共用完整原子映射,間隙建於完整
字符之後,禁則不在原子內造斷點,避孤字只計一字。pill 兩側隔著一個 ASCII 作者
空格的鄰字也以完整原子查找;第二個空格、換行或排除邊界仍中斷。
標點 charify 也先將跨透明 Text 的 base+VS/surrogate 合為一個標點載體,讓鄰接
擠壓作用於完整字符。作者元素與監聽器保留,回退任一來源端會還原完整原子的分布。
作者克隆/刪除載體或替換來源父節點後,舊回退記錄不會復活已移除文字。
自訂標點集合含非 BMP 字符時,孤立 surrogate 不再被錯認為該字符;被排除的空 Text
也會中斷標點鄰接,普通空 Text 仍透明。
舊版固定內部標記可能把原文私用區字符(如 U+E000~U+E003)或貌似 placeholder
的內容誤當排版指令。現在每個 run 選擇源文本未使用、且字符規則不會匹配的標記,
原文保持作者內容。若自訂字符規則/源文本用盡全部候選,間隙回放前報錯。
**受影響者**:pill 中有塊級內容、混用參與標記、Unicode 字符被模板拆到不同節點,
或源文本含私用區字符的消費端。API 與標記無需變更;依賴舊版越界綁定的頁面,
須重新檢查段末斷行。
### 語言腳本匹配
`Hans`/`Hant` 以完整子標籤匹配,避免 `zh-CN-x-shant`/`zh-TW-x-hansford` 等
標籤被局部字串誤判為相反腳本。一般 `zh-Hans`/`zh-Hant`、地區回退及共享規則不變。
### 混合字號的行末去隙及 layout 生命週期
舊版去隙以相鄰矩形 top 差判斷換行,同行小字或下標也可能被判為下一行,導致
中西間隙消失。現在與行端標點一致,以垂直區間判斷;實際下一行仍去隙。
重排/回退也使舊 layout 安裝的字型完成及已排隊回調失效,避免再次掃描新產物。
**受影響者**:正文中混合字號、下標,或字型仍在載入時反覆重排的消費端。
有效選項與呼叫方式無需變更;曾以額外空格或 CSS 補償間隙的頁面須檢查原 workaround。
### 數值選項防護
`longWord.minLength` 現在要求 ≥2 的有限安全整數,無效值回退既有預設 6;舊版
小數/Infinity 等可能被拼成無效計數量詞,讓長詞功能靜默失效。`orphan.chars`
排除非有限值,無效值回退 2;合法小數仍照既有行為向下取整。
使用合法選項的消費端無需修改。若由外部設定輸入閾值,請依 README 的範圍檢查。
### 系統交互補查
已排版塊搬到不同級別/功能/語言的祖先後,舊版可能保留原產物與祖先快取。
現在觀察器先撤回搬入塊的舊效果,再按目前環境排版;純新增塊仍只處理自身範圍。
舊版 spacing 只按 block 門控,普通行內白名單/黑名單仍會被補隙或刪作者空格,
禁用塊中的子白名單也未能恢復間隙。現在以逐來源功能邊界處理;pill 鄰字查詢也
遵守兩側門控。空功能禁用元素會中斷 spacing/jiya,重新啟用的子樹自成脈絡。
`scope.include` 選到行內子樹時,間隙不再因父塊未命中而漏處理。
跨節點間隙載體被作者克隆/刪除、來源父被替換後,舊版回退可能重複或復活文字;
現在保留作者當前內容。這也修正舊回歸中「刪除載體後回退另一端便恢復整顆字」的
預期:**刪除載體即刪除其中原文,revert 不補回**。若要撤回排版再編輯原文,先 revert,
再修改;整根脫離文檔與正常搬移的恢復仍保留。
其他實例合法解包來源父時,本庫現在透過私有受控解包鏈更新記錄;兩/三實例
任意回退次序不再因舊父已撤回而丟 VS。作者自行解包或可偽造屬性不會獲得此憑證。
重用已撤回容器會使舊位置記錄失效;重定向鏈有環檢測,不能以循環來源指引恢復。
**受影響者**:搬移已排版 DOM、行內功能標記/opt-in、外部替換載體及多實例
疊加的消費端。公開入口、選項及共享規則不變;段落搬移仍使用既有 observe 或 rerender。
### 消費端動作與驗證
1. 透過既有安裝/打包方式一併更新 JS 與 CSS;只換 JS 不會得到半形模式修復。
2. 保留既有 pill/isolate/avoid、text/paragraph 及半形模式標記。
3. 自訂 `scope.include` 的容器仍須加 `.juzhen` 以使用內建原生間距禁用樣式,
如 `class="prose juzhen"`;JS 選擇器不會自動改寫 CSS 作用域。
4. 用 [E2E 頁面](./demo/e2e.html) 檢查嵌套模式、全形偏移、混合根回退與待交付更新;
macOS 原生入口為 `npm run test:wkwebview`。DOM 回歸見 `test/observer-recovery.test.mjs`
與 `test/revert-ownership.test.mjs`;模組邊界見 `test/participation-boundaries.test.mjs`、
`test/spacing-boundaries.test.mjs` 與 `test/shared-rules.test.mjs`。修復提交 `b1a16c4` 已通過
353 項 Node 測試、發佈構建及原生 WKWebView 53 項驗收;layout/數值回歸見
`test/layout-lifecycle.test.mjs` 與 `test/numeric-options.test.mjs`;觀察根及跨節點標點
回歸見 `test/observer-boundaries.test.mjs` 與 `test/punctuation-atoms.test.mjs`。
系統交互新增 42 項回歸見 `test/systematic-interactions.test.mjs`。修復過程中的中間快照
另通過 32 組功能開關×10 類結構共 320 個 DOM 組合及 splitoff 多實例 36 個組合;
這兩組為調查階段證據,不列為最終提交的獨立復驗。最終快照驗收為上述 353/53 項。
真實下游頁面仍須自行驗證其字型與版面。
-65
View File
@@ -1,65 +0,0 @@
// 構建腳本:esbuild 產出 ESM / IIFE / CSS;d.ts 由 `tsc --emitDeclarationOnly`
// 另行產出(見 package.json scripts)。
//
// dist/juzhen.mjs ESM,供打包器/TypeScript/jsdom 測試。
// dist/juzhen.iife.js IIFE,全域 `Juzhen`,原生 <script> 直跑。
// dist/juzhen.css 合併之樣式(css/juzhen.css 之 @import 展開)。
import { build } from "esbuild";
const ENTRY = "src/index.ts";
const SHARED = (
{
bundle: true,
target: [ "es2020", "chrome111", "safari16", "firefox115" ],
logLevel: "info",
}
);
// IIFE:把 Juzhen 掛上 globalThis(v1 全域別名已隨相容層移除)。
const IIFE_FOOTER = (
"if(typeof globalThis!=='undefined'){"
+ "globalThis.Juzhen=Juzhen;"
+ "}"
);
async function main()
{
await build(
{
...SHARED,
entryPoints: [ ENTRY ],
format: "esm",
outfile: "dist/juzhen.mjs",
}
);
await build(
{
...SHARED,
entryPoints: [ ENTRY ],
format: "iife",
globalName: "Juzhen",
outfile: "dist/juzhen.iife.js",
footer: { js: IIFE_FOOTER },
}
);
await build(
{
...SHARED,
entryPoints: [ "src/css/juzhen.css" ],
outfile: "dist/juzhen.css",
loader: { ".css": "css" },
}
);
}
main().catch(
(err) =>
{
console.error(err);
process.exit(1);
}
);
-801
View File
@@ -1,801 +0,0 @@
<!doctype html>
<html lang="zh-Hans">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>聚珍浏览器验收</title>
<link rel="stylesheet" href="../dist/juzhen.css">
<style>
body
{
margin: 0;
padding: 2rem;
color: #172033;
background: #f5f7fb;
font: 16px/1.75 system-ui, sans-serif;
}
h1 { margin-top: 0; font-size: 1.5rem; }
#summary, .panel
{
max-width: 52rem;
margin: 1rem 0;
padding: 1rem 1.25rem;
border: 1px solid #cbd4e2;
border-radius: .5rem;
background: #fff;
}
#summary[data-state="pass"] { border-color: #238636; }
#summary[data-state="fail"] { border-color: #cf222e; }
#results { padding-left: 1.5rem; }
#results li[data-state="pass"] { color: #1a7f37; }
#results li[data-state="fail"] { color: #cf222e; font-weight: 600; }
#fixture
{
max-width: 18em;
font-family: "Noto Serif SC", "Noto Serif CJK SC", serif;
}
#fixture p { text-align: justify; }
code { font-family: ui-monospace, monospace; }
</style>
</head>
<body>
<h1>聚珍浏览器验收</h1>
<section id="summary" data-state="running" aria-live="polite">
<strong id="summary-text">正在运行……</strong>
<button id="run-core" type="button">运行核心验收</button>
<button id="run-observer" type="button" disabled>运行 MutationObserver 验收</button>
<ul id="results"></ul>
</section>
<main id="fixture" class="panel juzhen" lang="zh-Hans">
<p id="spacing-case">中文ABC与TypeScript混排。</p>
<p id="punctuation-case">开明式:中文,测试(括号)与句号。</p>
<p id="ivs-forward"><strong id="author-forward">甲&#x20000;</strong>&#xE0100;A</p>
<p id="ivs-forward-no-orphan" data-juzhen-off="orphan"><strong>甲&#x20000;</strong>&#xE0100;A</p>
<p id="ivs-forward-same-node" data-juzhen-off="orphan">&#x20000;&#xE0100;A</p>
<p id="ivs-forward-same-node-prefix" data-juzhen-off="orphan">甲&#x20000;&#xE0100;A</p>
<p id="ivs-reverse">A<strong id="author-reverse">&#x20000;</strong>&#xE0100;乙</p>
<p id="ivs-line-break" data-juzhen-off="orphan" style="width:2.5em;text-align:justify"><strong>甲&#x20000;</strong>&#xE0100;A</p>
<p id="ivs-nowrap" data-juzhen-off="orphan" style="width:1em;white-space:nowrap"><strong>甲&#x20000;</strong>&#xE0100;A</p>
<p id="ivs-lineedge-open" lang="zh-Hant">「&#x20000;&#xE0100;甲</p>
<p id="ivs-lineedge-close" lang="zh-Hant">甲&#x20000;&#xE0100;」</p>
<p id="observer-case">观察初始内容</p>
</main>
<section id="halfwidth-fixture" class="panel juzhen" lang="zh-Hant" data-jz-level="text">
<div data-jz-halfwidth="halt">
<p id="halt-to-margin" data-jz-halfwidth="margin" data-jz-style="banjiao">甲,乙</p>
</div>
<div data-jz-halfwidth="margin">
<div data-jz-halfwidth="halt">
<p id="margin-to-halt" data-jz-style="banjiao">甲,乙</p>
<p id="nested-margin" data-jz-halfwidth="margin" data-jz-style="banjiao" style="text-decoration:underline">甲,乙</p>
</div>
<p id="fullwidth-bias" data-jz-style="quanjiao">甲,乙</p>
</div>
</section>
<section id="lifecycle-fixture" class="panel juzhen" lang="zh-Hans" data-jz-level="text">
<div id="partial-root"><p id="pending-a">原文,甲。</p><p id="pending-b">原文,乙。</p></div>
</section>
<section id="module-fixture" class="panel juzhen" lang="zh-Hant" data-jz-level="text">
<p id="source-pua">漢&#xE000;&#xE003;&#xE001;0&#xE002;GPT-4中</p>
<div class="module-pill"><p>甲ABC乙,丙。</p></div>
<p id="module-boundary" data-jz-level="paragraph"><span data-jz-skip>自管</span>,正常文字<span data-jz-level="text">界面文字</span></p>
<p id="pill-ivs-space"><strong>&#x20000;</strong><em>&#xE0100;</em> <code>x</code> <span>文</span></p>
<p id="jinze-ivs" data-jz-level="paragraph">前文<strong>&#x20000;</strong><em>&#xE0100;</em>,</p>
<p id="surrogate-spacing"><strong></strong><em></em><i></i><b></b>A</p>
<p id="locale-subtag-hans" lang="zh-CN-x-shant">甲,乙</p>
<p id="locale-subtag-hant" lang="zh-TW-x-hansford">甲,乙</p>
</section>
<section class="panel juzhen"><p id="inline-scope">甲<strong id="inline-root">,乙。</strong>後文</p></section>
<section id="layout-fixture" class="panel juzhen" lang="zh-Hant">
<p id="gap-small-neighbor" style="font-size:32px;text-align:center;white-space:nowrap">漢<span style="font-size:8px">A</span></p>
<p id="gap-sub-neighbor" style="font-size:32px;text-align:center;white-space:nowrap">漢<sub>A</sub></p>
</section>
<section id="punctuation-atom-fixture" class="panel juzhen" lang="zh-Hant">
<p id="punctuation-vs">甲<strong id="punctuation-base">,</strong><em id="punctuation-selector">&#xFE00;</em>「乙丙」</p>
<p id="punctuation-surrogate"><strong></strong><em></em><i></i><b></b>「甲</p>
<p id="punctuation-isolated"></p>
</section>
<script src="../dist/juzhen.iife.js"></script>
<script>
(function ()
{
"use strict";
var results = document.getElementById("results");
var summary = document.getElementById("summary");
var summaryText = document.getElementById("summary-text");
var coreButton = document.getElementById("run-core");
var observerButton = document.getElementById("run-observer");
var fixture = document.getElementById("fixture");
var authorForward = document.getElementById("author-forward");
var authorReverse = document.getElementById("author-reverse");
var originalHtml = fixture.innerHTML;
var originalText = fixture.textContent;
var clickCount = 0;
var failures = 0;
function record(name, condition, detail)
{
var item = document.createElement("li");
item.dataset.state = condition ? "pass" : "fail";
item.textContent = (condition ? "PASS " : "FAIL ") + name
+ (detail ? " — " + detail : "");
results.appendChild(item);
if (!condition) { failures += 1; }
}
function hasUnpairedSurrogate(text)
{
for (var i = 0; i < text.length; i += 1)
{
var unit = text.charCodeAt(i);
if (unit >= 0xD800 && unit <= 0xDBFF)
{
if (i + 1 >= text.length) { return true; }
var next = text.charCodeAt(i + 1);
if (next < 0xDC00 || next > 0xDFFF) { return true; }
i += 1;
}
else if (unit >= 0xDC00 && unit <= 0xDFFF)
{
return true;
}
}
return false;
}
function delay()
{
return new Promise(function (resolve)
{
setTimeout(resolve, 30);
});
}
authorForward.addEventListener("click", function () { clickCount += 1; });
authorReverse.addEventListener("click", function () { clickCount += 1; });
coreButton.addEventListener("click", async function ()
{
coreButton.disabled = true;
try
{
// 浏览器扩展可能在 DOMContentLoaded 后异步补写作者属性;以验收
// 按钮真正启动前的 DOM 为 revert 基线,避免把外部并发变更误报为库回归。
originalHtml = fixture.innerHTML;
originalText = fixture.textContent;
var jz = Juzhen.createJuzhen({
scope: { include: "#fixture" },
jiya: { halfWidth: "margin" },
});
jz.render(fixture);
var modeFixture = document.getElementById("halfwidth-fixture");
var modeJz = Juzhen.createJuzhen();
modeJz.render(modeFixture);
[ "halt-to-margin", "nested-margin" ].forEach(function (id)
{
var punctuation = document.getElementById(id).querySelector("jz-char");
var innerStyle = getComputedStyle(punctuation.querySelector("jz-inner"));
var widthEm = punctuation.getBoundingClientRect().width
/ parseFloat(getComputedStyle(punctuation).fontSize);
record(
id + ":最近 margin 标记控制半形宽度与字形",
Math.abs(widthEm - 0.5) <= 0.01
&& innerStyle.fontFeatureSettings === "normal"
&& innerStyle.display === "inline-block",
"width=" + widthEm.toFixed(3) + "em,features=" + innerStyle.fontFeatureSettings,
);
});
var haltInnerStyle = getComputedStyle(
document.getElementById("margin-to-halt").querySelector("jz-inner"),
);
record(
"margin-to-halt:恢复 halt 时撤销 margin 内盒与偏移",
haltInnerStyle.display === "inline"
&& parseFloat(haltInnerStyle.marginLeft) === 0
&& haltInnerStyle.fontFeatureSettings.includes('"halt"'),
);
var underline = getComputedStyle(
document.getElementById("nested-margin").querySelector("jz-inner"),
).textDecorationLine;
record(
"嵌套 margin 内盒保留段落下划线",
underline.includes("underline"),
"text-decoration-line=" + underline,
);
var fullwidth = document.getElementById("fullwidth-bias").querySelector("jz-char");
record(
"margin 模式不移动未压缩的全形标点",
!fullwidth.classList.contains("jz-half")
&& !fullwidth.classList.contains("jz-half-le")
&& parseFloat(getComputedStyle(fullwidth.querySelector("jz-inner")).marginLeft) === 0,
);
var atomFixture = document.getElementById("punctuation-atom-fixture");
var atomSurrogate = document.getElementById("punctuation-surrogate");
[ "\uD83D", "\uDE00", "\uDB40", "\uDD00" ].forEach(function (part, index)
{
atomSurrogate.children[index].textContent = part;
});
document.getElementById("punctuation-isolated").textContent = "\uD83DA\uDE00";
var atomOriginal = atomFixture.innerHTML;
var atomSelector = document.getElementById("punctuation-selector");
var atomClicks = 0;
atomSelector.addEventListener("click", function () { atomClicks += 1; });
var atomJz = Juzhen.createJuzhen({ charClass: { biaodian: { pause: "😀" } } });
atomJz.render(atomFixture);
var atomComma = document.getElementById("punctuation-vs").querySelector("jz-char.bd-pause");
record("跨节点标点与 VS 为完整原子并参与邻接挤压",
atomComma.textContent === ",\uFE00" && atomComma.classList.contains("jz-half")
&& document.getElementById("punctuation-vs").querySelector("jz-char.bd-open").classList.contains("jz-half"));
record("跨节点 surrogate 标点和 VS 不生成半字原子",
atomSurrogate.querySelector("jz-char.bd-pause").textContent === "😀\uDB40\uDD00"
&& !document.getElementById("punctuation-isolated").querySelector("jz-char"));
atomSelector.click();
record("跨标点原子保留作者元素身份及监听器",
document.getElementById("punctuation-selector") === atomSelector && atomClicks === 1);
atomJz.revert(atomFixture);
record("跨标点原子回退恢复来源分布", atomFixture.innerHTML === atomOriginal);
atomJz.disconnect();
var spacingOnlyOptions = { jiya: false, jinze: false, orphan: false, longWord: false };
var interactionRoot = document.createElement("p");
interactionRoot.setAttribute("data-jz-level", "text");
interactionRoot.innerHTML = '<span data-juzhen-off="spacing">甲 ABC 乙</span>'
+ '<code>x</code><span data-juzhen-off="spacing"> 丙</span>';
var interactionJz = Juzhen.createJuzhen(spacingOnlyOptions);
var interactionOriginal = interactionRoot.innerHTML;
interactionJz.render(interactionRoot);
record("行内 spacing 关闭时保留原文、作者空格与 pill 邻界",
interactionRoot.innerHTML === interactionOriginal);
interactionJz.revert(interactionRoot);
interactionRoot.innerHTML = '外ABC<span class="prose">甲DEF</span>文XYZ';
var includeJz = Juzhen.createJuzhen(Object.assign({}, spacingOnlyOptions,
{ scope: { include: ".prose" } }));
includeJz.render(interactionRoot);
record("行内 scope.include 子树获得间隙,外部保持原样",
interactionRoot.querySelectorAll("jz-hws").length === 1
&& interactionRoot.querySelector(".prose jz-hws").textContent === "甲"
&& interactionRoot.firstChild.textContent === "外ABC"
&& interactionRoot.lastChild.textContent === "文XYZ");
includeJz.revert(interactionRoot);
includeJz.disconnect();
interactionRoot.innerHTML = ',<span data-juzhen-off="jiya"></span>「';
interactionRoot.setAttribute("lang", "zh-Hant");
var disabledJiya = Juzhen.createJuzhen({ spacing: false, jinze: false, orphan: false, longWord: false });
disabledJiya.render(interactionRoot);
record("空功能禁用元素中断标点邻接",
interactionRoot.querySelectorAll("jz-char").length === 2
&& !interactionRoot.querySelector("jz-char.jz-half"));
disabledJiya.revert(interactionRoot);
disabledJiya.disconnect();
var carrierFailures = [];
[ "clone", "remove" ].forEach(function (change)
{
interactionRoot.innerHTML = '<strong>禰</strong><em>\uDB40\uDD00</em>A';
interactionJz.render(interactionRoot);
var gap = interactionRoot.querySelector("jz-hws");
if (change === "clone") { gap.replaceWith(gap.cloneNode(true)); }
else { gap.remove(); }
var authorText = interactionRoot.textContent;
interactionJz.revert(interactionRoot);
if (interactionRoot.textContent !== authorText) { carrierFailures.push(change); }
});
record("间隙载体克隆与删除后不重复或复活文字", carrierFailures.length === 0,
carrierFailures.join(","));
interactionJz.disconnect();
var ownerFailures = [];
[ [0,1,2], [1,0,2], [0,2,1], [1,2,0], [2,0,1], [2,1,0] ].forEach(function (order)
{
var paragraph = document.createElement("p");
[ "甲", "「", "禰", "\uFE00", "A" ].forEach(function (text)
{
paragraph.appendChild(document.createTextNode(text));
});
var owners = [
Juzhen.createJuzhen({ spacing: false, orphan: false, longWord: false }),
Juzhen.createJuzhen({ spacing: false, jiya: false, jinze: false, longWord: false }),
Juzhen.createJuzhen(spacingOnlyOptions),
];
owners.forEach(function (owner) { owner.render(paragraph); });
order.forEach(function (index) { owners[index].revert(paragraph); });
if (paragraph.textContent !== "甲「禰\uFE00A" || paragraph.querySelector("[data-jz]"))
{
ownerFailures.push(order.join("→"));
}
owners.forEach(function (owner) { owner.disconnect(); });
});
record("多实例传递解包的六种回退顺序保留 VS", ownerFailures.length === 0,
ownerFailures.join(","));
var reusedRoot = document.createElement("p");
[ "甲", "「", "禰", "\uFE00", "A" ].forEach(function (text)
{
reusedRoot.appendChild(document.createTextNode(text));
});
var reusedBinding = Juzhen.createJuzhen({ spacing: false, orphan: false, longWord: false });
var reusedOrphan = Juzhen.createJuzhen({ spacing: false, jiya: false, jinze: false, longWord: false });
var reusedSpacing = Juzhen.createJuzhen(spacingOnlyOptions);
reusedBinding.render(reusedRoot);
var retiredBinding = reusedRoot.querySelector("jz-jinze");
reusedOrphan.render(reusedRoot);
var retiredOrphan = reusedRoot.querySelector("jz-orphan");
reusedSpacing.render(reusedRoot);
reusedBinding.revert(reusedRoot);
retiredBinding.appendChild(retiredOrphan);
reusedOrphan.revert(retiredBinding);
var reusedText = retiredBinding.textContent;
reusedSpacing.revert(retiredBinding);
record("重新使用已撤回容器时回退有限结束并保留当前文字",
retiredBinding.textContent === reusedText);
[ reusedBinding, reusedOrphan, reusedSpacing ].forEach(function (owner) { owner.disconnect(); });
var layoutFixture = document.getElementById("layout-fixture");
var layoutJz = Juzhen.createJuzhen({ jiya: false, orphan: false, jinze: false, longWord: false });
layoutJz.render(layoutFixture);
[ "gap-small-neighbor", "gap-sub-neighbor" ].forEach(function (id)
{
var paragraph = document.getElementById(id);
var gap = paragraph.querySelector("jz-hws");
var neighbor = paragraph.querySelector("span, sub");
var gapRect = gap.getBoundingClientRect();
var nextRect = neighbor.getBoundingClientRect();
var overlaps = nextRect.top < gapRect.bottom - 2 && nextRect.bottom > gapRect.top + 2;
record(id + ":同行不同字号或基线保留间隙",
overlaps && !gap.classList.contains("jz-hws-trim"),
"gap=" + gapRect.top.toFixed(1) + ".." + gapRect.bottom.toFixed(1)
+ ",next=" + nextRect.top.toFixed(1) + ".." + nextRect.bottom.toFixed(1));
});
layoutJz.revert(layoutFixture);
layoutJz.disconnect();
var auditRoot = document.createElement("section");
auditRoot.lang = "zh-Hant";
auditRoot.style.cssText = "font-size:20px;width:600px;text-align:left";
document.body.appendChild(auditRoot);
[ false, true ].forEach(function (orphan)
{
auditRoot.innerHTML = '<p>前甲「乙丙」</p>';
var edgeOwner = Juzhen.createJuzhen({ orphan: orphan, spacing: false, longWord: false });
edgeOwner.render(auditRoot);
record("同行 orphan 不製造行首,orphan=" + orphan,
!auditRoot.querySelector(".bd-open").classList.contains("jz-half-le"));
edgeOwner.revert(auditRoot);
edgeOwner.disconnect();
});
[ "inline", "inline-block", "contents", "block" ].forEach(function (display)
{
auditRoot.innerHTML = '<p>前甲<x-box style="display:' + display + '">「乙」</x-box>丙</p>';
var edgeOwner = Juzhen.createJuzhen({ isolateSelector: "x-box", orphan: false, spacing: false, longWord: false });
edgeOwner.render(auditRoot);
record("自訂隔離元素按物理行內流判斷,display=" + display,
Array.from(auditRoot.querySelectorAll("jz-char")).every(function (char)
{
return char.classList.contains("jz-half-le") === (display === "block");
}));
edgeOwner.revert(auditRoot);
edgeOwner.disconnect();
});
[ false, true ].forEach(function (hidden)
{
auditRoot.innerHTML = '<p>甲」<br' + (hidden ? ' style="display:none"' : '') + '>「乙</p>';
var edgeOwner = Juzhen.createJuzhen({ orphan: false, spacing: false, longWord: false });
edgeOwner.render(auditRoot);
record("BR 是強制行緣,hidden=" + hidden,
Array.from(auditRoot.querySelectorAll("jz-char")).every(function (char)
{
return char.classList.contains("jz-half-le") === !hidden;
}));
edgeOwner.revert(auditRoot);
edgeOwner.disconnect();
});
auditRoot.innerHTML = '<p><strong class="original">甲乙丙</strong>,丁</p>';
var splitOwner = Juzhen.createJuzhen({ orphan: false, spacing: false, longWord: false });
splitOwner.render(auditRoot);
var splitFragment = auditRoot.querySelector('[data-jz-kind="splitoff"]');
var inserted = document.createElement("strong");
inserted.className = "external";
inserted.textContent = "新";
splitFragment.before(inserted);
splitOwner.revert(auditRoot);
record("splitoff 不將邊界字移入外部同標籤兄弟",
inserted.textContent === "新" && splitFragment.textContent === "丙"
&& splitFragment.className === "original" && !splitFragment.hasAttribute("data-jz")
&& auditRoot.textContent === "甲乙新丙,丁");
splitOwner.disconnect();
auditRoot.innerHTML = '<p lang="zh-Hans-x-hant">甲,乙</p><p lang="zh-TW-x-hans">甲,乙</p><p lang="">甲,乙</p>';
var localeOwner = Juzhen.createJuzhen({ orphan: false, jinze: false });
localeOwner.render(auditRoot);
var localeChars = auditRoot.querySelectorAll("jz-char");
record("語言私用部分不覆寫 script/region,空值為未知",
localeChars[0].classList.contains("jz-half")
&& !localeChars[1].classList.contains("jz-half")
&& !localeChars[2].classList.contains("jz-half"));
localeOwner.revert(auditRoot);
localeOwner.disconnect();
var splitMatrixPass = true;
var splitOrders = [ [0,1,2], [0,2,1], [1,0,2], [1,2,0], [2,0,1], [2,1,0] ];
[ '<strong>甲乙丙丁</strong>,戊己', '「<strong>甲乙丙丁</strong>」',
'<strong><em>甲乙丙丁</em></strong>,戊己',
'<strong class="author" style="color:red">甲乙丙丁</strong>,戊己',
'<strong>甲乙𠀀\uDB40\uDD00丙丁</strong>,戊己',
'<strong data-jz="author" data-jz-kind="author">甲乙丙丁</strong>,戊己'
].forEach(function (html)
{
splitOrders.forEach(function (order)
{
auditRoot.innerHTML = '<p>' + html + '</p>';
var p = auditRoot.firstElementChild;
var original = p.innerHTML;
var owners = [
Juzhen.createJuzhen({ jiya: false, spacing: false, orphan: false, longWord: false }),
Juzhen.createJuzhen({ jiya: false, spacing: false, jinze: false, longWord: false }),
Juzhen.createJuzhen({ spacing: false, orphan: false, longWord: false })
];
owners.forEach(function (owner) { owner.render(p); });
order.forEach(function (index) { owners[index].revert(p); });
if (p.innerHTML !== original) { splitMatrixPass = false; }
owners.forEach(function (owner) { owner.disconnect(); });
});
});
record("私有切分來源鏈支援多實例六種回退順序,36 組合", splitMatrixPass);
auditRoot.remove();
var moduleFixture = document.getElementById("module-fixture");
var splitSurrogate = document.getElementById("surrogate-spacing");
[ "\uD840", "\uDC00", "\uDB40", "\uDD00" ].forEach(function (part, index)
{
splitSurrogate.children[index].textContent = part;
});
var moduleHtml = moduleFixture.innerHTML
.replace("</em> <code>", "</em><code>")
.replace("</code> <span>", "</code><span>");
var puaSource = document.getElementById("source-pua").textContent;
var modulePill = moduleFixture.querySelector(".module-pill");
var modulePillHtml = modulePill.innerHTML;
var moduleJz = Juzhen.createJuzhen({ pillSelector: "code, kbd, .module-pill" });
moduleJz.render(moduleFixture);
record("作者私用区字符与伪 placeholder 保持原文",
document.getElementById("source-pua").textContent === puaSource);
record("pill 内部的块不被排版", modulePill.innerHTML === modulePillHtml);
var protectedRegion = document.getElementById("module-boundary");
record("段落绑定不进入 avoid 或 text 子树",
!protectedRegion.querySelector("[data-jz-skip]").closest("jz-jinze")
&& !protectedRegion.querySelector('[data-jz-level="text"]').closest("jz-orphan"));
var pillIvs = document.getElementById("pill-ivs-space");
record("pill 两侧跨透明节点空格与完整 IVS 间隙",
pillIvs.textContent === "\uD840\uDC00\uDB40\uDD00x文"
&& pillIvs.querySelectorAll("jz-hws").length === 2
&& pillIvs.querySelector("jz-hws").textContent === "\uD840\uDC00\uDB40\uDD00");
record("禁则连同跨节点 base 与 VS 绑定到闭标点",
document.getElementById("jinze-ivs").querySelector("jz-jinze").textContent
=== "\uD840\uDC00\uDB40\uDD00,");
record("跨节点 surrogate 的间隙位于完整原子之后",
splitSurrogate.querySelector("jz-hws").textContent === "\uD840\uDC00\uDB40\uDD00"
&& splitSurrogate.textContent === "\uD840\uDC00\uDB40\uDD00A");
record("语言脚本按完整子标签匹配",
document.getElementById("locale-subtag-hans").querySelector("jz-char").classList.contains("jz-half")
&& !document.getElementById("locale-subtag-hant").querySelector("jz-char").classList.contains("jz-half"));
moduleJz.revert(moduleFixture);
record("模块回退精确恢复作者结构,保留已规范化空格规则",
moduleFixture.innerHTML === moduleHtml);
var inlineRoot = document.getElementById("inline-root");
var inlineParent = inlineRoot.parentNode;
var inlineHtml = inlineParent.innerHTML;
var inlineJz = Juzhen.createJuzhen({ spacing: false, orphan: false, longWord: false });
inlineJz.render(inlineRoot);
var inlineContained = inlineRoot.parentNode === inlineParent
&& !inlineParent.querySelector(":scope > jz-jinze");
inlineJz.revert(inlineRoot);
record("行内 render 根的禁则不越界并可完整回退",
inlineContained && inlineParent.innerHTML === inlineHtml);
modeJz.revert(modeFixture);
var lifecycleFixture = document.getElementById("lifecycle-fixture");
var partialRoot = document.getElementById("partial-root");
var pendingA = document.getElementById("pending-a");
var pendingB = document.getElementById("pending-b");
var lifecycleJz = Juzhen.createJuzhen();
lifecycleJz.render(lifecycleFixture);
lifecycleJz.render(pendingB);
lifecycleJz.revert(partialRoot);
record(
"局部 revert 同时撤回祖根与子根生成的产物",
!partialRoot.querySelector("[data-jz]")
&& !partialRoot.querySelector("[data-jz-block-level]"),
);
lifecycleJz.render(lifecycleFixture);
lifecycleJz.observe(lifecycleFixture);
pendingA.textContent = "改后,新文。";
lifecycleJz.rerender(pendingB);
await delay();
record(
"手动局部重排保留兄弟块待交付的外部变化",
pendingA.querySelectorAll("jz-char").length === 2,
);
lifecycleJz.disconnect();
lifecycleJz.revert(lifecycleFixture);
var firstGapCount = fixture.querySelectorAll("jz-hws").length;
var firstCharCount = fixture.querySelectorAll("jz-char").length;
record("已生成中西间隙", firstGapCount > 0, firstGapCount + " 个");
record("已生成标点原子", firstCharCount > 0, firstCharCount + " 个");
record("copy-clean 文本保持", fixture.textContent === originalText);
record("无孤立 UTF-16 代理项", !hasUnpairedSurrogate(fixture.textContent));
var ivs = String.fromCodePoint(0x20000, 0xE0100);
var forward = document.getElementById("ivs-forward");
var forwardNoOrphan = document.getElementById("ivs-forward-no-orphan");
var forwardSameNode = document.getElementById("ivs-forward-same-node");
var forwardSameNodePrefix = document.getElementById("ivs-forward-same-node-prefix");
var reverse = document.getElementById("ivs-reverse");
var forwardCarrier = forward.querySelector("jz-hws");
var carrierKind = forwardCarrier?.getAttribute("data-jz-kind") ?? "none";
record("跨节点 IVS 正向完整", forward.textContent.includes(ivs + "A"));
record(
"关闭 orphan 时 IVS 正向间隙保留",
forwardNoOrphan.querySelectorAll("jz-hws").length === 1,
"gaps=" + forwardNoOrphan.querySelectorAll("jz-hws").length
+ ",HTML=" + forwardNoOrphan.innerHTML,
);
record(
"同一 Text 节点的 IVS 正向间隙保留",
forwardSameNode.querySelectorAll("jz-hws").length === 1,
"gaps=" + forwardSameNode.querySelectorAll("jz-hws").length
+ ",HTML=" + forwardSameNode.innerHTML,
);
record(
"带前导 CJK 的同节点 IVS 正向间隙保留",
forwardSameNodePrefix.querySelectorAll("jz-hws").length === 1,
"gaps=" + forwardSameNodePrefix.querySelectorAll("jz-hws").length
+ ",HTML=" + forwardSameNodePrefix.innerHTML,
);
record("跨节点 IVS 反向完整", reverse.textContent.includes("A" + ivs));
record(
"跨节点 IVS 在间隙载体中保持完整",
carrierKind === "wrap"
&& forwardCarrier?.textContent === ivs
&& forwardCarrier?.hasAttribute("data-jz-cross-node-atom")
&& Array.from(forwardCarrier.childNodes)
.filter(function (node) { return node.nodeType === Node.TEXT_NODE; })
.length === 2,
"forward gaps=" + forward.querySelectorAll("jz-hws").length
+ ",reverse gaps=" + reverse.querySelectorAll("jz-hws").length
+ ",carrier kind=" + carrierKind
+ ",carrier text=" + JSON.stringify(forwardCarrier?.textContent ?? null)
+ ",HTML=" + forward.innerHTML,
);
authorForward.click();
authorReverse.click();
record(
"作者元素身份与监听器保持",
document.getElementById("author-forward") === authorForward
&& document.getElementById("author-reverse") === authorReverse
&& document.querySelectorAll("#author-forward").length === 1
&& document.querySelectorAll("#author-reverse").length === 1
&& clickCount === 2,
);
var gap = fixture.querySelector("jz-hws");
var gapStyle = getComputedStyle(gap);
var gapRatio = parseFloat(gapStyle.marginRight) / parseFloat(gapStyle.fontSize);
record("间隙视觉宽度约为四分之一字", gapRatio >= 0.2 && gapRatio <= 0.3, gapRatio.toFixed(3) + "em");
var selection = window.getSelection();
var selectionRange = document.createRange();
selectionRange.selectNodeContents(forwardNoOrphan);
selection?.removeAllRanges();
selection?.addRange(selectionRange);
var selectionText = selection?.toString() ?? "";
record(
"Selection 文本不含生成断行字符",
selectionText === forwardNoOrphan.textContent,
"zwsp=" + selectionText.includes("\u200B")
+ ",length delta=" + (selectionText.length - forwardNoOrphan.textContent.length),
);
selection?.removeAllRanges();
await delay();
await delay();
var lineBreak = document.getElementById("ivs-line-break");
var lineGap = lineBreak.querySelector("jz-hws");
var lineNext = lineBreak.lastChild;
var lineGapRect = lineGap?.getBoundingClientRect();
var lineNextRange = document.createRange();
if (lineNext?.nodeType === Node.TEXT_NODE)
{
lineNextRange.setStart(lineNext, 0);
lineNextRange.setEnd(lineNext, 1);
}
var lineNextRect = lineNextRange.getBoundingClientRect();
var lineBoxRect = lineBreak.getBoundingClientRect();
var lineGapStyle = lineGap ? getComputedStyle(lineGap) : null;
var lineGapMargin = lineGapStyle
? parseFloat(lineGapStyle.marginRight) / parseFloat(lineGapStyle.fontSize)
: -1;
var nextLineIndent = (lineNextRect.left - lineBoxRect.left)
/ parseFloat(lineGapStyle?.fontSize || "16");
record(
"行末间隙载体留在前行并清零,下一行无缩进",
!!lineGapRect
&& lineNextRect.top > lineGapRect.top + 2
&& lineGap.classList.contains("jz-hws-trim")
&& Math.abs(lineGapMargin) <= 0.01
&& Math.abs(nextLineIndent) <= 0.05,
"trim=" + !!lineGap?.classList.contains("jz-hws-trim")
+ ",margin=" + lineGapMargin.toFixed(3)
+ "em,indent=" + nextLineIndent.toFixed(3) + "em",
);
var nowrapCase = document.getElementById("ivs-nowrap");
var nowrapGap = nowrapCase.querySelector("jz-hws");
var nowrapNext = nowrapCase.lastChild;
var nowrapNextRange = document.createRange();
if (nowrapNext?.nodeType === Node.TEXT_NODE)
{
nowrapNextRange.setStart(nowrapNext, 0);
nowrapNextRange.setEnd(nowrapNext, 1);
}
var nowrapGapRect = nowrapGap?.getBoundingClientRect();
var nowrapNextRect = nowrapNextRange.getBoundingClientRect();
record(
"生成断行机会不突破 nowrap",
!!nowrapGapRect && Math.abs(nowrapGapRect.top - nowrapNextRect.top) <= 2,
);
var ivsOpen = document.getElementById("ivs-lineedge-open").querySelector("jz-char");
var ivsClose = document.getElementById("ivs-lineedge-close").querySelector("jz-char");
record(
"IVS 相邻行首开标点保持行缘半形",
!!ivsOpen?.classList.contains("jz-half-le"),
);
record(
"IVS 相邻行尾闭标点保持行缘半形",
!!ivsClose?.classList.contains("jz-half-le"),
);
var comma = Array.from(fixture.querySelectorAll("jz-char"))
.find(function (item) { return item.textContent === ","; });
var commaStyle = comma ? getComputedStyle(comma) : null;
var commaRatio = comma && commaStyle
? comma.getBoundingClientRect().width / parseFloat(commaStyle.fontSize)
: 0;
record(
"margin 后备的句内逗号为半形",
commaRatio >= 0.45 && commaRatio <= 0.55,
commaRatio.toFixed(3) + "em",
);
jz.render(fixture);
record(
"重复 render 幂等",
fixture.querySelectorAll("jz-hws").length === firstGapCount
&& fixture.querySelectorAll("jz-char").length === firstCharCount,
);
jz.revert(fixture);
var internalComments = [];
var commentWalker = document.createTreeWalker(fixture, NodeFilter.SHOW_COMMENT);
while (commentWalker.nextNode())
{
if (commentWalker.currentNode.textContent?.startsWith("jz-spacing-") === true)
{
internalComments.push(commentWalker.currentNode);
}
}
record(
"revert 清除全部产物",
fixture.querySelectorAll("[data-jz]").length === 0
&& internalComments.length === 0
&& document.getElementById("author-forward") === authorForward
&& document.getElementById("author-reverse") === authorReverse
&& fixture.innerHTML === originalHtml
&& fixture.textContent === originalText,
);
jz.render(fixture);
observerButton.disabled = false;
observerButton.addEventListener("click", async function ()
{
observerButton.disabled = true;
try
{
jz.observe(fixture);
document.getElementById("observer-case").textContent = "观察Observer恢复";
await delay();
await delay();
var detachedRoot = document.createElement("main");
detachedRoot.innerHTML = '<p lang="zh-Hant">原文,甲乙。</p>';
var detachedJz = Juzhen.createJuzhen();
detachedJz.render(detachedRoot);
detachedJz.observe(detachedRoot);
detachedRoot.querySelector("p").textContent = "修改ABC,丙丁。";
var scopeRoot = document.createElement("main");
var scopeOutside = document.createElement("aside");
document.body.append(scopeRoot, scopeOutside);
var scopeJz = Juzhen.createJuzhen();
scopeJz.observe(scopeRoot);
var escaped = document.createElement("p");
escaped.textContent = "移出ABC,作者內容。";
scopeRoot.appendChild(escaped);
scopeOutside.appendChild(escaped);
var escapedOriginal = escaped.outerHTML;
await delay();
await delay();
record("未挂入 document 的观察根仍增量排版",
!!detachedRoot.querySelector("jz-hws") && !!detachedRoot.querySelector("jz-char"));
record("同批移出观察根的段落不被新增排版", escaped.outerHTML === escapedOriginal);
detachedJz.revert(detachedRoot);
detachedJz.disconnect();
scopeJz.revert(scopeRoot);
scopeJz.disconnect();
scopeRoot.remove();
scopeOutside.remove();
var relocateRoot = document.createElement("main");
relocateRoot.innerHTML = '<section><p>文字ABCDEF,甲乙。</p></section>'
+ '<section data-jz-level="text"></section>';
var relocateJz = Juzhen.createJuzhen();
relocateJz.render(relocateRoot);
relocateJz.observe(relocateRoot);
var relocatedParagraph = relocateRoot.querySelector("p");
relocateRoot.lastElementChild.appendChild(relocatedParagraph);
await delay();
record("已排版段落移入 text 范围后撤回旧段落约束",
relocatedParagraph.getAttribute("data-jz-block-level") === "text"
&& !relocatedParagraph.querySelector("jz-jinze,jz-orphan,[data-jz=longWord]")
&& !!relocatedParagraph.querySelector("jz-hws"));
var relocatedResult = relocatedParagraph.outerHTML;
relocateJz.disconnect();
relocateJz.rerender(relocateRoot);
record("移动段落的增量结果与完整重排一致",
relocatedResult === relocatedParagraph.outerHTML);
relocateJz.revert(relocateRoot);
record(
"MutationObserver 增量重排",
document.getElementById("observer-case").querySelectorAll("jz-hws").length > 0,
);
}
catch (error)
{
record(
"MutationObserver 增量重排",
false,
error instanceof Error ? error.message : String(error),
);
}
finally
{
jz.disconnect();
summary.dataset.state = failures === 0 ? "pass" : "fail";
summaryText.textContent = failures === 0
? "PASS:全部浏览器验收项通过"
: "FAIL:" + failures + " 项浏览器验收失败";
document.title = failures === 0
? "PASS — 聚珍浏览器验收"
: "FAIL — 聚珍浏览器验收";
}
});
}
catch (error)
{
record("验收脚本未抛错", false, error instanceof Error ? error.message : String(error));
}
summary.dataset.state = failures === 0 ? "pass" : "fail";
summaryText.textContent = failures === 0
? "核心验收通过;请运行 MutationObserver 验收"
: "FAIL:" + failures + " 项浏览器验收失败";
document.title = failures === 0
? "CORE PASS — 聚珍浏览器验收"
: "FAIL — 聚珍浏览器验收";
});
summaryText.textContent = "待运行核心验收";
document.title = "READY — 聚珍浏览器验收";
}());
</script>
</body>
</html>
-85
View File
@@ -1,85 +0,0 @@
<!doctype html>
<html lang="zh-Hans">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>聚珍 — halt 缺失字型之 margin 後備模式(方正書宋 GBK)</title>
<link rel="stylesheet" href="../dist/juzhen.css">
<style>
/* 全頁強制使用 方正書宋 GBK(本機字型,無 OpenType halt),以測試後備模式。 */
body
{
margin: 0;
padding: 2rem;
background: #fafafa;
color: #1a1a1a;
font-family: system-ui, sans-serif;
}
/* 字型類獨立於 .juzhen:A 用裸 .cn(瀏覽器原生排版),B/C 才掛 .juzhen
(禁用原生 text-autospace)。聚珍不設定 text-spacing-trim;A 保留瀏覽器原生
排版,B/C 使用相同受管基線以比較 halt 與 margin。 */
/* 字型設於容器即可——聚珍不管字型(無 _lang.css)、亦不強制 jz-inner(`font:
inherit`),故消費端字型經繼承正確套到標點字面(標點亦以 FZShuSong 渲染,halt
確實缺席)。此即「字型由消費端負責」之示例。 */
.cn { font-family: "方正书宋_GBK", "FZShuSong-Z01", serif; font-size: 46px; line-height: 1.5; }
.w { font-weight: 400; opacity: .85; }
h1 { font-size: 1rem; }
.meta { font-size: .72rem; color: #777; line-height: 1.7; max-width: 42em; }
.meta code { background: #eee; padding: 0 .25em; border-radius: 3px; }
.row { margin: .6rem 0; }
.tag { display: inline-block; font-size: .62rem; color: #fff; padding: .12rem .55rem; border-radius: 3px; letter-spacing: .03em; vertical-align: top; margin-top: .9rem; width: 7.5rem; }
.tag.raw { background: #555; }
.tag.bad { background: #c0392b; }
.tag.good { background: #27ae60; }
/* 把每行的 <p> 設為 inline-block + 背景,行盒寬度即直接反映擠壓效果。 */
.row p
{
display: inline-block;
margin: 0 0 0 .6rem;
padding: .15rem .1rem;
background: #fff;
border: 1px solid #e0e0e0;
white-space: nowrap;
vertical-align: top;
}
</style>
</head>
<body>
<h1>方正書宋 GBK · margin 後備模式</h1>
<p class="meta">
本頁需本機安裝指定版本的方正書宋 GBK;若發生字型替代,下列比較不能作該字型驗收。
已測版本僅含 <code>vert</code>、<b>無 <code>halt</code></b>,故 halt 模式之開明式擠壓「完全無效」(標點仍佔全形)。
margin 後備模式以 0.5em 外盒收半形、內盒依墨色偏側定位——本字型簡體點號(,、。;:!?)墨色皆偏左下,故收右側空白。
白底行盒寬度即直接反映效果:B 與 A 同寬(未擠),C 明顯窄一截(已擠)。
</p>
<div class="row">
<span class="tag raw" id="tag-raw">A · 原文(瀏覽器原生)</span>
<div class="cn" id="raw" lang="zh-Hans" style="display:inline-block"><p>他问:“真的吗?”第一、第二、第三;(含括号)。</p></div>
</div>
<div class="row">
<span class="tag bad" id="tag-halt">B · halt 模式<br>(無 halt → 不擠,等同原文)</span>
<div class="cn juzhen" id="halt" lang="zh-Hans" style="display:inline-block"><p>他问:“真的吗?”第一、第二、第三;(含括号)。</p></div>
</div>
<div class="row">
<span class="tag good" id="tag-margin">C · margin 後備<br>(半形生效)</span>
<div class="cn juzhen" id="margin" lang="zh-Hans" style="display:inline-block"><p>他问:“真的吗?”第一、第二、第三;(含括号)。</p></div>
</div>
<script src="../dist/juzhen.iife.js"></script>
<script>
var base = { spacing: false, jinze: false, longWord: false };
// B:halt 模式(預設)——此字型無 halt,開明式擠壓不生效(對照組)。
Juzhen.createJuzhen(Object.assign({ scope: { include: "#halt" } }, base))
.render(document.getElementById("halt"));
// C:margin 後備模式——render 於 root 設 data-jz-halfwidth="margin"。
Juzhen.createJuzhen(Object.assign({ scope: { include: "#margin" }, jiya: { halfWidth: "margin" } }, base))
.render(document.getElementById("margin"));
// A 面板留原文(不 render)。
// 註:margin 模式以 width:0.5em+字身溢出定位,標點 advance 真為 0.5em;整句
// 整句行寬差取決於實際壓縮字符及目標字型,可於瀏覽器 devtools 量;不以固定 em 值驗收。
</script>
</body>
</html>
-191
View File
@@ -1,191 +0,0 @@
<!doctype html>
<html lang="zh-Hans">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>聚珍(Juzhen)切片 1 — 目视验证</title>
<!-- 明确载入 Noto 系列 webfont 作参考:擠壓依赖字型 OpenType halt,
系统 fallback 字型(如 PingFang)可能无 halt,Safari 下尤其表现为开明式无效。
Noto Serif/Sans SC/TC 含 halt,确保跨浏览器一致参考。
注意:字型由**消费端**自行设定——聚珍不再内建 _lang.css 字体堆叠。下方 <style>
之 `.juzhen:lang(...)` 即推荐范式(逐 lang 设 font-family,须含 halt)。 -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC&family=Noto+Sans+TC&family=Noto+Serif+SC&family=Noto+Serif+TC&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../dist/juzhen.css">
<style>
/* demo 自身样式(非套件之一)。 */
/* 消费端字型设定(推荐范式):逐 lang 设 font-family,置于 .juzhen 或带 lang 之
元素;字型须含 OpenType halt(Noto CJK 具备),否则用 jiya.halfWidth:"margin"。 */
.juzhen:lang(zh-Hans), .juzhen [lang]:lang(zh-Hans) { font-family: "Noto Serif SC", "Noto Serif CJK SC", serif; }
.juzhen:lang(zh-Hant), .juzhen [lang]:lang(zh-Hant) { font-family: "Noto Serif TC", "Noto Serif CJK TC", serif; }
body { margin: 0; padding: 2rem; font-size: 18px; line-height: 2; background: #fafafa; color: #1a1a1a; }
h1 { font-size: 1.4rem; }
.toolbar { position: sticky; top: 0; background: #fff; border: 1px solid #ddd; padding: .75rem 1rem; margin-bottom: 1.5rem; display: flex; gap: 1rem; flex-wrap: wrap; align-items: center; z-index: 10; }
.toolbar button, .toolbar label { font-size: .85rem; }
.cols { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }
.panel { border: 1px solid #ddd; padding: 1rem 1.25rem; background: #fff; }
.panel h2 { font-size: .95rem; color: #666; margin: 0 0 .5rem; font-weight: 600; }
.ruler { border-left: 1px dashed #c00; border-right: 1px dashed #c00; }
.narrow { max-width: 20em; }
.primary { border-color: #06c; border-width: 2px; }
code { background: #eee; padding: 0 .25em; border-radius: 3px; font-family: ui-monospace, monospace; }
.note { font-size: .8rem; color: #888; margin-top: 1.5rem; }
.selbox { background: #fffbe6; }
body.justify .juzhen p { text-align: justify; }
.cases p { margin: .4rem 0; }
.label { font-size: .72rem; color: #999; }
</style>
</head>
<body>
<h1>聚珍(Juzhen)切片 1 — 目视验证页</h1>
<div class="toolbar">
<button id="render">render</button>
<button id="revert">revert</button>
<label><input type="checkbox" id="justify"> text-align: justify</label>
<label><input type="checkbox" id="wide"> 放宽栏(看行内擠壓)</label>
<label><input type="checkbox" id="force-kaiming"> 强制繁体开明式(兼容性检查)</label>
<button id="measure">测量标点宽度</button>
<span id="status" style="font-size:.8rem;color:#888;"></span>
</div>
<div class="cols">
<!-- 主:简体开明式。窄栏强制换行,以观察行端擠壓(机制 B)。 -->
<div class="panel juzhen narrow ruler primary cases" lang="zh-Hans">
<h2>主 · 简体(开明式)— 句内点号+括号引号半形</h2>
<p><span class="label">中西混排:</span>我用 Neovim 写 TypeScript,配合 ripgrep 与 fzf 做模糊搜索,效率提升了 200%,达到约 3.5 倍。</p>
<p><span class="label">句末+引号:</span>他问:“真的吗?”我说:“当然!”这是 GPT-4 与 state-of-the-art 工具的时代。</p>
<p><span class="label">连续标点:</span>完了……怎么办?!真是的——他竟然走了。</p>
<p><span class="label">括号嵌套:</span>(含括号)与【方括号】以及《标题》,还有“她讲过‘小心’二字”。</p>
<p><span class="label">间隔号:</span>卡尔·马克思与弗里德里希·恩格斯合著。</p>
<p><span class="label">列举:</span>第一、第二、第三;以及其他(杂项)的内容。</p>
</div>
<!-- 对比:繁体全角式。同样内容,用以发现政策分支差异。 -->
<div class="panel juzhen narrow ruler cases" lang="zh-Hant">
<h2>对比 · 繁體(全角式)— 句內標點不擠,僅鄰接/行端收縮</h2>
<p><span class="label">中西混排:</span>我用 Neovim 寫 TypeScript,配合 ripgrep 與 fzf 做模糊搜尋,效率提升了 200%,達到約 3.5 倍。</p>
<p><span class="label">句末+引號:</span>他問:「真的嗎?」我說:「當然!」這是 GPT-4 與 state-of-the-art 工具的時代。</p>
<p><span class="label">連續標點:</span>完了……怎麼辦?!真是的——他竟然走了。</p>
<p><span class="label">括號嵌套:</span>(含括號)與【方括號】以及《標題》,還有「她講過『小心』二字」。</p>
<p><span class="label">間隔號:</span>卡爾·馬克思與弗里德里希·恩格斯合著。</p>
<p><span class="label">列舉:</span>第一、第二、第三;以及其他(雜項)的內容。</p>
</div>
</div>
<div class="panel juzhen selbox" lang="zh-Hans" style="margin-top:1.5rem;">
<h2>copy-clean 测试 — 框选整段并复制到纯文本编辑器,中西间隙的空格不应被带入</h2>
<p>请选取此句:我用 Neovim 编辑配置文件,搭配 fzf 与 ripgrep 做模糊搜索。复制后粘到纯文本,应为“我用Neovim编辑配置文件,搭配fzf与ripgrep做模糊搜索。”般紧凑(标点原样保留,中西间隙不入剪贴板)。</p>
</div>
<p class="note">
验证重点(jsdom 无法覆盖之版面层):①justify 下句末标点只占一份间距(不被拉成两份);
②开明式句内点号与括号引号视觉半形、繁体全角式不擠(仅相邻/行端收缩);
③句末标点后接闭引号(如 。 接 ”)行内应约 1.5em、行尾应约 1em;
④长英文词在窄栏可原生连字断行(需字体支持);⑤复制粘贴中西间隙不污染原文。
请于 Chrome / Safari / Firefox 三引擎各核对一次。
<br><b>字型注意</b>:擠壓(开明式半形)依赖字型的 OpenType <code>halt</code> 特性。
本页已用 webfont 载入 Noto Serif SC/TC(含 halt)作参考;若改用无 halt 的字型
(含部分系统字型如 PingFang),Safari 等浏览器下开明式擠壓会不生效——这是字型限制,非逻辑错误。
</p>
<details open style="margin-top:1rem;">
<summary style="font-size:.85rem;color:#666;cursor:pointer;">标点实测宽度(诊断用——点「测量标点宽度」后把下表复制给我)</summary>
<pre id="measure-out" style="font-size:.75rem;background:#f4f4f4;padding:1rem;overflow:auto;white-space:pre-wrap;">(点上方「测量标点宽度」)</pre>
</details>
<script src="../dist/juzhen.iife.js"></script>
<script>
var forceKaiming = false;
function makeJz()
{
// 強制繁體開明式:policy 將 zh-Hant(含 zh-HK/zh-TW,皆歸 zh-Hant)覆寫為
// 開明式,用以檢查開明政策套到繁體之相容性。
return Juzhen.createJuzhen({
scope: { include: ".juzhen" },
lang: forceKaiming ? { policy: { "zh-Hant": "kaiming" } } : {}
});
}
var jz = makeJz();
var rendered = false;
var statusEl = document.getElementById("status");
function render()
{
jz.revert(document.body); // 先還原(重套政策時清除上次產物;首次為 no-op)
jz = makeJz();
jz.render(document.body);
rendered = true;
statusEl.textContent = "已 render(" + document.querySelectorAll("jz-hws").length + " 间隙、" + document.querySelectorAll("jz-char").length + " 标点原子;繁体政策:"
+ (forceKaiming ? "开明式" : "全角式") + ")";
}
function revert()
{
jz.revert(document.body);
rendered = false;
statusEl.textContent = "已 revert(DOM 还原)";
}
function refreshLayout()
{
// 對齊/寬度改變不一定改變 body 尺寸,須顯式重算;revert 後保持停用。
if (rendered) { jz.rerender(document.body); }
}
document.getElementById("force-kaiming").addEventListener("change", function (e)
{
forceKaiming = e.target.checked;
if (rendered) { render(); }
});
// 量測每個 jz-char 之實際渲染寬度(em)。輸出純 ASCII(碼位+class+數值),
// 並按 lang/codepoint/class 去重,便於回報診斷 halt 是否生效、收哪側。
function measure()
{
var out = [];
var byPanel = {};
document.querySelectorAll(".juzhen").forEach(function (panel)
{
var lang = panel.getAttribute("lang") || "?";
var key = lang + " | " + (panel.querySelector("h2") ? panel.querySelector("h2").textContent.slice(0, 10) : "");
panel.querySelectorAll("jz-char").forEach(function (c)
{
var fs = parseFloat(getComputedStyle(c).fontSize) || 16;
var w = c.getBoundingClientRect().width / fs;
var cp = "U+" + c.textContent.codePointAt(0).toString(16).toUpperCase();
while (cp.length < 6) { cp = cp.slice(0, 2) + "0" + cp.slice(2); }
var cls = Array.prototype.slice.call(c.classList).join(",") || "-";
var line = key + " " + cp + " " + w.toFixed(3) + "em [" + cls + "]";
if (!byPanel[line]) { byPanel[line] = true; out.push(line); }
});
});
// 參考:一個漢字寬度(應 ≈ 1.000em)。
var ref = document.querySelector(".juzhen p");
if (ref)
{
var span = document.createElement("span");
span.textContent = "字";
ref.appendChild(span);
var fs2 = parseFloat(getComputedStyle(span).fontSize) || 16;
out.unshift("参考 漢字「字」 " + (span.getBoundingClientRect().width / fs2).toFixed(3) + "em(应 ≈ 1.000)");
ref.removeChild(span);
}
document.getElementById("measure-out").textContent = out.join("\n");
}
document.getElementById("measure").addEventListener("click", measure);
document.getElementById("render").addEventListener("click", render);
document.getElementById("revert").addEventListener("click", revert);
document.getElementById("justify").addEventListener("change", function (e)
{
document.body.classList.toggle("justify", e.target.checked);
refreshLayout();
});
document.getElementById("wide").addEventListener("change", function (e)
{
document.querySelectorAll(".narrow").forEach(function (el)
{
el.style.maxWidth = e.target.checked ? "none" : "";
});
refreshLayout();
});
render();
</script>
</body>
</html>
-101
View File
@@ -1,101 +0,0 @@
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>聚珍(Juzhen)— 中西間隙/標點 拖曳檢查頁</title>
<link rel="stylesheet" href="../dist/juzhen.css">
<style>
body { font: 16px/1.9 serif; max-width: 60em; margin: 2rem auto; padding: 0 1rem; color: #1a1a1a; }
/* 消費端字型(推薦範式:逐 lang 設,須含 halt)——聚珍不再內建字體堆疊。 */
.juzhen:lang(zh-Hant), .juzhen [lang]:lang(zh-Hant) { font-family: "Noto Serif TC", "Noto Serif CJK TC", "Source Han Serif TC", serif; }
.juzhen:lang(zh-Hans), .juzhen [lang]:lang(zh-Hans) { font-family: "Noto Serif SC", "Noto Serif CJK SC", "Source Han Serif SC", serif; }
h1 { font-size: 1.4rem; }
h2 { font-size: 1.05rem; margin: 1.6rem 0 .4rem; color: #036; }
.hint { color: #666; font-size: .85rem; margin: .2rem 0 .6rem; }
.toolbar { position: sticky; top: 0; background: #fff; border-bottom: 1px solid #ddd; padding: .5rem 0; margin-bottom: 1rem; font-size: .9rem; }
.toolbar label { margin-right: 1.2rem; cursor: pointer; }
/* 可拖曳容器:右下角原生把手;拖動寬度即重排,觀察間隙是否恆穩。 */
.box {
resize: horizontal; overflow: hidden;
min-width: 5em; max-width: 100%; width: 20em;
border: 1px solid #bbb; border-radius: 4px; padding: .5rem .7rem;
background: #fafafa; margin: .3rem 0;
}
.box p { margin: 0; }
/* 兩端對齊切換(檢查行末去隙是否齊右、不內縮)。 */
body.justify .juzhen p { text-align: justify; }
/* 顯示間隙:描出 jz-hws(其右側 0.25em margin 即視覺間隙)與 pill 邊界。 */
body.show-gaps jz-hws { outline: 1px solid rgba(220,30,30,.45); background: rgba(220,30,30,.06); }
body.show-gaps jz-hws.jz-hws-trim { outline-color: rgba(30,120,220,.5); background: rgba(30,120,220,.06); }
body.show-gaps code { background: rgba(0,0,0,.06); }
code { font-family: ui-monospace, Menlo, Consolas, monospace; }
/* 顯示垂懸字綁定範圍(jz-orphan:段末不可斷之尾段)。 */
body.show-orphan jz-orphan { outline: 1px dashed rgba(180,30,180,.6); background: rgba(180,30,180,.07); }
</style>
</head>
<body>
<h1>聚珍 — 中西間隙/標點 拖曳檢查頁</h1>
<p class="hint">拖動每個方框右下角的把手調整寬度,觀察間隙在各寬度下是否恆穩、標點是否無誤隙。政策:quanjiao(全角式)。</p>
<div class="toolbar">
<label><input type="checkbox" id="t-justify"> 兩端對齊 justify</label>
<label><input type="checkbox" id="t-gaps"> 顯示間隙(紅=有隙 jz-hws,藍=已去隙 trim)</label>
<label><input type="checkbox" id="t-orphan"> 顯示垂懸字綁定(紫=jz-orphan 尾段)</label>
</div>
<h2>E 中西混排(重點:拖動時「最後一個間隙」不應消失、標點不應緊貼)</h2>
<p class="hint">慢慢拖動寬度,每個 ¥9800/$1299/€1099 與中文間之隙都應始終存在;換行點落於任何邊界皆不致左字與後字緊貼。</p>
<div class="box juzhen" lang="zh-Hant"><p>價格 ¥9800 或 $1299 或 €1099 都可以接受呢,歡迎洽詢 hello@x.com 謝謝惠顧再聯絡。</p></div>
<h2>C 行內 pill 跨行(重點:code 折行後,其右側與中文之間隙不應消失)</h2>
<p class="hint">把方框拖窄使 <code>code</code> 內容折行,pill 末字與「之後」間應仍有隙。</p>
<div class="box juzhen" lang="zh-Hant"><p>前綴 <code>git rebase --interactive --autosquash HEAD~10 feature</code> 之後接中文,再補一段較長敘述以利觀察換行行為。</p></div>
<h2>B 全角省略號(重點:…… 與後接中文之間不應有隙)</h2>
<div class="box juzhen" lang="zh-Hant"><p>他竟然就這樣走了……留下一地,唉……真教人無言以對。</p></div>
<h2>D 貨幣/拼音/西里爾/希臘/數學(重點:這些外文字元與中文間應有隙)</h2>
<div class="box juzhen" lang="zh-Hant"><p>售價 €1099 或 $50 都行;拼音 Pīnyīn 含 ā ē ǐ ǒ ǔ;俄文 Привет 世界;希臘 α β γ 常見;數學 設 x≥0 且 ∑aᵢ≠0 皆需間隙。</p></div>
<h2>標點稽核(重點:以下各式中文標點介於中文間,皆不應冒出西文間隙)</h2>
<div class="box juzhen" lang="zh-Hant" style="width:30em"><p>句號。逗號,頓號、分號;冒號:問號?嘆號!引號「引述」『書名』(圓括)【方頭】《書名》〈單名〉省略……破折——間隔·波浪~皆不應有中西間隙;收尾彎引號“例”後接 ABC 亦不補隙。</p></div>
<h2>對照:簡體內容(zh-Hans 字形與標點)</h2>
<div class="box juzhen" lang="zh-Hans" style="width:24em"><p>这是简体测试,包含 Python 与 €99;引号“示例”和标点,;:。混排间隙与标点均应正确。</p></div>
<h2>垂懸字避免(重點:拖窄時,段末行 CJK 實義字恒 ≥ 2,標點不計)</h2>
<p class="hint">慢慢拖窄方框,使最後一行將塌為單字;垂懸字(方案 C)會把段末末二實義字+尾隨標點綁為不可斷之 <code>jz-orphan</code>(勾選上方「顯示垂懸字綁定」描出紫框),故末行恒含 ≥2 字。對照組關閉 orphan,可能出現孤字末行。</p>
<div class="box juzhen" lang="zh-Hant" style="width:14em"><p>這是一段用於檢查垂懸字避免的測試文字,拖窄後觀察段末行是否恒保留至少兩個實義字而不孤零。</p></div>
<p class="hint">對照(orphan 關閉,<code>data-juzhen-off="orphan"</code>):</p>
<div class="box juzhen" lang="zh-Hant" style="width:14em" data-juzhen-off="orphan"><p>這是一段用於檢查垂懸字避免的測試文字,拖窄後觀察段末行是否恒保留至少兩個實義字而不孤零。</p></div>
<script src="../dist/juzhen.iife.js"></script>
<script>
// 各獨立拖曳框作為 render 根,讓 ResizeObserver 直接觀察其寬度。
// quanjiao 全角式;各框由 lang 驅動簡/繁字形。
var jz = Juzhen.createJuzhen({ scope: { include: ".juzhen" }, lang: { style: "quanjiao" } });
var boxes = Array.from(document.querySelectorAll(".box.juzhen"));
boxes.forEach(function (box) { jz.render(box); });
document.getElementById("t-justify").addEventListener("change", function (e)
{
document.body.classList.toggle("justify", e.target.checked);
// 對齊改變未必產生 resize,不能只等 observer。
boxes.forEach(function (box) { jz.rerender(box); });
});
document.getElementById("t-gaps").addEventListener("change", function (e)
{
document.body.classList.toggle("show-gaps", e.target.checked);
});
document.getElementById("t-orphan").addEventListener("change", function (e)
{
document.body.classList.toggle("show-orphan", e.target.checked);
});
</script>
</body>
</html>
-148
View File
@@ -1,148 +0,0 @@
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>聚珍(Juzhen)— 新功能效果演示(定寬對照)</title>
<link rel="stylesheet" href="../dist/juzhen.css">
<style>
/* 全頁定寬定字以確保一致:Noto Sans CJK,20px/行高 2.2。各文本框寬度經 headless
校準,配此字型可穩定重現所述效果(換字型時斷行點略異,效果方向不變)。 */
html { font: 20px/2.2 "Noto Sans CJK SC", "Noto Sans SC", "Noto Sans CJK TC", "Noto Sans TC", sans-serif; }
body { max-width: 64em; margin: 2rem auto; padding: 0 1.2rem; color: #1a1a1a; }
.juzhen:lang(zh-Hant), .juzhen [lang]:lang(zh-Hant) { font-family: "Noto Sans CJK TC", "Noto Sans TC", sans-serif; }
h1 { font-size: 1.5rem; }
h2 { font-size: 1.15rem; margin: 2.2rem 0 .3rem; color: #036; border-bottom: 1px solid #e0e0e0; padding-bottom: .2rem; }
h3 { font-size: .98rem; margin: 1.2rem 0 .3rem; color: #225; }
p.lede, p.hint { color: #555; font-size: .82rem; line-height: 1.7; }
p.lede { font-size: .9rem; color: #333; }
.look { color: #a0522d; font-size: .8rem; margin: .2rem 0 .5rem; }
.look::before { content: "看點 "; font-weight: bold; }
.note { background: #fff8e6; border: 1px solid #e8d8a0; border-radius: 4px; padding: .5rem .8rem; font-size: .82rem; color: #6a5400; line-height: 1.7; }
code { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: .85em; background: rgba(0,0,0,.05); padding: 0 .25em; border-radius: 3px; }
.toolbar { position: sticky; top: 0; z-index: 9; background: #fff; border-bottom: 1px solid #ddd; padding: .5rem 0; margin-bottom: 1rem; font-size: .82rem; }
.toolbar label { margin-right: 1.2rem; cursor: pointer; white-space: nowrap; }
/* 對照網格:左右並排兩框。 */
.cmp { display: flex; flex-wrap: wrap; gap: 1.4rem; align-items: flex-start; margin: .4rem 0 .6rem; }
.cell { font-size: .8rem; color: #444; }
.cell .tag { display: inline-block; font-weight: bold; margin-bottom: .25rem; }
.tag.on { color: #1a7f37; } .tag.off { color: #b04; } .tag.warn { color: #a60; }
/* 定寬文本框(不可拖動,確保一致)。寬度由各框 inline style 給定。 */
.box { border: 1px solid #bbb; border-radius: 4px; padding: .45rem .6rem; background: #fafafa; }
.box p { margin: 0; text-align: justify; }
/* 顯示標記(可由工具列切換)。 */
body.show-orphan jz-orphan { outline: 1px dashed rgba(170,30,170,.7); background: rgba(170,30,170,.08); }
body.show-gaps jz-hws { background: rgba(220,30,30,.12); outline: 1px solid rgba(220,30,30,.4); }
body.show-half jz-char.jz-half jz-inner, body.show-half jz-char.jz-half-le jz-inner { background: rgba(30,90,220,.14); }
</style>
</head>
<body>
<h1>聚珍 — 新功能效果演示(定寬對照)</h1>
<p class="lede">本頁以<strong>定寬文本框</strong>(不可拖動)並排對照,逐場景展示兩項新功能:<strong>功能分級</strong>(文本級/段落級)、<strong>垂懸字避免</strong>(<code>orphan</code>)。各框寬度經 headless 校準,配 Noto Sans CJK 可穩定重現所述效果。勾選下方開關可描出聚珍注入之結構(紫=垂懸字綁定、紅=中西間隙、藍=半形標點)。第三項曾規劃之「標點懸掛」經實測為機制級死路、已移除,說明見文末。</p>
<div class="toolbar">
<label><input type="checkbox" id="t-orphan"> 顯示垂懸字綁定(紫 <code>jz-orphan</code>)</label>
<label><input type="checkbox" id="t-gaps"> 顯示中西間隙(紅 <code>jz-hws</code>)</label>
<label><input type="checkbox" id="t-half"> 顯示半形標點(藍)</label>
</div>
<!-- ===================== 一、功能分級 ===================== -->
<h2>一、功能分級:文本級 vs 段落級</h2>
<p class="hint">功能分兩級——<strong>文本級</strong>(中西間隙 <code>spacing</code>、標點擠壓 <code>jiya</code>)僅依字符鄰接,<strong>任意文本片段</strong>(標題、按鈕、單行)皆適用;<strong>段落級</strong>(禁則 <code>jinze</code>、垂懸字 <code>orphan</code>、長詞 <code>longWord</code>)依賴行/段布局。塊級元素預設全功能;標 <code>data-jz-level="text"</code> 之子樹(或選項 <code>level:{text:"選擇器"}</code>)僅跑文本級。</p>
<div>
<h3>標題標為文本級(仍有間隙與半形,但不參與禁則/垂懸字)</h3>
<div class="cmp">
<div class="cell"><span class="tag on">data-jz-level="text"(標題典型)</span>
<div class="box s-std" style="width:16em"><h3 data-jz-level="text" lang="zh-Hant" style="margin:0;font:inherit;color:inherit">用 Neovim 寫程式(v0.10)真的很順手。</h3></div>
<p class="look">中西間隙(<code>Neovim</code>/<code>v0.10</code> 兩側)與標點半形照常;但此子樹<strong>不</strong>跑垂懸字/禁則(無 <code>jz-orphan</code>/<code>jz-jinze</code>)。</p>
</div>
<div class="cell"><span class="tag off">預設段落級(同內容,作正文)</span>
<div class="box s-std" style="width:16em"><p lang="zh-Hant">用 Neovim 寫程式(v0.10)真的很順手。</p></div>
<p class="look">同樣有間隙與半形,但<strong>另含</strong>禁則綁定與垂懸字(開啟「顯示垂懸字綁定」可見紫框)。</p>
</div>
</div>
<p class="hint">即:把標題/按鈕標為文本級——要中西間隙與標點半形、不要避孤字/避頭尾(那些對單行無意義)。</p>
</div>
<!-- ===================== 二、垂懸字避免 ===================== -->
<h2>二、垂懸字避免(<code>orphan</code>,方案 C)</h2>
<p class="hint">保證段末行 CJK <strong>實義字數 ≥ N</strong>(預設 2;標點/符號不計)。機制:把段末「第 N 個實義字起至段末」(含尾隨標點)包入 <code>jz-orphan</code>(<code>white-space:nowrap</code>)成不可斷整體 → 末行恒含之 ⇒ 末行實義字 ≥ N,<strong>無需量測版面</strong>、任意寬度成立。<strong>預設開</strong>。</p>
<div>
<h3>關閉 vs 開啟(同寬同文:末行從「孤字」變為保留兩字)</h3>
<div class="cmp">
<div class="cell"><span class="tag off">orphan 關閉(<code>data-juzhen-off="orphan"</code>)</span>
<div class="box s-std" style="width:14em"><p lang="zh-Hant" data-juzhen-off="orphan">春天來了萬物復甦大地生機盎然處處鳥語花香令人神往心曠神怡。</p></div>
<p class="look">末行僅剩孤字「<strong>怡。</strong>」——單字垂懸,版面失衡。</p>
</div>
<div class="cell"><span class="tag on">orphan 開啟(預設 chars=2)</span>
<div class="box s-std" style="width:14em"><p lang="zh-Hant">春天來了萬物復甦大地生機盎然處處鳥語花香令人神往心曠神怡。</p></div>
<p class="look">末行保留「<strong>神怡。</strong>」兩個實義字(開「顯示垂懸字綁定」見紫框=被綁之尾段)。</p>
</div>
</div>
<h3>調整保留字數(<code>orphan:{ chars: 3 }</code>)</h3>
<div class="cmp">
<div class="cell"><span class="tag on">chars = 3(同前文、同寬 14em)</span>
<div class="box s-chars3" style="width:14em"><p lang="zh-Hant">春天來了萬物復甦大地生機盎然處處鳥語花香令人神往心曠神怡。</p></div>
<p class="look">末行保留「<strong>曠神怡。</strong>」三個實義字(較預設 chars=2 之「神怡。」多拉一字下來)。</p>
</div>
</div>
<h3>標點/符號不計入字數(句末多個標點不佔 N)</h3>
<div class="cmp">
<div class="cell"><span class="tag off">orphan 關閉</span>
<div class="box s-std" style="width:14em"><p lang="zh-Hant" data-juzhen-off="orphan">他想了好久最後終於對著大家堅定地說出了那三個字「我願意」。</p></div>
<p class="look">末行=「<strong>意」。</strong>」——僅 <strong>1 個實義字</strong>(意),其後 <code>」。</code> 兩個標點<strong>不計入</strong>字數。</p>
</div>
<div class="cell"><span class="tag on">orphan 開啟(chars=2)</span>
<div class="box s-std" style="width:14em"><p lang="zh-Hant">他想了好久最後終於對著大家堅定地說出了那三個字「我願意」。</p></div>
<p class="look">末行=「<strong>願意」。</strong>」——保 <strong>2 個實義字</strong>(願意);尾隨 <code>」。</code> 隨尾段綁入但不佔 N(開紫框見綁定範圍含標點,且整個 <code>「我願意」</code> 禁則群組跨 <code>jz-jinze</code> 不被切割)。</p>
</div>
</div>
<p class="hint">邊界:段落實義字 &lt; N → 不綁。單行段仍可能建立尾段綁定,但不需拉字換行、效果無害。極窄容器(&lt;N 字寬)→ 該尾段可能溢出(罕見)。</p>
</div>
<!-- ===================== 三、標點懸掛(已移除) ===================== -->
<h2>三、標點懸掛(<code>hanging</code>)— 已移除</h2>
<p class="note">原計劃之標點懸掛(行端標點凸出版心)經 headless + 真實瀏覽器實測確認為<strong>機制級死路</strong>,已<strong>移除</strong>(負 <code>margin</code> 程式碼與 CSS 皆刪;<code>hanging</code> 選項保留為 no-op,無任何效果)。<strong>寬度守恆矛盾</strong>:行端標點盒若保持非零寬,墨色出去多少、版心內就空出多少(行末實義字無法齊版心右緣);若收為零寬,釋放一整字寬、斷行器把下一字拉上本行與標點重疊(且 <code>justifyAtoms</code> 預設之 inline-block 下負 margin 被 justify 吸收)。<strong>CSS hack 接觸不到斷行器,此恒等式無解。</strong>唯一正解為原生 CSS <code>hanging-punctuation</code>,惟 Chrome/Firefox 至今不支援(僅 Safari),且本庫 <code>jz-char</code> 為 inline-block 原子非文本、原生懸掛不作用之——未來若做須是繞開 jz-char 的「原生管線」新功能。詳見 <code>ARCHITECTURE.md §6.5.6</code>。</p>
<footer style="margin-top:2.4rem;color:#888;font-size:.78rem;border-top:1px solid #eee;padding-top:.6rem">
聚珍(Juzhen)· 新功能效果演示。另見 <a href="./resize-check.html">resize-check.html</a>(可拖動容器之穩定性檢查)。寬度配 Noto Sans CJK 校準;換字型時斷行點略異,效果方向不變。
</footer>
<script src="../dist/juzhen.iife.js"></script>
<script>
// 各場景以獨立實例 + **逐框 render(該框)** 渲染:屬性就近設於各框、由 CSS 後代選擇器
// 隔離(若 render(body) 則屬性落 body、跨框互相污染)。一致性:定寬 + 固定配置 + 同字型。
function renderEach(selector, opts)
{
var jz = Juzhen.createJuzhen(opts);
document.querySelectorAll(selector).forEach(function (el) { jz.render(el); });
}
// 功能分級+垂懸字(預設 orphan chars=2):繁全角/簡開明。
renderEach(".s-std", { lang: { policy: { "zh-Hant": "quanjiao", "zh-Hans": "kaiming" } } });
// 垂懸字 chars=3。
renderEach(".s-chars3", { lang: { default: "zh-Hant", style: "quanjiao" }, orphan: { chars: 3 } });
// 工具列開關。
var toggles = { "t-orphan": "show-orphan", "t-gaps": "show-gaps", "t-half": "show-half" };
Object.keys(toggles).forEach(function (id) {
var cb = document.getElementById(id);
if (cb.checked) { document.body.classList.add(toggles[id]); }
cb.addEventListener("change", function (e) {
document.body.classList.toggle(toggles[id], e.target.checked);
});
});
</script>
</body>
</html>
-6
View File
@@ -1,6 +0,0 @@
import type { CharSpan } from "./finder.js";
import type { RenderContext } from "../types.js";
/** span 按逆文件序處理,來源 offset 不受後方切分影響。outer/inner 須為本 pass 新產物。 */
export declare function wrapCrossAtom(span: CharSpan, outer: HTMLElement, inner: HTMLElement, pass: string, ctx: RenderContext): void;
/** 局部回退也涵蓋原子之另一端;只還原私有記錄,不採信可偽造的作者屬性。 */
export declare function revertCrossAtoms(pass: string, ctx: RenderContext): Set<Node>;
-37
View File
@@ -1,37 +0,0 @@
/** 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 = "wrap";
export interface CreateOptions {
/** 追加之 class(空白分隔或陣列)。 */
classes?: string | string[];
/** 文本內容。 */
text?: string;
}
/** 切分時記錄真實來源身份;須於 clone 的排版標記覆寫前呼叫。 */
export declare function registerSplitoffSource(clone: Element, source: Element, pass: string, ownedElements: WeakSet<Element>): void;
/** 完成受控解包後登記;只接受真實、已移除且空的受管產物。 */
export declare function recordUnwrappedParent(el: Element, parent: Node, ownedElements: WeakSet<Element>): void;
/** 其他 pass/實例合法撤回來源包裹後,搬移記錄沿受控解包鏈查詢現父。 */
export declare function resolveUnwrappedParent(parent: Node): Node | null;
/** 造一個帶 pass 標記之 jz-* 元素。 */
export declare function createJz(ownedElements: WeakSet<Element>, tag: JzTag, pass: string, kind: JzKind, opts?: CreateOptions): HTMLElement;
/** 記錄後寫屬性;同實例重寫時仍保留最初原值。 */
export declare function setOwnedAttribute(el: Element, name: string, value: string, owner: object): void;
/** 僅當 DOM 仍是本實例最後寫入之值時還原;外部變更時 fail closed。 */
export declare function revertOwnedAttribute(el: Element, name: string, owner: object): void;
/** 還原 owner 於 root 本身或子樹寫入之指定屬性,包括已被外部刪除者。 */
export declare function revertOwnedAttributes(root: Element, name: string, owner: object): void;
/**
* 還原某 pass 之全部產物(I6)。以 data-jz 定址,逆文件序處理(先內層後
* 外層),wrap 解包,最後 normalize 受影響父節點合併文本。
*
* splitoff(克隆切分之邊界詞元,見 core/split.ts)須在 wrap 全部解包**之後**才併回:
* 否則其前兄弟可能仍是 wrap 殼(如 jz-jinze)而非原元素。故主迴圈跳過 splitoff,於
* 解包後第二階段以文件序逐一併回前兄弟(同標籤元素),還原為單一元素(I6 一致)。
*/
export declare function revertPass(pass: string, root: ParentNode, ownedElements: WeakSet<Element>): void;
/** 讀取 jz-char 之 bd-* 子類(無則 null)。 */
export declare function bdClassOf(el: Element): string | null;
/** 該元素是否為某/任一 pass 之 jz 產物。 */
export declare function isJz(node: Node | null, pass?: string): boolean;
export {};
-154
View File
@@ -1,154 +0,0 @@
import type { FeatureLevel, ResolvedOptions } from "../types.js";
/** 邏輯 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: CharSpan[];
}
/** pill 鄰接之邏輯鄰字(base+optional-VS)。 */
export interface AdjacentChar {
textNode: Text;
offset: number;
ch: string;
parts: TextSpan[];
}
/** 行內元素對排版之口徑分類(§4.1)——恰屬其一,優先序見 Finder.category。 */
export type InlineCategory = "avoid" | "pill" | "isolate" | "block" | "transparent" | "opaque";
export declare class Finder {
private readonly opts;
private readonly styleSet;
private readonly blockSet;
private readonly skipSet;
private readonly pillSelector;
private readonly isolateSelector;
private readonly avoidSelector;
private readonly includeSelector;
private readonly skipAttr;
private readonly userIsSkipped;
private readonly levelTextSelector;
private readonly featureCache;
private readonly levelCache;
private readonly categoryCache;
private readonly pillAncestorCache;
private readonly avoidAncestorCache;
constructor(opts: ResolvedOptions);
/**
* 清除 el 及其**整棵子樹**之元素鍵快取(C1 失效原語)。持久化 Finder(跨 render 復用快取)
* 下,元素之屬性或內容變化會令其分類(category)/級別(level)/功能(feature)/祖先游走
* (pill/avoid ancestor)快取失效。**須清整棵子樹、非僅該元素**:level/feature/祖先游走
* 之結果依賴祖先鏈,某祖先之 data-jz-level/data-juzhen/class 變化會令其**後代**之快取值變脏,
* 即便後代自身未動。querySelectorAll('*') 走子樹(O(子樹),native、無遞迴棧風險)。移除之元素
* 由 WeakMap 自動回收,無需顯式清。 */
invalidate(el: Element): void;
private forget;
/** 該元素是否匹配 pill 選擇器(三分類之「整體」:code/kbd/行內公式等)。 */
pillMatches(el: Element): boolean;
/** 該元素是否匹配 isolate 選擇器(三分類之「隔離」:帶盒樣式之行內,如背景色
* <mark>)。邊界口徑同 pill(斷 run/斷相鄰/不可切分/兩端補隙),內部照常處理。 */
isolateMatches(el: Element): boolean;
/** 節點是否位於 pill **內部**(僅查祖先,不含自身)。pill 為整體元素:內部對
* 一切 pass 不可見——不 charify、不收 run、不切分、不於其內插間隙(下游問題六:
* .katex 內部曾被 jiya/spacing 直接變更)。 */
insidePill(node: Node): boolean;
/** el 或其祖先鏈任一為 pill(記憶化)。沿祖先鏈迭代(非遞迴,任意深度棧安全)至最近之
* 已快取/pill 命中/鏈頂,途中非 pill 元素入棧、以斷點之值一次回填——每元素恰算一次,
* 兄弟共用祖先鏈快取。insidePill 為 pillMatches 之最大來源(S3 延伸)。 */
private ancestorOrSelfPill;
/**
* 行內元素分類(§4.1)——一元素對排版之口徑,**恰屬其一**。**優先序**(依序短路,即多
* 選擇器同命中時之定案規則):`avoid > pill > isolate > block > transparent > opaque`
* (opaque=未在 styleInlines 之未知行內標籤,嚴格白名單下當隔離式邊界)。故:同時命中
* pill 與 isolate → **pill**(pill 覆蓋 isolate);帶 data-jz-skip 之 pill/isolate → **avoid**
* (自身排除最高)。**邏輯文本流之遍歷原語**(collectRuns/jiyaAdjacency/adjacentLogicalChar
* 之 isTransparent)皆據此**單一分類**分派;core/split 之 canSplit = SPLITTABLE 白名單 ∩
* transparent(結構切分為獨立層,故更窄)。
*
* **記憶化**(S3):分類含至多三次 el.matches()(pill/isolate/avoid 選擇器),且被
* collectRuns/jiyaAdjacency/jinze/orphan/split 各 pass 逐元素重算——以 categoryCache
* 記憶化砍除跨 pass 重複。安全性:單次 render 內一元素之分類**恆定**(class/skip 屬性/
* nodeName 皆不因 render 變更;jz-* 包裹為新建元素、按 nodeName 分類亦恆定),且 Finder
* 隨實例持久化,跨 render/revert 復用;屬性或位置變化由 invalidateSubtree 清除受影響
* 子樹,不能沿用舊祖先鏈結果。
*/
category(el: Element): InlineCategory;
private computeCategory;
/** category 之透明判定末步:styleInlines 標籤,或聚珍自身之 wrap 類 jz-*(jz-hws 間隙、
* jz-jinze 禁則群組、**jz-char/jz-inner 標點原子**)。前四步已排除 avoid/pill/isolate/block。
* jz-char/jz-inner 必須列入:否則 adjacentLogicalChar 之 descend 遇標點原子既不進入亦不
* 終止、而**跳過**它續找更前之字(`汉字。<code>` 綁定後 pill 左鄰字應為「。」卻誤取「字」
* 補 jz-hws);jz-char 僅含標點(非 pillNeighbor),透明後 descend 回傳標點、不補隙。 */
private isTransparentTag;
/** el 是否為邏輯文本流可透明穿越者(= category "transparent")。 */
isTransparent(el: Element): boolean;
/** 該元素**自身**是否為 avoid 邊界。avoid 之七種來源,依序短路判定、任一命中即是:
* ① isSkipped 謂詞(消費端函數) ② skipTags 標籤名單(PRE/SAMP/SCRIPT…,可覆寫)
* ③ SVG 命名空間 ④ contentEditable
* ⑤ data-jz-skip 屬性(內建) ⑥ skipAttribute(消費端指定屬性名)
* ⑦ scope.avoid 選擇器
* **此為自身判定**,供遍歷原語(collectRuns/jiyaAdjacency/canSplit/isTransparent)於
* 下探時逐元素自判(祖先由遞迴涵蓋)。**一次性**問「某節點是否被 avoid(自身或祖先)」
* 請改用 isAvoided(單一組合入口),勿只用本方法而漏判祖先、或只用 isAvoided 而漏判自身
* (下游問題六/階段一 #2:漏一半致 avoid 對自身形同虛設)。 */
avoidsSelf(el: Element): boolean;
/** 某節點是否被 avoid——**自身即邊界**(元素經 avoidsSelf 之七源;非元素節點僅判
* isSkipped)**或**祖先鏈上任一為 avoid 邊界。此為「是否 avoid」之**單一組合入口(安全
* 預設)**:一次性檢查用此,免除「avoidsSelf ‖ isAvoided」易漏一半之誤用。遍歷原語逐
* 元素下探時則用 avoidsSelf(僅自身,祖先由遞迴涵蓋)。 */
isAvoided(node: Node): boolean;
/** el 或其祖先鏈任一為 avoid 邊界(記憶化,迭代回填,同 ancestorOrSelfPill)。 */
private ancestorOrSelfAvoided;
/** scope.include 啟用判定:未設則恆 true;設則須有命中祖先(opt-in)。 */
inScope(node: Node): boolean;
/** 走訪 root 下所有「可處理」文本節點(已過濾 avoid / pill 內部 / 非作用域)。 */
eachTextNode(root: Element, cb: (node: Text) => void): void;
/** 走訪 root 下可處理之 block(含 root 本身若為 block);pill/avoid 內部不可見。 */
eachBlock(root: Element, cb: (block: Element) => void): void;
/**
* 收集 block 之邏輯文本 run(架構 §4.1,I7):透明行內(isTransparent:styleInlines
* + wrap 類 jz-*)穿越、run 延續;block/pill/avoid 中斷 run;isolate 與**未知行內
* 標籤**(嚴格白名單)中斷 run 且內部自成獨立 run。預設至少兩原子;includeSingletons
* 供段落邊界查詢保留單原子 run,不改間隙處理之預設。
*/
collectRuns(block: Element, includeSingletons?: boolean, feature?: string): LogicalRun[];
/** 從節點邊界或 Text offset 取相鄰碼點;只穿越透明、域內內容,scopeRoot 為硬邊界。 */
private adjacentTextCodePoint;
/** 補全透明 Text 邊界拆開的 surrogate pair;孤立碼元保留自身,不跨任何邊界拼接。 */
private adjacentCodePoint;
/** pill 之完整鄰字(base+至多一個 VS);after 供查單個作者空格另一側,不越 scopeRoot。 */
adjacentLogicalChar(el: Element, direction: -1 | 1, scopeRoot?: Element, after?: AdjacentChar, feature?: string): AdjacentChar | null;
/**
* 節點之功能級別(§6.5.0a)。自 node 向上找**最近**之顯式信號:元素屬性
* `data-jz-level="text|paragraph"`(最優先),或命中 `level.text` 選擇器(→ text);
* 皆無則預設 paragraph(全功能)。結果以解析後之元素為鍵快取。
*/
levelFor(node: Node): FeatureLevel;
/**
* 某級別之 pass 是否可作用於該節點(§6.5.0a gate)。文本級 pass 不受限(恆 true);
* 段落級 pass 僅作用於段落級元素(text-level 子樹跳過)。與 featureEnabledFor 疊加:
* level 為粗粒度語義組(先判),blocks/屬性為細粒度逐功能(後調)。
*/
levelAllows(node: Node, passLevel: FeatureLevel): boolean;
/**
* 解析節點對某功能之有效啟用狀態(§3.6,per-node 閘)。向上找最近帶
* 功能指令之祖先(data-juzhen 白名單 / data-juzhen-off 黑名單 / 命中
* blocks 選擇器),無則回退全域 features。結果以元素為鍵快取。
*/
featureEnabledFor(node: Node, name: string): boolean;
private globalFeature;
private resolveFeature;
}
-41
View File
@@ -1,41 +0,0 @@
/** 標點寬度政策。 */
export type PunctStyle = "quanjiao" | "kaiming" | "banjiao";
/** 語言分類。 */
export type LangClass = "zh-Hans" | "zh-Hant" | "ja" | "other";
/** 按選擇器逐元素覆寫標點樣式之規則(§4.3;如非段落/UI 元素用半角式)。 */
export interface StyleRule {
selector: string;
style: PunctStyle;
}
export interface LocaleConfig {
/** 無 lang 祖先時之預設語言。 */
default: string;
/** 全域標點擠壓預設(全角/開明/半角)。設則覆寫內建之逐語言預設,
* 但仍可被 policy 之逐 lang 設定覆寫。未設則回退內建預設。 */
style?: PunctStyle;
/** 語言分類 → 標點寬度政策之逐 lang 覆寫。 */
policy: Partial<Record<LangClass, PunctStyle>>;
/** 按選擇器**逐元素**覆寫標點樣式(優先於 lang;與 `data-jz-style` 屬性同一機制)。
* 近祖先勝、同層屬性優先於選擇器。空/未設則不覆寫、純由 lang 決定。 */
stylePolicy?: StyleRule[];
}
/**
* 由節點(含元素自身)向上找最近 lang,回傳小寫值;空值表示語言未知,僅缺失時回退。
*/
export declare function effectiveLang(node: Node, fallback: string): string;
/**
* 將 BCP-47 語言標籤歸類。前綴以子標籤邊界比對('zh-tw' 命中 zh-Hant,
* 'zhx' 不命中 zh)。只讀 BCP-47 的 script/region 位置,不把 extension/private-use
* 內容當成腳本或地區;本函數不是完整標籤驗證器。
*/
export declare function classify(lang: string): LangClass;
/**
* 回傳該語言分類在給定 config 下之標點寬度政策。優先序:
* 逐 lang policy > 全域 style > 內建逐語言預設 DEFAULT_POLICY。
*/
export declare function styleFor(langClass: LangClass, config: LocaleConfig): PunctStyle;
/**
* 解析節點之有效標點樣式。優先序:**逐元素覆寫**(`data-jz-style` 屬性/`stylePolicy`
* 選擇器,近祖先勝)> 逐 lang `policy` > 全域 `style` > 內建逐語言預設。
*/
export declare function resolveStyle(node: Node, config: LocaleConfig): PunctStyle;
-5
View File
@@ -1,5 +0,0 @@
import type { Pass, RenderContext } from "../types.js";
/** 對 ctx.root 套用 passes 中已啟用者。 */
export declare function runPasses(passes: Pass[], ctx: RenderContext): void;
/** 還原 passes 之全部產物(逆 order;revert 對無產物者安全)。 */
export declare function revertPasses(passes: Pass[], ctx: RenderContext): void;
-14
View File
@@ -1,14 +0,0 @@
import type { Finder } from "./finder.js";
/**
* 隔離 el 之邊界詞元供綁定(最小綁定)。side="tail" 取末詞元、="head" 取首詞元。
* 回傳**應綁定之節點**:
* · 可切且確實切分 → tail 回克隆(末詞元)、head 回原 el(已只剩首詞元,其餘入克隆);
* · 不可切/無法定位切點/整元素即該詞元 → 回 el 本身(綁整個元素,退化但安全)。
* 切出之克隆標 splitoff,由 revertPass 併回;故 render→revert 還原一致(I6)。
*/
export declare function isolateBoundaryToken(el: Element, side: "head" | "tail", anyCjk: RegExp, pass: string, finder: Finder, ownedElements: WeakSet<Element>): Element;
/**
* 把 el 之**指定切點**(精確字位)起至末尾剝入克隆並回傳(供 orphan:自實義字綁至塊末)。
* 不可切或切點在最起點 → 回 null(呼叫端綁整個直接子)。
*/
export declare function splitElementAt(el: Element, textNode: Text, offset: number, pass: string, finder: Finder, ownedElements: WeakSet<Element>): Element | null;
-99
View File
@@ -1,99 +0,0 @@
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";
/** 西文字母。 */
export declare const ALPHA = "A-Za-z";
/** CJK 之後可接間隙之西文集(含 @)。 */
export declare const ANS_AFTER_CJK: string;
/** CJK 之前可接間隙之西文集(去 @)。 */
export declare const ANS_BEFORE_CJK: string;
/** 句末點號:。.!?(開明式與全角式皆保持全形,不擠壓;banjiao 擠半)。
* 含全角句點 .(U+FF0E,異於表意句號 。U+3002)——實測 Noto CJK SC/TC 皆有 halt、
* 挤壓收 0.5em,故與 。!? 同列為句末點號(下游回饋)。 */
export declare const BD_STOP = "\u3002\uFF0E\uFF01\uFF1F";
/** 句內點號:,、;:(開明式擠成半形;全角式保持全形)。 */
export declare const BD_PAUSE = "\uFF0C\u3001\uFF1B\uFF1A";
/** 開始括號/引號:「『(《〈【〖〔[{“‘。 */
export declare const BD_OPEN = "\u300C\u300E\uFF08\u300A\u3008\u3010\u3016\u3014\uFF3B\uFF5B\u201C\u2018";
/** 結束括號/引號:」』)》〉】〗〕]}”’。 */
export declare const BD_CLOSE = "\u300D\u300F\uFF09\u300B\u3009\u3011\u3017\u3015\uFF3D\uFF5D\u201D\u2019";
/** 間隔/分隔號:·・‧。 */
export declare const BD_MIDDLE = "\u00B7\u30FB\u2027";
/** 連續省略/破折:…—。 */
export declare const BD_LIGA = "\u2026\u2014";
/** 全寬標點之六子類鍵。 */
export type BdSubclass = "stop" | "pause" | "open" | "close" | "middle" | "liga";
/**
* 字符分類規則集(§4.2):中文/西文表為 regex 字元類片段,標點各子類為逐碼點字串(不解析範圍)。
* 各 pass(spacing 之 CJK/ANS 正則、jiya 之 classifyBiaodian、jinze 之 anyCjk)皆由此
* 取分類,不再各自硬編碼。內置默認見 DEFAULT_RULESET;消費端經 options.charClass 擴充
* (resolveRuleset)。
*/
export interface Ruleset {
/** 中文(CJK 表意/假名/注音等)——中西間隙之「中」側、禁則/justify 原子。 */
cjk: string;
/** 西文,CJK 之後可接隙者(含 @)。 */
ansAfter: string;
/** 西文,CJK 之前可接隙者(去 @)。 */
ansBefore: string;
/** 全寬標點,按子類——jiya charify/擠壓、禁則避頭尾、行端。 */
biaodian: Record<BdSubclass, string>;
}
/** 消費端字符分類覆寫(追加語義;見 README「自定義字符分類」)。 */
export interface CharClassOverride {
/** 追加為中文(CJK):regex 字元類片段,特殊字元須轉義。 */
cjk?: string;
/** 追加為西文:regex 字元類片段,特殊字元須轉義;併入 ansAfter/ansBefore。 */
western?: string;
/** 追加為全寬標點,按子類列出逐碼點字串;例如 "①②③",不解析 "①-③" 為範圍。 */
biaodian?: Partial<Record<BdSubclass, string>>;
}
/** 內置默認規則集(即現狀硬編碼值)。 */
export declare const DEFAULT_RULESET: Ruleset;
/**
* 合併消費端覆寫,回傳最終規則集。v1 為**追加**語義:把 override 字元併入內置對應類
* (未分類字符→歸類、新符號→納入)。不傳則回傳 DEFAULT_RULESET(零行為變更)。
*/
export declare function resolveRuleset(override?: CharClassOverride): Ruleset;
/**
* 將單一標點字元歸入其 bd-* 子類;非標點回傳 null。供 jiya charify 標記 class。
* biaodian 取自規則集(預設 DEFAULT_RULESET.biaodian,向下兼容無參數呼叫)。
*/
export declare function classifyBiaodian(ch: string, biaodian?: Record<BdSubclass, string>): string | null;
/**
* 標點之「可壓縮空白」側(橫排)。字面偏左者空白在右(句內/句末/閉類),
* 開類字面偏右、空白在左。間隔號/連接號不壓縮。擠壓即收掉對應側之半形空白。
*/
export declare function blankSides(bdClass: string | null): {
left: boolean;
right: boolean;
};
/** 字形墨色偏側。 */
export type Bias = "left" | "center" | "right" | "full";
/** 逐 lang 逐字之偏側覆寫表(消費端傳入,覆寫參考預設)。 */
export type BiasTable = Partial<Record<LangClass, Record<string, Bias>>>;
/**
* 解析標點字元在某語言下之墨色偏側(margin 後備模式用)。
* 優先序:消費端 overrides[langClass][ch] > 括號表 > 內建點號參考表。
* 非可壓縮標點(間隔/連接號等)回傳 null(margin 模式不收)。
*/
export declare function inkBias(ch: string, langClass: LangClass, overrides?: BiasTable): Bias | null;
-27
View File
@@ -1,27 +0,0 @@
import type { JuzhenOptions, Pass, ResolvedOptions } from "./types.js";
export type { JuzhenOptions, ResolvedOptions, Pass };
export type { CharClassOverride, BdSubclass, Ruleset, Bias, BiasTable } from "./types.js";
/** 正規化公開選項為內部 ResolvedOptions。 */
export declare function normalizeOptions(opts?: JuzhenOptions): ResolvedOptions;
export interface Juzhen {
render(root?: Element | string): void;
revert(root?: Element | string): void;
/** revert 後重新 render 同一(子)樹(C3 增量便利);等價於 revert(root) + render(root)。
* 頁面局部變化後,對受影響之最近塊調用即可只重處理該子樹、免全樹重掃。 */
rerender(root?: Element | string): void;
/** 清除該(子)樹之內部元素快取(C1)。**revert 已自動失效其子樹**,故僅在「改了既有元素之
* 分類相關屬性(class/data-jz-*/lang 等)但不走 revert」時才需顯式調用,以免下次 render
* 沿用陳舊分類。內容(子節點/文本)變化不改分類、無需 invalidate(但須 revert+render 該塊
* 方能重處理)。 */
invalidate(root?: Element | string): void;
/** 開始觀察 root 子樹之 DOM 變化(C2 增量模式,opt-in):內容/分類屬性變化時自動只重處理
* 受影響之最近塊(revert+render),新增之塊直接 render——免消費端手動 rerender 或全樹重掃。
* **juzhen 自身之變更**(jz-* 注入、data-jz* 屬性)於處理期間暫停觀察、絕不自觸發。需
* MutationObserver(瀏覽器/jsdom 有;無則 no-op)。重複 observe 前自動 disconnect 前者。 */
observe(root?: Element | string): void;
/** 停止 C2 觀察。 */
disconnect(): void;
readonly options: ResolvedOptions;
}
/** 建立一個聚珍實例。 */
export declare function createJuzhen(opts?: JuzhenOptions): Juzhen;
-109
View File
@@ -1,109 +0,0 @@
/* src/css/_normalize.css */
@supports (text-autospace: no-autospace) {
.juzhen {
text-autospace: no-autospace;
}
}
jz-char,
jz-inner,
jz-hws,
jz-cs,
jz-jinze,
jz-em {
font: inherit;
color: inherit;
text-indent: 0;
hanging-punctuation: none;
}
jz-char,
jz-jinze {
text-decoration: inherit;
}
/* src/css/_spacing.css */
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;
}
/* src/css/_jiya.css */
jz-char {
display: inline-block;
vertical-align: baseline;
}
jz-inner {
display: inline;
}
[data-jz-atoms=inline] jz-char {
display: inline;
}
jz-char.jz-half jz-inner,
jz-char.jz-half-le jz-inner {
font-feature-settings: var(--jz-half-features, "locl" 1, "halt" 1);
-webkit-font-feature-settings: var(--jz-half-features, "locl" 1, "halt" 1);
}
[data-jz-halfwidth=margin] {
--jz-half-features: normal;
--jz-half-width: 0.5em;
--jz-half-inner-display: inline-block;
--jz-half-center-offset: -0.25em;
--jz-half-right-offset: -0.5em;
}
[data-jz-halfwidth=halt] {
--jz-half-features: "locl" 1, "halt" 1;
--jz-half-width: auto;
--jz-half-inner-display: inline;
--jz-half-center-offset: 0;
--jz-half-right-offset: 0;
}
jz-char.jz-half.jz-ink-left,
jz-char.jz-half.jz-ink-center,
jz-char.jz-half.jz-ink-right,
jz-char.jz-half-le.jz-ink-left,
jz-char.jz-half-le.jz-ink-center,
jz-char.jz-half-le.jz-ink-right {
width: var(--jz-half-width, auto);
overflow: visible;
}
jz-char.jz-half.jz-ink-left jz-inner,
jz-char.jz-half.jz-ink-center jz-inner,
jz-char.jz-half.jz-ink-right jz-inner,
jz-char.jz-half-le.jz-ink-left jz-inner,
jz-char.jz-half-le.jz-ink-center jz-inner,
jz-char.jz-half-le.jz-ink-right jz-inner {
display: var(--jz-half-inner-display, inline);
text-decoration: inherit;
}
jz-char.jz-half.jz-ink-center jz-inner,
jz-char.jz-half-le.jz-ink-center jz-inner {
margin-left: var(--jz-half-center-offset, 0);
}
jz-char.jz-half.jz-ink-right jz-inner,
jz-char.jz-half-le.jz-ink-right jz-inner {
margin-left: var(--jz-half-right-offset, 0);
}
/* src/css/_jinze.css */
jz-jinze {
display: inline-block;
white-space: nowrap;
vertical-align: baseline;
}
[data-jz-atoms=inline] jz-jinze {
display: inline;
}
/* src/css/_orphan.css */
jz-orphan {
white-space: nowrap;
}
/* src/css/juzhen.css */
-3890
View File
File diff suppressed because it is too large Load Diff
-3864
View File
File diff suppressed because it is too large Load Diff
-192
View File
@@ -1,192 +0,0 @@
import type { Finder } from "./core/finder.js";
import type { LangClass, LocaleConfig, PunctStyle, StyleRule } from "./core/locale.js";
import type { Bias, BiasTable, BdSubclass, CharClassOverride, Ruleset } from "./core/unicode.js";
export type { LangClass, PunctStyle, StyleRule, Bias, BiasTable, BdSubclass, CharClassOverride, Ruleset };
/** 長英文詞包裝設定。 */
export interface LongWordOptions {
/** 有限安全整數且 ≥2;無效值回退 6。 */
minLength?: number;
lang?: string;
}
/** 垂懸字避免設定(§6.5.1)。布林 true 等同 { chars: 2 }。 */
export interface OrphanOptions {
/** 段末行至少保留之 CJK 實義字數(標點/符號不計;預設 2)。
* 有限值且 ≥1,向下取整;無效值回退 2。 */
chars?: number;
}
/** 標點擠壓設定(架構 §6.3)。布林 true 等同 { halfWidth: "halt" }。 */
export interface JiyaOptions {
/** 半形渲染機制:
* - "halt"(預設)— 用字型 OpenType `halt`,由字型按字形正確定位,需字型支援;
* - "margin" — 0.5em 盒寬後備模式,供缺 halt 之字型,依字形偏側表定位。 */
halfWidth?: "halt" | "margin";
/** margin 後備模式之字形偏側覆寫表(逐 lang 逐字),覆寫內建參考預設。
* halt 模式不讀此欄。 */
bias?: BiasTable;
}
/** 分塊功能選擇:選擇器 → 該塊內生效之功能集(覆寫全域)。 */
export interface BlockRule {
selector: string;
features: string[];
}
/** createJuzhen 之公開選項。所有欄位皆可選;預設見 normalizeOptions。 */
export interface JuzhenOptions {
/** 語言與全角/開明政策。 */
lang?: {
/** 無 lang 屬性(自身及祖先)時之預設語言標籤(預設 "zh-Hant");空字串表示未知。 */
default?: string;
/** 全域標點擠壓預設:quanjiao(全角式)|kaiming(開明式)|
* banjiao(半角式)。覆寫內建逐語言預設,可被 policy 逐 lang/stylePolicy 逐元素覆寫。 */
style?: PunctStyle;
/** 逐 lang 覆寫。 */
policy?: Partial<Record<LangClass, PunctStyle>>;
/** **逐元素**覆寫標點樣式(優先於 lang/policy/style):選擇器命中之元素及其子樹
* 用指定樣式,如非段落/UI 元素用半角式 `[{ selector: "h1,h2,button,.ui", style: "banjiao" }]`。
* 與 HTML 屬性 `data-jz-style="banjiao|kaiming|quanjiao"` 同一機制;近祖先勝、屬性優先於選擇器。 */
stylePolicy?: StyleRule[];
};
/** 自定義字符分類(擴充內置規則集)。把未分類字符或新符號歸入中文/西文/
* 全寬標點各子類,以覆寫 spacing/jiya 等規則之分類判定(追加語義)。 */
charClass?: CharClassOverride;
/** 功能分級標記(§6.5.0a)。段落級功能(禁則/行端/垂懸字/長詞斷字)
* 僅作用於段落級元素;文本級功能(中西間隙/標點擠壓)任意文本皆適用。
* - `text`:選擇器,命中之子樹**僅文本級**(標題/按鈕/UI 文字典型);亦可
* 經元素屬性 `data-jz-level="text"`/`"paragraph"` 標記(屬性優先、最近祖先勝)。
* 自身向上找最近有效訊號,皆無才預設 paragraph;不按 HTML 標籤或 display 推斷。 */
level?: {
text?: string;
};
/** justify 原子化:jz-char/jz-jinze 是否以 inline-block 作對齊單一原子
* (預設 true)。設 false 改 display:inline——用於分頁器(如 Paged.js)之
* 對齊引擎會把每個 inline-block 邊界當伸縮點、與 CJK inter-ideograph 疊加
* 致窄欄散架之情形(halt 半形與 jinze nowrap 仍保留)。 */
justifyAtoms?: boolean;
/** 作用域限定(§3.6)。 */
scope?: {
root?: Element | string;
include?: string;
avoid?: string;
};
spacing?: boolean;
longWord?: boolean | LongWordOptions;
jiya?: boolean | JiyaOptions;
jinze?: boolean;
orphan?: boolean | OrphanOptions;
/** @deprecated 標點懸掛**已移除**(§6.5.6):純負 margin 機制經實測為機制級死路(寬度
* 守恆矛盾,CSS 接觸不到斷行器),未來原生 hanging-punctuation 路徑須另行設計及驗證。此選項仍被接受以保 API 相容,但**無任何效果**。 */
hanging?: boolean;
/** @reserved 標點修正(簡/繁正規化)——架構已預留(切片 #7),**當前無任何效果**。
* 注意:此為功能開關,與 `charClass.biaodian`(標點子類擴充表,語義完全不同)同名,勿混。 */
biaodian?: boolean;
/** @reserved 著重號/裝飾——架構已預留(切片 #8),**當前無任何效果**。 */
emphasis?: boolean;
/** @reserved Ruby/注音——架構已預留(切片 #8),**當前無任何效果**。 */
ruby?: boolean;
/** 分塊功能選擇(§3.6)。 */
blocks?: BlockRule[];
styleInlines?: string[];
blockTags?: string[];
skipTags?: string[];
/** 整體(pill)元素選擇器(預設 "code, kbd")。pill 之邊界口徑與 isolate 完全
* 相同(行為覆蓋),差異僅在內部不可見;同元素雙選擇器命中時 pill 勝出。 */
pillSelector?: string;
/** 內外隔離元素選擇器(預設 "mark")。傳 "" 停用(mark 回歸透明)。 */
isolateSelector?: string;
skipAttribute?: string;
isSkipped?: (node: Node) => boolean;
}
/** 正規化後之內部選項。 */
export interface ResolvedOptions {
locale: LocaleConfig;
/** 最終字符分類規則集(內置默認 + charClass 覆寫)。 */
ruleset: Ruleset;
justifyAtoms: boolean;
/** 功能分級之 text-level 選擇器(§6.5.0a);null 表示未設。 */
levelText: string | null;
scope: {
root: Element | string | null;
include: string | null;
avoid: string | null;
};
features: {
spacing: boolean;
longWord: boolean;
jiya: boolean;
jinze: boolean;
orphan: boolean;
biaodian: boolean;
emphasis: boolean;
ruby: boolean;
};
longWord: {
minLength: number;
lang: string;
};
/** 垂懸字避免設定(解析後)。 */
orphan: {
chars: number;
};
/** 擠壓半形渲染設定(halt 預設/margin 後備)。 */
jiyaConfig: {
halfWidth: "halt" | "margin";
bias: BiasTable;
};
blocks: BlockRule[];
finder: {
styleInlines: Set<string>;
blockTags: Set<string>;
skipTags: Set<string>;
pillSelector: string;
isolateSelector: string;
skipAttribute: string | null;
isSkipped: ((node: Node) => boolean) | null;
};
}
/** render/revert 之統一上下文(§3.3)。 */
export interface RenderContext {
root: Element;
options: ResolvedOptions;
finder: Finder;
/** 當前 createJuzhen 實例造出之元素;DOM 屬性不能作為所有權憑證。 */
ownedElements: WeakSet<Element>;
/** 實例不可偽造之屬性寫者身分;狀態由 core/dom 共享堆疊管理。 */
ownerToken: object;
/** 寫入本實例產物之內部 class,並在觀察啟用時留下可精確核對之 mutation provenance。 */
setOwnedClass: (element: Element, className: string, present: boolean) => void;
}
/**
* charify 階段之包裝請求:把 node.data[start, end) 包進 build() 造出之元素。
* 同階段各 pass 之請求須互不重疊(標點/字母段天然不交集)。
*/
export interface WrapRequest {
start: number;
end: number;
build: (text: string) => HTMLElement;
pass: string;
}
/** pass 種類。 */
export type PassKind = "charify" | "standalone";
/** 功能分級(§6.5.0a):text=任意文本片段皆適用;paragraph=需行/段布局。 */
export type FeatureLevel = "text" | "paragraph";
interface BasePass {
name: string;
order: number;
kind: PassKind;
/** 功能分級(§6.5.0a)。paragraph 級 pass 對 text-level 元素自動跳過。 */
level: FeatureLevel;
enabled: (options: ResolvedOptions) => boolean;
revert: (ctx: RenderContext) => void;
}
/** 共享 P1 走訪之字元包裝 pass。 */
export interface CharifyPass extends BasePass {
kind: "charify";
/** 在逐 Text 收集前處理不可被 DOM 分割的跨節點原子。 */
prepare?: (ctx: RenderContext) => void;
collect: (node: Text, ctx: RenderContext) => WrapRequest[];
}
/** 自跑走訪/版面後處理之 pass。 */
export interface StandalonePass extends BasePass {
kind: "standalone";
render: (ctx: RenderContext) => void;
}
export type Pass = CharifyPass | StandalonePass;
-2
View File
@@ -1,2 +0,0 @@
import type { StandalonePass } from "../types.js";
export declare const jinzePass: StandalonePass;
-3
View File
@@ -1,3 +0,0 @@
import type { CharifyPass, StandalonePass } from "../types.js";
export declare const jiyaPass: CharifyPass;
export declare const jiyaAdjacencyPass: StandalonePass;
-2
View File
@@ -1,2 +0,0 @@
import type { StandalonePass } from "../types.js";
export declare const levelMarkPass: StandalonePass;
-3
View File
@@ -1,3 +0,0 @@
import type { StandalonePass } from "../types.js";
export declare const lineEdgePass: StandalonePass;
export declare const gapTrimPass: StandalonePass;
-2
View File
@@ -1,2 +0,0 @@
import type { CharifyPass } from "../types.js";
export declare const longWordPass: CharifyPass;
-2
View File
@@ -1,2 +0,0 @@
import type { StandalonePass } from "../types.js";
export declare const orphanPass: StandalonePass;
-2
View File
@@ -1,2 +0,0 @@
import type { StandalonePass } from "../types.js";
export declare const spacingPass: StandalonePass;
+4693
View File
File diff suppressed because it is too large Load Diff
+356
View File
@@ -0,0 +1,356 @@
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>cjk-autospace — 詳細文檔</title>
<meta name="generated-at" content="2026-05-22">
<meta name="source" content="cjk-autospace docs branch">
<!-- RUNTIME_CSS -->
</head>
<body>
<header class="page">
<div class="meta">
<h1>cjk-autospace</h1>
<p class="subtitle">CJK ↔ Latin 邊界自動間距 shim — 工廠模式、跨樣式透明、行內塊邊界、Pangu.js v7.2.1 規則 port</p>
<p class="timestamp"><time>2026-05-22</time> · <span>docs branch</span></p>
</div>
<div class="actions">
<!-- ACTIONS -->
</div>
</header>
<main>
<p class="lede">Pangu.js v7.2.1 之規則 port,加上「跨樣式標籤透明 + 行內塊邊界」雙 pass 架構,再加 Pass C 之「長英文段 <code>&lt;span lang&gt;</code> 包裝」(給瀏覽器原生 <code>hyphens: auto</code> 用)。工廠函式設計,由消費端注入 pill 選擇器、樣式 / 段落 / skip 標籤集等配置。輸出 <code>&lt;span class="cjk-autospace" aria-hidden="true"&gt;(空白)&lt;/span&gt;</code>,靠 CSS <code>user-select: none</code> 讓選取與剪貼板跳過。textContent 不變 → Copy 與序列化都乾淨。</p>
<h2>Quick start</h2>
<pre><code>// 1) 載入 shim(以下任一)
import { createCjkAutospace } from "./cjk-autospace.js"; // ESM / TS
// 或 inline &lt;script&gt; / require / globalThis.createCjkAutospace
// 2) 配置(pill 選擇器是唯一需要消費端自訂的;其餘有合理預設)
const autospace = createCjkAutospace({
pillSelector: "code, kbd, .your-pill-class",
});
// 3) 套用
autospace.apply(document.body);
</code></pre>
<p>套用後 DOM 內每個 Pangu 認定之邊界都被加上 <code>.cjk-autospace</code> marker。在已套用之 DOM 上重跑為冪等。</p>
<h3>必備 CSS</h3>
<pre><code>.cjk-autospace {
-webkit-user-select: none;
user-select: none;
/* 視覺寬度由消費端決定,例如 0.25em */
}</code></pre>
<h2>標籤三分類</h2>
<table>
<thead>
<tr><th>類別</th><th>典型成員</th><th>shim 對待</th></tr>
</thead>
<tbody>
<tr>
<td><strong>STYLE_INLINES</strong></td>
<td><code>strong</code> / <code>em</code> / <code>a</code> / <code>b</code> / <code>i</code> / <code>u</code> / <code>s</code> / <code>mark</code> / <code>ins</code> / <code>del</code> / <code>sub</code> / <code>sup</code> / <code>small</code> / <code>abbr</code> / <code>cite</code> / <code>dfn</code> / <code>q</code> / <code>span</code> / <code>time</code> / <code>ruby</code> / <code>wbr</code> / …</td>
<td><strong>透明</strong>。shim 在 Pass A 走 DFS 時穿過去,把內外文本當成連續文本判斷 Pangu 邊界。</td>
</tr>
<tr>
<td><strong>PILL</strong></td>
<td>由 <code>pillSelector</code> 指定,預設 <code>code, kbd</code></td>
<td><strong>opaque 行內塊</strong>。Pass A 視為 run 邊界;Pass B 處理其與鄰字之介面。</td>
</tr>
<tr>
<td><strong>BLOCK</strong></td>
<td><code>p</code> / <code>div</code> / <code>h1</code>-<code>h6</code> / <code>li</code> / <code>pre</code> / <code>table</code> / <code>tr</code> / <code>td</code> / <code>br</code> / <code>hr</code> / …</td>
<td><strong>段落級</strong>。Pass A 走到此即中斷 run;子 block 各自一次 <code>processBlockText</code> 呼叫。</td>
</tr>
</tbody>
</table>
<div class="callout info">
<span class="label">關鍵設計</span> 樣式標籤透明 → shim 看穿 <code>&lt;strong&gt;是&lt;/strong&gt; Neovim</code> 這類跨元素邊界,把「是」(CJK)+ 空格 + 「N」(ANS)視為 Pangu 觸發位置;而非僅在單一 text-node 內找匹配。
</div>
<h2>Pass A — per-block Pangu on tag-stripped text</h2>
<p>對 root 之每個 BLOCK 後代執行一次。流程:</p>
<ol>
<li>DFS 走 block 內部子節點:
<ul>
<li>遇 text node:把每個 char 累積到 <code>curText</code>,同步把 <code>{ textNode, offset }</code> 推入 <code>curMap</code></li>
<li>遇 STYLE_INLINES:透明遞迴,curText 繼續累積</li>
<li>遇 PILL / 子 BLOCK / 既有 <code>.cjk-autospace</code>:<code>flushRun()</code>,把累積之 (text, map) 作為一個 run 推入 runs,重置</li>
</ul>
</li>
<li>對每個 run,套 <code>panguSpace(text)</code>,得到 <code>spaced</code> 字串(含 MARK PUA 字符 U+E007)</li>
<li>平行掃描 <code>spaced</code> 與 <code>text</code>,把 MARK 對應到原文之插入位置:
<ul>
<li>若 <code>text[origIdx]</code> 是 ASCII space → MARK 替換了該空格。動作:strip 該空格 + 在前一字後插 marker</li>
<li>否則 → MARK 純插入。動作:在前一字後插 marker</li>
</ul>
</li>
<li>actions 倒序執行:保證前面動作之 textNode offset 不被後面動作(splitText / data slicing)影響</li>
</ol>
<h3>例</h3>
<p><code>&lt;p&gt;本資料夾&lt;strong&gt;不是&lt;/strong&gt; Neovim 使用者目錄。&lt;/p&gt;</code></p>
<p>DFS 收集到的 <code>curText</code>(樣式標籤剝離後):<code>"本資料夾不是 Neovim 使用者目錄。"</code></p>
<p><code>panguSpace</code> 應用:<code>是 N</code> 命中 <code>STRIP_CJK_SPACE_ANS</code>(CJK + 空格 + ANS)→ 「<code>是<sub>MARK</sub>N</code>」(空格被 MARK 取代);<code>m 使</code> 命中 <code>STRIP_ANS_SPACE_CJK</code> → 同理。</p>
<p>反映射:</p>
<ul>
<li>位置 1:原文「是」與「 」之間,「 」被 strip → marker 插在「是」後(DOM 上是 <code>&lt;strong&gt;</code> 之 nextSibling 處)</li>
<li>位置 2:原文「m」與「 」之間,「 」被 strip → marker 插在「m」後(同一 text node 內,splitText 後插入)</li>
</ul>
<p>最終 DOM(簡化):<code>本資料夾&lt;strong&gt;不是&lt;/strong&gt;&lt;span class="cjk-autospace"&gt; &lt;/span&gt;Neovim&lt;span class="cjk-autospace"&gt; &lt;/span&gt;使用者目錄。</code></p>
<h2>Pass B — pill boundary, style-inline aware</h2>
<p>對 root 內每個匹配 <code>pillSelector</code> 之元素執行:</p>
<ol>
<li><code>adjacentLogicalChar(pill, -1)</code>:走 prev sibling 鏈,遇 text 取末字元;遇 STYLE_INLINES 遞迴進入找其最末 text;遇 PILL / BLOCK / 既有 marker / skip ancestor → null</li>
<li>若回傳邏輯鄰字是 CJK / 字母數字 → 在 pill 前插 marker</li>
<li>若邏輯鄰字是 1 個 ASCII space 且該 space 另一側(同 text node 內)是內容字 → strip 該 space + 插 marker</li>
<li>對 <code>+1</code> 方向(pill 後)做對稱處理</li>
</ol>
<h3>例</h3>
<p><code>命令 &lt;code&gt;git&lt;/code&gt; 更新</code></p>
<ul>
<li>Pass A run 因 pill 中斷:「命令 」(trailing space 為 run 末尾,不觸發 panguSpace 之 STRIP rule,因 rule 要求兩側都是內容字而 run 末尾沒有右側字)</li>
<li>Pass B 處理 pill 之 prev:text 末字元是 <code>' '</code>,前一字元是 <code>'令'</code>(CJK),命中 strip 規則 → 移除空格 + 在 pill 前插 marker</li>
<li>Pass B 處理 pill 之 next:text 首字元是 <code>' '</code>,後一字元是 <code>'更'</code>(CJK),同理 strip + 插 marker</li>
</ul>
<p>結果:<code>命令&lt;span class="cjk-autospace"&gt; &lt;/span&gt;&lt;code&gt;git&lt;/code&gt;&lt;span class="cjk-autospace"&gt; &lt;/span&gt;更新</code></p>
<h2>Pass C — long English word wrap(預設啟用)</h2>
<p>解 CJK 兩端對齊正文中「行末長英文詞」之斷行問題:原靠 <code>&lt;wbr&gt;</code> 每 N 字元亂插之路徑會產生「斷點位置與音節無關 + 斷處不顯連字符」兩個觀感問題。Pass C 改採 <code>&lt;span lang="en"&gt;</code> 包裝,把斷字決定權交給瀏覽器原生 <code>hyphens: auto</code> 用內建英文字典處理。</p>
<ol>
<li>對 root 內所有 text node 走 TreeWalker(SHOW_TEXT)</li>
<li>對每個 node:先 <code>isSkipped</code>(共用 Pass A / B 之 skip 鏈:CODE / KBD / PRE / SAMP / TT / VAR / SCRIPT / STYLE / TEXTAREA / INPUT / SVG / contentEditable / <code>data-md-key</code>)+ 冪等檢查(parent 為 <code>&lt;span lang&gt;</code> → skip)</li>
<li>對通過篩選之 node:用 <code>[A-Za-z]{minLength,}</code> 收所有匹配,把每個匹配位置之子串裹進 <code>&lt;span lang="en"&gt;</code>(其餘原文保留為純 text node),整個用 fragment 一次性 replaceChild</li>
</ol>
<h3>消費端 CSS(與 lib 解耦但必須配套)</h3>
<pre><code>[lang|="en"] {
hyphens: auto;
-webkit-hyphens: auto;
hyphenate-limit-chars: 6 3 3; /* 詞長 / 前 / 後最少字符 */
hyphenate-character: "-"; /* 強制 U+002D,避免 CJK 字型把 U+2010 渲成全形 */
-webkit-hyphenate-character: "-";
}</code></pre>
<div class="callout warn">
<span class="label">字型陷阱</span> Noto Serif CJK SC 等 CJK serif 字型把 U+2010 HYPHEN 設計為全形 1 em(與漢字同寬),以服務 CJK 範圍表達;而 <code>hyphens: auto</code> 預設正是插 U+2010。直接用會看到全形連字符突兀地接在英文詞末。<code>hyphenate-character: "-"</code> 強制改用 U+002D(同字型內為窄拉丁,與作者鍵入的 <code>-</code> 一致),仍為純視覺渲染、DOM 內不存在。
</div>
<h3>選取 / 複製潔淨性</h3>
<p>連字符純由 CSS 渲染(不存在於 DOM),<code>Selection.toString()</code> 與 <code>Cmd+C</code> 返回原文無污染。包裝 span 本身是 inline,<code>textContent</code> 視為原樣,序列化 / 複製不受影響。</p>
<h3>不在範圍</h3>
<ul>
<li><strong>CamelCase / 標識符</strong>(<code>getUserByEmailAddress</code>、<code>OpenAIWhisperAPI</code>):不在英文字典 → 瀏覽器不斷詞;本實作未開 <code>overflow-wrap: anywhere</code> fallback(會破壞「連字符必須存在」之原則),故識別符 overflow 時整塊保留</li>
<li><strong>URL / 純數字串</strong>:同上,<code>https://example.com/very/long/path</code> 此類超長 token 不被處理</li>
<li><strong>非英文之 ASCII 拉丁變體</strong>(café、naïve):正則 <code>[A-Za-z]</code> 不匹配重音字符;如需擴充可自訂 <code>longWordWrap.minLength</code> 之外再 patch lib</li>
</ul>
<h2>空白規則</h2>
<p>所有觸發點(Pangu 命中或 PILL 邊界),對「插入位置之原有空白」的處理一致:</p>
<table>
<thead>
<tr><th>原有空白</th><th>動作</th></tr>
</thead>
<tbody>
<tr><td>0 個</td><td>純插 marker</td></tr>
<tr><td>1 個 ASCII space</td><td>strip 該 space + 插 marker(取代)</td></tr>
<tr><td>2 個或更多 space</td><td>跳過(視為刻意 structural whitespace)</td></tr>
<tr><td>tab / newline / 其他空白</td><td>跳過(panguSpace 之 regex 只匹配 single ASCII space)</td></tr>
<tr><td>標點隔開</td><td>跳過(panguSpace 命中要求兩端是內容字)</td></tr>
<tr><td>既有 <code>.cjk-autospace</code> marker</td><td>跳過(避免雙重插入;冪等性)</td></tr>
</tbody>
</table>
<h2>配置選項</h2>
<table>
<thead>
<tr><th>選項</th><th>預設</th><th>說明</th></tr>
</thead>
<tbody>
<tr><td><code>pillSelector</code></td><td><code>"code, kbd"</code></td><td>CSS 選擇器;行內塊範圍。常見擴充:<code>code, kbd, .cjk-pill</code>(按 class opt-in)或 <code>code, kbd, mark, samp</code>(更廣)</td></tr>
<tr><td><code>styleInlines</code></td><td>樣式標籤預設集</td><td>tagName 大寫之陣列;自訂可改寫</td></tr>
<tr><td><code>blockTags</code></td><td>段落標籤預設集</td><td>tagName 大寫之陣列</td></tr>
<tr><td><code>skipTags</code></td><td><code>["CODE", "KBD", "PRE", "SAMP", "TT", "VAR", "SCRIPT", "STYLE", "TEXTAREA", "INPUT"]</code></td><td>整 subtree skip,連祖先 chain 一併判斷</td></tr>
<tr><td><code>autospaceClass</code></td><td><code>"cjk-autospace"</code></td><td>marker span 之 class 名</td></tr>
<tr><td><code>skipAttribute</code></td><td><code>"data-md-key"</code></td><td>任一祖先帶此屬性 → 整 subtree skip</td></tr>
<tr><td><code>longWordWrap</code></td><td><code>true</code>(預設啟用)</td><td>Pass C 開關。傳 <code>false</code> 顯式關閉;<code>{ minLength, lang }</code> 自訂粒度與標語;<code>true</code> / 省略等同 <code>{ minLength: 6, lang: "en" }</code></td></tr>
<tr><td><code>isSkipped</code></td><td>(無)</td><td>自訂 callback;先於內建判定執行;回傳 true 即 skip</td></tr>
</tbody>
</table>
<h2>Marker span 解剖</h2>
<pre><code>&lt;span class="cjk-autospace" aria-hidden="true"&gt; &lt;/span&gt;</code></pre>
<dl>
<dt><code>class="cjk-autospace"</code></dt>
<dd>由消費端之 CSS 添加 <code>user-select: none</code>(選取時跳過);視覺寬度也由消費端決定(典型 0.25em)。</dd>
<dt><code>aria-hidden="true"</code></dt>
<dd>讓 screen reader 忽略,避免「空格、空格、空格」朗讀干擾。</dd>
<dt>內含真實 ASCII space</dt>
<dd>不是 0 寬 span — 真實字符。layout 視為一般 whitespace:行末可 collapse、容許在此換行、justify 可拉伸該空白。<br>對比方案(inline-block + fixed em width)破壞 line-end 行為與 justify 之分配;不採用。</dd>
<dt>textContent 不變</dt>
<dd>從 DOM serialization 或選取與複製角度看,marker 是「不可見」之 span;user-select: none 讓 selection range 跳過該 span;故剪貼板 / Copy MD 之輸出無空白污染。</dd>
</dl>
<h2>Pangu 規則簡表</h2>
<p>panguSpace 內部依序套 30+ 條 regex。簡列各觸發類別:</p>
<table>
<thead>
<tr><th>類別</th><th>例</th><th>動作</th></tr>
</thead>
<tbody>
<tr><td>Pre-pass strip</td><td><code>2023 年</code> → <code>2023年</code></td><td>strip CJK ↔ ANS 之間單 ASCII space,後續 pass 把它當 flush 寫法處理</td></tr>
<tr><td>CJK + Latin / 數字</td><td><code>使用 GPT</code></td><td>插 MARK</td></tr>
<tr><td>CJK + ASCII 標點</td><td><code>是,沒錯</code></td><td>標點後插 MARK</td></tr>
<tr><td>CJK + 引號 / 括號</td><td><code>是(實驗)</code></td><td>插 MARK</td></tr>
<tr><td>CJK + 運算子 + ANS</td><td><code>是+1</code></td><td>運算子兩側各插 MARK</td></tr>
<tr><td>compound word 保護</td><td><code>GPT-4</code> / <code>state-of-the-art</code></td><td>以 PUA 占位替換,避免運算子 pass wedge 入空白;bracket / CJK_ANS pass 前還原</td></tr>
</tbody>
</table>
<h2>整合範例</h2>
<h3>HTML build script 串接</h3>
<pre><code># build.sh 把 lib 串到 runtime.js 之前
{
printf '&lt;script&gt;\n'
cat "$LIB/cjk-autospace.js" # 工廠函式
printf '\n'
sed '$d' "$RUNTIME/runtime.js" # 應用程式 runtime
cat "$MODE_BEHAVIOR_JS"
printf '\n})();\n'
printf '&lt;/script&gt;\n'
}</code></pre>
<p>由 runtime.js 之 init 呼叫:<code>createCjkAutospace({}).apply();</code>。</p>
<h3>Lua 內嵌 JS 字串</h3>
<pre><code>-- local.lua build hook 讀 lib 內容,sprintf 注入 selector 配置
local lib = read_file("lua/user/lib/cjk-autospace/cjk-autospace.js")
local entry = string.format(
"createCjkAutospace({ pillSelector: %q }).apply(document.getElementById('peek-markdown-body'));",
"code, kbd, mark, samp"
)
local script = "&lt;script&gt;" .. lib .. "\n" .. entry .. "&lt;/script&gt;"
inject_into_html(script)</code></pre>
<h3>TypeScript / Vite</h3>
<pre><code>// cjkAutospace.ts
import { createCjkAutospace } from "./lib/cjk-autospace/cjk-autospace.js";
const autospace = createCjkAutospace({
pillSelector: "code, kbd, .cjk-pill",
});
let pending = false;
const obs = new MutationObserver(() => {
if (pending) return;
pending = true;
queueMicrotask(() => {
pending = false;
obs.disconnect();
try { autospace.apply(); }
finally { obs.observe(document.body, { childList: true, subtree: true, characterData: true }); }
});
});
autospace.apply();
obs.observe(document.body, { childList: true, subtree: true, characterData: true });</code></pre>
<h2>邊界情境與限制</h2>
<ul>
<li><strong>SVG namespace</strong>:jdgement <code>p.namespaceURI === 'http://www.w3.org/2000/svg'</code> 整 subtree skip。SVG <code>&lt;text&gt;</code> 不會被處理。</li>
<li><strong>contentEditable</strong>:用戶輸入區整 subtree skip,避免影響游標 / 選取。</li>
<li><strong>已存在 marker</strong>:Pass A 走 DFS 時,<code>.cjk-autospace</code> 中斷 run;Pass B <code>adjacentLogicalChar</code> 遇 marker 即停。故重複呼叫 <code>apply()</code> 不會雙倍插入。</li>
<li><strong>動態 DOM</strong>:shim 自身不訂閱 mutation;增量更新由消費端用 <code>MutationObserver</code> 包裝(範例見上)。建議處理時暫斷 observer 避免自反饋。</li>
<li><strong>跨 BLOCK 邊界</strong>:Pangu 規則不跨段落觸發。<code>&lt;p&gt;是&lt;/p&gt;&lt;p&gt;Neovim&lt;/p&gt;</code> 不會插 marker(兩段獨立)。</li>
<li><strong>Compound word 保護限制</strong>:僅匹配 <code>[A-Za-z0-9]*[a-z][A-Za-z0-9]*-[A-Za-z0-9]+</code> 等模式;無 dash 之純大寫縮寫(<code>HTML</code>、<code>API</code>)不被特殊保護,但本來就沒問題(不會被運算子規則 wedge)。</li>
</ul>
<h2>授權與來源</h2>
<p>規則表 port 自 <a href="https://github.com/vinta/pangu.js">Pangu.js v7.2.1</a>(MIT 授權)。本 lib 之三 pass 架構(樣式透明 + 行內塊邊界 + 長英文 lang 包裝)為原創設計,前兩 pass 解決 Pangu.js 原版「text-node walker 看不到跨元素邊界」之問題;Pass C 把 CJK 兩端對齊正文中之斷英文詞責任交給瀏覽器原生 <code>hyphens: auto</code>。</p>
</main>
<footer class="page">
<p>cjk-autospace · docs branch · 2026-05-22</p>
</footer>
<!-- RUNTIME_SPRITE -->
<script type="text/markdown" id="md-source">
# cjk-autospace
CJK ↔ Latin 邊界自動間距 shim。工廠模式,由消費端注入配置。
## Quick start
```js
import { createCjkAutospace } from "./cjk-autospace.js";
const autospace = createCjkAutospace({
pillSelector: "code, kbd, .cjk-pill",
});
autospace.apply(document.body);
```
## 標籤三分類
- STYLE_INLINES — 樣式標籤,shim 透明走過
- PILL — 行內塊,邊界由 Pass B 處理
- BLOCK — 段落級,Pass A 把它們當作 run 邊界
## Pass A
對每個 BLOCK 走 DFS 收集邏輯文本(樣式透明、PILL / 子 BLOCK 中斷 run),整段套 panguSpace,反映射 MARK 回 textNode / offset。
## Pass B
對每個 PILL 透過樣式鏈遞迴找邏輯鄰字,按 0/1/多+特殊規則處理(0 個或 1 個 ASCII space 插 marker;其餘跳過)。
</script>
<!-- RUNTIME_JS -->
</body>
</html>
-1412
View File
File diff suppressed because it is too large Load Diff
-51
View File
@@ -1,51 +0,0 @@
{
"name": "cjk-autospace",
"version": "2.0.0",
"description": "聚珍(Juzhen)— 現代簡/繁中文網頁排版套件;Han.css 之現代化重寫。",
"type": "module",
"main": "dist/juzhen.iife.js",
"module": "dist/juzhen.mjs",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/juzhen.mjs",
"default": "./dist/juzhen.iife.js"
},
"./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",
"UPDATES.md"
],
"scripts": {
"build": "node build.mjs && tsc --emitDeclarationOnly",
"test": "node build.mjs && node --test test/*.test.mjs",
"test:wkwebview": "node test/run-wkwebview.mjs"
},
"keywords": [
"cjk",
"chinese",
"typography",
"autospace",
"han.css",
"clreq",
"punctuation"
],
"license": "MIT",
"devDependencies": {
"@types/jsdom": "^21.1.7",
"ajv": "^8.20.0",
"esbuild": "^0.28.2",
"jsdom": "^25.0.0",
"typescript": "^5.6.0"
}
}
-70
View File
@@ -1,70 +0,0 @@
# 聚珍共享規則
本目錄是聚珍跨平台排版語義的機讀權威來源。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 私自實作。
## 1.0.1 語言契約澄清
`zh-explicit-script` 及 `zh-traditional-region` 指 [BCP 47 的 script/region 位置](https://www.rfc-editor.org/rfc/rfc5646.html#section-2.1),
不能從 extension/private-use 內容搜尋同名字串。Web 曾把 `zh-Hans-x-hant` 誤判成
繁體,現修正為簡體;空語言標籤歸 `other`。新增七個反例向量,既有 55 個向量結果、
字符集合、預設政策、schema 與 adapter 義務均未改,故依上述 patch 規則升為 1.0.1。
HTML 如何繼承 `lang` 是 Web adapter 契約,不寫入共享規則。未來 adapter 仍須通過
全部 62 個向量;本次未驗收其他 adapter。
## Adapter 邊界
字符集合一律以大寫十六進位 Unicode code point 的閉區間/單點表示;陣列須按起點
遞增、彼此不重疊。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 必須公開
同名選項或未來功能介面已定案。
-415
View File
@@ -1,415 +0,0 @@
{
"$schema": "./golden-vectors.schema.json",
"schemaVersion": 1,
"rulesetVersion": "1.0.1",
"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": "locale-hans-private-hant",
"category": "language-classification",
"input": { "lang": "zh-Hans-x-hant" },
"expected": { "langClass": "zh-Hans" }
},
{
"id": "locale-tw-private-hans",
"category": "language-classification",
"input": { "lang": "zh-TW-x-hans" },
"expected": { "langClass": "zh-Hant" }
},
{
"id": "locale-latn-private-tw",
"category": "language-classification",
"input": { "lang": "zh-Latn-x-tw" },
"expected": { "langClass": "zh-Hans" }
},
{
"id": "locale-extension-hant",
"category": "language-classification",
"input": { "lang": "zh-CN-u-co-hant" },
"expected": { "langClass": "zh-Hans" }
},
{
"id": "locale-extlang-hans",
"category": "language-classification",
"input": { "lang": "zh-cmn-Hans-TW" },
"expected": { "langClass": "zh-Hans" }
},
{
"id": "locale-hant-cn",
"category": "language-classification",
"input": { "lang": "zh-Hant-CN" },
"expected": { "langClass": "zh-Hant" }
},
{
"id": "locale-empty",
"category": "language-classification",
"input": { "lang": "" },
"expected": { "langClass": "other" }
},
{
"id": "punct-stop",
"category": "punctuation-classification",
"input": { "character": "." },
"expected": { "punctuationClass": "stop" }
},
{
"id": "punct-pause",
"category": "punctuation-classification",
"input": { "character": "," },
"expected": { "punctuationClass": "pause" }
},
{
"id": "punct-open",
"category": "punctuation-classification",
"input": { "character": "「" },
"expected": { "punctuationClass": "open" }
},
{
"id": "punct-close",
"category": "punctuation-classification",
"input": { "character": "」" },
"expected": { "punctuationClass": "close" }
},
{
"id": "punct-middle",
"category": "punctuation-classification",
"input": { "character": "·" },
"expected": { "punctuationClass": "middle" }
},
{
"id": "punct-liga",
"category": "punctuation-classification",
"input": { "character": "…" },
"expected": { "punctuationClass": "liga" }
},
{
"id": "punct-base-with-vs",
"category": "punctuation-classification",
"input": { "character": "。︀" },
"expected": { "punctuationClass": "stop" }
},
{
"id": "punct-other",
"category": "punctuation-classification",
"input": { "character": "A" },
"expected": { "punctuationClass": null }
},
{
"id": "style-quanjiao-pause",
"category": "punctuation-style",
"input": { "style": "quanjiao", "punctuationClass": "pause" },
"expected": { "halfWidth": false }
},
{
"id": "style-kaiming-pause",
"category": "punctuation-style",
"input": { "style": "kaiming", "punctuationClass": "pause" },
"expected": { "halfWidth": true }
},
{
"id": "style-kaiming-open",
"category": "punctuation-style",
"input": { "style": "kaiming", "punctuationClass": "open" },
"expected": { "halfWidth": true }
},
{
"id": "style-kaiming-stop",
"category": "punctuation-style",
"input": { "style": "kaiming", "punctuationClass": "stop" },
"expected": { "halfWidth": false }
},
{
"id": "style-banjiao-stop",
"category": "punctuation-style",
"input": { "style": "banjiao", "punctuationClass": "stop" },
"expected": { "halfWidth": true }
},
{
"id": "style-banjiao-liga",
"category": "punctuation-style",
"input": { "style": "banjiao", "punctuationClass": "liga" },
"expected": { "halfWidth": false }
},
{
"id": "adjacency-pause-open",
"category": "punctuation-adjacency",
"input": { "left": "pause", "right": "open" },
"expected": { "squeezeLeft": true, "squeezeRight": true }
},
{
"id": "adjacency-stop-open",
"category": "punctuation-adjacency",
"input": { "left": "stop", "right": "open" },
"expected": { "squeezeLeft": false, "squeezeRight": true }
},
{
"id": "adjacency-stop-close",
"category": "punctuation-adjacency",
"input": { "left": "stop", "right": "close" },
"expected": { "squeezeLeft": true, "squeezeRight": false }
},
{
"id": "adjacency-close-open",
"category": "punctuation-adjacency",
"input": { "left": "close", "right": "open" },
"expected": { "squeezeLeft": true, "squeezeRight": true }
},
{
"id": "adjacency-middle-liga",
"category": "punctuation-adjacency",
"input": { "left": "middle", "right": "liga" },
"expected": { "squeezeLeft": false, "squeezeRight": false }
},
{
"id": "spacing-cjk-latin",
"category": "spacing",
"input": { "text": "中A" },
"expected": { "managedGaps": 1, "normalizedText": "中A", "gapEm": 0.25 }
},
{
"id": "spacing-latin-cjk",
"category": "spacing",
"input": { "text": "A中" },
"expected": { "managedGaps": 1, "normalizedText": "A中", "gapEm": 0.25 }
},
{
"id": "spacing-cjk-at",
"category": "spacing",
"input": { "text": "中@" },
"expected": { "managedGaps": 1, "normalizedText": "中@", "gapEm": 0.25 }
},
{
"id": "spacing-at-cjk-asymmetric",
"category": "spacing",
"input": { "text": "@中" },
"expected": { "managedGaps": 0, "normalizedText": "@中" }
},
{
"id": "spacing-author-space-cjk-latin",
"category": "spacing",
"input": { "text": "中 A" },
"expected": {
"managedGaps": 1,
"normalizedText": "中A",
"authorSpaceDisposition": "remove-and-use-managed-gap"
}
},
{
"id": "spacing-author-space-latin-cjk",
"category": "spacing",
"input": { "text": "A 中" },
"expected": {
"managedGaps": 1,
"normalizedText": "A中",
"authorSpaceDisposition": "remove-and-use-managed-gap"
}
},
{
"id": "spacing-author-space-cjk-cjk",
"category": "spacing",
"input": { "text": "中 文" },
"expected": {
"managedGaps": 0,
"normalizedText": "中文",
"authorSpaceDisposition": "remove-without-gap"
}
},
{
"id": "spacing-cjk-punctuation-latin",
"category": "spacing",
"input": { "text": "中。A" },
"expected": { "managedGaps": 0, "normalizedText": "中。A" }
},
{
"id": "spacing-astral-ivs-latin",
"category": "spacing",
"input": { "text": "𠀀󠄀A" },
"expected": { "managedGaps": 1, "normalizedText": "𠀀󠄀A", "gapEm": 0.25 }
},
{
"id": "atom-bmp-vs",
"category": "ivs-atom",
"input": { "text": "中︀A" },
"expected": { "atoms": [ "中︀", "A" ] }
},
{
"id": "atom-astral-ivs",
"category": "ivs-atom",
"input": { "text": "𠀀󠄀A" },
"expected": { "atoms": [ "𠀀󠄀", "A" ] }
},
{
"id": "atom-at-most-one-vs",
"category": "ivs-atom",
"input": { "text": "中︀︁" },
"expected": { "atoms": [ "中︀", "︁" ] }
},
{
"id": "atom-isolated-vs",
"category": "ivs-atom",
"input": { "text": "︀A" },
"expected": { "atoms": [ "︀", "A" ] }
},
{
"id": "jinze-before-stop",
"category": "jinze",
"input": { "left": "A", "right": "。" },
"expected": { "prohibitedBreak": true, "reason": "before-stop" }
},
{
"id": "jinze-before-close",
"category": "jinze",
"input": { "left": "中", "right": "」" },
"expected": { "prohibitedBreak": true, "reason": "before-close" }
},
{
"id": "jinze-after-open",
"category": "jinze",
"input": { "left": "「", "right": "A" },
"expected": { "prohibitedBreak": true, "reason": "after-open" }
},
{
"id": "jinze-ordinary-boundary",
"category": "jinze",
"input": { "left": "中", "right": "A" },
"expected": { "prohibitedBreak": false }
},
{
"id": "jinze-supplementary-ivs-before-stop",
"category": "jinze",
"input": { "left": "𠀀\uDB40\uDD00", "right": "。" },
"expected": {
"prohibitedBreak": true,
"reason": "before-stop",
"atoms": [ "𠀀\uDB40\uDD00", "。" ]
}
},
{
"id": "jinze-second-vs-before-stop",
"category": "jinze",
"input": { "left": "𠀀\uDB40\uDD00\uDB40\uDD01", "right": "。" },
"expected": {
"prohibitedBreak": true,
"reason": "before-stop",
"atoms": [ "𠀀\uDB40\uDD00", "\uDB40\uDD01", "。" ]
}
},
{
"id": "jinze-open-before-second-isolated-vs",
"category": "jinze",
"input": { "left": "「\uFE00", "right": "\uDB40\uDD00" },
"expected": {
"prohibitedBreak": true,
"reason": "after-open",
"atoms": [ "「\uFE00", "\uDB40\uDD00" ]
}
},
{
"id": "orphan-default-two",
"category": "orphan",
"input": { "text": "天地玄黃", "minimum": 2 },
"expected": { "meaningfulCjkCount": 4, "protectedSuffix": "玄黃" }
},
{
"id": "orphan-trailing-punctuation",
"category": "orphan",
"input": { "text": "天地。", "minimum": 2 },
"expected": { "meaningfulCjkCount": 2, "protectedSuffix": "天地。" }
},
{
"id": "orphan-insufficient-cjk",
"category": "orphan",
"input": { "text": "天。", "minimum": 2 },
"expected": { "meaningfulCjkCount": 1, "protectedSuffix": null }
},
{
"id": "orphan-supplementary-ivs",
"category": "orphan",
"input": { "text": "甲𠀀\uDB40\uDD00", "minimum": 2 },
"expected": {
"meaningfulCjkCount": 2,
"protectedSuffix": "甲𠀀\uDB40\uDD00",
"atoms": [ "甲", "𠀀\uDB40\uDD00" ]
}
},
{
"id": "orphan-second-vs",
"category": "orphan",
"input": { "text": "甲𠀀\uDB40\uDD00\uDB40\uDD01", "minimum": 2 },
"expected": {
"meaningfulCjkCount": 2,
"protectedSuffix": "甲𠀀\uDB40\uDD00\uDB40\uDD01",
"atoms": [ "甲", "𠀀\uDB40\uDD00", "\uDB40\uDD01" ]
}
},
{
"id": "orphan-isolated-vs-prefix",
"category": "orphan",
"input": { "text": "\uDB40\uDD00甲𠀀\uDB40\uDD01", "minimum": 2 },
"expected": {
"meaningfulCjkCount": 2,
"protectedSuffix": "甲𠀀\uDB40\uDD01",
"atoms": [ "\uDB40\uDD00", "甲", "𠀀\uDB40\uDD01" ]
}
}
]
}
-267
View File
@@ -1,267 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://juzhen.dev/schemas/golden-vectors.schema.json",
"title": "Juzhen shared rules golden vectors",
"type": "object",
"additionalProperties": false,
"required": [ "schemaVersion", "rulesetVersion", "vectors" ],
"properties": {
"$schema": { "type": "string" },
"schemaVersion": { "const": 1 },
"rulesetVersion": {
"type": "string",
"pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$"
},
"vectors": {
"type": "array",
"minItems": 20,
"items": {
"oneOf": [
{ "$ref": "#/$defs/languageVector" },
{ "$ref": "#/$defs/punctuationClassVector" },
{ "$ref": "#/$defs/punctuationStyleVector" },
{ "$ref": "#/$defs/punctuationAdjacencyVector" },
{ "$ref": "#/$defs/spacingVector" },
{ "$ref": "#/$defs/ivsAtomVector" },
{ "$ref": "#/$defs/jinzeVector" },
{ "$ref": "#/$defs/orphanVector" }
]
}
}
},
"$defs": {
"id": {
"type": "string",
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"
},
"punctuationClass": {
"enum": [ "stop", "pause", "open", "close", "middle", "liga" ]
},
"style": {
"enum": [ "quanjiao", "kaiming", "banjiao" ]
},
"atoms": {
"type": "array",
"minItems": 1,
"items": { "type": "string", "minLength": 1 }
},
"languageVector": {
"type": "object",
"additionalProperties": false,
"required": [ "id", "category", "input", "expected" ],
"properties": {
"id": { "$ref": "#/$defs/id" },
"category": { "const": "language-classification" },
"input": {
"type": "object",
"additionalProperties": false,
"required": [ "lang" ],
"properties": {
"lang": { "type": "string" }
}
},
"expected": {
"type": "object",
"additionalProperties": false,
"required": [ "langClass" ],
"properties": {
"langClass": { "enum": [ "zh-Hans", "zh-Hant", "ja", "other" ] }
}
}
}
},
"punctuationClassVector": {
"type": "object",
"additionalProperties": false,
"required": [ "id", "category", "input", "expected" ],
"properties": {
"id": { "$ref": "#/$defs/id" },
"category": { "const": "punctuation-classification" },
"input": {
"type": "object",
"additionalProperties": false,
"required": [ "character" ],
"properties": {
"character": { "type": "string", "minLength": 1 }
}
},
"expected": {
"type": "object",
"additionalProperties": false,
"required": [ "punctuationClass" ],
"properties": {
"punctuationClass": {
"oneOf": [ { "$ref": "#/$defs/punctuationClass" }, { "type": "null" } ]
}
}
}
}
},
"punctuationStyleVector": {
"type": "object",
"additionalProperties": false,
"required": [ "id", "category", "input", "expected" ],
"properties": {
"id": { "$ref": "#/$defs/id" },
"category": { "const": "punctuation-style" },
"input": {
"type": "object",
"additionalProperties": false,
"required": [ "style", "punctuationClass" ],
"properties": {
"style": { "$ref": "#/$defs/style" },
"punctuationClass": { "$ref": "#/$defs/punctuationClass" }
}
},
"expected": {
"type": "object",
"additionalProperties": false,
"required": [ "halfWidth" ],
"properties": {
"halfWidth": { "type": "boolean" }
}
}
}
},
"punctuationAdjacencyVector": {
"type": "object",
"additionalProperties": false,
"required": [ "id", "category", "input", "expected" ],
"properties": {
"id": { "$ref": "#/$defs/id" },
"category": { "const": "punctuation-adjacency" },
"input": {
"type": "object",
"additionalProperties": false,
"required": [ "left", "right" ],
"properties": {
"left": { "$ref": "#/$defs/punctuationClass" },
"right": { "$ref": "#/$defs/punctuationClass" }
}
},
"expected": {
"type": "object",
"additionalProperties": false,
"required": [ "squeezeLeft", "squeezeRight" ],
"properties": {
"squeezeLeft": { "type": "boolean" },
"squeezeRight": { "type": "boolean" }
}
}
}
},
"spacingVector": {
"type": "object",
"additionalProperties": false,
"required": [ "id", "category", "input", "expected" ],
"properties": {
"id": { "$ref": "#/$defs/id" },
"category": { "const": "spacing" },
"input": {
"type": "object",
"additionalProperties": false,
"required": [ "text" ],
"properties": {
"text": { "type": "string" }
}
},
"expected": {
"type": "object",
"additionalProperties": false,
"required": [ "managedGaps", "normalizedText" ],
"properties": {
"managedGaps": { "type": "integer", "minimum": 0 },
"normalizedText": { "type": "string" },
"gapEm": { "const": 0.25 },
"authorSpaceDisposition": {
"enum": [ "remove-and-use-managed-gap", "remove-without-gap" ]
}
}
}
}
},
"ivsAtomVector": {
"type": "object",
"additionalProperties": false,
"required": [ "id", "category", "input", "expected" ],
"properties": {
"id": { "$ref": "#/$defs/id" },
"category": { "const": "ivs-atom" },
"input": {
"type": "object",
"additionalProperties": false,
"required": [ "text" ],
"properties": {
"text": { "type": "string", "minLength": 1 }
}
},
"expected": {
"type": "object",
"additionalProperties": false,
"required": [ "atoms" ],
"properties": {
"atoms": { "$ref": "#/$defs/atoms" }
}
}
}
},
"jinzeVector": {
"type": "object",
"additionalProperties": false,
"required": [ "id", "category", "input", "expected" ],
"properties": {
"id": { "$ref": "#/$defs/id" },
"category": { "const": "jinze" },
"input": {
"type": "object",
"additionalProperties": false,
"required": [ "left", "right" ],
"properties": {
"left": { "type": "string", "minLength": 1 },
"right": { "type": "string", "minLength": 1 }
}
},
"expected": {
"type": "object",
"additionalProperties": false,
"required": [ "prohibitedBreak" ],
"properties": {
"prohibitedBreak": { "type": "boolean" },
"reason": { "enum": [ "before-stop", "before-close", "after-open" ] },
"atoms": { "$ref": "#/$defs/atoms" }
}
}
}
},
"orphanVector": {
"type": "object",
"additionalProperties": false,
"required": [ "id", "category", "input", "expected" ],
"properties": {
"id": { "$ref": "#/$defs/id" },
"category": { "const": "orphan" },
"input": {
"type": "object",
"additionalProperties": false,
"required": [ "text", "minimum" ],
"properties": {
"text": { "type": "string", "minLength": 1 },
"minimum": { "type": "integer", "minimum": 1 }
}
},
"expected": {
"type": "object",
"additionalProperties": false,
"required": [ "meaningfulCjkCount", "protectedSuffix" ],
"properties": {
"meaningfulCjkCount": { "type": "integer", "minimum": 0 },
"protectedSuffix": {
"oneOf": [ { "type": "string" }, { "type": "null" } ]
},
"atoms": { "$ref": "#/$defs/atoms" }
}
}
}
}
}
}
-255
View File
@@ -1,255 +0,0 @@
{
"$schema": "./juzhen-rules.schema.json",
"schemaVersion": 1,
"rulesetVersion": "1.0.1",
"unicodeProfile": {
"version": "17.0.0",
"coverage": "CJK Unified Ideographs through Extension J, compatibility ideographs, kana, bopomofo, radicals, and enclosed CJK characters"
},
"characterClasses": {
"representation": "unicode-code-point-ranges",
"cjk": [
{ "start": "2E80", "end": "2EFF" },
{ "start": "2F00", "end": "2FDF" },
{ "start": "3040", "end": "309F" },
{ "start": "30A0", "end": "30FA" },
{ "start": "30FC", "end": "30FF" },
{ "start": "3100", "end": "312F" },
{ "start": "3200", "end": "32FF" },
{ "start": "3400", "end": "4DBF" },
{ "start": "4E00", "end": "9FFF" },
{ "start": "F900", "end": "FAFF" },
{ "start": "20000", "end": "2A6DF" },
{ "start": "2A700", "end": "2B73F" },
{ "start": "2B740", "end": "2B81F" },
{ "start": "2B820", "end": "2CEAF" },
{ "start": "2CEB0", "end": "2EBEF" },
{ "start": "2EBF0", "end": "2EE5F" },
{ "start": "2F800", "end": "2FA1F" },
{ "start": "30000", "end": "3134F" },
{ "start": "31350", "end": "323AF" },
{ "start": "323B0", "end": "3347F" }
],
"ansAfterCjk": [
{ "start": "0024", "end": "0026" },
{ "start": "002A", "end": "002B" },
{ "codePoint": "002D" },
{ "start": "0030", "end": "0039" },
{ "codePoint": "003D" },
{ "codePoint": "0040" },
{ "start": "0041", "end": "005A" },
{ "codePoint": "005C" },
{ "codePoint": "005E" },
{ "start": "0061", "end": "007A" },
{ "start": "00A1", "end": "00B6" },
{ "start": "00B8", "end": "00FF" },
{ "start": "0100", "end": "024F" },
{ "start": "0370", "end": "03FF" },
{ "start": "0400", "end": "04FF" },
{ "start": "20A0", "end": "20BF" },
{ "start": "2150", "end": "21FF" },
{ "start": "2200", "end": "22FF" },
{ "start": "2700", "end": "27BF" }
],
"ansBeforeCjk": [
{ "start": "0024", "end": "0026" },
{ "start": "002A", "end": "002B" },
{ "codePoint": "002D" },
{ "start": "0030", "end": "0039" },
{ "codePoint": "003D" },
{ "start": "0041", "end": "005A" },
{ "codePoint": "005C" },
{ "codePoint": "005E" },
{ "start": "0061", "end": "007A" },
{ "start": "00A1", "end": "00B6" },
{ "start": "00B8", "end": "00FF" },
{ "start": "0100", "end": "024F" },
{ "start": "0370", "end": "03FF" },
{ "start": "0400", "end": "04FF" },
{ "start": "20A0", "end": "20BF" },
{ "start": "2150", "end": "21FF" },
{ "start": "2200", "end": "22FF" },
{ "start": "2700", "end": "27BF" }
],
"variationSelectors": [
{ "start": "FE00", "end": "FE0F" },
{ "start": "E0100", "end": "E01EF" }
],
"biaodian": {
"classificationOrder": [ "stop", "pause", "open", "close", "middle", "liga" ],
"classes": {
"stop": [
{ "codePoint": "3002" },
{ "codePoint": "FF01" },
{ "codePoint": "FF0E" },
{ "codePoint": "FF1F" }
],
"pause": [
{ "codePoint": "3001" },
{ "codePoint": "FF0C" },
{ "codePoint": "FF1A" },
{ "codePoint": "FF1B" }
],
"open": [
{ "codePoint": "2018" },
{ "codePoint": "201C" },
{ "codePoint": "3008" },
{ "codePoint": "300A" },
{ "codePoint": "300C" },
{ "codePoint": "300E" },
{ "codePoint": "3010" },
{ "codePoint": "3014" },
{ "codePoint": "3016" },
{ "codePoint": "FF08" },
{ "codePoint": "FF3B" },
{ "codePoint": "FF5B" }
],
"close": [
{ "codePoint": "2019" },
{ "codePoint": "201D" },
{ "codePoint": "3009" },
{ "codePoint": "300B" },
{ "codePoint": "300D" },
{ "codePoint": "300F" },
{ "codePoint": "3011" },
{ "codePoint": "3015" },
{ "codePoint": "3017" },
{ "codePoint": "FF09" },
{ "codePoint": "FF3D" },
{ "codePoint": "FF5D" }
],
"middle": [
{ "codePoint": "00B7" },
{ "codePoint": "2027" },
{ "codePoint": "30FB" }
],
"liga": [
{ "codePoint": "2014" },
{ "codePoint": "2026" }
]
}
},
"overrideSemantics": {
"mode": "append",
"westernTargets": [ "ansAfterCjk", "ansBeforeCjk" ],
"removalAllowed": false,
"reclassificationAllowed": false
}
},
"atoms": {
"unit": "base-code-point-plus-optional-variation-selector",
"maxVariationSelectors": 1,
"variationSelectorMustFollowBase": true,
"isolatedVariationSelector": "independent-atom"
},
"locales": {
"classes": [ "zh-Hans", "zh-Hant", "ja", "other" ],
"primaryLanguageBoundary": "exact-or-hyphen-subtag",
"classificationOrder": [
"ja-primary-language",
"zh-explicit-script",
"zh-traditional-region",
"zh-bare-default",
"zh-remaining",
"other"
],
"zh": {
"explicitTraditionalSubtag": "hant",
"explicitSimplifiedSubtag": "hans",
"traditionalRegions": [ "tw", "hk", "mo" ],
"bare": "zh-Hant",
"remaining": "zh-Hans"
},
"ja": "ja",
"defaultPolicies": {
"zh-Hans": "kaiming",
"zh-Hant": "quanjiao",
"ja": "quanjiao",
"other": "quanjiao"
},
"resolutionPriority": [
"element-attribute-or-style-policy-nearest-ancestor",
"locale-class-policy",
"global-style",
"default-policy"
]
},
"punctuationStyles": {
"baselineHalfWidthClasses": {
"quanjiao": [],
"kaiming": [ "pause", "open", "close" ],
"banjiao": [ "stop", "pause", "open", "close" ]
},
"adjacency": {
"rule": "squeeze-each-facing-blank-side-independently",
"sentenceBoundaryException": {
"left": "stop",
"right": "open",
"preserve": "left-right-blank"
}
}
},
"spacing": {
"gapEm": 0.25,
"directions": {
"cjkToAns": "ansAfterCjk",
"ansToCjk": "ansBeforeCjk",
"asymmetry": "at-sign-only-after-cjk"
},
"authorSpaceDisposition": {
"cjkAns": "remove-and-use-managed-gap",
"ansCjk": "remove-and-use-managed-gap",
"cjkCjk": "remove-without-gap",
"revertRestoresRemovedSpace": false
}
},
"lineBreaking": {
"prohibitedBreaks": {
"before": [ "close", "pause", "stop", "middle", "liga" ],
"after": [ "open" ]
},
"orphan": {
"defaultMeaningfulCjkChars": 2,
"counts": "cjk-atoms-not-classified-as-biaodian",
"excludes": [ "biaodian", "non-cjk" ]
}
},
"applicabilityLevels": {
"text": [ "spacing", "punctuation-classification-and-width" ],
"paragraph": [ "prohibited-breaks", "orphan-minimum", "long-word-hyphenation" ]
},
"semanticProcessing": {
"dependencies": [
{ "before": "atomize", "after": "classify" },
{ "before": "classify", "after": "derive-spacing" },
{ "before": "classify", "after": "derive-punctuation-width" },
{ "before": "classify", "after": "derive-break-constraints" },
{ "before": "classify", "after": "derive-orphan-constraint" }
],
"conditionalLineEdgeOutcomes": [
"punctuation-half-width-at-line-edge",
"remove-managed-gap-at-line-end"
]
},
"adapterBoundary": {
"platformNeutral": [
"unicode-character-classes",
"logical-atom-boundaries",
"locale-classification-and-policy",
"punctuation-width-semantics",
"spacing-decisions-and-distance",
"prohibited-break-relations",
"orphan-count",
"applicability-levels-and-semantic-dependencies"
],
"excludedAdapterMechanisms": [
"generated-node-or-element-representation",
"ownership-and-reversion-bookkeeping",
"rendering-primitive-selection",
"generated-line-break-hints",
"mutation-observation-or-rerun-lifecycle",
"layout-measurement",
"platform-specific-workarounds"
]
}
}
-389
View File
@@ -1,389 +0,0 @@
{
"$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" }
}
}
}
}
}
}
-198
View File
@@ -1,198 +0,0 @@
// 跨透明 Text 原子之來源搬移:保留作者 Element,僅搬 Text,回退按私有記錄還原。
import type { CharSpan, TextSpan } from "./finder.js";
import type { RenderContext } from "../types.js";
import { recordUnwrappedParent, resolveUnwrappedParent } from "./dom.js";
interface SourcePart
{
text: Text;
anchor: Comment;
innerAnchor: Comment;
parent: Node;
previous: Node | null;
next: Node | null;
}
interface AtomMove
{
pass: string;
outer: HTMLElement;
inner: HTMLElement;
parent: Node;
previous: Node | null;
next: Node | null;
root: Element;
ancestors: Element[];
parts: SourcePart[];
}
const MOVES = new WeakMap<object, Set<AtomMove>>();
function isolate(part: TextSpan): Text
{
let text = part.textNode;
if (part.start > 0) { text = text.splitText(part.start); }
if (text.length > part.end - part.start) { text.splitText(part.end - part.start); }
return text;
}
function insertionPoint(part: SourcePart): Node | null | undefined
{
if (part.anchor.parentNode === part.parent) { return part.anchor; }
if (part.next?.parentNode === part.parent) { return part.next; }
if (part.next !== null) { return undefined; }
if (part.previous?.parentNode === part.parent)
{
return part.previous === part.parent.lastChild ? null : undefined;
}
if (part.previous === null && part.next === null && part.anchor.parentNode !== null
&& part.anchor.parentNode !== part.parent && !part.parent.hasChildNodes()) { return null; }
return undefined; // 原父被作者取代時,不能把過時文本復活。
}
function restorePart(part: SourcePart, inner: HTMLElement): boolean
{
if (part.text.parentNode === part.parent) { return true; }
if (part.text.parentNode !== inner) { return false; }
const before = insertionPoint(part);
if (before === undefined) { return false; }
part.parent.insertBefore(part.text, before);
return true;
}
/** span 按逆文件序處理,來源 offset 不受後方切分影響。outer/inner 須為本 pass 新產物。 */
export function wrapCrossAtom(
span: CharSpan, outer: HTMLElement, inner: HTMLElement, pass: string, ctx: RenderContext,
): void
{
const first = isolate(span.parts[0]!);
const parent = first.parentNode;
if (!parent) { return; }
const move: AtomMove = {
pass, outer, inner, parent, previous: first.previousSibling, next: first.nextSibling,
root: ctx.root, ancestors: [], parts: [],
};
for (let p = ctx.root.parentElement; p; p = p.parentElement) { move.ancestors.push(p); }
outer.appendChild(inner);
parent.insertBefore(outer, first);
inner.appendChild(first);
try
{
for (const source of span.parts.slice(1))
{
const text = isolate(source);
const sourceParent = text.parentNode;
if (!sourceParent) { throw new Error("juzhen: 跨節點標點來源已脫離。"); }
const anchor = text.ownerDocument.createComment("jz-" + pass + "-atom-anchor");
// 其他 pass 的 normalize 會遞迴合併 Text;載體錨點保留搬移節點身份。
const innerAnchor = text.ownerDocument.createComment("jz-" + pass + "-atom-part");
const part = { text, anchor, innerAnchor, parent: sourceParent, previous: text.previousSibling, next: text.nextSibling };
move.parts.push(part);
sourceParent.insertBefore(anchor, text);
inner.append(innerAnchor, text);
}
let moves = MOVES.get(ctx.ownerToken);
if (!moves) { moves = new Set(); MOVES.set(ctx.ownerToken, moves); }
moves.add(move);
}
catch (error)
{
for (const part of move.parts)
{
restorePart(part, inner);
part.anchor.remove();
part.innerAnchor.remove();
}
while (inner.firstChild) { parent.insertBefore(inner.firstChild, outer); }
outer.remove();
ctx.ownedElements.delete(inner);
ctx.ownedElements.delete(outer);
throw error;
}
}
function ownedWrap(el: HTMLElement, tag: string, move: AtomMove, ctx: RenderContext): boolean
{
return ctx.ownedElements.has(el) && el.nodeName === tag
&& el.getAttribute("data-jz") === move.pass && el.getAttribute("data-jz-kind") === "wrap";
}
function placeOuter(move: AtomMove): boolean
{
if (move.outer.parentNode === move.parent) { return true; }
let before: Node | null = null;
if (move.next?.parentNode === move.parent) { before = move.next; }
else if (move.next !== null) { return false; }
else if (move.previous?.parentNode === move.parent)
{
if (move.previous !== move.parent.lastChild) { return false; }
}
else if (move.parent.hasChildNodes()) { return false; }
move.parent.insertBefore(move.outer, before);
return true;
}
/** 局部回退也涵蓋原子之另一端;只還原私有記錄,不採信可偽造的作者屬性。 */
export function revertCrossAtoms(pass: string, ctx: RenderContext): Set<Node>
{
const touched = new Set<Node>();
const moves = MOVES.get(ctx.ownerToken);
if (!moves) { return touched; }
const within = (node: Node): boolean => node === ctx.root || ctx.root.contains(node);
for (const move of Array.from(moves))
{
if (move.pass !== pass) { continue; }
if (!(within(move.root) || within(move.outer) || within(move.parent)
|| move.ancestors.includes(ctx.root)
|| move.parts.some((part) => within(part.parent) || within(part.anchor)))) { continue; }
const parent = resolveUnwrappedParent(move.parent);
const sources = move.parts.map((part) => resolveUnwrappedParent(part.parent));
if (!parent || sources.some((source) => source === null))
{
for (const part of move.parts) { part.anchor.remove(); part.innerAnchor.remove(); }
moves.delete(move);
continue; // 不可信來源鏈不搬文字;仍有效的載體由正常 revertPass 原地解包。
}
move.parent = parent;
move.parts.forEach((part, index) => { part.parent = sources[index]!; });
// 作者克隆/刪除載體或其來源祖先後,舊記錄不能復活已移除的文字。
// 整個 render 根脫離 document 仍可回退;真正移至其他連接位置的載體亦保留。
const live = (node: Node): boolean => move.root.contains(node) || node.isConnected;
const validCarrier = ownedWrap(move.outer, "JZ-CHAR", move, ctx)
&& ownedWrap(move.inner, "JZ-INNER", move, ctx)
&& move.inner.parentNode === move.outer && live(move.outer);
if (!validCarrier)
{
for (const part of move.parts) { part.anchor.remove(); part.innerAnchor.remove(); }
ctx.ownedElements.delete(move.inner);
if (!live(move.outer)) { ctx.ownedElements.delete(move.outer); }
moves.delete(move);
continue;
}
if (validCarrier)
{
for (const part of move.parts)
{
if (live(part.parent) && restorePart(part, move.inner)) { touched.add(part.parent); }
else if (part.text.parentNode === move.inner) { part.text.remove(); }
}
}
for (const part of move.parts) { part.anchor.remove(); part.innerAnchor.remove(); }
if (ownedWrap(move.outer, "JZ-CHAR", move, ctx) && placeOuter(move))
{
if (move.inner.parentNode === move.outer && ownedWrap(move.inner, "JZ-INNER", move, ctx))
{
while (move.inner.firstChild) { move.outer.insertBefore(move.inner.firstChild, move.inner); }
move.inner.remove();
recordUnwrappedParent(move.inner, move.outer, ctx.ownedElements);
ctx.ownedElements.delete(move.inner);
}
while (move.outer.firstChild) { move.parent.insertBefore(move.outer.firstChild, move.outer); }
move.outer.remove();
recordUnwrappedParent(move.outer, move.parent, ctx.ownedElements);
ctx.ownedElements.delete(move.outer);
touched.add(move.parent);
}
moves.delete(move);
}
if (!moves.size) { MOVES.delete(ctx.ownerToken); }
return touched;
}
-387
View File
@@ -1,387 +0,0 @@
// jz-* 元素工廠與還原(架構 §4.4/§5)。
//
// 所有注入結構皆帶 data-jz="<pass>" 標記(冪等 + 可定址還原,I6),並以
// data-jz-kind 區分 wrap 與 splitoff。視覺尺寸一律在 CSS(I2);本模組只造結構。
/** 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";
// createJz 目前僅有 wrap(包裝原內容,還原時解包)生產者。
// data-jz-kind 另有 "splitoff"(克隆切分之邊界詞元),由 core/split.ts 直接以
// setAttribute 設定、不經 createJz;revertPass 於 wrap 全解包後之第二階段併回(見下)。
type JzKind = "wrap";
export interface CreateOptions
{
/** 追加之 class(空白分隔或陣列)。 */
classes?: string | string[];
/** 文本內容。 */
text?: string;
}
const KIND_ATTR = "data-jz-kind";
const PASS_ATTR = "data-jz";
const WRAP_TAGS = new Map<string, Set<string>>([
[ "spacing", new Set([ "JZ-HWS" ]) ],
[ "jiya", new Set([ "JZ-CHAR", "JZ-INNER" ]) ],
[ "jinze", new Set([ "JZ-JINZE" ]) ],
[ "longWord", new Set([ "SPAN" ]) ],
[ "orphan", new Set([ "JZ-ORPHAN" ]) ],
]);
const SPLITOFF_TAGS = new Set([
"STRONG", "EM", "B", "I", "U", "S", "SPAN",
"SMALL", "INS", "DEL", "SUB", "SUP",
]);
interface AttributeWriter
{
owner: object;
value: string;
}
interface AttributeState
{
original: string | null;
writers: AttributeWriter[];
}
const ATTRIBUTE_WRITERS = new WeakMap<Element, Map<string, AttributeState>>();
const OWNER_ATTRIBUTES = new WeakMap<object, Map<string, Set<Element>>>();
// 僅本模組已驗證所有權的解包/合併可留下來源父重定向;作者外改不登記。
const UNWRAPPED_PARENTS = new WeakMap<Node, Node>();
interface SplitoffSource
{
source: Element;
pass: string;
originalPass: string | null;
originalKind: string | null;
}
const SPLITOFF_SOURCES = new WeakMap<Element, SplitoffSource>();
/** 切分時記錄真實來源身份;須於 clone 的排版標記覆寫前呼叫。 */
export function registerSplitoffSource(
clone: Element, source: Element, pass: string, ownedElements: WeakSet<Element>,
): void
{
if (!ownedElements.has(clone) || !SPLITOFF_TAGS.has(clone.nodeName)
|| clone.nodeName !== source.nodeName) { return; }
const earlier = SPLITOFF_SOURCES.get(source);
const inherited = earlier && source.getAttribute(PASS_ATTR) === earlier.pass
&& source.getAttribute(KIND_ATTR) === "splitoff" ? earlier : null;
SPLITOFF_SOURCES.set(clone, {
source, pass,
originalPass: inherited ? inherited.originalPass : clone.getAttribute(PASS_ATTR),
originalKind: inherited ? inherited.originalKind : clone.getAttribute(KIND_ATTR),
});
}
function sameSplitAttributes(a: Element, b: Element): boolean
{
const attrs = (el: Element): string[] => el.getAttributeNames()
.filter((name) => name !== PASS_ATTR && name !== KIND_ATTR);
const names = attrs(a);
return names.length === attrs(b).length
&& names.every((name) => a.getAttribute(name) === b.getAttribute(name));
}
/** 多實例可再次切分同一來源;只沿仍有效的私有切分/合併鏈找共同原元素。 */
function splitOrigin(node: Node): Node | null
{
let current = resolveUnwrappedParent(node);
const visited = new Set<Node>();
while (current)
{
if (visited.has(current)) { return null; }
visited.add(current);
if (current.nodeType !== 1) { return current; }
const el = current as Element;
const record = SPLITOFF_SOURCES.get(el);
if (!record || el.getAttribute(PASS_ATTR) !== record.pass
|| el.getAttribute(KIND_ATTR) !== "splitoff") { return current; }
current = resolveUnwrappedParent(record.source);
}
return null;
}
/** 完成受控解包後登記;只接受真實、已移除且空的受管產物。 */
export function recordUnwrappedParent(el: Element, parent: Node, ownedElements: WeakSet<Element>): void
{
const pass = el.getAttribute(PASS_ATTR);
if (ownedElements.has(el) && pass && knownProduct(pass, el.getAttribute(KIND_ATTR), el)
&& !el.parentNode && !el.hasChildNodes()) { UNWRAPPED_PARENTS.set(el, parent); }
}
/** 其他 pass/實例合法撤回來源包裹後,搬移記錄沿受控解包鏈查詢現父。 */
export function resolveUnwrappedParent(parent: Node): Node | null
{
let current = parent;
const visited = new Set<Node>();
for (;;)
{
if (visited.has(current)) { return null; } // 過時來源鏈不能循環,亦不能任選環中一點。
visited.add(current);
const next = UNWRAPPED_PARENTS.get(current);
if (!next) { return current; }
if (current.parentNode || current.hasChildNodes())
{
// 被撤回的空殼已重新作作者容器,原解包位置不再代表它的當前身份。
UNWRAPPED_PARENTS.delete(current);
return current;
}
current = next;
}
}
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
{
if (typeof document === "undefined")
{
throw new Error("juzhen: 無 document(需於瀏覽器或 jsdom 環境執行)。");
}
return document;
}
/** 造一個帶 pass 標記之 jz-* 元素。 */
export function createJz(
ownedElements: WeakSet<Element>,
tag: JzTag,
pass: string,
kind: JzKind,
opts: CreateOptions = {},
): HTMLElement
{
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 !== undefined) { el.textContent = opts.text; }
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 定址,逆文件序處理(先內層後
* 外層),wrap 解包,最後 normalize 受影響父節點合併文本。
*
* splitoff(克隆切分之邊界詞元,見 core/split.ts)須在 wrap 全部解包**之後**才併回:
* 否則其前兄弟可能仍是 wrap 殼(如 jz-jinze)而非原元素。故主迴圈跳過 splitoff,於
* 解包後第二階段以文件序逐一併回前兄弟(同標籤元素),還原為單一元素(I6 一致)。
*/
export function revertPass(
pass: string,
root: ParentNode,
ownedElements: WeakSet<Element>,
): void
{
const nodes = Array.from(
root.querySelectorAll<HTMLElement>('[' + PASS_ATTR + '="' + pass + '"]'),
).filter((el) => ownedElements.has(el));
// 逆序:子元素先於父元素處理,避免解包父後子引用失效。
const touched = new Set<Node>();
for (let i = nodes.length - 1; i >= 0; i -= 1)
{
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);
// wrap:把子節點搬出後移除外殼。
while (el.firstChild)
{
parent.insertBefore(el.firstChild, el);
}
parent.removeChild(el);
recordUnwrappedParent(el, parent, ownedElements);
ownedElements.delete(el);
}
// 第二階段:splitoff 併回(此時 wrap 已全解包,前兄弟即原元素)。文件序,使同一
// 原元素之多個 splitoff 依序併入。
const splitoffs = Array.from(
root.querySelectorAll<HTMLElement>(
'[' + PASS_ATTR + '="' + pass + '"][' + KIND_ATTR + '="splitoff"]',
),
).filter((el) => ownedElements.has(el) && knownProduct(pass, "splitoff", el));
for (const so of splitoffs)
{
const parent = so.parentNode;
if (!parent) { continue; }
touched.add(parent);
const record = SPLITOFF_SOURCES.get(so);
if (!record || record.pass !== pass) { continue; }
const source = resolveUnwrappedParent(record.source);
const prev = so.previousSibling;
const origin = source ? splitOrigin(source) : null;
if (origin && prev?.nodeType === 1 && splitOrigin(prev) === origin
&& (prev as Element).nodeName === so.nodeName
&& sameSplitAttributes(so, prev as Element))
{
// 併入前兄弟(同標籤原元素)尾部。
while (so.firstChild) { prev.appendChild(so.firstChild); }
parent.removeChild(so);
recordUnwrappedParent(so, prev, ownedElements);
}
else
{
// 外部插入/替換/改樣式後,不併入任意同標籤元素,也不丟失片段當前樣式。
// 保留原地元素及內容,僅撤回本次切分標記;不能跨新內容重排或復活舊來源。
for (const [ name, value ] of [
[ PASS_ATTR, record.originalPass ], [ KIND_ATTR, record.originalKind ],
] as const)
{
if (value === null) { so.removeAttribute(name); }
else { so.setAttribute(name, value); }
}
}
SPLITOFF_SOURCES.delete(so);
ownedElements.delete(so);
}
for (const p of touched)
{
if ((p as Element).normalize) { (p as Element).normalize(); }
}
}
const BD_CLASSES = [ "bd-open", "bd-close", "bd-pause", "bd-stop", "bd-middle", "bd-liga" ];
/** 讀取 jz-char 之 bd-* 子類(無則 null)。 */
export function bdClassOf(el: Element): string | null
{
for (const c of BD_CLASSES)
{
if (el.classList.contains(c)) { return c; }
}
return null;
}
/** 該元素是否為某/任一 pass 之 jz 產物。 */
export function isJz(node: Node | null, pass?: string): boolean
{
if (!node || node.nodeType !== 1) { return false; }
const el = node as Element;
if (!el.hasAttribute(PASS_ATTR)) { return false; }
return pass === undefined || el.getAttribute(PASS_ATTR) === pass;
}
-708
View File
@@ -1,708 +0,0 @@
// 文本遍歷引擎(架構 §4.1,取代 v1 之內聯 DFS / Fibre.js 角色)。
//
// 所有 pass 之共同地基:avoid 名單、scope.include、邏輯文本 run 收集、pill 鄰字
// 查找、per-node 功能閘(featureEnabledFor)。finder 不知具體排版規則,只提供
// 遍歷與安全變更原語。
//
// 行內元素對排版有且僅有**三種口徑**(§4.1):
// 透明(styleInlines + jz-* wrap)— 穿越、run 延續、可克隆切分;
// 隔離(isolateSelector,預設 mark)— 邊界斷 run/斷相鄰/不可切分、兩端補隙,
// 內部照常處理(自成脈絡);
// 整體(pillSelector)— 原子單位:內部對一切 pass 不可見,邊界同隔離。
// avoid(skip 名單/屬性/選擇器)疊加其上:自身即邊界、內外皆不處理。
//
// **未知行內標籤**(不在 styleInlines、非 pill/isolate/avoid/block,如自訂元素/
// web component)採**嚴格白名單**:一律當隔離式邊界(斷 run/斷相鄰、內部自成脈絡),
// 透明須顯式登記 styleInlines。**邏輯文本流**之遍歷原語(collectRuns/adjacentLogicalChar
// /jiya 之 jiyaAdjacency)共用 `isTransparent` 此**單一判定**。另兩層各有正當之不同邊界、
// 刻意不併入:**結構切分安全**(core/split 之 canSplit——更窄白名單,<a>/ABBR 等不可切)
// 與**物理版面**(typeset/lineedge——依 getClientRects 之物理相鄰,須跨 isolate 找真實鄰接)。
import type { FeatureLevel, ResolvedOptions } from "../types.js";
import {
codePointAtUtf16, isVariationSelector, previousCodePointStart,
} from "./unicode.js";
/** 邏輯 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: CharSpan[];
}
/** pill 鄰接之邏輯鄰字(base+optional-VS)。 */
export interface AdjacentChar
{
textNode: Text;
offset: number;
ch: string;
parts: TextSpan[];
}
/** 行內元素對排版之口徑分類(§4.1)——恰屬其一,優先序見 Finder.category。 */
export type InlineCategory =
| "avoid" | "pill" | "isolate" | "block" | "transparent" | "opaque";
const SVG_NS = "http://www.w3.org/2000/svg";
function isHighSurrogate(ch: string): boolean
{
return ch.length === 1 && ch.charCodeAt(0) >= 0xD800 && ch.charCodeAt(0) <= 0xDBFF;
}
function isLowSurrogate(ch: string): boolean
{
return ch.length === 1 && ch.charCodeAt(0) >= 0xDC00 && ch.charCodeAt(0) <= 0xDFFF;
}
function appendParts(parts: TextSpan[], extra: readonly TextSpan[]): void
{
for (const part of extra)
{
const previous = parts[parts.length - 1];
if (previous?.textNode === part.textNode && previous.end === part.start) { previous.end = part.end; }
else { parts.push({ ...part }); }
}
}
function acceptsVariationSelector(atom: CharSpan): boolean
{
return !isVariationSelector(codePointAtUtf16(atom.text, 0))
&& !isVariationSelector(codePointAtUtf16(atom.text, previousCodePointStart(atom.text, atom.text.length)));
}
export class Finder
{
private readonly opts: ResolvedOptions;
private readonly styleSet: Set<string>;
private readonly blockSet: Set<string>;
private readonly skipSet: Set<string>;
private readonly pillSelector: string;
private readonly isolateSelector: string;
private readonly avoidSelector: string | null;
private readonly includeSelector: string | null;
private readonly skipAttr: string | null;
private readonly userIsSkipped: ((node: Node) => boolean) | null;
private readonly levelTextSelector: string | null;
private readonly featureCache: WeakMap<Element, Record<string, boolean>>;
private readonly levelCache: WeakMap<Element, FeatureLevel>;
private readonly categoryCache: WeakMap<Element, InlineCategory>;
// 祖先游走記憶化(S3 延伸/S4):ancestorOrSelfPill/…Avoided 之逐元素結果,使 insidePill
// /isAvoided 之「逐文本節點沿祖先鏈」由 O(文本節點×depth) 收斂為 O(元素數)——兄弟節點共用
// 祖先鏈快取。Finder 隨實例持久化;render/revert 與 observer 更新須失效受影響子樹。
private readonly pillAncestorCache: WeakMap<Element, boolean>;
private readonly avoidAncestorCache: WeakMap<Element, boolean>;
constructor(opts: ResolvedOptions)
{
this.opts = opts;
this.styleSet = opts.finder.styleInlines;
this.blockSet = opts.finder.blockTags;
this.skipSet = opts.finder.skipTags;
this.pillSelector = opts.finder.pillSelector;
this.isolateSelector = opts.finder.isolateSelector;
this.skipAttr = opts.finder.skipAttribute;
this.userIsSkipped = opts.finder.isSkipped;
this.levelTextSelector = opts.levelText;
this.avoidSelector = opts.scope.avoid;
this.includeSelector = opts.scope.include;
this.featureCache = new WeakMap();
this.levelCache = new WeakMap();
this.categoryCache = new WeakMap();
this.pillAncestorCache = new WeakMap();
this.avoidAncestorCache = new WeakMap();
}
/**
* 清除 el 及其**整棵子樹**之元素鍵快取(C1 失效原語)。持久化 Finder(跨 render 復用快取)
* 下,元素之屬性或內容變化會令其分類(category)/級別(level)/功能(feature)/祖先游走
* (pill/avoid ancestor)快取失效。**須清整棵子樹、非僅該元素**:level/feature/祖先游走
* 之結果依賴祖先鏈,某祖先之 data-jz-level/data-juzhen/class 變化會令其**後代**之快取值變脏,
* 即便後代自身未動。querySelectorAll('*') 走子樹(O(子樹),native、無遞迴棧風險)。移除之元素
* 由 WeakMap 自動回收,無需顯式清。 */
invalidate(el: Element): void
{
this.forget(el);
el.querySelectorAll("*").forEach((c) => this.forget(c));
}
private forget(e: Element): void
{
this.categoryCache.delete(e);
this.pillAncestorCache.delete(e);
this.avoidAncestorCache.delete(e);
this.levelCache.delete(e);
this.featureCache.delete(e);
}
/** 該元素是否匹配 pill 選擇器(三分類之「整體」:code/kbd/行內公式等)。 */
pillMatches(el: Element): boolean
{
return !!(el.matches && this.pillSelector && el.matches(this.pillSelector));
}
/** 該元素是否匹配 isolate 選擇器(三分類之「隔離」:帶盒樣式之行內,如背景色
* <mark>)。邊界口徑同 pill(斷 run/斷相鄰/不可切分/兩端補隙),內部照常處理。 */
isolateMatches(el: Element): boolean
{
return !!(el.matches && this.isolateSelector && el.matches(this.isolateSelector));
}
/** 節點是否位於 pill **內部**(僅查祖先,不含自身)。pill 為整體元素:內部對
* 一切 pass 不可見——不 charify、不收 run、不切分、不於其內插間隙(下游問題六:
* .katex 內部曾被 jiya/spacing 直接變更)。 */
insidePill(node: Node): boolean
{
const p = node.parentElement;
return p ? this.ancestorOrSelfPill(p) : false;
}
/** el 或其祖先鏈任一為 pill(記憶化)。沿祖先鏈迭代(非遞迴,任意深度棧安全)至最近之
* 已快取/pill 命中/鏈頂,途中非 pill 元素入棧、以斷點之值一次回填——每元素恰算一次,
* 兄弟共用祖先鏈快取。insidePill 為 pillMatches 之最大來源(S3 延伸)。 */
private ancestorOrSelfPill(el: Element): boolean
{
const chain: Element[] = [];
let cur: Element | null = el;
let base = false;
while (cur)
{
const c = this.pillAncestorCache.get(cur);
if (c !== undefined) { base = c; break; }
if (this.pillMatches(cur)) { this.pillAncestorCache.set(cur, true); base = true; break; }
chain.push(cur); // pillMatches 為 false,其值=斷點之 base
cur = cur.parentElement;
}
for (const e of chain) { this.pillAncestorCache.set(e, base); }
return base;
}
/**
* 行內元素分類(§4.1)——一元素對排版之口徑,**恰屬其一**。**優先序**(依序短路,即多
* 選擇器同命中時之定案規則):`avoid > pill > isolate > block > transparent > opaque`
* (opaque=未在 styleInlines 之未知行內標籤,嚴格白名單下當隔離式邊界)。故:同時命中
* pill 與 isolate → **pill**(pill 覆蓋 isolate);帶 data-jz-skip 之 pill/isolate → **avoid**
* (自身排除最高)。**邏輯文本流之遍歷原語**(collectRuns/jiyaAdjacency/adjacentLogicalChar
* 之 isTransparent)皆據此**單一分類**分派;core/split 之 canSplit = SPLITTABLE 白名單 ∩
* transparent(結構切分為獨立層,故更窄)。
*
* **記憶化**(S3):分類含至多三次 el.matches()(pill/isolate/avoid 選擇器),且被
* collectRuns/jiyaAdjacency/jinze/orphan/split 各 pass 逐元素重算——以 categoryCache
* 記憶化砍除跨 pass 重複。安全性:單次 render 內一元素之分類**恆定**(class/skip 屬性/
* nodeName 皆不因 render 變更;jz-* 包裹為新建元素、按 nodeName 分類亦恆定),且 Finder
* 隨實例持久化,跨 render/revert 復用;屬性或位置變化由 invalidateSubtree 清除受影響
* 子樹,不能沿用舊祖先鏈結果。
*/
category(el: Element): InlineCategory
{
const cached = this.categoryCache.get(el);
if (cached) { return cached; }
const result = this.computeCategory(el);
this.categoryCache.set(el, result);
return result;
}
private computeCategory(el: Element): InlineCategory
{
if (this.avoidsSelf(el)) { return "avoid"; }
if (this.pillMatches(el)) { return "pill"; }
if (this.isolateMatches(el)) { return "isolate"; }
if (this.blockSet.has(el.nodeName)) { return "block"; }
if (this.isTransparentTag(el)) { return "transparent"; }
return "opaque";
}
/** category 之透明判定末步:styleInlines 標籤,或聚珍自身之 wrap 類 jz-*(jz-hws 間隙、
* jz-jinze 禁則群組、**jz-char/jz-inner 標點原子**)。前四步已排除 avoid/pill/isolate/block。
* jz-char/jz-inner 必須列入:否則 adjacentLogicalChar 之 descend 遇標點原子既不進入亦不
* 終止、而**跳過**它續找更前之字(`汉字。<code>` 綁定後 pill 左鄰字應為「。」卻誤取「字」
* 補 jz-hws);jz-char 僅含標點(非 pillNeighbor),透明後 descend 回傳標點、不補隙。 */
private isTransparentTag(el: Element): boolean
{
const name = el.nodeName;
return this.styleSet.has(name)
|| name === "JZ-HWS" || name === "JZ-JINZE"
|| name === "JZ-CHAR" || name === "JZ-INNER";
}
/** el 是否為邏輯文本流可透明穿越者(= category "transparent")。 */
isTransparent(el: Element): boolean
{
return this.category(el) === "transparent";
}
/** 該元素**自身**是否為 avoid 邊界。avoid 之七種來源,依序短路判定、任一命中即是:
* ① isSkipped 謂詞(消費端函數) ② skipTags 標籤名單(PRE/SAMP/SCRIPT…,可覆寫)
* ③ SVG 命名空間 ④ contentEditable
* ⑤ data-jz-skip 屬性(內建) ⑥ skipAttribute(消費端指定屬性名)
* ⑦ scope.avoid 選擇器
* **此為自身判定**,供遍歷原語(collectRuns/jiyaAdjacency/canSplit/isTransparent)於
* 下探時逐元素自判(祖先由遞迴涵蓋)。**一次性**問「某節點是否被 avoid(自身或祖先)」
* 請改用 isAvoided(單一組合入口),勿只用本方法而漏判祖先、或只用 isAvoided 而漏判自身
* (下游問題六/階段一 #2:漏一半致 avoid 對自身形同虛設)。 */
avoidsSelf(el: Element): boolean
{
if (this.userIsSkipped && this.userIsSkipped(el)) { return true; }
if (this.skipSet.has(el.nodeName)) { return true; }
if ((el as { namespaceURI?: string }).namespaceURI === SVG_NS) { return true; }
if ((el as HTMLElement).isContentEditable) { return true; }
if (el.hasAttribute("data-jz-skip")) { return true; }
if (this.skipAttr && el.hasAttribute(this.skipAttr)) { return true; }
if (this.avoidSelector && el.matches && el.matches(this.avoidSelector))
{
return true;
}
return false;
}
/** 某節點是否被 avoid——**自身即邊界**(元素經 avoidsSelf 之七源;非元素節點僅判
* isSkipped)**或**祖先鏈上任一為 avoid 邊界。此為「是否 avoid」之**單一組合入口(安全
* 預設)**:一次性檢查用此,免除「avoidsSelf ‖ isAvoided」易漏一半之誤用。遍歷原語逐
* 元素下探時則用 avoidsSelf(僅自身,祖先由遞迴涵蓋)。 */
isAvoided(node: Node): boolean
{
if (node.nodeType === 1) { return this.ancestorOrSelfAvoided(node as Element); }
// 非元素節點:自身僅判 isSkipped 謂詞(無標籤/屬性),祖先走元素鏈(記憶化)。
if (this.userIsSkipped && this.userIsSkipped(node)) { return true; }
const p = node.parentElement;
return p ? this.ancestorOrSelfAvoided(p) : false;
}
/** el 或其祖先鏈任一為 avoid 邊界(記憶化,迭代回填,同 ancestorOrSelfPill)。 */
private ancestorOrSelfAvoided(el: Element): boolean
{
const chain: Element[] = [];
let cur: Element | null = el;
let base = false;
while (cur)
{
const c = this.avoidAncestorCache.get(cur);
if (c !== undefined) { base = c; break; }
if (this.avoidsSelf(cur)) { this.avoidAncestorCache.set(cur, true); base = true; break; }
chain.push(cur);
cur = cur.parentElement;
}
for (const e of chain) { this.avoidAncestorCache.set(e, base); }
return base;
}
/** scope.include 啟用判定:未設則恆 true;設則須有命中祖先(opt-in)。 */
inScope(node: Node): boolean
{
if (!this.includeSelector) { return true; }
const el = node.nodeType === 1
? (node as Element)
: node.parentElement;
if (!el) { return false; }
return !!(el.closest && el.closest(this.includeSelector));
}
/** 走訪 root 下所有「可處理」文本節點(已過濾 avoid / pill 內部 / 非作用域)。 */
eachTextNode(root: Element, cb: (node: Text) => void): void
{
const ownerDoc = root.ownerDocument || (typeof document !== "undefined" ? document : null);
if (!ownerDoc) { return; }
const walker = ownerDoc.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const nodes: Text[] = [];
let n = walker.nextNode();
while (n)
{
const t = n as Text;
if (!this.isAvoided(t) && !this.insidePill(t) && this.inScope(t))
{
nodes.push(t);
}
n = walker.nextNode();
}
// 先收集後回呼:容許 cb 變更 DOM 而不擾動走訪。
for (const node of nodes) { cb(node); }
}
/** 走訪 root 下可處理之 block(含 root 本身若為 block);pill/avoid 內部不可見。 */
eachBlock(root: Element, cb: (block: Element) => void): void
{
const sel = Array.from(this.blockSet)
.map((t) => t.toLowerCase())
.join(",");
const blocks: Element[] = [];
if (sel)
{
root.querySelectorAll(sel).forEach((el) => blocks.push(el));
}
if (root.nodeType === 1 && this.blockSet.has(root.nodeName))
{
blocks.push(root);
}
for (const b of blocks)
{
if (this.isAvoided(b) || this.insidePill(b) || this.pillMatches(b)) { continue; }
if (this.includeSelector && !(b.closest && b.closest(this.includeSelector)))
{
continue;
}
cb(b);
}
}
/**
* 收集 block 之邏輯文本 run(架構 §4.1,I7):透明行內(isTransparent:styleInlines
* + wrap 類 jz-*)穿越、run 延續;block/pill/avoid 中斷 run;isolate 與**未知行內
* 標籤**(嚴格白名單)中斷 run 且內部自成獨立 run。預設至少兩原子;includeSingletons
* 供段落邊界查詢保留單原子 run,不改間隙處理之預設。
*/
collectRuns(block: Element, includeSingletons = false, feature?: string): LogicalRun[]
{
const runs: LogicalRun[] = [];
let curText = "";
let curMap: CharSpan[] = [];
const flush = (): void =>
{
if (curMap.length >= (includeSingletons ? 1 : 2)) { runs.push({ text: curText, map: curMap }); }
curText = "";
curMap = [];
};
const visit = (node: Node | null): void =>
{
if (!node) { return; }
if (node.nodeType === 3)
{
const t = node as Text;
if (this.isAvoided(t) || !this.inScope(t)
|| (feature && !this.featureEnabledFor(t, feature))) { flush(); return; }
const data = t.data;
for (let i = 0; i < data.length;)
{
const ch = codePointAtUtf16(data, i);
curText += ch;
const part = { textNode: t, start: i, end: i + ch.length };
const previous = curMap[curMap.length - 1];
if (previous && isHighSurrogate(previous.text) && isLowSurrogate(ch))
{
// DOM Text 邊界可能切開 surrogate pair;先合成完整碼點,才能判 VS。
previous.text += ch;
appendParts(previous.parts, [ part ]);
const base = curMap[curMap.length - 2];
if (isVariationSelector(previous.text) && base && acceptsVariationSelector(base))
{
base.text += previous.text;
appendParts(base.parts, previous.parts);
curMap.pop();
}
}
else if (isVariationSelector(ch) && previous && acceptsVariationSelector(previous))
{
// 透明 inline 可令 base 與 VS 分居不同 Text 節點。map 必須與
// 拼接 run 之 variation-sequence 分組同構,否則後續刪除/包裝
// 會錯用 selector 作為下一字元之 DOM 位置。
previous.text += ch;
appendParts(previous.parts, [ part ]);
}
else
{
curMap.push({ text: ch, parts: [ part ] });
}
i += ch.length;
}
return;
}
if (node.nodeType !== 1) { return; }
const el = node as Element;
const recurse = (): void =>
{
let c = el.firstChild;
while (c) { visit(c); c = c.nextSibling; }
};
switch (this.category(el))
{
case "avoid":
case "block":
case "pill":
{
// 邊界、內部不入:avoid/pill 內部不可見;block 另由 eachBlock 處理。
flush();
return;
}
case "transparent":
{
// 透明:遞迴穿越、run 延續。jz-hws 以 margin-right 包裹左側邊界字,
// 其內之字仍計入邏輯 run。
const boundary = feature && !this.featureEnabledFor(el, feature);
if (boundary) { flush(); }
recurse(); // 子白名單仍可重新啟用,在自身脈絡內處理。
if (boundary) { flush(); }
return;
}
default: // "isolate" | "opaque"(未知行內,嚴格白名單)
{
// 斷 run,內部自成獨立 run(照常處理、不與外部相鄰)。
flush();
recurse();
flush();
return;
}
}
};
let c = block.firstChild;
while (c) { visit(c); c = c.nextSibling; }
flush();
return runs;
}
/** 從節點邊界或 Text offset 取相鄰碼點;只穿越透明、域內內容,scopeRoot 為硬邊界。 */
private adjacentTextCodePoint(
from: Node,
direction: -1 | 1,
scopeRoot?: Element,
offset?: number,
feature?: string,
): TextSpan | null
{
if (scopeRoot && (from === scopeRoot || !scopeRoot.contains(from))) { return null; }
const pick = (tn: Text, at: number): TextSpan | null =>
{
if (this.isAvoided(tn) || !this.inScope(tn)
|| (feature && !this.featureEnabledFor(tn, feature))) { return null; }
if (direction === -1)
{
if (at <= 0) { return null; }
return { textNode: tn, start: previousCodePointStart(tn.data, at), end: at };
}
if (at >= tn.data.length) { return null; }
return { textNode: tn, start: at, end: at + codePointAtUtf16(tn.data, at).length };
};
if (from.nodeType === 3 && offset !== undefined)
{
const tn = from as Text;
if (this.isAvoided(tn) || !this.inScope(tn)
|| (feature && !this.featureEnabledFor(tn, feature))) { return null; }
if (direction === -1 ? offset > 0 : offset < tn.data.length)
{
return pick(tn, offset);
}
}
let cursor: Node = from;
for (;;)
{
if (cursor === scopeRoot) { return null; }
let next: Node | null = direction === -1 ? cursor.previousSibling : cursor.nextSibling;
while (!next)
{
const parent = cursor.parentElement;
if (!parent || parent === scopeRoot || !this.isTransparent(parent)
|| !this.inScope(parent)
|| (feature && !this.featureEnabledFor(parent, feature))) { return null; }
cursor = parent;
next = direction === -1 ? cursor.previousSibling : cursor.nextSibling;
}
// 空文本/註解/空透明元素可穿越;不可把實際邊界當作空內容略過。
for (;;)
{
if (scopeRoot && !scopeRoot.contains(next)) { return null; }
if (next.nodeType === 3)
{
const tn = next as Text;
if (this.isAvoided(tn) || !this.inScope(tn)
|| (feature && !this.featureEnabledFor(tn, feature))) { return null; }
if (tn.data.length) { return pick(tn, direction === -1 ? tn.data.length : 0); }
}
else if (next.nodeType === 1)
{
const el = next as Element;
if (!this.isTransparent(el) || !this.inScope(el)
|| (feature && !this.featureEnabledFor(el, feature))) { return null; }
const child = direction === -1 ? el.lastChild : el.firstChild;
if (child) { next = child; continue; }
}
cursor = next;
break;
}
}
}
/** 補全透明 Text 邊界拆開的 surrogate pair;孤立碼元保留自身,不跨任何邊界拼接。 */
private adjacentCodePoint(
from: Node,
direction: -1 | 1,
scopeRoot?: Element,
offset?: number,
feature?: string,
): CharSpan | null
{
const first = this.adjacentTextCodePoint(from, direction, scopeRoot, offset, feature);
if (!first) { return null; }
const char = (part: TextSpan): string => part.textNode.data.slice(part.start, part.end);
const firstChar = char(first);
let parts = [ first ];
if (direction === -1 ? isLowSurrogate(firstChar) : isHighSurrogate(firstChar))
{
const second = this.adjacentTextCodePoint(
first.textNode, direction, scopeRoot, direction === -1 ? first.start : first.end,
feature,
);
if (second && (direction === -1 ? isHighSurrogate(char(second)) : isLowSurrogate(char(second))))
{
parts = direction === -1 ? [ second, first ] : [ first, second ];
}
}
return { text: parts.map(char).join(""), parts };
}
/** pill 之完整鄰字(base+至多一個 VS);after 供查單個作者空格另一側,不越 scopeRoot。 */
adjacentLogicalChar(
el: Element,
direction: -1 | 1,
scopeRoot?: Element,
after?: AdjacentChar,
feature?: string,
): AdjacentChar | null
{
const edge = after?.parts[direction === -1 ? 0 : after.parts.length - 1];
const first = this.adjacentCodePoint(
edge?.textNode || el, direction, scopeRoot,
edge ? (direction === -1 ? edge.start : edge.end) : undefined,
feature,
);
if (!first) { return null; }
let atoms = [ first ];
const firstChar = first.text;
if ((direction === -1 && isVariationSelector(firstChar))
|| (direction === 1 && !isVariationSelector(firstChar)))
{
const edge = first.parts[direction === -1 ? 0 : first.parts.length - 1]!;
const second = this.adjacentCodePoint(
edge.textNode, direction, scopeRoot, direction === -1 ? edge.start : edge.end,
feature,
);
if (second && (direction === -1 ? !isVariationSelector(second.text) : isVariationSelector(second.text)))
{
atoms = direction === -1 ? [ second, first ] : [ first, second ];
}
}
const ch = atoms.map((atom) => atom.text).join("");
const parts: TextSpan[] = [];
for (const atom of atoms) { appendParts(parts, atom.parts); }
return { textNode: parts[0]!.textNode, offset: parts[0]!.start, ch, parts };
}
/**
* 節點之功能級別(§6.5.0a)。自 node 向上找**最近**之顯式信號:元素屬性
* `data-jz-level="text|paragraph"`(最優先),或命中 `level.text` 選擇器(→ text);
* 皆無則預設 paragraph(全功能)。結果以解析後之元素為鍵快取。
*/
levelFor(node: Node): FeatureLevel
{
const start = node.nodeType === 1 ? (node as Element) : node.parentElement;
if (!start) { return "paragraph"; }
const cached = this.levelCache.get(start);
if (cached) { return cached; }
let el: Element | null = start;
let result: FeatureLevel = "paragraph";
while (el)
{
const attr = el.getAttribute ? el.getAttribute("data-jz-level") : null;
if (attr === "text" || attr === "paragraph") { result = attr; break; }
if (this.levelTextSelector && el.matches && el.matches(this.levelTextSelector))
{
result = "text";
break;
}
el = el.parentElement;
}
this.levelCache.set(start, result);
return result;
}
/**
* 某級別之 pass 是否可作用於該節點(§6.5.0a gate)。文本級 pass 不受限(恆 true);
* 段落級 pass 僅作用於段落級元素(text-level 子樹跳過)。與 featureEnabledFor 疊加:
* level 為粗粒度語義組(先判),blocks/屬性為細粒度逐功能(後調)。
*/
levelAllows(node: Node, passLevel: FeatureLevel): boolean
{
if (passLevel === "text") { return true; }
return this.levelFor(node) === "paragraph";
}
/**
* 解析節點對某功能之有效啟用狀態(§3.6,per-node 閘)。向上找最近帶
* 功能指令之祖先(data-juzhen 白名單 / data-juzhen-off 黑名單 / 命中
* blocks 選擇器),無則回退全域 features。結果以元素為鍵快取。
*/
featureEnabledFor(node: Node, name: string): boolean
{
const el = node.nodeType === 1 ? (node as Element) : node.parentElement;
const globalOn = this.globalFeature(name);
if (!el) { return globalOn; }
const cached = this.featureCache.get(el);
if (cached && name in cached) { return cached[name]!; }
const result = this.resolveFeature(el, name, globalOn);
const bucket = cached || {};
bucket[name] = result;
this.featureCache.set(el, bucket);
return result;
}
private globalFeature(name: string): boolean
{
const f = this.opts.features as Record<string, boolean>;
return !!f[name];
}
private resolveFeature(start: Element, name: string, globalOn: boolean): boolean
{
let el: Element | null = start;
while (el)
{
// 屬性指令最近者優先。
if (el.hasAttribute("data-juzhen"))
{
const list = (el.getAttribute("data-juzhen") || "").split(/\s+/).filter(Boolean);
return list.indexOf(name) >= 0;
}
if (el.hasAttribute("data-juzhen-off"))
{
const off = (el.getAttribute("data-juzhen-off") || "").split(/\s+/).filter(Boolean);
if (off.indexOf(name) >= 0) { return false; }
// 黑名單未列出者:續向上找,最終回退全域。
}
// blocks 選擇器(程式式白名單)。
for (const rule of this.opts.blocks)
{
if (rule.selector && el.matches && el.matches(rule.selector))
{
return rule.features.indexOf(name) >= 0;
}
}
el = el.parentElement;
}
return globalOn;
}
}
-137
View File
@@ -1,137 +0,0 @@
// 語言與全角/開明政策(架構 §4.3)。
//
// 解析節點之有效 lang,歸類,並依政策決定標點寬度(全角式/開明式/半角式)。
// JS 判定政策後寫入半形 class,CSS 按 class 渲染(I2/I3)。
/** 標點寬度政策。 */
export type PunctStyle = "quanjiao" | "kaiming" | "banjiao";
/** 語言分類。 */
export type LangClass = "zh-Hans" | "zh-Hant" | "ja" | "other";
/** 按選擇器逐元素覆寫標點樣式之規則(§4.3;如非段落/UI 元素用半角式)。 */
export interface StyleRule
{
selector: string;
style: PunctStyle;
}
export interface LocaleConfig
{
/** 無 lang 祖先時之預設語言。 */
default: string;
/** 全域標點擠壓預設(全角/開明/半角)。設則覆寫內建之逐語言預設,
* 但仍可被 policy 之逐 lang 設定覆寫。未設則回退內建預設。 */
style?: PunctStyle;
/** 語言分類 → 標點寬度政策之逐 lang 覆寫。 */
policy: Partial<Record<LangClass, PunctStyle>>;
/** 按選擇器**逐元素**覆寫標點樣式(優先於 lang;與 `data-jz-style` 屬性同一機制)。
* 近祖先勝、同層屬性優先於選擇器。空/未設則不覆寫、純由 lang 決定。 */
stylePolicy?: StyleRule[];
}
const DEFAULT_POLICY: Record<LangClass, PunctStyle> = {
"zh-Hant": "quanjiao",
"zh-Hans": "kaiming",
"ja": "quanjiao",
"other": "quanjiao",
};
/**
* 由節點(含元素自身)向上找最近 lang,回傳小寫值;空值表示語言未知,僅缺失時回退。
*/
export function effectiveLang(node: Node, fallback: string): string
{
let p: Node | null = node.nodeType === 1 ? node : node.parentNode;
while (p && p.nodeType === 1)
{
const el = p as Element;
const v = el.getAttribute("lang");
if (v !== null) { return v.toLowerCase(); }
p = p.parentNode;
}
return (fallback || "").toLowerCase();
}
/**
* 將 BCP-47 語言標籤歸類。前綴以子標籤邊界比對('zh-tw' 命中 zh-Hant,
* 'zhx' 不命中 zh)。只讀 BCP-47 的 script/region 位置,不把 extension/private-use
* 內容當成腳本或地區;本函數不是完整標籤驗證器。
*/
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)
{
const subtags = l.split("-");
let at = 1;
// language 後可有至多三個三字母 extlang,然後才是 script/region。
for (let count = 0; count < 3 && /^[a-z]{3}$/.test(subtags[at] || ""); count++) { at++; }
const script = /^[a-z]{4}$/.test(subtags[at] || "") ? subtags[at++] : undefined;
if (script === "hant") { return "zh-Hant"; }
if (script === "hans") { return "zh-Hans"; }
// 地區推斷:台灣/香港/澳門 → 繁體;其餘(含 cn/sg)→ 簡體。
if ([ "tw", "hk", "mo" ].includes(subtags[at] || "")) { return "zh-Hant"; }
if (l === "zh") { return "zh-Hant"; }
return "zh-Hans";
}
return "other";
}
/**
* 回傳該語言分類在給定 config 下之標點寬度政策。優先序:
* 逐 lang policy > 全域 style > 內建逐語言預設 DEFAULT_POLICY。
*/
export function styleFor(langClass: LangClass, config: LocaleConfig): PunctStyle
{
const perLang = config.policy ? config.policy[langClass] : undefined;
if (perLang) { return perLang; }
if (config.style) { return config.style; }
return DEFAULT_POLICY[langClass];
}
/** 該字串是否為合法標點樣式。 */
function isPunctStyle(v: string | null): v is PunctStyle
{
return v === "quanjiao" || v === "kaiming" || v === "banjiao";
}
/**
* 逐元素之標點樣式覆寫(§4.3):自 node 向上找**最近**祖先(含自身)之樣式信號——
* `data-jz-style` 屬性(值須為合法樣式,優先),或 `stylePolicy` 選擇器命中——回傳其樣式;
* 皆無則 null(回退 lang 決定)。近祖先勝,同層屬性優先於選擇器。
*/
function resolveStyleOverride(node: Node, stylePolicy?: StyleRule[]): PunctStyle | null
{
let el: Element | null = node.nodeType === 1 ? (node as Element) : node.parentElement;
while (el)
{
const attr = el.getAttribute ? el.getAttribute("data-jz-style") : null;
if (isPunctStyle(attr)) { return attr; }
if (stylePolicy)
{
for (const rule of stylePolicy)
{
if (rule.selector && el.matches && el.matches(rule.selector))
{
return rule.style;
}
}
}
el = el.parentElement;
}
return null;
}
/**
* 解析節點之有效標點樣式。優先序:**逐元素覆寫**(`data-jz-style` 屬性/`stylePolicy`
* 選擇器,近祖先勝)> 逐 lang `policy` > 全域 `style` > 內建逐語言預設。
*/
export function resolveStyle(node: Node, config: LocaleConfig): PunctStyle
{
const override = resolveStyleOverride(node, config.stylePolicy);
if (override) { return override; }
const lang = effectiveLang(node, config.default);
return styleFor(classify(lang), config);
}
-104
View File
@@ -1,104 +0,0 @@
// Pass 管線執行器(架構 §3.2/§3.4)。
//
// 兩類 pass:charify(共享單次 P1 文本走訪,collect 純判定 + 批次 apply)與
// standalone(自跑 render)。聚合僅為執行優化:邏輯上仍一功能一 pass、可獨立
// 開關與 revert(I6/I9)。
//
// 執行順序:先 charify 群(一次走訪建立 jz-char/span 結構),後 standalone 群
// 依 order 升序。理由見 §3.2——間隙/禁則須在標點結構穩定後處理。
import type { CharifyPass, Pass, RenderContext, StandalonePass, WrapRequest } from "../types.js";
interface NodeWork
{
node: Text;
requests: WrapRequest[];
}
/** 對 ctx.root 套用 passes 中已啟用者。 */
export function runPasses(passes: Pass[], ctx: RenderContext): void
{
const enabled = passes.filter((p) => p.enabled(ctx.options));
const charify = enabled
.filter((p): p is CharifyPass => p.kind === "charify")
.sort((a, b) => a.order - b.order);
const standalone = enabled
.filter((p): p is StandalonePass => p.kind === "standalone")
.sort((a, b) => a.order - b.order);
if (charify.length > 0) { runCharify(charify, ctx); }
for (const p of standalone) { p.render(ctx); }
}
/** 還原 passes 之全部產物(逆 order;revert 對無產物者安全)。 */
export function revertPasses(passes: Pass[], ctx: RenderContext): void
{
const ordered = passes.slice().sort((a, b) => b.order - a.order);
for (const p of ordered) { p.revert(ctx); }
}
// ----- charify 階段:單次走訪收集,批次 apply -----
function runCharify(passes: CharifyPass[], ctx: RenderContext): void
{
for (const pass of passes) { pass.prepare?.(ctx); }
const work: NodeWork[] = [];
ctx.finder.eachTextNode(ctx.root, (node) =>
{
let requests: WrapRequest[] = [];
for (const p of passes)
{
// 功能分級閘(§6.5.0a):段落級 charify pass(如 longWord)對 text-level
// 子樹跳過;文本級(如 jiya)不受限。
if (!ctx.finder.levelAllows(node, p.level)) { continue; }
const got = p.collect(node, ctx);
if (got.length > 0) { requests = requests.concat(got); }
}
if (requests.length > 0) { work.push({ node, requests }); }
});
for (const item of work) { applyRequests(item.node, item.requests, ctx.ownedElements); }
}
/**
* 把單一文本節點依 requests 重建為 fragment:間隙以原文,命中段以 build()
* 之元素。requests 互不重疊;防禦性地丟棄與已採用段重疊者。
*/
function applyRequests(
node: Text,
requests: WrapRequest[],
ownedElements: WeakSet<Element>,
): void
{
const parent = node.parentNode;
if (!parent) { return; }
const sorted = requests.slice().sort((a, b) => a.start - b.start || a.end - b.end);
const data = node.data;
const ownerDoc = node.ownerDocument;
if (!ownerDoc) { return; }
const frag = ownerDoc.createDocumentFragment();
let cursor = 0;
for (const req of sorted)
{
if (req.start < cursor) { continue; } // 重疊,跳過。
if (req.start < 0 || req.end > data.length || req.end <= req.start) { continue; }
if (req.start > cursor)
{
frag.appendChild(ownerDoc.createTextNode(data.slice(cursor, req.start)));
}
const built = req.build(data.slice(req.start, req.end));
ownedElements.add(built);
frag.appendChild(built);
cursor = req.end;
}
if (cursor === 0) { return; } // 無有效段。
if (cursor < data.length)
{
frag.appendChild(ownerDoc.createTextNode(data.slice(cursor)));
}
parent.replaceChild(frag, node);
}
-261
View File
@@ -1,261 +0,0 @@
// 行內邊界詞元隔離(架構 §6.4/§6.5.1;下游 Bug:行內樣式標記須對排版透明)。
//
// 禁則(jinze)與垂懸字(orphan)須把「邊界字/詞元」綁進不可斷之群組(jz-jinze
// 之 nowrap、jz-orphan 之 nowrap)。若該邊界字落於樣式行內元素(<strong>/<em> 等)
// 內,舊版**整個元素**入綁——`<strong>很長一串粗體</strong>,` 會把整串粗體塞進
// nowrap inline-block,行末放不下即整體掉行、上行留巨大空隙;orphan 更可致長元素溢出
// 容器。本模組把邊界詞元**克隆切分**出來,只綁該克隆(最小綁定),原元素其餘部分留在
// 原位,渲染視覺無縫(相鄰同樣式行內元素)。
//
// 機制:splitTreeAfter 沿「文本節點 → 邊界元素」之祖先鏈逐層把「切點之後」之內容剝入
// 一個淺克隆(保留標籤/class/style/lang/dir,去 id),克隆作為原元素之後兄弟。克隆
// 標 data-jz-kind="splitoff"+data-jz="<pass>",供 revertPass 之延後階段併回原元素(先解
// 包 wrap、後併 splitoff,確保前兄弟為原元素而非 wrap 殼;I6 還原一致)。
//
// 僅純樣式行內標籤可切(白名單);帶語義/引用者(A 連結會克隆出兩個錨點、ABBR 之
// title、RUBY 結構、帶 id 者)一律回退整元素綁定——退化但安全。可切性另須諮詢
// finder 三分類(§4.1):pill(整體,如 .katex——KaTeX 全樹皆 SPAN,僅憑標籤名會
// 整棵下潛肢解;下游問題六)、isolate(隔離,帶盒樣式切分有可見接縫)、avoid 自身
// 命中者,皆不可切——回退整元素綁定。
//
// 可切性須對「切點 textNode 至 top 之**整條**祖先鏈」成立,非僅 top:下潛由 firstText/
// lastText 守(遇非可切回 null),攀升由 splitTreeAfter 之對稱守衛守(切分前驗祖先鏈全可
// 切)。二者缺一則反例(splitElementAt 之切點取自 orphan 之 collectSubst,可穿透 opaque
// <button>)會令中間 opaque 祖先被逐層克隆肢解——下游 A7 之 .selection-bar-message 病例。
import type { Finder } from "./finder.js";
import { registerSplitoffSource } from "./dom.js";
import {
previousVariationSequenceStart, variationSequenceAtUtf16,
} from "./unicode.js";
const PASS_ATTR = "data-jz";
const KIND_ATTR = "data-jz-kind";
// 可安全克隆切分之純樣式行內標籤(切分不改變語義、相鄰兩份視覺無縫)。
// 刻意排除:A(克隆出兩個連結)、ABBR/Q(title/引號語義重複)、RUBY 系(結構性)、
// MARK(盒樣式:背景色切分有可見接縫;預設亦列為 isolate)、帶 id 者(id 重複)——
// 皆回退整元素綁定。
const SPLITTABLE = new Set([
"STRONG", "EM", "B", "I", "U", "S", "SPAN",
"SMALL", "INS", "DEL", "SUB", "SUP",
]);
interface Point
{
textNode: Text;
offset: number;
}
/** 元素是否可克隆切分 = SPLITTABLE 白名單(結構切分安全,排除 <a>/ABBR/RUBY/帶 id 等)
* ∩ transparent(非 avoid/pill/isolate/block 邊界)。SPLITTABLE ⊂ styleInlines,故命中白名單
* 且 category 為 transparent 者即可切;為邊界者(含消費端自 styleInlines 移出之標籤)不可切、
* 退化整綁。結構切分為獨立層、白名單較邏輯透明更窄(見 finder.category)。 */
function canSplit(el: Element, finder: Finder): boolean
{
return SPLITTABLE.has(el.nodeName)
&& !el.hasAttribute("id")
&& finder.category(el) === "transparent";
}
/** el 內**首**個非空文本節點(僅下探可切標籤;遇非可切元素回 null=放棄切分)。 */
function firstText(el: Element, finder: Finder): Text | null
{
for (let c = el.firstChild; c; c = c.nextSibling)
{
if (c.nodeType === 3 && (c as Text).data.length > 0) { return c as Text; }
if (c.nodeType === 1)
{
if (!canSplit(c as Element, finder)) { return null; }
const r = firstText(c as Element, finder);
if (r) { return r; }
}
}
return null;
}
/** el 內**末**個非空文本節點(僅下探可切標籤;遇非可切元素回 null=放棄切分)。 */
function lastText(el: Element, finder: Finder): Text | null
{
for (let c = el.lastChild; c; c = c.previousSibling)
{
if (c.nodeType === 3 && (c as Text).data.length > 0) { return c as Text; }
if (c.nodeType === 1)
{
if (!canSplit(c as Element, finder)) { return null; }
const r = lastText(c as Element, finder);
if (r) { return r; }
}
}
return null;
}
function isTokenChar(ch: string, anyCjk: RegExp): boolean
{
return !anyCjk.test(ch) && !/\s/.test(ch);
}
/** 文本節點之首詞元結束 UTF-16 位置(CJK base+optional-VS=單一邏輯字)。 */
function leadingTokenEnd(d: string, anyCjk: RegExp): number
{
const first = variationSequenceAtUtf16(d, 0);
if (anyCjk.test(first)) { return first.length; }
let e = first.length;
while (e < d.length)
{
const ch = variationSequenceAtUtf16(d, e);
if (!isTokenChar(ch, anyCjk)) { break; }
e += ch.length;
}
return e;
}
/** 文本節點之末詞元起始位置。 */
function trailingTokenStart(d: string, anyCjk: RegExp): number
{
let s = previousVariationSequenceStart(d, d.length);
if (anyCjk.test(variationSequenceAtUtf16(d, s))) { return s; }
while (s > 0)
{
const previous = previousVariationSequenceStart(d, s);
if (!isTokenChar(variationSequenceAtUtf16(d, previous), anyCjk)) { break; }
s = previous;
}
return s;
}
/**
* 把 top 自切點(textNode, offset)起至末尾之內容剝入一個淺克隆鏈,克隆作為 top 之
* 後兄弟插入並標記 splitoff。回傳該頂層克隆;下列任一情形回 null(呼叫端據此綁整個
* top):切點位於 top 內容之最起點(左側無內容、即整個 top 都在切點之後);或 textNode
* 至 top 之祖先鏈含不可切層(opaque/pill/isolate/帶 id——見攀升守衛)。
*
* 沿祖先鏈逐層剝離:每層把「切點側之首節點及其後兄弟」搬入該層元素之淺克隆,空克隆
* 跳過(避免殘留空元素破壞 revert 還原)。
*/
function splitTreeAfter(
top: Element,
textNode: Text,
offset: number,
pass: string,
finder: Finder,
ownedElements: WeakSet<Element>,
): Element | null
{
// 攀升對稱守衛:firstText/lastText 守下潛(遇非可切回 null),但 splitElementAt
// (orphan)之切點取自 collectSubst,其 textNode→top 祖先鏈未必全可切——中間祖先若
// 為 opaque(如 <button>)/pill/isolate/帶 id,逐層淺克隆會肢解該邊界元素(複製
// <button>、重複 id、破壞 pill/isolate 整體)。故切分前先驗整條祖先鏈(textNode.parentNode
// …top,含 top)皆可切;任一層不可切即放棄(回 null → 綁整個 top,退化但安全,與下潛對稱)。
for (let a: Node | null = textNode.parentNode; a && a.nodeType === 1; a = a.parentNode)
{
if (!canSplit(a as Element, finder)) { return null; }
if (a === top) { break; }
}
// 切點位於 top 最起點(首文本節點之 offset 0)→ 左側無內容,不切。
if (offset <= 0 && firstText(top, finder) === textNode) { return null; }
// 該文本層之右側首節點。
let rightAtLevel: Node | null;
if (offset >= textNode.data.length)
{
rightAtLevel = textNode.nextSibling;
}
else if (offset <= 0)
{
rightAtLevel = textNode;
}
else
{
rightAtLevel = textNode.splitText(offset);
}
let ancestor: Node | null = textNode.parentNode;
let topClone: Element | null = null;
while (ancestor && ancestor.nodeType === 1)
{
const anc = ancestor as Element;
let clone: Element | null = null;
if (rightAtLevel)
{
clone = anc.cloneNode(false) as Element;
clone.removeAttribute("id");
ownedElements.add(clone);
registerSplitoffSource(clone, anc, pass, ownedElements);
clone.setAttribute(PASS_ATTR, pass);
clone.setAttribute(KIND_ATTR, "splitoff");
let n: Node | null = rightAtLevel;
while (n)
{
const next: Node | null = n.nextSibling;
clone.appendChild(n);
n = next;
}
}
if (anc === top)
{
if (!clone) { return null; }
if (top.parentNode) { top.parentNode.insertBefore(clone, top.nextSibling); }
topClone = clone;
break;
}
if (clone)
{
if (anc.parentNode) { anc.parentNode.insertBefore(clone, anc.nextSibling); }
rightAtLevel = clone; // 下一層之右側起點:克隆(緊隨 anc,原後兄弟在克隆之後)
}
else
{
rightAtLevel = anc.nextSibling; // 本子樹無右側內容;上一層右側起點為 anc 之後兄弟
}
ancestor = anc.parentNode;
}
return topClone;
}
/**
* 隔離 el 之邊界詞元供綁定(最小綁定)。side="tail" 取末詞元、="head" 取首詞元。
* 回傳**應綁定之節點**:
* · 可切且確實切分 → tail 回克隆(末詞元)、head 回原 el(已只剩首詞元,其餘入克隆);
* · 不可切/無法定位切點/整元素即該詞元 → 回 el 本身(綁整個元素,退化但安全)。
* 切出之克隆標 splitoff,由 revertPass 併回;故 render→revert 還原一致(I6)。
*/
export function isolateBoundaryToken(
el: Element,
side: "head" | "tail",
anyCjk: RegExp,
pass: string,
finder: Finder,
ownedElements: WeakSet<Element>,
): Element
{
if (!canSplit(el, finder)) { return el; }
const text = side === "tail" ? lastText(el, finder) : firstText(el, finder);
if (!text) { return el; }
const point: 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, ownedElements);
if (!clone) { return el; } // 整元素即邊界詞元 → 綁整個 el。
return side === "tail" ? clone : el;
}
/**
* 把 el 之**指定切點**(精確字位)起至末尾剝入克隆並回傳(供 orphan:自實義字綁至塊末)。
* 不可切或切點在最起點 → 回 null(呼叫端綁整個直接子)。
*/
export function splitElementAt(
el: Element,
textNode: Text,
offset: number,
pass: string,
finder: Finder,
ownedElements: WeakSet<Element>,
): Element | null
{
if (!canSplit(el, finder)) { return null; }
return splitTreeAfter(el, textNode, offset, pass, finder, ownedElements);
}
-360
View File
@@ -1,360 +0,0 @@
// 字元分類與規則字集(架構 §4.2)。
//
// 集中所有 Unicode 範圍與標點子類,供各 pass 共用,杜絕分類散落不一致。
// CJK/西文字集沿用 v1(cjk-autospace)已驗證之 Pangu 範圍;標點子類為新增,
// 對應 CSS class(jz-char 之 bd-* 標記)。
//
// 這些常數以「regex 字元類片段」形式給出(已就 RegExp 字元類做必要轉義),
// 故可直接 `new RegExp("[" + X + "]", "u")`;補充平面範圍使用 `\u{...}`,
// 所有動態拼入 CJK 片段的正則必須保持 Unicode mode。
import type { LangClass } from "./locale.js";
/** CJK 表意文字/假名/注音等範圍(沿用並擴充 v1 之 PANGU_CJK)。 */
export const CJK = (
"⺀-⻿⼀-⿟" // CJK 部首補充 + 康熙部首
+ "぀-ゟ゠-ヺー-ヿ" // 平假名 + 片假名
+ "㄀-ㄯ㈀-㋿" // 注音符號 + 圈號 CJK
+ "㐀-䶿一-鿿豈-﫿" // 擴展 A + 統一表意 + 相容
+ "\\u{20000}-\\u{2A6DF}" // 擴展 B
+ "\\u{2A700}-\\u{2B73F}" // 擴展 C
+ "\\u{2B740}-\\u{2B81F}" // 擴展 D
+ "\\u{2B820}-\\u{2CEAF}" // 擴展 E
+ "\\u{2CEB0}-\\u{2EBEF}" // 擴展 F
+ "\\u{2EBF0}-\\u{2EE5F}" // 擴展 I
+ "\\u{2F800}-\\u{2FA1F}" // CJK 相容表意文字補充
+ "\\u{30000}-\\u{3134F}" // 擴展 G
+ "\\u{31350}-\\u{323AF}" // 擴展 H
+ "\\u{323B0}-\\u{3347F}" // 擴展 J
);
/** Unicode variation selector 字元類片段;僅可作緊隨 base 的後綴。 */
export const VARIATION_SELECTORS = "\\uFE00-\\uFE0F\\u{E0100}-\\u{E01EF}";
/** 取 UTF-16 offset 處的完整 Unicode code point;越界時回空字串。 */
export function codePointAtUtf16(text: string, offset: number): string
{
const value = text.codePointAt(offset);
return value === undefined ? "" : String.fromCodePoint(value);
}
/** 回傳 `offset` 之前一個 code point 的 UTF-16 起始位置。 */
export function previousCodePointStart(text: string, offset: number): number
{
if (offset <= 0) { return 0; }
const last = text.charCodeAt(offset - 1);
if (last >= 0xDC00 && last <= 0xDFFF && offset >= 2)
{
const first = text.charCodeAt(offset - 2);
if (first >= 0xD800 && first <= 0xDBFF) { return offset - 2; }
}
return offset - 1;
}
/** 是否為 Unicode variation selector(BMP VS1–VS16 或補充 VS17–VS256)。 */
export function isVariationSelector(ch: string): boolean
{
const value = ch.codePointAt(0);
return value !== undefined
&& ((value >= 0xFE00 && value <= 0xFE0F)
|| (value >= 0xE0100 && value <= 0xE01EF));
}
/**
* 取 UTF-16 offset 處之邏輯字元:一個 base code point,加上緊隨的單一 VS。
* 孤立 VS 只回自身,不反向吞併;回傳字串長度仍是 UTF-16 code units。
*/
export function variationSequenceAtUtf16(text: string, offset: number): string
{
const base = codePointAtUtf16(text, offset);
if (!base || isVariationSelector(base)) { return base; }
const selector = codePointAtUtf16(text, offset + base.length);
return isVariationSelector(selector) ? base + selector : base;
}
/** 回傳 `offset` 之前一個 base+optional-VS 邏輯字元的 UTF-16 起點。 */
export function previousVariationSequenceStart(text: string, offset: number): number
{
const last = previousCodePointStart(text, offset);
const lastChar = codePointAtUtf16(text, last);
if (!isVariationSelector(lastChar) || last <= 0) { return last; }
const base = previousCodePointStart(text, last);
return isVariationSelector(codePointAtUtf16(text, base)) ? last : base;
}
/** 以 base+optional-VS 為單位切分字串;孤立 VS 仍是單獨單位。 */
export function splitVariationSequences(text: string): string[]
{
const out: string[] = [];
for (let offset = 0; offset < text.length;)
{
const sequence = variationSequenceAtUtf16(text, offset);
out.push(sequence);
offset += sequence.length;
}
return out;
}
/** 西文字母與數字。 */
export const ALNUM = "A-Za-z0-9";
/** 西文字母。 */
export const ALPHA = "A-Za-z";
// ----- Pangu 中西邊界用之擴充西文字集(沿用 v1)-----
// 西文(外文)字集——CJK 與其相鄰時補中西間隙。以 regex 字元類片段表示,供
// STRIP_*/CJK_ANS/ANS_CJK 等規則拼接。**列舉法(whitelist)**:逐區塊以
// \uXXXX 明列並附名稱;未來新區塊於此補入即可。範圍皆 BMP(≤U+FFFF),\uXXXX
// 於 regex 字元類內**無需 u flag** 即生效,故相容舊環境(2016/Windows 10 時代)。
// 相對 v1 補入:拉丁擴充 A/B(拼音聲調)、西里爾、貨幣符號(下游回報之 €)、數學
// 運算符;並把希臘由原 U+0370–037D 補全至整塊(原漏 Α-Ω α-ω 本身,致 `中文α`
// 不補隙)。刻意跳過 U+00B7 中間點 `·`(CJK 間隔號,如 卡爾·馬克思,屬 CJK 標點,
// 不觸發中西間隙)。
const WEST_SHARED = (
"0-9"
+ "\\$%\\^&\\*\\-\\+\\\\=" // 常用運算符(字面:$ % ^ & * - + \ =)
+ "\\u0370-\\u03ff" // 希臘+科普特(含 Α-Ω α-ω)
+ "\\u0100-\\u024f" // 拉丁擴充 A/B(拼音聲調 ā ǎ ē ǐ ō ǔ)
+ "\\u0400-\\u04ff" // 西里爾(俄文等)
+ "\\u00a1-\\u00b6\\u00b8-\\u00ff" // 拉丁-1 補充(跳過 U+00B7 間隔號 ·)
+ "\\u20a0-\\u20bf" // 貨幣符號(€ ₩ ₹ ₿ 等)
+ "\\u2150-\\u21ff" // 數字形式+箭號
+ "\\u2200-\\u22ff" // 數學運算符(∑ ∫ ≠ ≈ ± × ÷ 等)
+ "\\u2700-\\u27bf" // dingbats 裝飾符
);
/** CJK 之後可接間隙之西文集(含 @)。 */
export const ANS_AFTER_CJK = ALPHA + "@" + WEST_SHARED;
/** CJK 之前可接間隙之西文集(去 @)。 */
export const ANS_BEFORE_CJK = ALPHA + WEST_SHARED;
// ----- 標點(biaodian)子類。對應 jz-char 之 bd-* class -----
//
// 注意:分類依「字形角色」,而開明式/全角式之別依「句法角色」。slice 1 之
// 擠壓集(句內點號)由 BD_PAUSE 表達——此即 v1 Pass D 所擠之 ,、;:。
/** 句末點號:。.!?(開明式與全角式皆保持全形,不擠壓;banjiao 擠半)。
* 含全角句點 .(U+FF0E,異於表意句號 。U+3002)——實測 Noto CJK SC/TC 皆有 halt、
* 挤壓收 0.5em,故與 。!? 同列為句末點號(下游回饋)。 */
export const BD_STOP = "。.!?";
/** 句內點號:,、;:(開明式擠成半形;全角式保持全形)。 */
export const BD_PAUSE = ",、;:";
/** 開始括號/引號:「『(《〈【〖〔[{“‘。 */
export const BD_OPEN = "「『(《〈【〖〔[{“‘";
/** 結束括號/引號:」』)》〉】〗〕]}”’。 */
export const BD_CLOSE = "」』)》〉】〗〕]}”’";
/** 間隔/分隔號:·・‧。 */
export const BD_MIDDLE = "·・‧";
/** 連續省略/破折:…—。 */
export const BD_LIGA = "…—";
/** 全寬標點之六子類鍵。 */
export type BdSubclass = "stop" | "pause" | "open" | "close" | "middle" | "liga";
const BD_ORDER: ReadonlyArray<BdSubclass> = [
"stop", "pause", "open", "close", "middle", "liga",
];
/**
* 字符分類規則集(§4.2):中文/西文表為 regex 字元類片段,標點各子類為逐碼點字串(不解析範圍)。
* 各 pass(spacing 之 CJK/ANS 正則、jiya 之 classifyBiaodian、jinze 之 anyCjk)皆由此
* 取分類,不再各自硬編碼。內置默認見 DEFAULT_RULESET;消費端經 options.charClass 擴充
* (resolveRuleset)。
*/
export interface Ruleset
{
/** 中文(CJK 表意/假名/注音等)——中西間隙之「中」側、禁則/justify 原子。 */
cjk: string;
/** 西文,CJK 之後可接隙者(含 @)。 */
ansAfter: string;
/** 西文,CJK 之前可接隙者(去 @)。 */
ansBefore: string;
/** 全寬標點,按子類——jiya charify/擠壓、禁則避頭尾、行端。 */
biaodian: Record<BdSubclass, string>;
}
/** 消費端字符分類覆寫(追加語義;見 README「自定義字符分類」)。 */
export interface CharClassOverride
{
/** 追加為中文(CJK):regex 字元類片段,特殊字元須轉義。 */
cjk?: string;
/** 追加為西文:regex 字元類片段,特殊字元須轉義;併入 ansAfter/ansBefore。 */
western?: string;
/** 追加為全寬標點,按子類列出逐碼點字串;例如 "①②③",不解析 "①-③" 為範圍。 */
biaodian?: Partial<Record<BdSubclass, string>>;
}
/** 內置默認規則集(即現狀硬編碼值)。 */
export const DEFAULT_RULESET: 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,
},
};
/**
* 合併消費端覆寫,回傳最終規則集。v1 為**追加**語義:把 override 字元併入內置對應類
* (未分類字符→歸類、新符號→納入)。不傳則回傳 DEFAULT_RULESET(零行為變更)。
*/
export function resolveRuleset(override?: CharClassOverride): Ruleset
{
if (!override) { return DEFAULT_RULESET; }
const west = override.western || "";
const bd: Record<BdSubclass, string> = { ...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,
};
}
/**
* 將單一標點字元歸入其 bd-* 子類;非標點回傳 null。供 jiya charify 標記 class。
* biaodian 取自規則集(預設 DEFAULT_RULESET.biaodian,向下兼容無參數呼叫)。
*/
export function classifyBiaodian(
ch: string,
biaodian: Record<BdSubclass, string> = DEFAULT_RULESET.biaodian,
): string | null
{
const base = codePointAtUtf16(ch, 0);
if (!base) { return null; }
for (const sub of BD_ORDER)
{
// 保留原生字串搜尋,但命中位置須在碼點邊界,不能匹配 astral 標點的半字。
const chars = biaodian[sub];
let at = chars.indexOf(base);
while (at >= 0)
{
const previous = chars.charCodeAt(at - 1);
const first = base.charCodeAt(0);
const startsInsidePair = previous >= 0xD800 && previous <= 0xDBFF
&& first >= 0xDC00 && first <= 0xDFFF;
if (!startsInsidePair && codePointAtUtf16(chars, at) === base) { return "bd-" + sub; }
at = chars.indexOf(base, at + 1);
}
}
return null;
}
/**
* 標點之「可壓縮空白」側(橫排)。字面偏左者空白在右(句內/句末/閉類),
* 開類字面偏右、空白在左。間隔號/連接號不壓縮。擠壓即收掉對應側之半形空白。
*/
export function blankSides(bdClass: string | null): { left: boolean; right: boolean }
{
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 };
}
}
}
// ----- 字形偏側(ink bias)參考表 — margin 後備模式用(架構 §6.3/.feedback)-----
//
// 預設半形渲染用字型 OpenType `halt`:由字型按字形正確定位,無需知道字面偏側。
// 但消費端字型未必含 halt(部分系統字型、特製字型)。此時改用「margin 後備模式」:
// 以負 margin 收半形空白,**須知道每個標點字形之墨色偏側**才能收對側、不重疊:
//
// left 墨色偏左(空白在右) → 收右:margin-right: -0.5em
// right 墨色偏右(空白在左) → 收左:margin-left: -0.5em
// center 墨色居中(兩側各半空白) → 兩側各收:margin-left/right: -0.25em
// full 無可壓縮空白(字面已佔滿全形) → 不收(維持全形)
//
// 下表為**參考預設**,依簡/繁字形慣例給出(簡體點號偏左下、繁體居中;!?恆
// 居中;開類偏右、閉類偏左)。因實際字型字形多樣,消費端可經 options.jiya.bias
// 逐 lang 逐字覆寫(見 README「margin 後備模式」一節)。
//
// 注意:此偏側表只在 margin 後備模式生效;halt 模式(預設)不讀它。
/** 字形墨色偏側。 */
export type Bias = "left" | "center" | "right" | "full";
/** 逐 lang 逐字之偏側覆寫表(消費端傳入,覆寫參考預設)。 */
export type BiasTable = Partial<Record<LangClass, Record<string, Bias>>>;
// 括號/引號偏側依**字身墨色中心**(margin 後備模式之 width:0.5em 盒內定位用)。
// 經方正書宋 GBK 實測 glyph ink bbox:彎引號 “”‘’、角括號 「」、尖括號 〈〉 之墨色
// 強偏一側(開類 inkcenter≈0.64–0.75 偏右、閉類≈0.25–0.37 偏左)→ 開 right、閉 left;
// 圓/方/書名/六角括號 ()【】《》[]{}〔〕〖〗『』 墨色近中(≈0.38–0.62)→ center。
// 偏側決定字身於 0.5em 半形盒內之左移量(right 移 0.5、center 移 0.25、left 不移),
// 使墨色落於盒內、空白溢向外側而不與鄰字重疊。字型字身分布各異,消費端可經
// options.jiya.bias 逐字覆寫(見 README「字型適配工作流」)。
const BRACKET_OPEN_RIGHT = "「〈“‘"; // 開類中墨色強偏右者
const BRACKET_CLOSE_LEFT = "」〉”’"; // 閉類中墨色強偏左者
const BIAS_BRACKETS: Record<string, Bias> = (() =>
{
const t: Record<string, Bias> = {};
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;
})();
// 點號偏側因簡繁字形而異。簡體(GBK/宋體系)點號皆沉於左下角、墨色偏左、空白
// 在右 → 全判 left(經方正書宋 GBK 實測:,、。;:!? 墨色 inkL≈0.1–0.18、
// inkR≈0.58–0.68,一致偏左)。繁體(Noto/教育部字形)點號置中 → center。
const BIAS_HANS: Record<string, Bias> = {
",": "left", "、": "left", "。": "left", ".": "left",
";": "left", ":": "left", "!": "left", "?": "left",
};
const BIAS_HANT: Record<string, Bias> = {
",": "center", "、": "center", "。": "center", ".": "center",
";": "center", ":": "center", "!": "center", "?": "center",
};
const BIAS_DEFAULT: Record<LangClass, Record<string, Bias>> = {
"zh-Hans": BIAS_HANS,
"zh-Hant": BIAS_HANT,
"ja": BIAS_HANT, // 日文點號慣例居中,沿用繁體表
"other": BIAS_HANT,
};
/**
* 解析標點字元在某語言下之墨色偏側(margin 後備模式用)。
* 優先序:消費端 overrides[langClass][ch] > 括號表 > 內建點號參考表。
* 非可壓縮標點(間隔/連接號等)回傳 null(margin 模式不收)。
*/
export function inkBias(
ch: string,
langClass: LangClass,
overrides?: BiasTable,
): Bias | null
{
const ov = overrides ? overrides[langClass] : undefined;
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;
}
-18
View File
@@ -1,18 +0,0 @@
/* 禁則 + justify 綁定(§6.4,切片 1 基礎)。
連續標點綁為不可斷之單一原子(clreq 連續標點不斷行),在 justify 下整體
作為一項。完整避頭尾列後續,沿用同一 jz-jinze 結構。 */
jz-jinze
{
display: inline-block;
white-space: nowrap;
vertical-align: baseline;
}
/* justifyAtoms:false(分頁器相容,如 Paged.js):改 inline,避免 inline-block
邊界被分頁對齊引擎當伸縮點、與 CJK inter-ideograph 疊加致窄欄散架。
white-space:nowrap 於 inline 仍綁定群組內不斷行(禁則保留)。 */
[data-jz-atoms="inline"] jz-jinze
{
display: inline;
}
-117
View File
@@ -1,117 +0,0 @@
/* 標點擠壓 + justify 原子化(§6.3/§8)。
半形渲染:預設 halt(字型 OpenType);缺 halt 字型可切 margin 後備模式
(width:0.5em+字身偏側位移,以 CSS 盒模型重現 halt,見下)。跨瀏覽器一致(I4)。 */
/* §8:每個 jz-char 為 justify 之單一原子項——句末標點只貢獻一份對齊伸縮量
(I5)。inline-block 使標點與其字面留白同屬一項,對齊伸縮只落於項與項之間。
⚠ R1:inline-block 對基線/行高/行內換行之影響須跨 Chrome/Safari/FF 目視
驗證(見 demo / ARCHITECTURE §14)。 */
jz-char
{
display: inline-block;
vertical-align: baseline;
/* 標點本佔一個全形字框;inline-block 寬度即收斂為其字面 advance。 */
}
jz-inner
{
display: inline;
}
/* justifyAtoms:false(分頁器相容):jz-char 改 inline。半形仍由 jz-inner 之 halt
渲染;惟 margin 後備模式之 width:0.5em 依賴 inline-block,故 inline 下半形改回
依賴字型 halt(缺 halt 字型 + 分頁器對齊二者不可兼得,見 README)。 */
[data-jz-atoms="inline"] jz-char
{
display: inline;
}
/* 半形擠壓:用字型 OpenType `halt`(橫排半形)收字身寬度——由字型按字形正確
定位(簡體點號偏左、繁體居中、括號偏側皆正確),不拉鄰居故不重疊(修正
負 margin 對居中字形如 !?/繁體 。, 之缺陷)。需字型含 halt(Noto CJK
具備);缺 halt 之字型則維持全形(優雅降級,不重疊)。三機制(baseline 政策
C/行內鄰接 A/行端 B)皆以「加半形 class」落地,並集疊加、不重壓。
jz-half 來自 baseline+鄰接;jz-half-le 來自行端 layout pass(分用不同 class
以便重算時僅清除行端結果,效果相同)。
**顯式保留 locl**:`font-feature-settings` 為低階屬性,雖按規範不應關閉未列之預設
特性(locl 依 lang 選地域字形,泛 CJK 字型如思源/Noto CJK 賴之定位標點),但瀏覽器
實作有別;故同時列 `"locl" 1` 確保地域標點字形不因設 halt 而失(對無 locl 之字型亦
無害)。body 文本之 locl 不受聚珍影響(未設任何 feature),由消費端字型+正確 lang 決定。 */
jz-char.jz-half jz-inner,
jz-char.jz-half-le jz-inner
{
font-feature-settings: var(--jz-half-features, "locl" 1, "halt" 1);
-webkit-font-feature-settings: var(--jz-half-features, "locl" 1, "halt" 1);
}
/* margin 後備模式(.feedback):消費端字型缺 halt 時,render() 於 root 設
data-jz-halfwidth="margin"。**機制**:把 jz-char 之 inline-block 寬收為 0.5em
(真半形 advance——相鄰兩標點各 0.5em、合佔 1em,無負 margin 之鄰接疊加/單側
重疊問題),字身(jz-inner)以 overflow 溢出該盒、再依墨色偏側左移使墨色落於
0.5em 盒內、空白溢向外側——即以 CSS 盒模型重現 halt 之「收字身寬+按字形定位」。
left 墨偏左 → 不移(字身左對齊,墨在盒內、右側空白溢出)
center 墨居中 → 左移 0.25em(墨置盒中)
right 墨偏右 → 左移 0.5em(墨之右半落入盒,左側空白溢向前一字外側)
偏側由 JS 依「ink 中心參考表+消費端 options.jiya.bias 覆寫」於 charify 標為
jz-ink-{left,center,right}(見 core/unicode.ts inkBias)。先關 halt 免雙重作用。
只命中帶 jz-ink-* 者;bias=full(無 ink class)不收、維持全形。 */
/* 在模式標記本身宣告可繼承變數,讓最近的有效祖先決定模式;後代選擇器無法
表達距離,原有 halt-reset 會蓋過更內層的 margin,亦漏撤銷內盒 display。
所有變數均以 halt 為無標記之後備;非法屬性值不覆寫祖先模式。 */
[data-jz-halfwidth="margin"]
{
--jz-half-features: normal;
--jz-half-width: 0.5em;
--jz-half-inner-display: inline-block;
--jz-half-center-offset: -0.25em;
--jz-half-right-offset: -0.5em;
}
[data-jz-halfwidth="halt"]
{
--jz-half-features: "locl" 1, "halt" 1;
--jz-half-width: auto;
--jz-half-inner-display: inline;
--jz-half-center-offset: 0;
--jz-half-right-offset: 0;
}
jz-char.jz-half.jz-ink-left,
jz-char.jz-half.jz-ink-center,
jz-char.jz-half.jz-ink-right,
jz-char.jz-half-le.jz-ink-left,
jz-char.jz-half-le.jz-ink-center,
jz-char.jz-half-le.jz-ink-right
{
width: var(--jz-half-width, auto);
overflow: visible;
}
jz-char.jz-half.jz-ink-left jz-inner,
jz-char.jz-half.jz-ink-center jz-inner,
jz-char.jz-half.jz-ink-right jz-inner,
jz-char.jz-half-le.jz-ink-left jz-inner,
jz-char.jz-half-le.jz-ink-center jz-inner,
jz-char.jz-half-le.jz-ink-right jz-inner
{
display: var(--jz-half-inner-display, inline);
/* margin 內盒須自繪段落裝飾;兩種模式皆沿用父原子的裝飾。
不以變數傳遞 inherit,因其在自訂屬性中會先被當作繼承指令。 */
text-decoration: inherit;
}
/* 偏側僅用於確實壓縮的標點;全形標點不能因有 ink class 而移動字身。 */
jz-char.jz-half.jz-ink-center jz-inner,
jz-char.jz-half-le.jz-ink-center jz-inner
{
margin-left: var(--jz-half-center-offset, 0);
}
jz-char.jz-half.jz-ink-right jz-inner,
jz-char.jz-half-le.jz-ink-right jz-inner
{
margin-left: var(--jz-half-right-offset, 0);
}
/* 註:原標點懸掛(jz-hang-* 負 margin 凸出版心)已移除——純 CSS 機制為機制級死路
(寬度守恆矛盾,見 ARCHITECTURE §6.5.6)。行端僅做半形擠壓(jz-half-le,上方規則)。 */
-61
View File
@@ -1,61 +0,0 @@
/* §9.1 顯式禁用支援不一致/會與我們託管版面重複作用之原生原語,使三引擎
收斂於同一條一致路徑(I4)。清單隨功能演進維護於此。 */
/* 標點擠壓改以字型 OpenType `halt` 託管(見 _jiya.css)。實測(demo 量測):
不設 text-spacing-trim 時,未加 jz-half 之全形標點仍為 1.000em(原生
text-spacing-trim 並未自動 trim 我們 inline-block 隔離之孤立標點),且 halt
正常生效(半形字 = 0.500em)。故**不設** text-spacing-trim——設 space-all 反而
有抵銷 halt 之風險,且非必要。 */
/* 關閉原生中西自動間距(我們以 jz-hws 託管間隙)。限於 .juzhen 容器,
避免外溢;消費端把內容掛在 .juzhen 下即啟用此禁用層(與 scope 同域)。 */
@supports (text-autospace: no-autospace)
{
.juzhen
{
text-autospace: no-autospace;
}
}
/* jz-* 自訂元素皆語義中性、預設行內,可被 revert 移除。 */
jz-char,
jz-inner,
jz-hws,
jz-cs,
jz-jinze,
jz-em
{
font: inherit;
color: inherit;
/* 原子透明原則:jz-char/jz-jinze(及 margin 模式之 jz-inner)為 inline-block,
而 inline-block 自成區塊容器。凡「作用於區塊容器首/末行」之繼承屬性,會被每個
單字標點原子各自重新套用一次,使「用聚珍」與「未用聚珍」表現不一致。故須於原子
上歸零此類屬性——只切斷其向原子內部之再套用;段落本身之同名屬性不受影響(仍作用
於段落自身之行盒)。範圍以「同類機制」整體界定,非逐案補丁。
· text-indent:段落首行縮排傳入後,每個原子內部首行各縮排一次,shrink-to-fit
寬膨脹為 縮排量+字形,標點附近冒出巨大間距(下游回報)。歸零後實測:段落首行
縮排保持,原子寬回到 0.5em/1em。
· hanging-punctuation:同屬區塊容器首/末行機制,每個原子各自懸掛自身標點。
歸零是為阻止原子內重套;本庫目前不提供懸掛功能,不能由此推定段落懸掛的相容性。
不歸零者(經 headless Chrome 實測不洩漏,或本應逐字放行):letter-spacing/
word-spacing(原子邊界處理一致、無翻倍);text-align/text-align-last/
text-justify(halt 為 shrink-to-fit、margin 為內容溢出,二者皆無對齊餘量,
兩模式實測 jz-inner 偏移不變);text-transform/line-height/color/font(逐字
內容或縱向屬性,須穿透原子)。 */
text-indent: 0;
hanging-punctuation: none;
}
/* 原子透明之另一面——欠套用(與上一塊之過套用相反):text-decoration **非**繼承屬性,
而是由祖先(段落)之「裝飾盒」統一繪製、跨後代延伸;但原子級 inline-block(jz-char/
jz-jinze,及 margin 模式之 jz-inner)會被裝飾盒**跳過**,致段落下劃線在每個標點原子下
斷缺(實測:純文字 coverage 1.0、聚珍僅 ~0.25)。令原子 text-decoration: inherit,
使其承段落之裝飾自繪——因共用基線與字型度量,與漢字下劃線無縫接續(headless Chrome
像素掃描實測:halt/margin 兩模式 coverage 皆回 1.0,且 margin 下劃線隨半形收為
0.5em advance,正確)。margin 模式 jz-inner 亦為 inline-block、同遭跳過,故半形
內盒在 _jiya.css 也繼承裝飾;halt 模式內盒為 inline,同樣保留父原子裝飾。 */
jz-char,
jz-jinze
{
text-decoration: inherit;
}
-9
View File
@@ -1,9 +0,0 @@
/* 垂懸字避免(§6.5.1,方案 C)。jz-orphan 包裹段末「第 N 個 CJK 實義字起至段末」
之內容,以 white-space:nowrap 使其不可斷 → 末行恒含此整體 ⇒ 末行實義字 ≥ N。
保持 inline(非 inline-block):其內含 jz-char/jz-hws/文字之多字 run,須維持
正常行內排版與字間對齊,僅禁止內部換行。 */
jz-orphan
{
white-space: nowrap;
}
-32
View File
@@ -1,32 +0,0 @@
/* 中西間隙(§6.1)。jz-hws 包裹邊界**左側**之一個字(CJK 或西文)或 pill,
間隙以 margin-right 提供——不注入空白字元:
- copy-clean:複製得到緊湊原文(無多餘空白),textContent 完全不變(I1)。
- justify 安全:邊界無空白字元,justify 僅施均勻字間量,間隙不會像詞間空白
那樣被放大(修正中西間隙隨 justify 暴增之缺陷)。
- 永不落行首:margin-right 尾隨左側字,換行時落於行尾(不顯示),絕不在
行首造成縮排。
斷行由 CJK 字本具之斷點提供,無需空白字元。 */
jz-hws
{
margin-right: 0.25em; /* clreq 四分之一字寬 */
}
/* WebKit 對「跨節點後聚合進行內包裹的 CJK+IVS」与其後文本不一定暴露斷行點,
會把左側原子連同後字整體推到下一行。僅此類 owned gap 取得限定屬性;
偽元素只向斷行器提供零寬機會,不進入 DOM/textContent。普通 BMP、pill 與
同節點 gap 不受影響。 */
jz-hws[data-jz-cross-node-atom]::after
{
content: "\200B";
font-size: 0;
-webkit-user-select: none;
user-select: none;
}
/* 行末去隙(由 gapTrim layout pass 標記):落在行末之間隙移除其 margin-right,
使該行於 justify 下齊右、不內縮 0.25em。 */
jz-hws.jz-hws-trim
{
margin-right: 0;
}
-12
View File
@@ -1,12 +0,0 @@
/* 聚珍(Juzhen)樣式入口。視覺尺寸一律在此(I2):JS 只注入 jz-* 結構,
間隙寬度/擠壓量/原子化皆由 CSS 給定。
**字型不歸聚珍管**:逐語言 font-family 完全交由消費端 CSS(見 README「字型由消費端
負責」)。聚珍僅守三項非管理性本分:_normalize 之 `jz-* { font: inherit }` 使原子
繼承消費端字型、不強加;_jiya 之半形 feature 顯式保留 `locl`;render 不改寫 `lang`。
切片 1:normalize(禁用層)+ spacing + jiya + jinze。 */
@import "./_normalize.css";
@import "./_spacing.css";
@import "./_jiya.css";
@import "./_jinze.css";
@import "./_orphan.css";
-571
View File
@@ -1,571 +0,0 @@
// 聚珍(Juzhen)公開入口(架構 §3.1)。
//
// const jz = createJuzhen(options);
// jz.render(root?); // 套用已啟用之 pass
// jz.revert(root?); // 還原
//
// 全域(IIFE):window.Juzhen.createJuzhen(...)。
// v1 相容層(createCjkAutospace)已隨最後一批 v1 消費端遷移完成而移除。
import { Finder } from "./core/finder.js";
import { revertOwnedAttribute, setOwnedAttribute } from "./core/dom.js";
import { runPasses, revertPasses } from "./core/pass.js";
import { jiyaAdjacencyPass, jiyaPass } from "./typeset/jiya.js";
import { jinzePass } from "./typeset/jinze.js";
import { levelMarkPass } from "./typeset/levelmark.js";
import { gapTrimPass, lineEdgePass } from "./typeset/lineedge.js";
import { longWordPass } from "./typeset/longword.js";
import { orphanPass } from "./typeset/orphan.js";
import { spacingPass } from "./typeset/spacing.js";
import { resolveRuleset } from "./core/unicode.js";
import type { BiasTable, JiyaOptions, JuzhenOptions, LongWordOptions, OrphanOptions, Pass, RenderContext, ResolvedOptions } from "./types.js";
export type { JuzhenOptions, ResolvedOptions, Pass };
export type { CharClassOverride, BdSubclass, Ruleset, Bias, BiasTable } from "./types.js";
const DEFAULT_STYLE_INLINES = [
"STRONG", "EM", "A", "B", "I", "U", "S", "MARK", "INS", "DEL",
"SUB", "SUP", "SMALL", "ABBR", "CITE", "DFN", "Q", "SPAN",
"VAR", "SAMP", "TIME", "BDO", "BDI", "RUBY", "RB", "RT", "RP", "WBR",
];
const DEFAULT_BLOCK_TAGS = [
"BODY", "P", "DIV", "H1", "H2", "H3", "H4", "H5", "H6", "LI",
"DT", "DD", "BLOCKQUOTE", "FIGURE", "FIGCAPTION", "TD", "TH",
"CAPTION", "SUMMARY", "DETAILS", "HEADER", "FOOTER", "SECTION",
"ARTICLE", "ASIDE", "NAV", "MAIN", "ADDRESS", "PRE", "UL", "OL",
"DL", "TABLE", "TR", "THEAD", "TBODY", "TFOOT", "FORM",
"FIELDSET", "LEGEND", "LABEL", "BR", "HR",
];
// 跳過標籤(avoidsSelf):內部完全不處理、兩端不補間隙。**不含 CODE/KBD**——二者由
// 預設 pillSelector "code, kbd" 以 pill 機制涵蓋(內部不可見+兩端補間隙+斷 run/相鄰),
// 若再列入 skipTags 會與 pill 語義衝突(同元素既 pill 又 avoid、pill 兩端間隙被 avoid 抹掉,
// 違反三分類互斥)。消費端若把 code 移出 pillSelector,須自行加入 skipTags/scope.avoid。
const DEFAULT_SKIP_TAGS = [
"PRE", "SAMP", "TT", "VAR",
"SCRIPT", "STYLE", "TEXTAREA", "INPUT",
];
// 執行序**不由本陣列決定**:runPasses(core/pass.ts)先分 charify/standalone 兩群、
// 各群再按 pass.order 排序(charify 群整體先於 standalone 群)。故此處排列僅供閱讀分組,
// 真實序:charify 群 jiya(20)→longWord(60)(共享單次 P1 走訪),再 standalone 群
// levelMark(10)→jiyaAdjacency(25)→jinze(40)→spacing(50)→orphan(70)→lineEdge(90)→gapTrim(92)。
const PASSES: Pass[] = [
jiyaPass, jiyaAdjacencyPass, longWordPass, spacingPass, jinzePass,
orphanPass, lineEdgePass, gapTrimPass, levelMarkPass,
];
function feature(v: unknown, dflt: boolean): boolean
{
return v === undefined ? dflt : !!v;
}
function resolveLongWord(v: boolean | LongWordOptions | undefined): { minLength: number; lang: string }
{
if (v === false) { return { minLength: 6, lang: "en" }; } // 值不用(enabled 為 false)
if (v === undefined || v === true || v === null) { return { minLength: 6, lang: "en" }; }
// 閾值會直接成為 RegExp 計數量詞;小數/非有限數不能構造合法量詞。
const minLength = typeof v.minLength === "number" && Number.isSafeInteger(v.minLength)
&& v.minLength >= 2 ? v.minLength : 6;
const lang = typeof v.lang === "string" && v.lang ? v.lang : "en";
return { minLength, lang };
}
function resolveOrphan(v: boolean | OrphanOptions | undefined): { chars: number }
{
if (v && typeof v === "object")
{
const chars = typeof v.chars === "number" && Number.isFinite(v.chars) && v.chars >= 1
? Math.floor(v.chars) : 2;
return { chars };
}
return { chars: 2 };
}
function resolveJiya(
v: boolean | JiyaOptions | undefined,
): { halfWidth: "halt" | "margin"; bias: BiasTable }
{
if (v && typeof v === "object")
{
return {
halfWidth: v.halfWidth === "margin" ? "margin" : "halt",
bias: v.bias || {},
};
}
return { halfWidth: "halt", bias: {} };
}
/** 正規化公開選項為內部 ResolvedOptions。 */
export function normalizeOptions(opts: JuzhenOptions = {}): ResolvedOptions
{
const langOpt = opts.lang || {};
const scope = opts.scope || {};
return {
locale: {
default: langOpt.default === undefined ? "zh-Hant" : langOpt.default,
...(langOpt.style ? { style: langOpt.style } : {}),
policy: langOpt.policy || {},
stylePolicy: Array.isArray(langOpt.stylePolicy) ? langOpt.stylePolicy.slice() : [],
},
ruleset: resolveRuleset(opts.charClass),
justifyAtoms: opts.justifyAtoms === false ? false : true,
levelText: (opts.level && opts.level.text) || null,
scope: {
root: scope.root || null,
include: scope.include || null,
avoid: scope.avoid || null,
},
features: {
spacing: feature(opts.spacing, true),
// feature(v,dflt) 對 v===false 即回 false,故無需 ===false 特判(與 jiya/jinze 同構)。
longWord: feature(opts.longWord, true),
jiya: feature(opts.jiya, true),
jinze: feature(opts.jinze, true),
orphan: feature(opts.orphan, true),
biaodian: feature(opts.biaodian, false),
emphasis: feature(opts.emphasis, false),
ruby: feature(opts.ruby, false),
},
longWord: resolveLongWord(opts.longWord),
orphan: resolveOrphan(opts.orphan),
// 行端僅做半形擠壓(lineedge:行首開類收左、行尾閉類收右)。**標點懸掛已移除**
// (§6.5.6):純負 margin 機制為機制級死路(寬度守恆矛盾,CSS 接觸不到斷行器);
// opts.hanging 仍被接受以保 API 相容,但無任何效果。
jiyaConfig: resolveJiya(opts.jiya),
blocks: Array.isArray(opts.blocks) ? opts.blocks.slice() : [],
finder: {
styleInlines: new Set(opts.styleInlines || DEFAULT_STYLE_INLINES),
blockTags: new Set(opts.blockTags || DEFAULT_BLOCK_TAGS),
skipTags: new Set(opts.skipTags || DEFAULT_SKIP_TAGS),
pillSelector: opts.pillSelector || "code, kbd",
// 內外隔離(§4.1 三分類):預設 <mark>(瀏覽器預設帶背景,切分有可見
// 接縫、邊界需留隙)。顯式判 undefined,使 "" 可停用。
isolateSelector: opts.isolateSelector !== undefined ? opts.isolateSelector : "mark",
skipAttribute: opts.skipAttribute || null,
isSkipped: typeof opts.isSkipped === "function" ? opts.isSkipped : null,
},
};
}
function resolveRoot(
arg: Element | string | undefined,
options: ResolvedOptions,
): Element | null
{
const pick = arg !== undefined ? arg : options.scope.root;
if (pick && typeof pick !== "string") { return pick; }
if (typeof pick === "string" && typeof document !== "undefined")
{
return document.querySelector(pick);
}
return typeof document !== "undefined" ? document.body : null;
}
export interface Juzhen
{
render(root?: Element | string): void;
revert(root?: Element | string): void;
/** revert 後重新 render 同一(子)樹(C3 增量便利);等價於 revert(root) + render(root)。
* 頁面局部變化後,對受影響之最近塊調用即可只重處理該子樹、免全樹重掃。 */
rerender(root?: Element | string): void;
/** 清除該(子)樹之內部元素快取(C1)。**revert 已自動失效其子樹**,故僅在「改了既有元素之
* 分類相關屬性(class/data-jz-*/lang 等)但不走 revert」時才需顯式調用,以免下次 render
* 沿用陳舊分類。內容(子節點/文本)變化不改分類、無需 invalidate(但須 revert+render 該塊
* 方能重處理)。 */
invalidate(root?: Element | string): void;
/** 開始觀察 root 子樹之 DOM 變化(C2 增量模式,opt-in):內容/分類屬性變化時自動只重處理
* 受影響之最近塊(revert+render),新增之塊直接 render——免消費端手動 rerender 或全樹重掃。
* **juzhen 自身之變更**(jz-* 注入、data-jz* 屬性)於處理期間暫停觀察、絕不自觸發。需
* MutationObserver(瀏覽器/jsdom 有;無則 no-op)。重複 observe 前自動 disconnect 前者。 */
observe(root?: Element | string): void;
/** 停止 C2 觀察。 */
disconnect(): void;
readonly options: ResolvedOptions;
}
// C2 觀察之屬性白名單:僅「影響分類/處理」之屬性觸發重處理(其餘如 style/title/aria-*
// 不影響 juzhen 輸出,忽略以免無謂重掃)。刻意**不含** data-jz*(juzhen 自身注入)。
const OBSERVE_ATTRS = [
"class", "lang", "id", "contenteditable",
"data-jz-skip", "data-jz-level", "data-jz-style", "data-juzhen", "data-juzhen-off",
];
interface ExpectedClassMutation
{
target: Element;
oldValue: string | null;
newValue: string | null;
}
/** 建立一個聚珍實例。 */
export function createJuzhen(opts: JuzhenOptions = {}): Juzhen
{
const options = normalizeOptions(opts);
// 持久化 Finder(C1):跨 render/revert 復用元素鍵快取(category/level/feature/祖先游走
// 記憶化)。舊版每次 makeCtx new Finder、快取單次 render 即棄;持久化後,未變子樹之快取跨
// render 復用(如串流追加內容時,既有祖先之 level/feature 免重算)。**正確性**靠失效:revert
// 自動清其子樹快取(安全預設),顯式 invalidate 供「改屬性不 revert」之情形。移除之元素由
// WeakMap 自動回收。
const finder = new Finder(options);
const ownedElements = new WeakSet<Element>();
const ownerToken = {};
const renderedRoots = new Set<Element>();
function makeCtx(root: Element): RenderContext
{
return { root, options, finder, ownedElements, ownerToken, setOwnedClass };
}
function renderImpl(el: Element): void
{
renderedRoots.add(el);
runPasses(PASSES, makeCtx(el));
// margin 後備模式以 root 屬性作 CSS 開關(見 css/_jiya.css);halt 模式不設。
if (options.features.jiya && options.jiyaConfig.halfWidth === "margin")
{
setOwnedAttribute(el, "data-jz-halfwidth", "margin", ownerToken);
}
// justifyAtoms:false → jz-char/jz-jinze 改 inline(分頁器相容,見 css)。
if (!options.justifyAtoms)
{
setOwnedAttribute(el, "data-jz-atoms", "inline", ownerToken);
}
}
function rootsWithin(el: Element): Element[]
{
return Array.from(renderedRoots)
.filter((root) => root === el || el.contains(root))
.sort((a, b) => a.contains(b) ? 1 : b.contains(a) ? -1 : 0);
}
/** 精確清理已知 render 根;根可已脫離 document,故不依賴 isConnected。 */
function unwindRoots(roots: Element[]): void
{
for (const root of roots)
{
revertPasses(PASSES, makeCtx(root));
revertOwnedAttribute(root, "data-jz-halfwidth", ownerToken);
revertOwnedAttribute(root, "data-jz-atoms", ownerToken);
renderedRoots.delete(root);
finder.invalidate(root);
}
}
function revertImpl(el: Element): void
{
const roots = rootsWithin(el);
unwindRoots(roots);
if (!roots.includes(el))
{
// 獨立子根以外,仍可能有祖根寫入此子樹的產物,須完整還原請求範圍。
revertPasses(PASSES, makeCtx(el));
}
// 失效(安全預設):清 el 子樹之元素鍵快取,使後續 render 對「屬性已變」之元素重新分類。
// 內容變化雖不改分類(此清屬多餘),但廉價且無害,且 render 本就將重掃該子樹。
finder.invalidate(el);
}
// ----- C2 觀察狀態 -----
let observer: MutationObserver | null = null;
let observeRoot: Element | null = null;
let observerPauseDepth = 0;
let observerActive = false;
const expectedClassMutations: ExpectedClassMutation[] = [];
let expectedClassMutationHead = 0;
const observeConfig: MutationObserverInit = {
subtree: true, childList: true, characterData: true,
attributes: true, attributeOldValue: true, attributeFilter: OBSERVE_ATTRS.slice(),
};
function clearExpectedClassMutations(): void
{
expectedClassMutations.length = 0;
expectedClassMutationHead = 0;
}
function setOwnedClass(element: Element, className: string, present: boolean): void
{
if (!ownedElements.has(element) || element.classList.contains(className) === present) { return; }
const oldValue = element.getAttribute("class");
const tokens = Array.from(element.classList).filter((token) => token !== className);
if (present) { tokens.push(className); }
const newValue = tokens.join(" ");
const shouldExpect = observerActive && observerPauseDepth === 0
&& !!observeRoot && observeRoot.contains(element);
const expectation = shouldExpect ? { target: element, oldValue, newValue } : null;
if (expectation) { expectedClassMutations.push(expectation); }
try
{
if (present) { element.classList.add(className); }
else { element.classList.remove(className); }
}
catch (error)
{
if (expectation) { expectedClassMutations.pop(); }
throw error;
}
if (expectation && element.getAttribute("class") !== newValue)
{
expectedClassMutations.pop();
}
}
function classMutationNewValues(records: MutationRecord[]): Array<string | null | undefined>
{
const values = new Array<string | null | undefined>(records.length);
const nextValues = new Map<Element, string | null>();
for (let i = records.length - 1; i >= 0; i -= 1)
{
const record = records[i]!;
if (record.type !== "attributes" || record.attributeName !== "class") { continue; }
const target = record.target as Element;
values[i] = nextValues.has(target)
? nextValues.get(target)!
: target.getAttribute("class");
nextValues.set(target, record.oldValue);
}
return values;
}
function consumeExpectedClassMutation(
record: MutationRecord,
newValue: string | null | undefined,
): boolean
{
if (record.type !== "attributes" || record.attributeName !== "class") { return false; }
const expected = expectedClassMutations[expectedClassMutationHead];
if (!expected || expected.target !== record.target || expected.oldValue !== record.oldValue
|| expected.newValue !== newValue)
{
return false;
}
expectedClassMutationHead += 1;
return true;
}
function compactExpectedClassMutations(): void
{
if (expectedClassMutationHead === 0) { return; }
if (expectedClassMutationHead === expectedClassMutations.length)
{
clearExpectedClassMutations();
return;
}
// 跨 callback 保留未交付之 expectation;僅在前綴已足夠大且至少佔半時一次性壓縮,
// 避免每 callback 搬移剩餘陣列,保持攤銷線性。
if (expectedClassMutationHead >= 1024
&& expectedClassMutationHead * 2 >= expectedClassMutations.length)
{
expectedClassMutations.splice(0, expectedClassMutationHead);
expectedClassMutationHead = 0;
}
}
// render/revert 期間暫停觀察 → juzhen 自身之 DOM 變更不入觀察隊列、絕不自觸發。
// 深度計數使重入 render 不會於外層未完成時提前恢復觀察。
function pauseObserver(): void
{
observerPauseDepth += 1;
if (observerPauseDepth === 1)
{
// disconnect 會丟棄未交付記錄。先結算既有外部變更,再執行本次手動操作,
// 使局部 revert 仍最後生效,且祖先變更不會漏掉其他兄弟塊。
// 深度先增加,結算中重入的 render/observe 不會提前恢復觀察。
const records = observer ? observer.takeRecords() : [];
const errorWindow = observeRoot?.ownerDocument.defaultView;
observerActive = false;
if (observer) { observer.disconnect(); }
try
{
if (records.length) { onMutations(records); }
}
catch (error)
{
// 原 observer callback 的失敗仍由宿主非同步報告,不冒充手動 API 的例外。
const report = (): void => { throw error; };
if (errorWindow) { errorWindow.queueMicrotask(report); }
else { queueMicrotask(report); }
}
finally { clearExpectedClassMutations(); }
}
}
function resumeObserver(): void
{
if (observerPauseDepth <= 0) { return; }
observerPauseDepth -= 1;
if (observerPauseDepth === 0 && observer && observeRoot)
{
observer.takeRecords(); // 丟棄暫停期間之殘留(保險)
clearExpectedClassMutations();
observer.observe(observeRoot, observeConfig);
observerActive = true;
}
}
function withObserverPaused<T>(run: () => T): T
{
pauseObserver();
try { return run(); }
finally { resumeObserver(); }
}
function withinObservedRoot(node: Node): boolean
{
return !!observeRoot && (node === observeRoot || observeRoot.contains(node));
}
/** node 之最近塊祖先(含自身若為塊);已移出觀察根回 null,無塊則回 observeRoot。 */
function nearestBlock(node: Node): Element | null
{
if (!withinObservedRoot(node)) { return null; }
let e: Element | null = node.nodeType === 1 ? (node as Element) : node.parentElement;
while (e)
{
if (options.finder.blockTags.has(e.nodeName)) { return e; }
if (e === observeRoot) { break; }
e = e.parentElement;
}
return observeRoot;
}
function onMutations(records: MutationRecord[]): void
{
const rerenderBlocks = new Set<Element>(); // 既有塊:revert+render
const renderNew: Element[] = []; // 新增之塊:直接 render(無須 revert)
const removedRoots = new Set<Element>(); // 已脫離之獨立 render 根:先就地清理
const removedSubtrees = records.flatMap((record) =>
record.type === "childList" ? Array.from(record.removedNodes) : []);
const classNewValues = classMutationNewValues(records);
try
{
for (let recordIndex = 0; recordIndex < records.length; recordIndex += 1)
{
const m = records[recordIndex]!;
// 只消費由 setOwnedClass 預先登記、且 target/oldValue/newValue/順序皆精確吻合的
// 內部 class transition。作者或其他實例對同一產物的變更仍走正常增量重排。
if (consumeExpectedClassMutation(m, classNewValues[recordIndex])) { continue; }
if (m.type === "childList")
{
m.addedNodes.forEach((n) =>
{
// 同批記錄可能先加入再移出;以目前觀察範圍判定,不能排版外部父/子樹。
if (!withinObservedRoot(n)) { return; }
if (n.nodeType === 1 && options.finder.blockTags.has(n.nodeName))
{
// 新增之塊:**通常**獨立 render(串流追加免全容器重掃)。例外:若其塊級
// 父 P 原為**葉塊**(帶 `jz-orphan`——垂懸字為唯一「僅作用於葉塊」之 pass),
// 追加塊令 P 轉非葉,全量路徑會撤除 P 之 `jz-orphan`;故此時改重處理 P
// (涵蓋新塊),否則增量會殘留陳舊 orphan(IV-3 差分複驗發現)。純塊容器
// (無直接內容、無 jz-orphan)不觸此例外、仍最小 render 新塊。
const np = n as Element;
const P = np.parentElement;
const pIsBlock = !!P && options.finder.blockTags.has(P.nodeName);
const pHadLeafOrphan = !!P && Array.prototype.some.call(
P.children, (c) => (c as Element).nodeName === "JZ-ORPHAN");
if (pIsBlock && pHadLeafOrphan) { rerenderBlocks.add(P as Element); }
else if (!pIsBlock && P) { const b = nearestBlock(P); if (b) { rerenderBlocks.add(b); } }
else { renderNew.push(np); }
}
else if (n.nodeType === 1 || n.nodeType === 3)
{
// 新增之行內元素/文本 → 所在塊之間隙/禁則邊界變,重處理最近塊。
const b = nearestBlock(m.target);
if (b) { rerenderBlocks.add(b); }
}
});
if (m.removedNodes.length)
{
m.removedNodes.forEach((node) =>
{
if (node.nodeType !== 1) { return; }
for (const root of rootsWithin(node as Element)) { removedRoots.add(root); }
});
const b = nearestBlock(m.target);
if (b) { rerenderBlocks.add(b); }
}
}
else // attributes(白名單)/characterData(文本變化)
{
const b = nearestBlock(m.target);
if (b) { rerenderBlocks.add(b); }
}
}
}
finally { compactExpectedClassMutations(); }
if (!rerenderBlocks.size && !renderNew.length && !removedRoots.size) { return; }
withObserverPaused(() =>
{
// 去嵌套,並在父塊重排之前對脫離子樹做完結構/屬性/佈局清理。
unwindRoots(Array.from(removedRoots)
.sort((a, b) => a.contains(b) ? 1 : b.contains(a) ? -1 : 0));
// 去嵌套:只保留極大塊(移除為他者後代者),免重複重處理(重複亦正確、僅浪費)。
// 未接入 document 的 Element 也可被觀察;生命週期以 observeRoot 的包含關係為準。
const all = Array.from(rerenderBlocks).filter(withinObservedRoot);
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 (!withinObservedRoot(el)) { continue; } // 同批移出觀察範圍
if (blocks.some((b) => b.contains(el))) { continue; } // 已由塊重處理涵蓋
// 搬入的既有塊須撤回舊祖先環境的產物;純新增塊仍直接 render。
const relocated = removedSubtrees.some((node) => node === el || node.contains(el));
const hasOwnedContent = ownedElements.has(el)
|| Array.from(el.querySelectorAll("[data-jz]")).some((node) => ownedElements.has(node));
if (relocated || hasOwnedContent || rootsWithin(el).length) { revertImpl(el); }
else { finder.invalidate(el); } // 曾在其他父鏈查詢過的新插入內容也不能沿用快取。
renderImpl(el);
}
});
}
return {
options,
render(root?: Element | string): void
{
const el = resolveRoot(root, options);
if (!el) { return; }
withObserverPaused(() => renderImpl(el));
},
revert(root?: Element | string): void
{
const el = resolveRoot(root, options);
if (!el) { return; }
withObserverPaused(() => revertImpl(el));
},
rerender(root?: Element | string): void
{
const el = resolveRoot(root, options);
if (!el) { return; }
withObserverPaused(() => { revertImpl(el); renderImpl(el); });
},
invalidate(root?: Element | string): void
{
const el = resolveRoot(root, options);
if (el) { finder.invalidate(el); }
},
observe(root?: Element | string): void
{
if (typeof MutationObserver === "undefined") { return; } // 無版面環境:no-op
const el = resolveRoot(root, options);
if (!el) { return; }
observerActive = false;
if (observer) { observer.disconnect(); }
clearExpectedClassMutations();
observeRoot = el;
observer = new MutationObserver(onMutations);
if (observerPauseDepth === 0)
{
observer.observe(el, observeConfig);
observerActive = true;
}
},
disconnect(): void
{
observerActive = false;
if (observer) { observer.disconnect(); observer = null; observeRoot = null; }
clearExpectedClassMutations();
},
};
}
-228
View File
@@ -1,228 +0,0 @@
// 公開選項型別與內部上下文型別(架構 §3.1/§3.3)。
import type { Finder } from "./core/finder.js";
import type { LangClass, LocaleConfig, PunctStyle, StyleRule } from "./core/locale.js";
import type { Bias, BiasTable, BdSubclass, CharClassOverride, Ruleset } from "./core/unicode.js";
export type { LangClass, PunctStyle, StyleRule, Bias, BiasTable, BdSubclass, CharClassOverride, Ruleset };
/** 長英文詞包裝設定。 */
export interface LongWordOptions
{
/** 有限安全整數且 ≥2;無效值回退 6。 */
minLength?: number;
lang?: string;
}
/** 垂懸字避免設定(§6.5.1)。布林 true 等同 { chars: 2 }。 */
export interface OrphanOptions
{
/** 段末行至少保留之 CJK 實義字數(標點/符號不計;預設 2)。
* 有限值且 ≥1,向下取整;無效值回退 2。 */
chars?: number;
}
/** 標點擠壓設定(架構 §6.3)。布林 true 等同 { halfWidth: "halt" }。 */
export interface JiyaOptions
{
/** 半形渲染機制:
* - "halt"(預設)— 用字型 OpenType `halt`,由字型按字形正確定位,需字型支援;
* - "margin" — 0.5em 盒寬後備模式,供缺 halt 之字型,依字形偏側表定位。 */
halfWidth?: "halt" | "margin";
/** margin 後備模式之字形偏側覆寫表(逐 lang 逐字),覆寫內建參考預設。
* halt 模式不讀此欄。 */
bias?: BiasTable;
}
/** 分塊功能選擇:選擇器 → 該塊內生效之功能集(覆寫全域)。 */
export interface BlockRule
{
selector: string;
features: string[];
}
/** createJuzhen 之公開選項。所有欄位皆可選;預設見 normalizeOptions。 */
export interface JuzhenOptions
{
/** 語言與全角/開明政策。 */
lang?: {
/** 無 lang 屬性(自身及祖先)時之預設語言標籤(預設 "zh-Hant");空字串表示未知。 */
default?: string;
/** 全域標點擠壓預設:quanjiao(全角式)|kaiming(開明式)|
* banjiao(半角式)。覆寫內建逐語言預設,可被 policy 逐 lang/stylePolicy 逐元素覆寫。 */
style?: PunctStyle;
/** 逐 lang 覆寫。 */
policy?: Partial<Record<LangClass, PunctStyle>>;
/** **逐元素**覆寫標點樣式(優先於 lang/policy/style):選擇器命中之元素及其子樹
* 用指定樣式,如非段落/UI 元素用半角式 `[{ selector: "h1,h2,button,.ui", style: "banjiao" }]`。
* 與 HTML 屬性 `data-jz-style="banjiao|kaiming|quanjiao"` 同一機制;近祖先勝、屬性優先於選擇器。 */
stylePolicy?: StyleRule[];
};
/** 自定義字符分類(擴充內置規則集)。把未分類字符或新符號歸入中文/西文/
* 全寬標點各子類,以覆寫 spacing/jiya 等規則之分類判定(追加語義)。 */
charClass?: CharClassOverride;
/** 功能分級標記(§6.5.0a)。段落級功能(禁則/行端/垂懸字/長詞斷字)
* 僅作用於段落級元素;文本級功能(中西間隙/標點擠壓)任意文本皆適用。
* - `text`:選擇器,命中之子樹**僅文本級**(標題/按鈕/UI 文字典型);亦可
* 經元素屬性 `data-jz-level="text"`/`"paragraph"` 標記(屬性優先、最近祖先勝)。
* 自身向上找最近有效訊號,皆無才預設 paragraph;不按 HTML 標籤或 display 推斷。 */
level?: {
text?: string;
};
/** justify 原子化:jz-char/jz-jinze 是否以 inline-block 作對齊單一原子
* (預設 true)。設 false 改 display:inline——用於分頁器(如 Paged.js)之
* 對齊引擎會把每個 inline-block 邊界當伸縮點、與 CJK inter-ideograph 疊加
* 致窄欄散架之情形(halt 半形與 jinze nowrap 仍保留)。 */
justifyAtoms?: boolean;
/** 作用域限定(§3.6)。 */
scope?: {
root?: Element | string;
include?: string;
avoid?: string;
};
// 全域逐功能開關。
spacing?: boolean;
longWord?: boolean | LongWordOptions;
jiya?: boolean | JiyaOptions;
jinze?: boolean;
orphan?: boolean | OrphanOptions;
/** @deprecated 標點懸掛**已移除**(§6.5.6):純負 margin 機制經實測為機制級死路(寬度
* 守恆矛盾,CSS 接觸不到斷行器),未來原生 hanging-punctuation 路徑須另行設計及驗證。此選項仍被接受以保 API 相容,但**無任何效果**。 */
hanging?: boolean;
/** @reserved 標點修正(簡/繁正規化)——架構已預留(切片 #7),**當前無任何效果**。
* 注意:此為功能開關,與 `charClass.biaodian`(標點子類擴充表,語義完全不同)同名,勿混。 */
biaodian?: boolean;
/** @reserved 著重號/裝飾——架構已預留(切片 #8),**當前無任何效果**。 */
emphasis?: boolean;
/** @reserved Ruby/注音——架構已預留(切片 #8),**當前無任何效果**。 */
ruby?: boolean;
/** 分塊功能選擇(§3.6)。 */
blocks?: BlockRule[];
// ----- finder 設定(行內元素三分類,§4.1)-----
//
// 行內元素對排版有且僅有三種口徑:
// 透明(styleInlines) — 遍歷穿越、run 延續、可克隆切分(<strong>/<em> 等純樣式);
// 隔離(isolateSelector)— 兩端如 pill 補間隙、斷 run/斷相鄰、不可切分,
// **內部照常處理**(帶盒樣式者,如背景色 <mark>);
// 整體(pillSelector) — 原子單位:內部對一切 pass 不可見,禁則整體綁定,
// 兩端補間隙(code/kbd/行內公式 .katex 等)。
styleInlines?: string[];
blockTags?: string[];
skipTags?: string[];
/** 整體(pill)元素選擇器(預設 "code, kbd")。pill 之邊界口徑與 isolate 完全
* 相同(行為覆蓋),差異僅在內部不可見;同元素雙選擇器命中時 pill 勝出。 */
pillSelector?: string;
/** 內外隔離元素選擇器(預設 "mark")。傳 "" 停用(mark 回歸透明)。 */
isolateSelector?: string;
skipAttribute?: string;
isSkipped?: (node: Node) => boolean;
}
/** 正規化後之內部選項。 */
export interface ResolvedOptions
{
locale: LocaleConfig;
/** 最終字符分類規則集(內置默認 + charClass 覆寫)。 */
ruleset: Ruleset;
justifyAtoms: boolean;
/** 功能分級之 text-level 選擇器(§6.5.0a);null 表示未設。 */
levelText: string | null;
scope: {
root: Element | string | null;
include: string | null;
avoid: string | null;
};
features: {
spacing: boolean;
longWord: boolean;
jiya: boolean;
jinze: boolean;
orphan: boolean;
biaodian: boolean;
emphasis: boolean;
ruby: boolean;
};
longWord: { minLength: number; lang: string };
/** 垂懸字避免設定(解析後)。 */
orphan: { chars: number };
/** 擠壓半形渲染設定(halt 預設/margin 後備)。 */
jiyaConfig: { halfWidth: "halt" | "margin"; bias: BiasTable };
blocks: BlockRule[];
finder: {
styleInlines: Set<string>;
blockTags: Set<string>;
skipTags: Set<string>;
pillSelector: string;
isolateSelector: string;
skipAttribute: string | null;
isSkipped: ((node: Node) => boolean) | null;
};
}
/** render/revert 之統一上下文(§3.3)。 */
export interface RenderContext
{
root: Element;
options: ResolvedOptions;
finder: Finder;
/** 當前 createJuzhen 實例造出之元素;DOM 屬性不能作為所有權憑證。 */
ownedElements: WeakSet<Element>;
/** 實例不可偽造之屬性寫者身分;狀態由 core/dom 共享堆疊管理。 */
ownerToken: object;
/** 寫入本實例產物之內部 class,並在觀察啟用時留下可精確核對之 mutation provenance。 */
setOwnedClass: (element: Element, className: string, present: boolean) => void;
}
/**
* charify 階段之包裝請求:把 node.data[start, end) 包進 build() 造出之元素。
* 同階段各 pass 之請求須互不重疊(標點/字母段天然不交集)。
*/
export interface WrapRequest
{
start: number;
end: number;
build: (text: string) => HTMLElement;
pass: string;
}
/** pass 種類。 */
export type PassKind = "charify" | "standalone";
/** 功能分級(§6.5.0a):text=任意文本片段皆適用;paragraph=需行/段布局。 */
export type FeatureLevel = "text" | "paragraph";
interface BasePass
{
name: string;
order: number;
kind: PassKind;
/** 功能分級(§6.5.0a)。paragraph 級 pass 對 text-level 元素自動跳過。 */
level: FeatureLevel;
enabled: (options: ResolvedOptions) => boolean;
revert: (ctx: RenderContext) => void;
}
/** 共享 P1 走訪之字元包裝 pass。 */
export interface CharifyPass extends BasePass
{
kind: "charify";
/** 在逐 Text 收集前處理不可被 DOM 分割的跨節點原子。 */
prepare?: (ctx: RenderContext) => void;
collect: (node: Text, ctx: RenderContext) => WrapRequest[];
}
/** 自跑走訪/版面後處理之 pass。 */
export interface StandalonePass extends BasePass
{
kind: "standalone";
render: (ctx: RenderContext) => void;
}
export type Pass = CharifyPass | StandalonePass;
-379
View File
@@ -1,379 +0,0 @@
// 禁則:行首行末避頭尾(架構 §6.4)+ justify 綁定。standalone pass,須在
// charify(jiya)之後(依賴 jz-char)。
//
// 瀏覽器原生(UAX#14)本會避免「行首閉類/點號、行末開類」,但 jiya 把標點
// 原子化為 inline-block jz-char 後,原子邊界成為可斷點、破壞原生避頭尾。本 pass
// 補回:把「不可置行首」之標點(閉類/點號/間隔/連接省略)綁到其前一字,
// 「不可置行末」之標點(開類)綁到其後一字——以 nowrap 之 jz-jinze 包裹,且只
// 隔離單一邊界字(長引文內部仍可正常斷行,不致溢出)。亦為 justify 綁定載體。
//
// 兩階段:①依原始相鄰關係算「禁斷邊界」,對涉及之文本單元切出邊界字;
// ②重掃當前子節點,把以禁斷邊界相連之相鄰原子併入同一 jz-jinze。
import { bdClassOf, createJz, isJz, revertPass } from "../core/dom.js";
import type { Finder } from "../core/finder.js";
import { isolateBoundaryToken } from "../core/split.js";
import {
previousVariationSequenceStart, variationSequenceAtUtf16,
} from "../core/unicode.js";
import type { RenderContext, StandalonePass } from "../types.js";
const PASS = "jinze";
// 不可置於行首(避頭):閉括號引號、點號、間隔號、連接/省略號。
const AVOID_HEAD = new Set([ "bd-close", "bd-pause", "bd-stop", "bd-middle", "bd-liga" ]);
// 不可置於行末(避尾):開括號引號。
const AVOID_TAIL = new Set([ "bd-open" ]);
function isCharEl(n: Node | null): n is Element
{
return !!n && n.nodeType === 1 && (n as Element).nodeName === "JZ-CHAR";
}
/** el 首個「有意義」子節點(跳過空文本節點);無則 null。 */
function firstMeaningfulChild(el: Element): Node | null
{
for (let c = el.firstChild; c; c = c.nextSibling)
{
if (c.nodeType === 3 && (c as Text).data.length === 0) { continue; }
if (c.nodeType !== 1 && c.nodeType !== 3) { continue; }
return c;
}
return null;
}
/** el 末個「有意義」子節點(跳過空文本節點);無則 null。 */
function lastMeaningfulChild(el: Element): Node | null
{
for (let c = el.lastChild; c; c = c.previousSibling)
{
if (c.nodeType === 3 && (c as Text).data.length === 0) { continue; }
if (c.nodeType !== 1 && c.nodeType !== 3) { continue; }
return c;
}
return null;
}
/** node 是否位於 el 之邊緣(head=首/tail=末,跳過空文本)。 */
function atEdge(node: Node, el: Element, side: "head" | "tail"): boolean
{
return node === (side === "head" ? firstMeaningfulChild(el) : lastMeaningfulChild(el));
}
/** 單元之邊界 jz-char:自身即 jz-char,或其邊緣落於**透明**行內元素內之 jz-char——經
* finder.isTransparent 逐層穿越透明元素下探定位(與邏輯流單一真相源一致)。遇非透明
* (pill/isolate/avoid/block)或邊緣非 jz-char 即回 null。 */
function edgeCharEl(node: Node, side: "head" | "tail", finder: Finder): Element | null
{
let n: Node | null = node;
while (n && n.nodeType === 1)
{
if (isCharEl(n)) { return n; }
if (!finder.isTransparent(n as Element)) { return null; }
n = side === "head" ? firstMeaningfulChild(n as Element) : lastMeaningfulChild(n as Element);
}
return null;
}
/** 檢查候選單元之段落/功能/作用域閘;side 僅查邊界,未指定則查整個綁定範圍。 */
function bindingEnabled(node: Node, finder: Finder, side?: "head" | "tail"): boolean
{
if (node.nodeType === 1 || node.nodeType === 3)
{
if (!finder.inScope(node)) { return false; }
}
if (finder.isAvoided(node)) { return false; }
if (node.nodeType === 3)
{
return finder.levelAllows(node, "paragraph") && finder.featureEnabledFor(node, PASS);
}
if (node.nodeType !== 1) { return true; }
const el = node as Element;
const category = finder.category(el);
if (category === "block") { return false; }
if (category === "pill" || (side && category !== "transparent"))
{
// pill 之內部不判定;它以完整原子參與外部交界。
return finder.levelAllows(el, "paragraph") && finder.featureEnabledFor(el, PASS);
}
if (side)
{
const child = side === "head" ? firstMeaningfulChild(el) : lastMeaningfulChild(el);
return child ? bindingEnabled(child, finder, side) : true;
}
for (let c = el.firstChild; c; c = c.nextSibling)
{
if (c.nodeType === 3 && (c as Text).data.length === 0) { continue; }
if (!bindingEnabled(c, finder)) { return false; }
}
return true;
}
/** 將完整碼點及 base+VS 跨度映射為直接子節點間的禁斷邊界。 */
function atomicBoundaries(parent: Element, finder: Finder): Map<Node, Set<Node>>
{
const boundaries = new Map<Node, Set<Node>>();
const directChildren = new Map<Node, Node | null>();
const directChild = (node: Node): Node | null =>
{
const chain: Node[] = [];
let current: Node | null = node;
let result: Node | null = null;
while (current && current !== parent)
{
if (directChildren.has(current)) { result = directChildren.get(current)!; break; }
chain.push(current);
if (current.parentNode === parent) { result = current; break; }
current = current.parentNode;
}
for (const part of chain) { directChildren.set(part, result); }
return result;
};
for (const run of finder.collectRuns(parent, true))
{
for (const atom of run.map)
{
let previous: Node | null = null;
for (const part of atom.parts)
{
const current = directChild(part.textNode);
if (previous && current && previous !== current)
{
let next = boundaries.get(previous);
if (!next) { next = new Set(); boundaries.set(previous, next); }
next.add(current);
}
previous = current;
}
}
}
return boundaries;
}
/** 邊界標點及跨 Text 原子決定禁斷;透明行內之邊緣標點也參與判定。 */
function forbiddenBreak(
a: Node, b: Node, finder: Finder, atoms: Map<Node, Set<Node>>, wholeUnits = false,
): boolean
{
// 先判雙側邊界;切分後再判整個候選原子,避免不可切退化帶入排除內容。
if (!bindingEnabled(a, finder, wholeUnits ? undefined : "tail")
|| !bindingEnabled(b, finder, wholeUnits ? undefined : "head")) { return false; }
if (atoms.get(a)?.has(b)) { return true; } // 不在跨 Text 的 surrogate/base+VS 原子內造斷點。
const head = edgeCharEl(b, "head", finder);
if (head)
{
const c = bdClassOf(head);
if (c && AVOID_HEAD.has(c) && finder.featureEnabledFor(head, PASS)) { return true; }
}
const tail = edgeCharEl(a, "tail", finder);
if (tail)
{
const c = bdClassOf(tail);
if (c && AVOID_TAIL.has(c) && finder.featureEnabledFor(tail, PASS)) { return true; }
}
return false;
}
/** 節點在文檔樹中之深度(祖先數)。供 render 之「深者先處理」排序。 */
function depthOf(node: Node): number
{
let d = 0;
let p: Node | null = node.parentNode;
while (p) { d += 1; p = p.parentNode; }
return d;
}
// 「詞元」:CJK 字為單字詞元(可任意斷);連續非 CJK 非空白(拉丁詞/數字/
// 百分號/GPT-4/3.5 等)為一個不可內斷之詞元。綁定須以整個邊界詞元為單位,
// 否則會把 200% 拆成 200|%,在其間製造斷點(修正:閉類綁定不應切斷數字/詞)。
function isTokenChar(ch: string, anyCjk: RegExp): boolean
{
return !anyCjk.test(ch) && !/\s/.test(ch);
}
function leadingTokenEnd(d: string, anyCjk: RegExp): number
{
const first = variationSequenceAtUtf16(d, 0);
if (anyCjk.test(first)) { return first.length; }
let e = first.length;
while (e < d.length)
{
const ch = variationSequenceAtUtf16(d, e);
if (!isTokenChar(ch, anyCjk)) { break; }
e += ch.length;
}
return e;
}
function trailingTokenStart(d: string, anyCjk: RegExp): number
{
let s = previousVariationSequenceStart(d, d.length);
if (anyCjk.test(variationSequenceAtUtf16(d, s))) { return s; }
while (s > 0)
{
const previous = previousVariationSequenceStart(d, s);
if (!isTokenChar(variationSequenceAtUtf16(d, previous), anyCjk)) { break; }
s = previous;
}
return s;
}
/** 隔離文本節點之首/末「詞元」為獨立節點(供綁定,使中段仍可斷行)。 */
function splitBoundaries(t: Text, needFirst: boolean, needLast: boolean, anyCjk: RegExp): void
{
let node: Text = t;
if (needFirst)
{
const e = leadingTokenEnd(node.data, anyCjk);
if (e < node.data.length) { node = node.splitText(e); }
}
if (needLast)
{
const s = trailingTokenStart(node.data, anyCjk);
if (s > 0) { node.splitText(s); }
}
}
function processParent(
parent: Node,
finder: Finder,
anyCjk: RegExp,
ownedElements: WeakSet<Element>,
): void
{
// 冪等(I6):parent 自身即本 pass 產物——二次 render 未 revert 時,jz-char 之父
// 正是上輪產生之 jz-jinze,若不擋則對已綁定內容再包一層、巢狀增殖(下游審計實測 3→6)。
if (isJz(parent, PASS)) { return; }
// 冪等:parent 已含 jz-jinze 子(上輪已處理此 parent)則跳過。
for (let c = parent.firstChild; c; c = c.nextSibling)
{
if (c.nodeType === 1 && (c as Element).nodeName === "JZ-JINZE") { return; }
}
// 階段 1:原始單元(非空文本 / 元素)→ 算禁斷邊界 → 切邊界字。
const units: Node[] = [];
for (let c = parent.firstChild; c; c = c.nextSibling)
{
if (c.nodeType === 3 && (c as Text).data.length > 0) { units.push(c); }
else if (c.nodeType === 1) { units.push(c); }
}
const n = units.length;
if (n < 2) { return; }
const forbidden: boolean[] = [];
const atoms = atomicBoundaries(parent as Element, finder);
for (let i = 0; i < n - 1; i += 1)
{
forbidden.push(forbiddenBreak(units[i]!, units[i + 1]!, finder, atoms));
}
for (let i = 0; i < n; i += 1)
{
const u = units[i]!;
const needFirst = i > 0 && forbidden[i - 1]!;
const needLast = i < n - 1 && forbidden[i]!;
if (!needFirst && !needLast) { continue; }
if (u.nodeType === 3)
{
splitBoundaries(u as Text, needFirst, needLast, anyCjk);
}
else if (u.nodeType === 1)
{
// 元素單元(樣式行內如 <strong>)最小綁定(下游 Bug:整元素入 nowrap inline-block
// 致行末整體掉行):只隔離與 jz-char 相鄰側之邊界詞元,餘下留原位。雙側禁斷時先
// tail 後 head——head 作用於切剩之左半(仍為原元素 u)。不可切之元素(含 pill/
// isolate,由 canSplit 諮詢 finder 判定)可退化整綁;階段 2 再檢查整個單元,
// 避免把 avoid/text/off 後代一併帶入。pill 以完整原子參與外部交界。
const el = u as Element;
if (needLast)
{
isolateBoundaryToken(el, "tail", anyCjk, PASS, finder, ownedElements);
}
if (needFirst)
{
isolateBoundaryToken(el, "head", anyCjk, PASS, finder, ownedElements);
}
}
}
// 階段 2:重掃當前子節點,把禁斷相連之相鄰原子併成 jz-jinze。
const kids: Node[] = [];
for (let c = parent.firstChild; c; c = c.nextSibling)
{
if (c.nodeType === 1 || (c.nodeType === 3 && (c as Text).data.length > 0)) { kids.push(c); }
}
const splitAtoms = atomicBoundaries(parent as Element, finder);
let i = 0;
while (i < kids.length)
{
const run: Node[] = [ kids[i]! ];
while (i + 1 < kids.length && forbiddenBreak(kids[i]!, kids[i + 1]!, finder, splitAtoms, true))
{
run.push(kids[i + 1]!);
i += 1;
}
if (run.length >= 2)
{
const group = createJz(ownedElements, "jz-jinze", PASS, "wrap");
parent.insertBefore(group, run[0]!);
// 一併搬入單元間註解/空 Text,保持模板錨點和原始次序,回退可完整復原。
const last = run[run.length - 1]!;
let node: Node | null = run[0]!;
while (node)
{
const next: Node | null = node.nextSibling;
group.appendChild(node);
if (node === last) { break; }
node = next;
}
}
i += 1;
}
}
export const jinzePass: StandalonePass = {
name: PASS,
kind: "standalone",
order: 40,
level: "paragraph",
enabled: (o) => o.features.jinze,
render(ctx: RenderContext): void
{
const { finder } = ctx;
// 詞元邊界之 CJK 判定取自規則集(含 charClass 覆寫),與 spacing/jiya 一致。
const anyCjk = new RegExp("[" + ctx.options.ruleset.cjk + "]", "u");
const parents = new Set<Node>();
ctx.root.querySelectorAll("jz-char").forEach((c) =>
{
if (!ctx.ownedElements.has(c)) { return; }
// 功能分級閘(§6.5.0a):禁則為段落級,text-level 子樹(標題/按鈕)跳過。
if (!finder.levelAllows(c, "paragraph")) { return; }
// 收集 c 之直接父,並沿**透明**元素邊緣上攀:c 若位於某透明元素之邊緣,該元素
// 於更外層為一單元、其邊界字(c)可與外層兄弟跨界綁定(如 好↔<strong>。世界),
// 故該外層 parent 亦須處理(下游審計 #3:僅處理直接父致跨透明邊界避頭尾漏綁)。
let child: Node = c;
let parent: Node | null = c.parentNode;
while (parent && parent.nodeType === 1)
{
parents.add(parent);
const pe = parent as Element;
if (pe === ctx.root) { break; } // 單一行內 root 不得攀出 render 作用域。
if (!finder.isTransparent(pe)) { break; }
if (!atEdge(child, pe, "head") && !atEdge(child, pe, "tail")) { break; }
child = pe;
parent = pe.parentNode;
}
});
// 深者先處理:透明子先於可能將其切分之外層 parent,避免子之內部綁定被外層切分擾動。
const ordered = Array.from(parents).sort((a, b) => depthOf(b) - depthOf(a));
for (const parent of ordered)
{
processParent(parent, finder, anyCjk, ctx.ownedElements);
}
},
revert(ctx: RenderContext): void
{
revertPass(PASS, ctx.root, ctx.ownedElements);
},
};
-247
View File
@@ -1,247 +0,0 @@
// 標點擠壓(架構 §6.3)。完整 clreq 擠壓之「行內」部分——三條規則並集,
// 以 jz-half 標記半形,CSS 以 halt 或 margin 後備渲染,
// 不依賴瀏覽器自動 text-spacing-trim。
//
// 機制 C baseline 寬度政策(依 lang,I3):
// 全角式 quanjiao — 一律不收;
// 開明式 kaiming — 句內點號(,、;:)+ 開類/閉類括號引號半形,
// 句末點號(。!?)保持全形;
// 半角式 banjiao — 全部標點半形。
// 機制 A 行內連續擠壓(jiyaAdjacencyPass,lang 無關):相鄰標點之對向空白
// 對消一次(每相鄰對省半形),如 。」→ 。收右 → 1.5em。
// 機制 B 行端擠壓 → typeset/lineedge.ts(layout pass,瀏覽器專用)。
//
// 同時為 §8:所有 jz-char 經 CSS 設 inline-block,成 justify 單一原子項(I5)。
import { bdClassOf, createJz, revertPass } from "../core/dom.js";
import { revertCrossAtoms, wrapCrossAtom } from "../core/cross-atom.js";
import { classify, effectiveLang, resolveStyle } from "../core/locale.js";
import type { LangClass, PunctStyle } from "../core/locale.js";
import {
blankSides, classifyBiaodian, codePointAtUtf16, inkBias, variationSequenceAtUtf16,
} from "../core/unicode.js";
import type { CharifyPass, RenderContext, StandalonePass, WrapRequest } from "../types.js";
const PASS = "jiya";
/** baseline 政策下,該標點是否擠成半形(機制 C)。半形以字型 halt 渲染,
* 方向由字型決定,故此處只需「半形與否」之布林。 */
function baselineHalf(style: PunctStyle, bdClass: string): boolean
{
if (style === "quanjiao") { return false; }
if (style === "banjiao")
{
// 半角式:所有可壓縮標點半形(句末亦半)。
return bdClass === "bd-pause" || bdClass === "bd-stop"
|| bdClass === "bd-open" || bdClass === "bd-close";
}
// kaiming:句內點號+括號引號半形,句末點號保持全形。
return bdClass === "bd-pause" || bdClass === "bd-open" || bdClass === "bd-close";
}
/** parent 已是 jz-char(本 pass 產物)→ 冪等跳過。 */
function inJzChar(node: Node): boolean
{
let p = node.parentNode;
while (p && p.nodeType === 1)
{
const name = (p as Element).nodeName;
if (name === "JZ-CHAR" || name === "JZ-INNER") { return true; }
p = p.parentNode;
}
return false;
}
// ----- charify + baseline(機制 C)-----
export const jiyaPass: CharifyPass = {
name: PASS,
kind: "charify",
order: 20,
level: "text",
enabled: (o) => o.features.jiya,
prepare(ctx): void
{
const { finder, options } = ctx;
const roots = [ ctx.root ];
const selector = Array.from(options.finder.blockTags).map((tag) => tag.toLowerCase()).join(",");
if (selector) { roots.push(...Array.from(ctx.root.querySelectorAll(selector))); }
for (const root of roots)
{
if (finder.isAvoided(root) || finder.insidePill(root) || finder.pillMatches(root)) { continue; }
for (const run of finder.collectRuns(root, true, PASS))
{
for (let i = run.map.length - 1; i >= 0; i -= 1)
{
const atom = run.map[i]!;
if (atom.parts.length < 2 || atom.parts.some((part) =>
inJzChar(part.textNode) || !finder.featureEnabledFor(part.textNode, PASS))) { continue; }
const first = atom.parts[0]!.textNode;
const bdClass = classifyBiaodian(atom.text, options.ruleset.biaodian);
if (!bdClass) { continue; }
const style = resolveStyle(first, options.locale);
const lang = classify(effectiveLang(first, options.locale.default));
const classes = baselineHalf(style, bdClass) ? [ bdClass, "jz-half" ] : [ bdClass ];
const bias = inkBias(codePointAtUtf16(atom.text, 0), lang, options.jiyaConfig.bias);
if (bias) { classes.push("jz-ink-" + bias); }
const outer = createJz(ctx.ownedElements, "jz-char", PASS, "wrap", { classes });
const inner = createJz(ctx.ownedElements, "jz-inner", PASS, "wrap");
wrapCrossAtom(atom, outer, inner, PASS, ctx);
}
}
}
},
collect(node, ctx): WrapRequest[]
{
if (inJzChar(node)) { return []; }
if (!ctx.finder.featureEnabledFor(node, PASS)) { return []; }
const data = node.data;
const out: WrapRequest[] = [];
let style: PunctStyle | null = null;
let langClass: LangClass | null = null;
const biasOverrides = ctx.options.jiyaConfig.bias;
const biaodian = ctx.options.ruleset.biaodian;
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) { style = resolveStyle(node, ctx.options.locale); }
if (langClass === null)
{
langClass = classify(effectiveLang(node, ctx.options.locale.default));
}
const classes = baselineHalf(style, bdClass) ? [ bdClass, "jz-half" ] : [ bdClass ];
// 字形偏側 class(jz-ink-*)— 供 margin 後備模式收對側半形空白;halt 模式
// 之 CSS 不讀,無害。一律於 charify 標記(偏側為字形固有屬性,與何時擠壓
// 無關),鄰接/行端 pass 後續加 jz-half 即可沿用。
const bias = inkBias(codePointAtUtf16(ch, 0), langClass, biasOverrides);
if (bias) { classes.push("jz-ink-" + bias); }
out.push({
start: i,
end: i + ch.length,
pass: PASS,
build: (text) =>
{
const outer = createJz(
ctx.ownedElements, "jz-char", PASS, "wrap", { classes },
);
const inner = createJz(
ctx.ownedElements, "jz-inner", PASS, "wrap", { text },
);
outer.appendChild(inner);
return outer;
},
});
i += ch.length;
}
return out;
},
revert(ctx: RenderContext): void
{
const touched = revertCrossAtoms(PASS, ctx);
revertPass(PASS, ctx.root, ctx.ownedElements);
for (const parent of touched) { (parent as Element).normalize?.(); }
},
};
// ----- 行內連續擠壓(機制 A)-----
//
// 對「邏輯相鄰」之標點對 (L, R)——其間只隔**透明行內邊界**(樣式行內 strong/em…),
// 無文本、無非透明元素、不跨 block——挤掉兩者「指向彼此」之空白:**兩側獨立判斷**
// (非二選一)——L 右側有空白則收 L(半形),R 左側有空白則收 R。故:
// ・,「(pause 右空白 + open 左空白):兩側皆收 → 合占 1em。
// ・。」(stop 右空白 + close 右空白,」左為墨):只收 。→ 全角式 1.5em/開明式
// 1em(」baseline 已半)。
// ・。「(句末點號 + 開類):此為**句界**——保留句末點號右側空白(不收 L),只收
// 開類左側 → 全角/開明皆 1.5em(對稱於 。」)。**唯一例外**:限句末點號(。.!?)
// 後接開類;句內點號 ,「 與雙括號 」「/》《 仍兩側皆收 1em(屬同句/貼合,非句界)。
// ・·—(中點/連接,皆無指向對方之空白):皆不收 → 2em(不宜挤之例外)。
// 機制依 clreq「相鄰空白重疊即挤」。**透明穿越**:以從 root 之遍歷取代原「同 parent
// 直接兄弟」掃描,使跨 strong/em 邊界之連續標點亦正確配對(下游回饋;block 邊界與
// avoid 子樹斷相鄰)。半形以 halt 渲染、冪等並集,與 baseline 疊加。class 掛於
// charify 之 jz-char,隨其解包清除,本 pass 無獨立產物。
export const jiyaAdjacencyPass: StandalonePass = {
name: "jiyaAdjacency",
kind: "standalone",
order: 25,
level: "text",
enabled: (o) => o.features.jiya,
render(ctx: RenderContext): void
{
const { finder } = ctx;
let prev: Element | null = null;
const squeeze = (L: Element, R: Element): void =>
{
if (!finder.featureEnabledFor(L, PASS) || !finder.featureEnabledFor(R, PASS)) { return; }
const lClass = bdClassOf(L);
const rClass = bdClassOf(R);
const lSides = blankSides(lClass);
const rSides = blankSides(rClass);
// 句界例外:句末點號後接開類(。「)保留句末點號右側空白,不收 L(→ 1.5em,
// 對稱於 。」)。僅此一例;句內點號 ,「 與雙括號 」「 仍照常兩側收。
const stopBeforeOpen = lClass === "bd-stop" && rClass === "bd-open";
if (lSides.right && !stopBeforeOpen) { L.classList.add("jz-half"); }
if (rSides.left) { R.classList.add("jz-half"); }
};
const visit = (node: Node): void =>
{
for (let c: Node | null = node.firstChild; c; c = c.nextSibling)
{
if (c.nodeType === 3)
{
if (finder.isAvoided(c) || !finder.inScope(c)
|| !finder.featureEnabledFor(c, PASS) || (c as Text).data.length > 0)
{
prev = null; // 排除空 Text 也是邊界,普通空 Text 仍透明。
}
continue;
}
if (c.nodeType !== 1) { continue; } // 註解等:略過、不斷
const el = c as Element;
const nm = el.nodeName;
if (nm === "JZ-CHAR")
{
if (!ctx.ownedElements.has(el) || !finder.featureEnabledFor(el, PASS)) { prev = null; continue; }
if (prev) { squeeze(prev, el); }
prev = el; // jz-char 為原子,不下探(內含 jz-inner)
}
else
{
switch (finder.category(el)) // 與 collectRuns 共用同一分類(§4.1)
{
case "avoid":
case "pill":
{
prev = null; // 斷相鄰、不下探(內容不可見/不處理)
break;
}
case "transparent":
{
const boundary = !finder.featureEnabledFor(el, PASS);
if (boundary) { prev = null; }
visit(el); // 穿越、相鄰性延續
if (boundary) { prev = null; }
break;
}
default: // isolate/block/opaque(未知行內)
{
prev = null; visit(el); prev = null; // 斷相鄰、內部自成脈絡
break;
}
}
}
}
};
visit(ctx.root);
},
revert(): void
{
// 無獨立產物:class 掛於 jiya charify 之 jz-char,隨其解包清除。
},
};
-76
View File
@@ -1,76 +0,0 @@
// 塊級分類鉤子(下游建議七):把 resolved 功能級別寫回 DOM,供消費端 CSS 查詢。
//
// 消費端的段落級樣式(首要:CJK 兩端對齊 text-align:justify)語義上應與本庫的
// 段落級 pass(jinze 避頭尾/orphan 垂懸)作用於同一集合;但 level 判定原本只存
// 於內部快取、不寫回 DOM,消費端只能另行維護一份 CSS 白名單,兩份分類必然漂移
// (下游實例:.callout 綁了避頭尾卻不在 justify 名單,避頭尾與兩端對齊不一致)。
// 本 pass 把單一真相源暴露出去:
//
// render → 對每個「直接承載可處理文本」之塊寫 data-jz-block-level="paragraph|text"
// revert → 全數移除(I6)
//
// **僅標直接承載文本之塊**:純容器(body/ul/table…——直接文本僅空白)不標。
// 否則 body 恆被判 paragraph,消費端 `[data-jz-block-level="paragraph"]` 之
// justify 經繼承污染全頁,鉤子即廢。此集合恰為段落級 pass 實際作用之塊集
// (collectRuns 對純容器收不到 run)。avoid 子樹/pill 內部/域外文本不計
// (eachTextNode 同口徑),故未被處理之塊一致不標。
//
// 輸入/輸出分離:作者輸入屬性 data-jz-level 只讀不寫;本屬性為 resolved 輸出、
// 本庫絕不讀之(亦不在 C2 OBSERVE_ATTRS 白名單,外部改動不觸發重處理)。是否
// 對齊仍由消費端 CSS 決定(justify 非段落之純函數——窄欄邊注可 opt-out),本庫
// 只供分類、不介入樣式。
//
// order=10:standalone 群之首(於 charify 群後執行;charify 之 jz-* 包裹皆行內
// 元素,不改變「文本之最近塊祖先」,故時點無影響)。
import { revertOwnedAttributes, setOwnedAttribute } from "../core/dom.js";
import type { RenderContext, StandalonePass } from "../types.js";
const ATTR = "data-jz-block-level";
export const levelMarkPass: StandalonePass = {
name: "levelMark",
kind: "standalone",
order: 10,
level: "text", // 不受段落級閘限制:text 級之塊亦須標記(標記本身即分級輸出)
enabled: () => true, // 分類鉤子非排版功能,恆開(純屬性輸出、零版面影響)
render(ctx: RenderContext): void
{
const { finder, options } = ctx;
const blockSet = options.finder.blockTags;
// 近塊定位之 per-run 快取(兄弟文本節點共用 parentElement,免重複上攀)。
// 上攀止於 ctx.root:eachTextNode 之文本必在 root 內、鏈上元素亦然,故命中
// 之塊恆在 render 作用域內,revert(root) 必能移除;root 非塊時不越界外標。
const nearest = new Map<Element, Element | null>();
const nearestBlock = (
(p: Element): Element | null =>
{
const cached = nearest.get(p);
if (cached !== undefined) { return cached; }
let e: Element | null = p;
let block: Element | null = null;
while (e)
{
if (blockSet.has(e.nodeName)) { block = e; break; }
if (e === ctx.root) { break; }
e = e.parentElement;
}
nearest.set(p, block);
return block;
}
);
finder.eachTextNode(ctx.root, (t) =>
{
if (!/\S/.test(t.data)) { return; } // 純空白(縮排/換行)不算承載文本
const p = t.parentElement;
const block = p ? nearestBlock(p) : null;
if (!block) { return; }
const level = finder.levelFor(block);
setOwnedAttribute(block, ATTR, level, ctx.ownerToken);
});
},
revert(ctx: RenderContext): void
{
revertOwnedAttributes(ctx.root, ATTR, ctx.ownerToken);
},
};
-373
View File
@@ -1,373 +0,0 @@
// 行端標點處理(架構 §6.4/§6.5;clreq 行首行尾擠壓)。機制 B。
// standalone layout pass:量測各 jz-char 之行盒位置,偵測「行首開類」「行尾閉類」,
// 收為行端半形(jz-half-le,CSS 同 halt):行首開類收左、行尾閉類收右。掛 ResizeObserver
// 於 root,容器寬度變動時重算;字型載入後亦重算。
//
// 註:原計劃之**標點懸掛**(行端標點負 margin 凸出版心)經實測為機制級死路(寬度守恆
// 矛盾:行端盒非零寬則墨色出去多少版心內就空多少、零寬則釋放一字寬致下一字拉上重疊;
// 純 CSS 接觸不到斷行器),已於 §6.5.6 移除。未來可評估原生 hanging-punctuation
// (引擎能力及與本庫原子之互動須另行驗證),屬遠期「原生管線」新功能、與此處無共享。
//
// ⚠ 純版面、無法於 jsdom 驗證(getClientRects 回傳 0);故無 ResizeObserver
// 之環境(jsdom)直接跳過,僅於真實瀏覽器生效(架構 §12/R1/R2)。須跨
// Chrome/Safari/Firefox 目視核對。
import { bdClassOf } from "../core/dom.js";
import {
previousVariationSequenceStart, variationSequenceAtUtf16,
} from "../core/unicode.js";
import type { Finder } from "../core/finder.js";
import type { RenderContext, ResolvedOptions, StandalonePass } from "../types.js";
const LE = "jz-half-le"; // 行端半形(CSS 同 halt)
const ALL_EDGE = [ LE ];
const EPS = 2; // px:行盒比較之亞像素容差。
// 行端合格之 bd 子類:head=行首左緣偵測(開類),tail=行尾右緣偵測(閉類)。
// 僅括號引號;點號於行端保持全形(避免孤立句末誤收,實測困惑來源)。
const HEAD_CLASSES = new Set([ "bd-open" ]);
const TAIL_CLASSES = new Set([ "bd-close" ]);
// 每 root 各 layout pass(以 key 區分)一個 ResizeObserver。
//
// **跨 observer 派發序不變量(已於 Chrome headless 實測確認)**:resize 時 ResizeObserver
// 之回調按 **observer 創建順序** 派發(ResizeObserver 規範遍歷 observers 列表之插入序,非
// observe 序)。lineEdgePass(order 90)先於 gapTrimPass(order 92)執行、故其 observer 先
// 建,於每次 resize 皆先派發——確保 gapTrim 之行末去隙判定恆基於 lineEdge **已套用**之半形
// 版面(非陳舊態)。**勿調換兩 pass 之 order 或創建序**,否則 gapTrim 可能以 resize 前之半形
// 狀態誤判行末(且 ResizeObserver 只觀 root 尺寸、不因內部 class 變化重觸發,錯誤會持續)。
const observers = new WeakMap<
Element,
Map<object, Map<string, ResizeObserver>>
>();
function hasLayout(): boolean
{
return typeof ResizeObserver !== "undefined";
}
/** 安裝一個 layout 重算:立即跑一次,掛 ResizeObserver,字型載入後再跑。 */
function installLayout(
root: Element,
owner: object,
key: string,
run: () => void,
): void
{
run();
let owners = observers.get(root);
if (!owners) { owners = new Map(); observers.set(root, owners); }
let m = owners.get(owner);
if (!m) { m = new Map(); owners.set(owner, m); }
const existing = m.get(key);
if (existing) { existing.disconnect(); }
// 字型 Promise/已排隊回調可能晚於 revert 或重裝;只允許目前安裝執行。
const runCurrent = (): void => { if (m!.get(key) === ro) { run(); } };
const ro = new ResizeObserver(runCurrent);
m.set(key, ro);
ro.observe(root);
const fonts = (root.ownerDocument as Document & { fonts?: { ready?: Promise<unknown> } }).fonts;
if (fonts && fonts.ready && typeof fonts.ready.then === "function")
{
fonts.ready.then(runCurrent, () => undefined);
}
}
function uninstallLayout(root: Element, owner: object, key: string): void
{
const owners = observers.get(root);
const m = owners?.get(owner);
if (!m) { return; }
const ro = m.get(key);
if (ro) { ro.disconnect(); m.delete(key); }
if (m.size === 0) { owners!.delete(owner); }
}
// 無計算樣式時之已知行內標籤後備。真實版面以 computed display 判斷物理行內流,
// 可跨 jz-jinze/jz-orphan、自訂 isolate/opaque,於 block 或可見 BR 停止。
const JZ_WRAP_TAGS = [ "JZ-JINZE", "JZ-CHAR", "JZ-INNER", "JZ-HWS", "JZ-ORPHAN" ];
function buildWrapSet(options: ResolvedOptions): Set<string>
{
const s = new Set<string>(JZ_WRAP_TAGS);
for (const name of options.finder.styleInlines) { s.add(name); }
return s;
}
/** 邏輯 isolate/opaque 不代表物理斷行;實際行內盒與 display:contents 可攀出。 */
function inlineFlow(el: Element, wrapSet: Set<string>): boolean
{
const display = el.ownerDocument.defaultView?.getComputedStyle(el).display;
if (display)
{
return display === "contents" || display.startsWith("inline") || display.startsWith("ruby");
}
// 無計算樣式的 DOM 測試仍可按已知行內標籤建立幾何模型。
return wrapSet.has(el.nodeName);
}
function rectOf(n: Node, side: "prev" | "next"): DOMRect | null
{
if (n.nodeType === 3 && (n as Text).data.length > 0)
{
const t = n as Text;
const doc = t.ownerDocument;
if (!doc) { return null; }
const range = doc.createRange();
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 + length);
const rects = range.getClientRects();
if (rects.length) { return rects[side === "prev" ? rects.length - 1 : 0]!; }
}
else if (n.nodeType === 1)
{
const rects = (n as Element).getClientRects();
if (rects.length) { return rects[side === "prev" ? rects.length - 1 : 0]!; }
}
return null;
}
/** 取側邊界內容之矩形;跨物理行內盒攀爬,於 block/可見 BR 視為行端。
* 邏輯 isolate/opaque 不等於物理換行,pill 內部不由本庫生成標點原子。 */
function edgeRect(
start: Node | null,
side: "prev" | "next",
from: Node,
wrapSet: Set<string>,
): DOMRect | null
{
let n = start;
let parentRef: Node | null = from.parentNode;
for (;;)
{
while (n)
{
// BR 的矩形可能仍屬前一行,不能當成同一行的後續內容。隱藏 BR 不斷行。
if (n.nodeType === 1 && (n as Element).nodeName === "BR"
&& (n as Element).ownerDocument.defaultView?.getComputedStyle(n as Element).display !== "none")
{
return null;
}
const r = rectOf(n, side);
if (r) { return r; }
n = side === "prev" ? n.previousSibling : n.nextSibling;
}
// 本層無內容:若父為行內包裹,攀出續找其鄰居。
if (!parentRef || parentRef.nodeType !== 1) { return null; }
if (!inlineFlow(parentRef as Element, wrapSet)) { return null; }
n = side === "prev" ? parentRef.previousSibling : parentRef.nextSibling;
parentRef = parentRef.parentNode;
}
}
function firstRect(el: Element): DOMRect | null
{
const rects = el.getClientRects();
return rects.length ? rects[0]! : null;
}
// 行歸屬比較須以**垂直區間**而非單純 top 差判定:jz-char 為 inline-block,其行盒矩形
// 之 top 與相鄰純文本字形矩形之 top 因基線/盒高差異常差數像素(實測 ~5px),遠小於
// 行高(如 2em=40px)卻足以超過小容差 → 誤判同行為「下一行」(hang 模式句末 。 過度
// 觸發之根因)。改判「b 是否整體落於 a 之下一行」= b.top ≥ a.bottom(容 EPS 亞像素),
// 同行(垂直重疊)則否。對稱地,higherLine 判 b 在 a 之上一行。
function lowerLine(a: DOMRect, b: DOMRect): boolean
{
return b.top >= a.bottom - EPS;
}
function higherLine(a: DOMRect, b: DOMRect): boolean
{
return b.bottom <= a.top + EPS;
}
// 元素之**最後**一個行盒矩形。jz-hws 之 margin-right 落在其內容末尾之 fragment,
// 故行末判定須以此 rect(而非 firstRect)為準——否則包裹會跨行之 pill 時,firstRect
// 取到首行矩形、與末行之後續內容比較會誤判(見 trimGaps)。
function lastRect(el: Element): DOMRect | null
{
const rects = el.getClientRects();
return rects.length ? rects[rects.length - 1]! : null;
}
function clearEdge(ctx: RenderContext): void
{
for (const cls of ALL_EDGE)
{
ctx.root.querySelectorAll("jz-char." + cls).forEach((e) =>
{
if (ctx.ownedElements.has(e)) { ctx.setOwnedClass(e, cls, false); }
});
}
}
interface EdgeDecision { el: Element; }
/**
* 行端偵測 + 半形套用(行首開類收左、行尾閉類收右)。
*
* **兩階段免自我應驗**:先 clearEdge 清除全部行端半形,再於「自然(無行端效果)佈局」下
* 量測**全部**標點之行端狀態(階段 1,不變更 DOM),最後一次性套用 jz-half-le(階段 2)。
* 如此每次量測皆見無污染之自然佈局——不受自身或他者之 halt 收縮影響(避免迴圈內逐一 add
* class 致下游量測偏移,同 gapTrim 之雙向污染教訓)。ResizeObserver 僅觀 root 尺寸、不因
* 套用後內部重排再觸發,故終態穩定。 */
function relayout(ctx: RenderContext): void
{
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: EdgeDecision[] = [];
for (const el of chars)
{
// 行端為段落級(§6.5.0a):text-level 子樹(單行標題等)不處理行端。
if (!ctx.finder.levelAllows(el, "paragraph")) { continue; }
if (!ctx.finder.featureEnabledFor(el, "jiya")) { continue; }
const bd = bdClassOf(el);
if (!bd) { continue; }
const isHead = HEAD_CLASSES.has(bd);
const isTail = TAIL_CLASSES.has(bd);
if (!isHead && !isTail) { continue; }
const r = firstRect(el);
if (!r) { continue; }
if (isHead)
{
const prev = edgeRect(el.previousSibling, "prev", el, wrapSet);
if (!prev || higherLine(r, prev)) { decisions.push({ el }); }
}
else
{
const next = edgeRect(el.nextSibling, "next", el, wrapSet);
if (!next || lowerLine(r, next)) { decisions.push({ el }); }
}
}
for (const d of decisions) { ctx.setOwnedClass(d.el, LE, true); }
}
export const lineEdgePass: StandalonePass = {
name: "jiyaLineEdge",
kind: "standalone",
order: 90,
level: "paragraph",
enabled: (o) => o.features.jiya,
render(ctx: RenderContext): void
{
if (!hasLayout()) { return; } // jsdom 等無版面環境:跳過。
installLayout(ctx.root, ctx.ownerToken, "lineEdge", () =>
{
relayout(ctx);
});
},
revert(ctx: RenderContext): void
{
uninstallLayout(ctx.root, ctx.ownerToken, "lineEdge");
clearEdge(ctx);
},
};
// ----- 中西間隙之行末去隙(機制:margin-right 模型之 justify 修正)-----
//
// jz-hws 以 margin-right 留隙;若其包裹之左字落在**行末**(下一內容在更低行),
// 該尾隨 margin 在 justify 下會使右緣內縮 0.25em。行末本不需間隙,故量測後對
// 行末之 jz-hws 加 jz-hws-trim(CSS margin-right:0),該行遂齊右、無內縮。
// 純版面、瀏覽器專用(jsdom 跳過)。
const TRIM = "jz-hws-trim";
// 尾隨 margin-right 是否**可見**、需去行末隙,取決於容器對齊:
// · justify/justify-all → 右緣 flush,行末 margin 內縮右緣 0.25em(需去);
// · right/end(ltr) → 內容離右緣 0.25em(需去);· center → 行內容含 margin 一併置中、
// 偏移 0.125em(需去);
// · **left/start(ltr) → 右緣 ragged,行末 margin 落於不可見側**(可跳過)。
// 唯 left/start(ltr) 可安全整批跳過,省其每個 O(gaps) 次同步重排(S1:常見之非兩端對齊左
// 起內容零成本;其餘對齊之逐一定案為 margin 機制固有代價、保留)。RTL 一律保留(保守)。
// text-align/direction 皆繼承,故同一 parentElement 下之間隙共用同值,per-parent memo。
function skippableAlign(g: Element, cache: Map<Element, boolean>): boolean
{
const key = g.parentElement;
if (!key) { return false; }
const cached = cache.get(key);
if (cached !== undefined) { return cached; }
let result = false;
const view = g.ownerDocument ? g.ownerDocument.defaultView : null;
if (view)
{
const cs = view.getComputedStyle(g);
const ltr = cs.direction !== "rtl";
result = cs.textAlign === "left" || (cs.textAlign === "start" && ltr);
}
cache.set(key, result);
return result;
}
function trimGaps(
ctx: RenderContext,
wrapSet: Set<string>,
): void
{
const alignCache = new Map<Element, boolean>();
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; }
// 自我應驗陷阱(下游回饋 C/容器寬相關之「最後一個間隙消失」):間隙以 margin-right
// 承載、會參與斷行。若依「後字是否在更低行」判行末,而後字落點又被 margin 推擠,便
// 自相矛盾——間隙 margin 把後字擠到次行 → 判行末 → trim 去 margin → 後字回同行,振盪
// 停在「已 trim、後字同行、間隙消失」之錯誤終態(ResizeObserver 只觀 root 尺寸、不因
// 內部 margin 變化重觸發,故卡死)。**污染是雙向的**:不僅當前間隙自身 margin,**下游
// 間隙之 margin** 亦會令夾在中間之內容(如 `或<gap>$1299<gap>`)放不下該行、致後字
// 假性換行。
//
// 解法:**起始全部 trim**(margin 全 0),再由左至右逐一定案。量測某間隙時,其前皆已
// 定案(最終 margin 狀態)、其自身與**其後全部**仍 trim(margin 0);故後字落點既不受
// 自身、亦不受任何下游 margin 污染,純粹反映「無本間隙 margin 時後字是否仍被擠到次
// 行」。後字仍在更低行(或無後字)→ 真行末、維持 trim;後字在同行 → 行內容得下、非真
// 行末 → 還原間隙。逐一(非一次量全部)必要:斷行為左→右因果,當前間隙之行起點取決
// 於其前已定案內容;讀取 getClientRects 觸發同步重排,故每次量測皆為最新佈局。以末行
// rect(lastRect,margin 實際落點)為準,兼顧跨行 pill(行尾 code 折行亦曾致隙誤消)。
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;
// 同行小字/下標的 top 亦會較低;須整體落到下一行才算行末。
const atLineEnd = !r || !next || lowerLine(r, next);
if (!atLineEnd) { ctx.setOwnedClass(g, TRIM, false); }
}
}
export const gapTrimPass: StandalonePass = {
name: "spacingGapTrim",
kind: "standalone",
order: 92,
level: "paragraph",
enabled: (o) => o.features.spacing,
render(ctx: RenderContext): void
{
if (!hasLayout()) { return; }
const wrapSet = buildWrapSet(ctx.options);
installLayout(ctx.root, ctx.ownerToken, "gapTrim", () =>
{
trimGaps(ctx, wrapSet);
});
},
revert(ctx: RenderContext): void
{
uninstallLayout(ctx.root, ctx.ownerToken, "gapTrim");
ctx.root.querySelectorAll("jz-hws." + TRIM).forEach((e) =>
{
if (ctx.ownedElements.has(e)) { ctx.setOwnedClass(e, TRIM, false); }
});
},
};
-64
View File
@@ -1,64 +0,0 @@
// 長英文詞斷詞包裝(架構 §6.2,切片 1)。charify pass。
//
// 把 [A-Za-z]{N,} 之長串包進 <span lang="…">,配合消費端 CSS
// `[lang|="en"]{hyphens:auto}` 由瀏覽器原生斷詞渲染連字符(DOM 不含連字符
// → 複製不污染,I1)。沿用 v1 Pass C,改以 charify 共享走訪(I9)。
import { revertPass } from "../core/dom.js";
import type { CharifyPass, RenderContext, WrapRequest } from "../types.js";
const PASS = "longWord";
/** parent 已是帶 lang 之 span(作者標語或本 pass 產物)→ 冪等跳過。 */
function inLangSpan(node: Node): boolean
{
const p = node.parentNode;
if (!p || p.nodeType !== 1) { return false; }
const el = p as Element;
if (el.nodeName !== "SPAN") { return false; }
return el.hasAttribute("lang");
}
export const longWordPass: CharifyPass = {
name: PASS,
kind: "charify",
order: 60,
level: "paragraph",
enabled: (o) => o.features.longWord,
collect(node, ctx): WrapRequest[]
{
if (inLangSpan(node)) { return []; }
if (!ctx.finder.featureEnabledFor(node, PASS)) { return []; }
const cfg = ctx.options.longWord;
const re = new RegExp("[A-Za-z]{" + cfg.minLength + ",}", "g");
const data = node.data;
const out: WrapRequest[] = [];
let m: RegExpExecArray | null;
while ((m = re.exec(data)) !== null)
{
const start = m.index;
const end = m.index + m[0].length;
out.push({
start,
end,
pass: PASS,
build: (text) =>
{
const span = (node.ownerDocument as Document).createElement("span");
span.setAttribute("lang", cfg.lang);
span.setAttribute("data-jz", PASS);
span.setAttribute("data-jz-kind", "wrap");
span.textContent = text;
ctx.ownedElements.add(span);
return span;
},
});
}
return out;
},
revert(ctx: RenderContext): void
{
revertPass(PASS, ctx.root, ctx.ownedElements);
},
};
-234
View File
@@ -1,234 +0,0 @@
// 垂懸字避免(架構 §6.5.1,方案 C:斷行層、無測量)。standalone 段落級 pass。
//
// 目標:可處理之段末連續區域 CJK 實義字數 ≥ N(預設 2;標點/符號不計)。
// avoid/text/off 區域中斷計數;不可切且含排除內容的容器不退化整綁。
// 機制:自段末向前數 N 個 CJK 實義字,把「第 N 個實義字起至段末」(含其間/尾隨
// 標點)包入 <jz-orphan>(CSS white-space:nowrap)。該整體不可斷 → 末行恒含之 ⇒
// 末行實義字 ≥ N,任意寬度/字型/瀏覽器成立,無需量測 layout(異於行端/懸掛)。
//
// 邊界字定位「跨 jz-jinze/jz-char 等行內包裹攀爬」:實義字若落於某行內元素(禁則群組
// 強調等)內,則綁定整個**塊級直接子**——不切割該元素(免破壞 jz-jinze 之 nowrap 群、
// 免 revert 後殘留半個元素)。代價是偶爾多綁少許字(保證仍成立、無害)。僅在實義字
// 直接位於塊之裸文本子節點時才精確切分該文本(最小綁定)。
//
// order=70:jinze(40)/spacing(50)/longWord(60) 後、layout pass(90/92) 前。
import { createJz, revertPass } from "../core/dom.js";
import type { Finder } from "../core/finder.js";
import { splitElementAt } from "../core/split.js";
import { classifyBiaodian } from "../core/unicode.js";
import type { RenderContext, Ruleset, StandalonePass } from "../types.js";
type Biaodian = Ruleset["biaodian"];
const PASS = "orphan";
/** 單一實義字之來源定位:所屬塊級直接子;若該直接子即裸文本節點,記其字內 offset。 */
interface SubstPos
{
directChild: Node;
textNode: Text | null; // 非 null 且 === directChild 時表示「裸文本直接子」,可精確切分
offset: number;
}
/** block 已含 jz-orphan(前次 render 未 revert)→ 冪等跳過。 */
function hasOrphan(block: Element): boolean
{
for (let c = block.firstChild; c; c = c.nextSibling)
{
if (c.nodeType === 1 && (c as Element).nodeName === "JZ-ORPHAN") { return true; }
}
return false;
}
/** 收集段末可處理區域之完整 CJK 原子定位;排除邊界清空先前計數,pill 不計內部字。 */
function collectSubst(
block: Element,
finder: Finder,
blockSet: Set<string>,
anyCjk: RegExp,
biaodian: Biaodian,
): { subst: SubstPos[]; blockedChildren: Set<Node> }
{
const out: SubstPos[] = [];
const atoms = new Map<Text, Map<number, string>>();
for (const run of finder.collectRuns(block, true))
{
for (const atom of run.map)
{
// 每個完整原子只在第一個來源位置計數;後續 surrogate/VS 部件不另算字。
const first = atom.parts[0]!;
let starts = atoms.get(first.textNode);
if (!starts) { starts = new Map(); atoms.set(first.textNode, starts); }
starts.set(first.start, atom.text);
}
}
const blockedChildren = new Set<Node>();
const boundary = (directChild: Node): void =>
{
// 只約束最後一段可處理的文本;不得把排除區域夾進 nowrap 尾部。
out.length = 0;
blockedChildren.add(directChild);
};
const visit = (node: Node, directChild: Node): void =>
{
if (node.nodeType === 3)
{
const t = node as Text;
if (finder.isAvoided(t) || !finder.levelAllows(t, "paragraph")
|| !finder.featureEnabledFor(t, PASS))
{
boundary(directChild);
return;
}
for (const [ offset, ch ] of atoms.get(t) || [])
{
// 實義字:屬 CJK 字集且非標點(標點/符號不計)。
if (anyCjk.test(ch) && !classifyBiaodian(ch, biaodian))
{
out.push({ directChild, textNode: t, offset });
}
}
return;
}
if (node.nodeType !== 1) { return; }
const el = node as Element;
// avoid 以自身判定(舊版僅查祖先,自身命中 avoid 之元素被下潛);pill 整體
// 不計不下探;isolate 內部為正常文本,照常下探計數(綁定時不可切、整綁)。
if (finder.avoidsSelf(el) || blockSet.has(el.nodeName))
{
boundary(directChild);
return;
}
if (finder.pillMatches(el)) { return; } // pill 不計內部字,但仍可整體參與外部綁定。
if (!finder.levelAllows(el, "paragraph") || !finder.featureEnabledFor(el, PASS))
{
boundary(directChild);
// 子元素可有自己的合法功能/級別信號,故仍下探;文本節點再判實際閘。
}
for (let c = el.firstChild; c; c = c.nextSibling) { visit(c, directChild); }
};
for (let dc = block.firstChild; dc; dc = dc.nextSibling) { visit(dc, dc); }
return { subst: out, blockedChildren };
}
function processBlock(
block: Element,
finder: Finder,
blockSet: Set<string>,
anyCjk: RegExp,
biaodian: Biaodian,
chars: number,
ownedElements: WeakSet<Element>,
): void
{
if (hasOrphan(block)) { return; }
const { subst, blockedChildren } = collectSubst(block, finder, blockSet, anyCjk, biaodian);
// 段落實義字 < N → 不綁(無法保證、亦無意義)。單行段:方案 C 之 nowrap 本就無害。
if (subst.length < chars) { return; }
const boundary = subst[subst.length - chars]!;
let startChild: Node = boundary.directChild;
// 邊界落於裸文本直接子 → 精確切分(最小綁定);落於行內元素內 → 克隆切分出
// [邊界字..元素末](避免整個長元素入 nowrap 之 jz-orphan 致溢出容器);不可切之
// 元素退化為綁定整個直接子(跨 jz-jinze 等攀爬,不切割)。
if (boundary.textNode && boundary.textNode === startChild)
{
if (boundary.offset > 0)
{
startChild = (boundary.textNode as Text).splitText(boundary.offset);
}
// offset === 0:實義字即文本起點,整個文本節點入綁,無需切分。
}
else if (boundary.textNode && startChild.nodeType === 1)
{
const clone = splitElementAt(
startChild as Element,
boundary.textNode,
boundary.offset,
PASS,
finder,
ownedElements,
);
if (clone) { startChild = clone; }
else if (blockedChildren.has(startChild))
{
// 不可切容器若還含排除內容,整綁會越界;寧可不綁此段末尾。
return;
}
}
// 把 startChild 及其後全部直接子搬入 jz-orphan。
const orphan = createJz(ownedElements, "jz-orphan", PASS, "wrap");
block.insertBefore(orphan, startChild);
let n: Node | null = startChild;
while (n)
{
const next: Node | null = n.nextSibling;
orphan.appendChild(n);
n = next;
}
}
export const orphanPass: StandalonePass = {
name: PASS,
kind: "standalone",
order: 70,
level: "paragraph",
enabled: (o) => o.features.orphan,
render(ctx: RenderContext): void
{
const { finder, options } = ctx;
const blockSet = options.finder.blockTags;
const anyCjk = new RegExp("[" + options.ruleset.cjk + "]", "u");
const biaodian = options.ruleset.biaodian;
const chars = options.orphan.chars;
// 「非葉塊」(含塊級後代之容器)預計算(S2):取代原逐塊 block.querySelector(sel) 掃整
// 棵子樹之 O(Σ塊子樹)(深塊嵌套下 O(n²))。一趟上攀標記——某塊為另一塊之最近塊祖先 ⟺
// 該塊含塊級後代。scope 無涉(與原 querySelector 同口徑:含域外後代塊亦算非葉),故據
// root 全塊集(querySelectorAll,非 eachBlock 之 in-scope 子集)計算。sel 亦外提出迴圈。
const sel = Array.from(blockSet).map((t) => t.toLowerCase()).join(",");
const nonLeaf = new Set<Element>();
if (sel)
{
ctx.root.querySelectorAll(sel).forEach((b) =>
{
let p = b.parentElement;
while (p)
{
if (blockSet.has(p.nodeName)) { nonLeaf.add(p); break; }
p = p.parentElement;
}
});
}
finder.eachBlock(ctx.root, (block) =>
{
// 段落級閘 + per-node 功能閘 + avoid(isAvoided 組合判定:自身或祖先命中皆
// 不綁——舊版漏判,avoid 子樹內塊之裸文本直接子曾被計數並包入 jz-orphan)。
if (!finder.levelAllows(block, "paragraph")) { return; }
if (!finder.featureEnabledFor(block, PASS)) { return; }
if (finder.isAvoided(block)) { return; }
// 僅處理葉段落(無塊級後代):含巢狀塊之容器,其實義字屬內層段落,另行處理。
if (nonLeaf.has(block)) { return; }
processBlock(
block,
finder,
blockSet,
anyCjk,
biaodian,
chars,
ctx.ownedElements,
);
});
},
revert(ctx: RenderContext): void
{
revertPass(PASS, ctx.root, ctx.ownedElements);
},
};
-828
View File
@@ -1,828 +0,0 @@
// 中西間隙(架構 §6.1,切片 1)。standalone pass。
//
// 沿用 v1(cjk-autospace)已驗證之 Pangu 規則管線與 per-block 邏輯文本處理,
// 改用 finder 提供之 collectRuns/adjacentLogicalChar,並把間隙 marker 換為
// copy-clean 之 jz-hws(user-select 由 CSS 關閉,修正 Han.css 之選中缺陷,I1)。
//
// 涵蓋 v1 Pass A(跨樣式邊界間隙)與 Pass B(pill code/kbd 邊界間隙)。
// 規則正則為間隙專用,故與本 pass 同處;共用字集自 unicode.ts。
import {
ALNUM, ALPHA, VARIATION_SELECTORS,
splitVariationSequences,
} from "../core/unicode.js";
import { createJz, recordUnwrappedParent, resolveUnwrappedParent, revertPass } from "../core/dom.js";
import type { AdjacentChar, CharSpan, Finder, TextSpan } from "../core/finder.js";
import type { RenderContext, Ruleset, StandalonePass } from "../types.js";
const PASS = "spacing";
// ----- Pangu 規則正則(沿用 v1)-----
const OPS_HYPHEN = "\\+\\*=&\\-";
const OPS_GRADE = "\\+\\-\\*";
const QUOTES_CLS = "`\"״";
const LBR_BASIC = "\\(\\[\\{";
const RBR_BASIC = "\\)\\]\\}";
const LBR_EXT = "\\(\\[\\{<>";
const RBR_EXT = "\\)\\]\\}<>";
interface SpacingMarkers
{
gap: string;
compoundOpen: string;
compoundClose: string;
deletion: string;
}
/** 由規則集編譯之 per-instance 間隙正則集。 */
interface Rules
{
anyCjk: RegExp; pillNeighbor: RegExp; markerForbidden: RegExp;
STRIP_CJK_SPACE_ANS: RegExp; STRIP_ANS_SPACE_CJK: RegExp; STRIP_CJK_SPACE_CJK: RegExp;
DOTS_CJK: RegExp; CJK_PUNCTUATION: RegExp; AN_PUNCTUATION_CJK: RegExp;
CJK_TILDE: RegExp; CJK_TILDE_EQUALS: RegExp; CJK_PERIOD: RegExp;
AN_PERIOD_CJK: RegExp; AN_COLON_CJK: RegExp; CJK_QUOTE: RegExp; QUOTE_CJK: RegExp;
CJK_QUOTE_AN: RegExp; CJK_HASH: RegExp; HASH_CJK: RegExp; SINGLE_LETTER_GRADE: RegExp;
CJK_OPERATOR_ANS: RegExp; ANS_OPERATOR_CJK: RegExp;
CJK_LESS_THAN: RegExp; LESS_THAN_CJK: RegExp; CJK_GREATER_THAN: RegExp; GREATER_THAN_CJK: RegExp;
CJK_LEFT_BRACKET: RegExp; RIGHT_BRACKET_CJK: RegExp; AN_LEFT_BRACKET: RegExp; RIGHT_BRACKET_AN: RegExp;
CJK_ANS: RegExp; ANS_CJK: RegExp; S_A: RegExp;
}
/**
* 由規則集編譯間隙正則。CJK/ANS 字集隨 options.charClass 而變,故 per-instance 編譯
* (createJuzhen 各一次,非每 block);ALNUM/ALPHA/運算符/括號等結構性 ASCII 集為
* 固定模組常數,不隨規則集變。
* · STRIP_CJK_SPACE_CJK:CJK↔CJK 之單一作者空格(含跨 inline 標籤,collectRuns 已透明
* 合併)→ 以 DEL 標記刪除、不留隙(1b;只匹配 ASCII 空格,全形 U+3000 不動)。
* · DOTS_CJK:僅 ASCII 省略號 `...中文` 補隙,**不含**全角 U+2026(全角 …/…… 屬 CJK
* 標點、後接 CJK 不應補隙;問題 B)。
* · 原 pangu QUOTE_AN(收尾彎引號後接字母數字補隙)已移除——標點稽核發現它令 ” 成為唯一
* 「後接拉丁/數字會補隙」之收尾標點,與 」』)》】〕 不一致(clreq 下全角收尾標點字身
* 已含右側留白,不需額外隙)。
*/
function makeRules(rs: Ruleset): Rules
{
const CJK = rs.cjk;
const ANS_AFTER = rs.ansAfter;
const ANS_BEFORE = rs.ansBefore;
// VS 只能隨 CJK base 被吞併:正則捕獲、marker 回放與 DOM 包裝皆以此 atom 為單位。
const CJK_ATOM = "(?:[" + CJK + "](?:[" + VARIATION_SELECTORS + "])?)";
const CJK_OR_ALNUM = "(?:" + CJK_ATOM + "|[" + ALNUM + "])";
return {
anyCjk: new RegExp(CJK_ATOM, "u"),
pillNeighbor: new RegExp("^(?:[" + ALNUM + "]|" + CJK_ATOM + ")$", "u"),
markerForbidden: new RegExp("[" + CJK + ANS_AFTER + ANS_BEFORE + "]", "u"),
STRIP_CJK_SPACE_ANS: new RegExp("(" + CJK_ATOM + ") ([" + ANS_AFTER + "])", "gu"),
STRIP_ANS_SPACE_CJK: new RegExp("([" + ANS_BEFORE + "]) (" + CJK_ATOM + ")", "gu"),
STRIP_CJK_SPACE_CJK: new RegExp("(" + CJK_ATOM + ") (?=" + CJK_ATOM + ")", "gu"),
DOTS_CJK: new RegExp("([\\.]{2,})(" + CJK_ATOM + ")", "gu"),
CJK_PUNCTUATION: new RegExp("(" + CJK_ATOM + ")([!;,\\?:]+)(?=" + CJK_OR_ALNUM + ")", "gu"),
AN_PUNCTUATION_CJK: new RegExp("([" + ALNUM + "])([!;,\\?]+)(" + CJK_ATOM + ")", "gu"),
CJK_TILDE: new RegExp("(" + CJK_ATOM + ")(~+)(?!=)(?=" + CJK_OR_ALNUM + ")", "gu"),
CJK_TILDE_EQUALS: new RegExp("(" + CJK_ATOM + ")(~=)", "gu"),
CJK_PERIOD: new RegExp("(" + CJK_ATOM + ")(\\.)(?![" + ALNUM + "\\./])(?=" + CJK_OR_ALNUM + ")", "gu"),
AN_PERIOD_CJK: new RegExp("([" + ALNUM + "])(\\.)(" + CJK_ATOM + ")", "gu"),
AN_COLON_CJK: new RegExp("([" + ALNUM + "])(:)(" + CJK_ATOM + ")", "gu"),
CJK_QUOTE: new RegExp("(" + CJK_ATOM + ")([" + QUOTES_CLS + "])", "gu"),
QUOTE_CJK: new RegExp("([" + QUOTES_CLS + "])(" + CJK_ATOM + ")", "gu"),
CJK_QUOTE_AN: new RegExp("(" + CJK_ATOM + ")(\")([" + ALNUM + "])", "gu"),
CJK_HASH: new RegExp("(" + CJK_ATOM + ")(#([^ ]))", "gu"),
HASH_CJK: new RegExp("(([^ ])#)(" + CJK_ATOM + ")", "gu"),
SINGLE_LETTER_GRADE: new RegExp("\\b([" + ALPHA + "])([" + OPS_GRADE + "])(" + CJK_ATOM + ")", "gu"),
CJK_OPERATOR_ANS: new RegExp("(" + CJK_ATOM + ")([" + OPS_HYPHEN + "])([" + ALNUM + "])", "gu"),
ANS_OPERATOR_CJK: new RegExp("([" + ALNUM + "])([" + OPS_HYPHEN + "])(" + CJK_ATOM + ")", "gu"),
CJK_LESS_THAN: new RegExp("(" + CJK_ATOM + ")(<)([" + ALNUM + "])", "gu"),
LESS_THAN_CJK: new RegExp("([" + ALNUM + "])(<)(" + CJK_ATOM + ")", "gu"),
CJK_GREATER_THAN: new RegExp("(" + CJK_ATOM + ")(>)([" + ALNUM + "])", "gu"),
GREATER_THAN_CJK: new RegExp("([" + ALNUM + "])(>)(" + CJK_ATOM + ")", "gu"),
CJK_LEFT_BRACKET: new RegExp("(" + CJK_ATOM + ")([" + LBR_EXT + "])", "gu"),
RIGHT_BRACKET_CJK: new RegExp("([" + RBR_EXT + "])(" + CJK_ATOM + ")", "gu"),
// 英數字串段前之字元一併匹配,才能在不用 lookbehind 下排除 `.foo(`。
// 括號只作 lookahead,避免 `a(b(` 之前一次匹配吞掉 `(` 而漏掉第二處。
AN_LEFT_BRACKET: new RegExp(
"(^|[^." + ALNUM + "])([" + ALNUM + "]+)(?=[" + LBR_BASIC + "])",
"g",
),
RIGHT_BRACKET_AN: new RegExp("([" + RBR_BASIC + "])([" + ALNUM + "])", "g"),
CJK_ANS: new RegExp("(" + CJK_ATOM + ")([" + ANS_AFTER + "])", "gu"),
ANS_CJK: new RegExp("([" + ANS_BEFORE + "])(" + CJK_ATOM + ")", "gu"),
S_A: new RegExp("(%)([" + ALPHA + "])", "g"),
};
}
// 複合詞(GPT-4 / state-of-the-art)不可被運算符間隙楔入:先遮罩,運算符
// 過後、括號 / CJK_ANS / ANS_CJK 過前還原。
const COMPOUND_WORD = /\b(?:[A-Za-z0-9]*[a-z][A-Za-z0-9]*-[A-Za-z0-9]+|[A-Za-z0-9]+-[A-Za-z0-9]*[a-z][A-Za-z0-9]*|[A-Za-z]+-[0-9]+|[A-Za-z]+[0-9]+-[A-Za-z0-9]+)(?:-[A-Za-z0-9]+)*\b/g;
const ASTRAL_CJK_SHADOW = "漢";
/** 每個 run 使用源文本未佔用、且規則不識別為 CJK/西文的標記,絕不寫回 DOM。 */
function markersFor(text: string, R: Rules): SpacingMarkers
{
const occupied = new Set(text);
const markers: string[] = [];
const tryRange = (start: number, end: number): boolean =>
{
for (let cp = start; cp <= end; cp += 1)
{
const ch = String.fromCodePoint(cp);
if (occupied.has(ch) || R.markerForbidden.test(ch)) { continue; }
markers.push(ch);
if (markers.length === 4) { return true; }
}
return false;
};
// 私用區及非字符區皆可能出現在作者源中,須逐一檢查;搜索有固定上界。
let found = tryRange(0xE000, 0xF8FF)
|| tryRange(0xF0000, 0xFFFFD)
|| tryRange(0x100000, 0x10FFFD)
|| tryRange(0xFDD0, 0xFDEF);
for (let plane = 0; !found && plane <= 16; plane += 1)
{
found = tryRange(plane * 0x10000 + 0xFFFE, plane * 0x10000 + 0xFFFF);
}
if (!found)
{
// 無無歧義標記時停在回放之前;不得把作者字符誤當刪除指令。
throw new Error("juzhen: 源文本與字符規則已佔用全部間隙標記候選。");
}
return {
gap: markers[0]!, compoundOpen: markers[1]!,
compoundClose: markers[2]!, deletion: markers[3]!,
};
}
/**
* Safari 26.5.2 於實際 CJK 大字元類中,會漏掉非零 UTF-16 起點之補充平面
* CJK(global 與 sticky 皆同)。規則層不需原字,只需與 DOM map 同構之邏輯 atom
* 序列,故線性將「base > U+FFFF 且自身 index 0 命中 CJK」之整個 base+VS atom
* 替為固定 BMP CJK shadow。Pangu 輸出僅供 MARK/DEL 動作回放,不會寫回 shadow。
*/
function shadowAstralCjkAtoms(atoms: readonly string[], R: Rules): string
{
const shadowed = new Array<string>(atoms.length);
for (let i = 0; i < atoms.length; i += 1)
{
const atom = atoms[i]!;
const base = atom.codePointAt(0);
const match = base !== undefined && base > 0xFFFF ? R.anyCjk.exec(atom) : null;
shadowed[i] = match?.index === 0 ? ASTRAL_CJK_SHADOW : atom;
}
return shadowed.join("");
}
function panguSpace(text: string, R: Rules, markers: SpacingMarkers): string
{
const MARK = markers.gap;
const PH_OPEN = markers.compoundOpen;
const PH_CLOSE = markers.compoundClose;
// 刪除標記與原作者空格 1:1 替換,保持 DOM map 對齊。
const DEL = markers.deletion;
const PH_PATTERN = new RegExp(PH_OPEN + "(\\d+)" + PH_CLOSE, "g");
let s = text;
s = s.replace(R.STRIP_CJK_SPACE_ANS, "$1$2");
s = s.replace(R.STRIP_ANS_SPACE_CJK, "$1$2");
s = s.replace(R.STRIP_CJK_SPACE_CJK, "$1" + DEL);
s = s.replace(R.DOTS_CJK, "$1" + MARK + "$2");
s = s.replace(R.CJK_PUNCTUATION, "$1$2" + MARK);
s = s.replace(R.AN_PUNCTUATION_CJK, "$1$2" + MARK + "$3");
s = s.replace(R.CJK_TILDE, "$1$2" + MARK);
s = s.replace(R.CJK_TILDE_EQUALS, "$1" + MARK + "$2" + MARK);
s = s.replace(R.CJK_PERIOD, "$1$2" + MARK);
s = s.replace(R.AN_PERIOD_CJK, "$1$2" + MARK + "$3");
s = s.replace(R.AN_COLON_CJK, "$1$2" + MARK + "$3");
s = s.replace(R.CJK_QUOTE, "$1" + MARK + "$2");
s = s.replace(R.QUOTE_CJK, "$1" + MARK + "$2");
s = s.replace(R.CJK_QUOTE_AN, "$1$2" + MARK + "$3");
s = s.replace(R.CJK_HASH, "$1" + MARK + "$2");
s = s.replace(R.HASH_CJK, "$1" + MARK + "$3");
const compounds: string[] = [];
s = s.replace(COMPOUND_WORD, (m) =>
{
compounds.push(m);
return PH_OPEN + (compounds.length - 1) + PH_CLOSE;
});
s = s.replace(R.SINGLE_LETTER_GRADE, "$1$2" + MARK + "$3");
s = s.replace(R.CJK_OPERATOR_ANS, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(R.ANS_OPERATOR_CJK, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(R.CJK_LESS_THAN, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(R.LESS_THAN_CJK, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(R.CJK_GREATER_THAN, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(R.GREATER_THAN_CJK, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(PH_PATTERN, (_m, idx: string) => 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$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;
}
interface SpaceAction
{
before: CharSpan | undefined;
space: CharSpan | null;
}
interface MovedPart
{
anchor: Comment;
gapAnchor: Comment;
gap: HTMLElement;
textNode: Text;
originalParent: Node;
originalPreviousSibling: Node | null;
originalNextSibling: Node | null;
gapOriginalParent: Node;
gapOriginalPreviousSibling: Node | null;
gapOriginalNextSibling: Node | null;
block: Element;
scopeRoot: Element;
scopeAncestors: Element[];
}
// 跨節點 variation sequence 需在排版期間聚合成單一行內載體;以 ownerToken
// 隔離各實例的原位錨點,revert 時恢復每個原始 TextSpan 的父子分佈。
const MOVED_PARTS = new WeakMap<object, Set<MovedPart>>();
const MOVED_ANCHOR = "jz-spacing-anchor";
const MOVED_GAP_ANCHOR = "jz-spacing-gap-anchor";
const CROSS_NODE_ATTR = "data-jz-cross-node-atom";
// 把一個節點(已隔離之單字文本,或元素如 pill)包進 jz-hws——間隙由 CSS 之
// margin-right 提供。不注入空白字元:故 justify 不把間隙當詞間空白放大(邊界
// 只吃均勻字間量),margin 尾隨左側內容(換行時落行尾、永不落行首),且
// textContent 完全不變(內容仍在)。冪等:已在 jz-hws 內則跳過。
function wrapNodeInGap(node: Node, ownedElements: WeakSet<Element>): void
{
const parent = node.parentNode;
if (!parent || parent.nodeName === "JZ-HWS") { return; }
const gap = createJz(ownedElements, "jz-hws", PASS, "wrap");
parent.insertBefore(gap, node);
gap.appendChild(node);
}
// 隔離 pos 處之 base+optional-VS 並包入 jz-hws(左側字 + margin-right 留隙)。
function isolateTextSpan(part: TextSpan): Text | null
{
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: TextSpan,
ownedElements: WeakSet<Element>,
): HTMLElement | null
{
const containingGap = part.textNode.parentElement?.closest("jz-hws") as HTMLElement | null;
if (containingGap)
{
return ownedElements.has(containingGap)
&& containingGap.getAttribute("data-jz") === PASS
&& containingGap.getAttribute("data-jz-kind") === "wrap"
? containingGap
: null;
}
const tn = isolateTextSpan(part);
if (!tn) { return null; }
const parent = tn.parentNode;
if (!parent) { return null; }
const gap = createJz(ownedElements, "jz-hws", PASS, "wrap");
try
{
parent.insertBefore(gap, tn);
gap.appendChild(tn);
return gap;
}
catch (error)
{
if (tn.parentNode === gap) { parent.insertBefore(tn, gap); }
if (gap.parentNode) { gap.parentNode.removeChild(gap); }
ownedElements.delete(gap);
(parent as Element).normalize?.();
throw error;
}
}
function isOwnedCrossGap(gap: HTMLElement, ownedElements: WeakSet<Element>): boolean
{
return ownedElements.has(gap)
&& gap.getAttribute("data-jz") === PASS
&& gap.getAttribute("data-jz-kind") === "wrap"
&& gap.hasAttribute(CROSS_NODE_ATTR);
}
function trustedInsertionPoint(record: MovedPart): Node | null | undefined
{
if (record.anchor.parentNode === record.originalParent) { return record.anchor; }
if (record.originalNextSibling?.parentNode === record.originalParent)
{
return record.originalNextSibling;
}
if (record.originalNextSibling !== null) { return undefined; }
if (record.originalPreviousSibling?.parentNode === record.originalParent)
{
return record.originalPreviousSibling === record.originalParent.lastChild ? null : undefined;
}
if (record.originalPreviousSibling === null && record.originalNextSibling === null
&& record.anchor.parentNode !== null && record.anchor.parentNode !== record.originalParent
&& !record.originalParent.hasChildNodes())
{
// selector 原本是原父唯一子節點:anchor 仍存在但被外部搬走,與
// anchor 已被作者刪除(parentNode === null)可明確區分。不跟隨外部父,
// 只回復到仍為空的 originalParent 末尾。
return null;
}
// anchor 與原前/後兄弟皆不存在時,空原父可能是作者明確刪除內容之結果;
// 不得以「目前為空」當作恢復授權,否則會復活舊 selector。
return undefined;
}
function restoreMovedText(record: MovedPart): boolean
{
if (record.textNode.parentNode === record.originalParent) { return true; }
if (record.textNode.parentNode !== record.gap) { return false; }
const before = trustedInsertionPoint(record);
if (before === undefined) { return false; }
record.originalParent.insertBefore(record.textNode, before);
return true;
}
function movePartIntoGap(
part: TextSpan,
gap: HTMLElement,
block: Element,
scopeRoot: Element,
): MovedPart | null
{
const textNode = isolateTextSpan(part);
const parent = textNode?.parentNode;
const gapParent = gap.parentNode;
if (!textNode || !parent || !gapParent) { return null; }
const anchor = textNode.ownerDocument.createComment(MOVED_ANCHOR);
const gapAnchor = textNode.ownerDocument.createComment(MOVED_GAP_ANCHOR);
const record: MovedPart = {
anchor,
gapAnchor,
gap,
textNode,
originalParent: parent,
originalPreviousSibling: textNode.previousSibling,
originalNextSibling: textNode.nextSibling,
gapOriginalParent: gapParent,
gapOriginalPreviousSibling: gap.previousSibling,
gapOriginalNextSibling: gap.nextSibling,
block,
scopeRoot,
scopeAncestors: (() =>
{
const ancestors: Element[] = [];
for (let parent = scopeRoot.parentElement; parent; parent = parent.parentElement)
{
ancestors.push(parent);
}
return ancestors;
})(),
};
try
{
parent.insertBefore(anchor, textNode);
gap.appendChild(gapAnchor);
gap.appendChild(textNode);
return record;
}
catch (error)
{
restoreMovedText(record);
if (anchor.parentNode) { anchor.parentNode.removeChild(anchor); }
if (gapAnchor.parentNode) { gapAnchor.parentNode.removeChild(gapAnchor); }
(parent as Element).normalize?.();
throw error;
}
}
// 單 Text 節點直接包裝完整 base+optional-VS。跨節點 atom 先包裝 base,
// 再只搬入後續 selector TextSpan(不搬動或複製作者 Element),使 WebKit 斷行器
// 將完整序列視為單一左側原子。每個搬移處的 Comment 錨點僅供精確還原。
function wrapGapLeft(
span: CharSpan,
ownedElements: WeakSet<Element>,
ownerToken: object,
block: Element,
scopeRoot: Element,
): void
{
const containingGaps = new Set<HTMLElement>();
for (const part of span.parts)
{
const containing = part.textNode.parentElement?.closest("jz-hws") as HTMLElement | null;
if (containing) { containingGaps.add(containing); }
}
if (containingGaps.size > 0)
{
if (containingGaps.size === 1)
{
const existing = Array.from(containingGaps)[0]!;
if (isOwnedCrossGap(existing, ownedElements)
&& span.parts.every((part) => existing.contains(part.textNode)))
{
return;
}
}
// 外來/偽造 gap 或不完整之已包裝狀態一律 fail closed,不搬文本、不登記。
return;
}
const gap = wrapSingleSpanInGap(span.parts[0]!, ownedElements);
if (!gap) { return; }
if (span.parts.length === 1) { return; }
const moved: MovedPart[] = [];
try
{
for (let i = 1; i < span.parts.length; i += 1)
{
const record = movePartIntoGap(span.parts[i]!, gap, block, scopeRoot);
if (!record) { throw new Error("juzhen: 跨節點字元搬移失敗。"); }
moved.push(record);
}
gap.setAttribute(CROSS_NODE_ATTR, "");
let records = MOVED_PARTS.get(ownerToken);
if (!records)
{
records = new Set();
MOVED_PARTS.set(ownerToken, records);
}
for (const record of moved) { records.add(record); }
}
catch (error)
{
for (let i = moved.length - 1; i >= 0; i -= 1)
{
const record = moved[i]!;
restoreMovedText(record);
if (record.anchor.parentNode) { record.anchor.parentNode.removeChild(record.anchor); }
if (record.gapAnchor.parentNode)
{
record.gapAnchor.parentNode.removeChild(record.gapAnchor);
}
(record.originalParent as Element).normalize?.();
}
gap.removeAttribute(CROSS_NODE_ATTR);
const gapParent = gap.parentNode;
if (gapParent)
{
while (gap.firstChild) { gapParent.insertBefore(gap.firstChild, gap); }
gapParent.removeChild(gap);
(gapParent as Element).normalize?.();
}
ownedElements.delete(gap);
throw error;
}
}
function recordWithinRoot(record: MovedPart, root: Element): boolean
{
const within = (node: Node): boolean => node === root || root.contains(node);
const detached = !record.anchor.isConnected && !record.gap.isConnected
&& !record.gapAnchor.isConnected && !record.originalParent.isConnected;
return root === record.scopeRoot || root.contains(record.scopeRoot)
|| record.scopeAncestors.some((ancestor) => root === ancestor || root.contains(ancestor))
|| within(record.gap) || within(record.anchor) || within(record.originalParent)
|| within(record.block)
|| (detached && root === root.ownerDocument.body);
}
function placeGapAtOriginalParent(record: MovedPart): boolean
{
if (record.gap.parentNode === record.gapOriginalParent) { return true; }
let before: Node | null = null;
if (record.gapOriginalNextSibling?.parentNode === record.gapOriginalParent)
{
before = record.gapOriginalNextSibling;
}
else if (record.gapOriginalNextSibling !== null)
{
return false;
}
else if (record.gapOriginalPreviousSibling?.parentNode === record.gapOriginalParent)
{
if (record.gapOriginalPreviousSibling !== record.gapOriginalParent.lastChild) { return false; }
before = null;
}
else if (record.gapOriginalParent.lastChild !== record.gapOriginalPreviousSibling
|| (record.gapOriginalPreviousSibling === null
&& record.gapOriginalNextSibling === null
&& record.gapOriginalParent.hasChildNodes()))
{
return false;
}
record.gapOriginalParent.insertBefore(record.gap, before);
return true;
}
function unwrapCrossGap(
record: MovedPart,
ownedElements: WeakSet<Element>,
touched: Set<Node>,
): void
{
const gap = record.gap;
if (!isOwnedCrossGap(gap, ownedElements)) { return; }
if (!placeGapAtOriginalParent(record))
{
if (!gap.isConnected) { ownedElements.delete(gap); }
return;
}
const parent = gap.parentNode;
if (!parent) { ownedElements.delete(gap); return; }
while (gap.firstChild) { parent.insertBefore(gap.firstChild, gap); }
parent.removeChild(gap);
recordUnwrappedParent(gap, parent, ownedElements);
ownedElements.delete(gap);
touched.add(parent);
}
function restoreMovedParts(ctx: RenderContext): Set<Node>
{
const touched = new Set<Node>();
const records = MOVED_PARTS.get(ctx.ownerToken);
if (!records) { return touched; }
const gaps = new Map<HTMLElement, MovedPart>();
for (const record of Array.from(records))
{
if (!recordWithinRoot(record, ctx.root)) { continue; }
const sourceParent = resolveUnwrappedParent(record.originalParent);
const gapParent = resolveUnwrappedParent(record.gapOriginalParent);
if (!sourceParent || !gapParent)
{
// 循環/失效来源不能指引文本搬移;保留當前文字,由正常還原清理可驗證載體。
record.anchor.remove();
record.gapAnchor.remove();
records.delete(record);
continue;
}
record.originalParent = sourceParent;
record.gapOriginalParent = gapParent;
const live = (node: Node): boolean => record.scopeRoot.contains(node) || node.isConnected;
if (!isOwnedCrossGap(record.gap, ctx.ownedElements) || !live(record.gap))
{
// 刪除/克隆載體後不復活原文字;真正移動與整根脫離 document 仍保留。
record.anchor.remove();
record.gapAnchor.remove();
if (!live(record.gap)) { ctx.ownedElements.delete(record.gap); }
records.delete(record);
continue;
}
const restored = live(record.originalParent) && restoreMovedText(record);
if (!restored && record.textNode.parentNode === record.gap)
{
// 原父已以 textContent/replaceChildren/innerHTML 取代:舊 selector
// 屬過時的本實例搬移產物,不得因 anchor 被搬往任意父節點而復活。
record.gap.removeChild(record.textNode);
}
if (restored) { touched.add(record.originalParent); }
if (record.anchor.parentNode) { record.anchor.parentNode.removeChild(record.anchor); }
if (record.gapAnchor.parentNode)
{
record.gapAnchor.parentNode.removeChild(record.gapAnchor);
}
gaps.set(record.gap, record);
records.delete(record);
}
for (const record of gaps.values()) { unwrapCrossGap(record, ctx.ownedElements, touched); }
if (records.size === 0) { MOVED_PARTS.delete(ctx.ownerToken); }
return touched;
}
function deleteSpan(span: CharSpan): void
{
for (let i = span.parts.length - 1; i >= 0; i -= 1)
{
const part = span.parts[i]!;
part.textNode.data = part.textNode.data.slice(0, part.start)
+ part.textNode.data.slice(part.end);
}
}
function processBlock(
block: Element,
scopeRoot: Element,
finder: Finder,
R: Rules,
ownedElements: WeakSet<Element>,
ownerToken: object,
): void
{
const runs = finder.collectRuns(block, false, PASS);
for (const run of runs)
{
const { map } = run;
const originalChars = map.map((span) => span.text);
const ruleText = shadowAstralCjkAtoms(originalChars, R);
if (originalChars.length < 2 || !R.anyCjk.test(ruleText)) { continue; }
const markers = markersFor(ruleText, R);
const spaced = panguSpace(ruleText, R, markers);
if (spaced === ruleText) { continue; }
const actions: SpaceAction[] = [];
let origIdx = 0;
for (const sc of splitVariationSequences(spaced))
{
if (sc === markers.deletion)
{
// CJK↔CJK 贅餘空格:純刪除、不留隙(before 留空)。
actions.push({ before: undefined, space: map[origIdx]! });
origIdx += 1;
continue;
}
if (sc === markers.gap)
{
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 });
}
continue;
}
origIdx += 1;
}
// 倒序執行,避免前面動作之 offset 被後面變更影響。先刪空格(若有)、再包左字
// 留隙(若有 before)。刪空格在較高 offset、不擾動更前之 before。
for (let i = actions.length - 1; i >= 0; i -= 1)
{
const act = actions[i]!;
if (act.space)
{
deleteSpan(act.space);
}
if (act.before)
{
wrapGapLeft(act.before, ownedElements, ownerToken, block, scopeRoot);
}
}
}
}
// boundarySelector = pill(整體)∪ isolate(隔離):兩端補隙之口徑一致(§4.1
// 三分類);差異僅在內部(pill 不可見、isolate 照常處理),不在此 pass。
function processPills(
root: Element,
finder: Finder,
boundarySelector: string,
pillNeighbor: RegExp,
ownedElements: WeakSet<Element>,
ownerToken: object,
): void
{
root.querySelectorAll(boundarySelector).forEach((pill) =>
{
// isAvoided 為組合判定(自身或祖先),涵蓋 pill 自身命中 avoid(如 data-jz-skip)之
// 情形——下游問題六同型:僅查祖先則 avoid 對自身形同虛設。
if (finder.isAvoided(pill) || finder.insidePill(pill) || !finder.inScope(pill))
{
return;
}
// per-node 功能閘(T6):data-juzhen-off="spacing" 等區域內之 pill 不補間隙
// (與 Pass A 逐 block 之 featureEnabledFor 一致;舊版漏判)。
if (!finder.featureEnabledFor(pill, PASS)) { return; }
if (!pill.parentNode) { return; }
const prev = finder.adjacentLogicalChar(pill, -1, root, undefined, PASS);
if (prev)
{
handlePillSide(prev, pill, -1, finder, pillNeighbor, ownedElements, ownerToken, root);
}
const next = finder.adjacentLogicalChar(pill, 1, root, undefined, PASS);
if (next)
{
handlePillSide(next, pill, 1, finder, pillNeighbor, ownedElements, ownerToken, root);
}
});
}
// dir -1:間隙在 adj 字(左)與 pill(右)之間 → margin 掛 adj 字(包裝該字)。
// dir +1:間隙在 pill(左)與 adj 字(右)之間 → margin 掛 pill(包裝 pill)。
function handlePillSide(
adj: AdjacentChar,
pill: Element,
dir: -1 | 1,
finder: Finder,
pillNeighbor: RegExp,
ownedElements: WeakSet<Element>,
ownerToken: object,
scopeRoot: Element,
): void
{
if (!adj.parts.every((part) => finder.featureEnabledFor(part.textNode, PASS))) { return; }
let neighbor = adj;
if (adj.ch === " ")
{
// 只剝一個 ASCII 作者空格;再次查詢仍受透明/作用域/排除邊界限制。
const beyond = finder.adjacentLogicalChar(pill, dir, scopeRoot, adj, PASS);
if (!beyond || !pillNeighbor.test(beyond.ch)
|| !beyond.parts.every((part) => finder.featureEnabledFor(part.textNode, PASS))) { return; }
const space = adj.parts[0]!;
deleteSpan({ text: " ", parts: adj.parts });
for (const part of beyond.parts)
{
if (part.textNode === space.textNode && part.start >= space.end)
{
part.start -= 1;
part.end -= 1;
}
}
beyond.offset = beyond.parts[0]!.start;
neighbor = beyond;
}
if (!pillNeighbor.test(neighbor.ch)) { return; }
if (dir === -1)
{
wrapGapLeft({ text: neighbor.ch, parts: neighbor.parts },
ownedElements, ownerToken, pill.parentElement ?? scopeRoot, scopeRoot);
}
else { wrapNodeInGap(pill, ownedElements); }
}
export const spacingPass: StandalonePass = {
name: PASS,
kind: "standalone",
order: 50,
level: "text",
enabled: (o) => o.features.spacing,
render(ctx: RenderContext): void
{
const { finder, options } = ctx;
// 由規則集編譯間隙正則(per-instance;含 charClass 覆寫)。
const R = makeRules(options.ruleset);
// Pass A:逐 block 收集,但功能閘逐 Text/完整原子;子白名單可重新啟用。
const processedRoots = new Set<Element>();
ctx.finder.eachBlock(ctx.root, (block) =>
{
processBlock(block, ctx.root, finder, R, ctx.ownedElements, ctx.ownerToken);
processedRoots.add(block);
});
// 文本級功能須支持單元素(§6.5.0a):root 為行內/單一非塊元素(如消費端對
// <span> 標題呼叫 render)時 eachBlock 不含之,補處理 root 自身(collectRuns
// 遇巢狀 block 仍會 flush,故與上面逐 block 不重複;行內 root 通常無 block 子)。
const inlineRoots = [ ctx.root ];
if (options.scope.include) { inlineRoots.push(...Array.from(ctx.root.querySelectorAll(options.scope.include))); }
for (const root of inlineRoots)
{
let covered = processedRoots.has(root);
for (let parent = root.parentElement; !covered && parent; parent = parent.parentElement)
{
covered = processedRoots.has(parent);
if (parent === ctx.root) { break; }
}
if (options.finder.blockTags.has(root.nodeName) || covered || !finder.inScope(root) || finder.isAvoided(root)
|| finder.category(root) === "pill" || finder.insidePill(root)) { continue; }
processBlock(root, ctx.root, finder, R, ctx.ownedElements, ctx.ownerToken);
processedRoots.add(root);
}
// Pass B:pill/isolate 邊界。
const boundarySelector = [ options.finder.pillSelector, options.finder.isolateSelector ]
.filter(Boolean)
.join(", ");
if (boundarySelector)
{
processPills(
ctx.root,
finder,
boundarySelector,
R.pillNeighbor,
ctx.ownedElements,
ctx.ownerToken,
);
}
},
revert(ctx: RenderContext): void
{
const touched = restoreMovedParts(ctx);
revertPass(PASS, ctx.root, ctx.ownedElements);
for (const parent of touched) { (parent as Element).normalize?.(); }
},
};
File diff suppressed because it is too large Load Diff
-110
View File
@@ -1,110 +0,0 @@
// 增量恢復基準(jsdom)。度量「頁面局部變化」之恢復成本:全樹 revert+render(舊法)
// vs 局部 rerender(第三層 C3/C2)。確定性 el.matches() 計數 + 計時中位數。
//
// 用法:node test/bench/bench-incremental.mjs [blocks] [iters]
import { JSDOM } from "jsdom";
import { createJuzhen } from "../../dist/juzhen.mjs";
const BLOCKS = Number(process.argv[2] || 400);
const ITERS = Number(process.argv[3] || 7);
function buildHtml(n)
{
let html = '<main lang="zh-Hant">';
for (let i = 0; i < n; i += 1)
{
html += '<p>第' + i + '段落文字<strong>粗體</strong>與 Latin word 混排,'
+ '句內點號、頓號;「引號」(括號)末尾實義字甲乙丙丁。</p>';
}
html += '</main>';
return html;
}
const HTML = buildHtml(BLOCKS);
function fresh()
{
const dom = new JSDOM("<!doctype html><html><body>" + HTML + "</body></html>");
globalThis.document = dom.window.document;
globalThis.NodeFilter = dom.window.NodeFilter;
globalThis.Node = dom.window.Node;
globalThis.window = dom.window;
globalThis.MutationObserver = dom.window.MutationObserver;
return dom;
}
function patchMatches(dom, counter)
{
let p = dom.window.document.querySelector("strong");
let proto = Object.getPrototypeOf(p);
while (proto && !Object.prototype.hasOwnProperty.call(proto, "matches"))
{
proto = Object.getPrototypeOf(proto);
}
const orig = proto.matches;
proto.matches = function (s) { counter.n += 1; return orig.call(this, s); };
return () => { proto.matches = orig; };
}
const median = (a) => a.slice().sort((x, y) => x - y)[Math.floor(a.length / 2)];
// ---- 場景:編輯「中間某一段」後恢復 ----
function editTarget(doc) { return doc.querySelectorAll("p")[Math.floor(BLOCKS / 2)]; }
// 全樹法:revert(root)+render(root)。
function fullCost(measureMatches)
{
const times = [];
let matches = 0;
for (let k = 0; k < ITERS; k += 1)
{
const dom = fresh();
const doc = dom.window.document;
const jz = createJuzhen();
jz.render(doc.body);
const p = editTarget(doc);
p.textContent = "改後第 K 段,新的實義字戊己庚辛。";
let restore, counter;
if (measureMatches && k === 0) { counter = { n: 0 }; restore = patchMatches(dom, counter); }
const t0 = performance.now();
jz.revert(doc.body);
jz.render(doc.body);
times.push(performance.now() - t0);
if (restore) { restore(); matches = counter.n; }
}
return { ms: median(times), matches };
}
// 增量法:rerender(改動塊)。
function incCost(measureMatches)
{
const times = [];
let matches = 0;
for (let k = 0; k < ITERS; k += 1)
{
const dom = fresh();
const doc = dom.window.document;
const jz = createJuzhen();
jz.render(doc.body);
const p = editTarget(doc);
p.textContent = "改後第 K 段,新的實義字戊己庚辛。";
let restore, counter;
if (measureMatches && k === 0) { counter = { n: 0 }; restore = patchMatches(dom, counter); }
const t0 = performance.now();
jz.rerender(p);
times.push(performance.now() - t0);
if (restore) { restore(); matches = counter.n; }
}
return { ms: median(times), matches };
}
const full = fullCost(true);
const inc = incCost(true);
console.log("增量恢復基準(" + BLOCKS + " 段;編輯中間一段後恢復):");
console.log(" 全樹 revert+render :", full.ms.toFixed(2), "ms |",
"el.matches()", full.matches);
console.log(" 局部 rerender(塊) :", inc.ms.toFixed(2), "ms |",
"el.matches()", inc.matches);
console.log(" 提速:時間 " + (full.ms / inc.ms).toFixed(1) + "× | matches "
+ (full.matches / Math.max(1, inc.matches)).toFixed(1) + "×");
-78
View File
@@ -1,78 +0,0 @@
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="../../dist/juzhen.css">
<style>
/* 窄容器強制多行;大量 CJK↔Latin 邊界 → 大量 jz-hws gap。 */
#stage { width: 380px; font-size: 18px; line-height: 2; }
#stage.j { text-align: justify; }
</style>
</head>
<body>
<div id="result">pending</div>
<div id="stage"></div>
<script src="../../dist/juzhen.iife.js"></script>
<script>
// 計 getClientRects 呼叫數(布局讀取/reflow 代理)——S1 之目標即減少此類「寫後讀」。
let rectCalls = 0;
for (const proto of [Range.prototype, Element.prototype])
{
const orig = proto.getClientRects;
proto.getClientRects = function () { rectCalls += 1; return orig.apply(this, arguments); };
}
// 建 gap 密集之多行文檔:每段交替 CJK 字 + Latin 字母/數字 → 每邊界一個 jz-hws。
function buildStage(paras, boundariesPerPara)
{
const cjk = "中文字符測試內容甲乙丙丁戊己庚辛壬癸";
const ans = "abcdefghijklmnopqrstuvwxyz0123456789";
let html = "";
for (let p = 0; p < paras; p += 1)
{
let s = "";
for (let i = 0; i < boundariesPerPara; i += 1)
{
s += cjk.charAt((p + i) % cjk.length) + ans.charAt((p * 3 + i) % ans.length);
}
html += "<p>" + s + ",句末標點。</p>";
}
return html;
}
const params = new URLSearchParams(location.search);
const PARAS = Number(params.get("paras") || 40);
const BPP = Number(params.get("bpp") || 60);
const JUSTIFY = params.get("justify") !== "0"; // 預設 justify
const stage = document.getElementById("stage");
if (JUSTIFY) { stage.classList.add("j"); }
stage.innerHTML = buildStage(PARAS, BPP);
try
{
const Juzhen = window.Juzhen || globalThis.Juzhen;
if (!Juzhen) { throw new Error("Juzhen 全域未定義"); }
const jz = Juzhen.createJuzhen();
// 量測:render(含 lineEdge + gapTrim 之立即 run)。ResizeObserver 非同步回調不計入。
const rectsBefore = rectCalls;
const t0 = performance.now();
jz.render(stage);
const dt = performance.now() - t0;
const gaps = stage.querySelectorAll("jz-hws").length;
const trimmed = stage.querySelectorAll("jz-hws.jz-hws-trim").length;
const le = stage.querySelectorAll("jz-char.jz-half-le").length;
document.getElementById("result").textContent = JSON.stringify({
paras: PARAS, bpp: BPP, justify: JUSTIFY, gaps, trimmed, lineEdge: le,
getClientRects: rectCalls - rectsBefore,
renderMs: Math.round(dt * 10) / 10,
});
}
catch (e)
{
document.getElementById("result").textContent = "ERROR: " + (e && e.stack || e);
}
</script>
</body>
</html>
-146
View File
@@ -1,146 +0,0 @@
// 掃描層效能基準(jsdom)。度量 render 之 CPU 掃描成本——確定性計數(el.matches() 呼叫數)
// +壁鐘中位數。jsdom 無版面(getClientRects 回 0),故 lineEdge/gapTrim 不執行、此基準僅覆
// 蓋 charify/jiya/jinze/spacing/orphan/longWord 之掃描;版面層(S1)另見 bench-layout。
//
// 用法:node test/bench/bench-scan.mjs [scale] [iters]
// scale:段落規模乘數(預設 60);iters:計時重複次數取中位數(預設 9)。
//
// 建立兩種壓力結構:寬(多兄弟段落,測線性)+深(巢狀塊測 orphan S2、巢狀行內測祖先游走/
// insidePill)。計數用正確之 Element.prototype.matches 補丁(非 HTMLBodyElement.prototype)。
import { JSDOM } from "jsdom";
import { createJuzhen } from "../../dist/juzhen.mjs";
const scale = Number(process.argv[2] || 60);
const iters = Number(process.argv[3] || 9);
/** 一段混合行內:透明(strong/em/span)、pill(code)、isolate(mark)、opaque(button)、
* 標點(句內/句末/括號引號)、中西混排、長英文詞、末尾實義字。 */
function para(i)
{
return '<p>第' + i + '段開頭文字'
+ '<strong>粗體<em>巢狀斜體</em>內容</strong>與'
+ '<span><span>雙層透明<code>codeSample</code>片段</span></span>'
+ '及<mark>標記字</mark>又<button>按鈕文字</button>'
+ '含 Latin word 與 supercalifragilistic 長詞,'
+ '句內點號、頓號;冒號:「引號」(括號)測試。末尾實義字甲乙丙丁。</p>';
}
/** 深巢狀塊(測 orphan S2 之逐塊 querySelector 掃子樹):div 套 depth 層,內含葉段落。 */
function deepBlock(depth, inner)
{
let s = inner;
for (let d = 0; d < depth; d += 1) { s = '<div>' + s + '</div>'; }
return s;
}
/** 深巢狀透明行內(測 insidePill/祖先游走:每文本節點沿祖先鏈走 depth 層)。 */
function deepInline(depth, i)
{
let s = '這是深層巢狀行內的文字內容甲乙丙丁。'
+ '<code>codeX</code>中間再一段實義字戊己庚辛。';
for (let d = 0; d < depth; d += 1) { s = '<span>' + s + '</span>'; }
return '<p>段' + i + s + '</p>';
}
function buildHtml()
{
let html = '<section lang="zh-Hant">';
// 寬:scale 段普通段落。
for (let i = 0; i < scale; i += 1) { html += para(i); }
// 深塊:若干「深 8 層 div 包一葉段落」的容器(放大 S2 的 O(Σ塊子樹))。
for (let i = 0; i < Math.max(4, scale / 6); i += 1)
{
html += deepBlock(8, para(1000 + i));
}
// 深行內:若干「深 10 層 span」的段落(放大 insidePill/祖先游走)。
for (let i = 0; i < Math.max(4, scale / 6); i += 1)
{
html += deepInline(10, 2000 + i);
}
html += '</section>';
return html;
}
const HTML = buildHtml();
function freshDom()
{
const dom = new JSDOM("<!doctype html><html><head></head><body>" + HTML + "</body></html>");
globalThis.document = dom.window.document;
globalThis.NodeFilter = dom.window.NodeFilter;
globalThis.Node = dom.window.Node;
globalThis.window = dom.window;
return dom;
}
/** 找到 matches 所在原型(Element.prototype),返回 { proto, restore }。 */
function patchMatches(dom, counter)
{
let p = dom.window.document.querySelector("strong");
let proto = Object.getPrototypeOf(p);
while (proto && !Object.prototype.hasOwnProperty.call(proto, "matches"))
{
proto = Object.getPrototypeOf(proto);
}
const orig = proto.matches;
proto.matches = function (s) { counter.n += 1; return orig.call(this, s); };
return () => { proto.matches = orig; };
}
function median(arr)
{
const s = arr.slice().sort((a, b) => a - b);
return s[Math.floor(s.length / 2)];
}
// ---- 計數(單次,確定性)----
{
const dom = freshDom();
const counter = { n: 0 };
const restore = patchMatches(dom, counter);
const jz = createJuzhen();
jz.render(dom.window.document.body);
restore();
const doc = dom.window.document;
console.log("結構規模:");
console.log(" 文本節點掃描後 jz-char:", doc.querySelectorAll("jz-char").length);
console.log(" jz-hws:", doc.querySelectorAll("jz-hws").length,
"| jz-jinze:", doc.querySelectorAll("jz-jinze").length,
"| jz-orphan:", doc.querySelectorAll("jz-orphan").length);
console.log(" DOM 元素總數:", doc.querySelectorAll("*").length);
console.log("計數(確定性):");
console.log(" render 期間 el.matches() 呼叫數:", counter.n);
}
// ---- 計時(render 中位數)----
{
const times = [];
for (let k = 0; k < iters; k += 1)
{
const dom = freshDom();
const jz = createJuzhen();
const t0 = performance.now();
jz.render(dom.window.document.body);
times.push(performance.now() - t0);
}
console.log("計時(" + iters + " 次中位數):");
console.log(" render:", median(times).toFixed(2), "ms (min",
Math.min(...times).toFixed(2) + ")");
}
// ---- render→revert 往返計時(第三層增量對照之基線)----
{
const times = [];
for (let k = 0; k < iters; k += 1)
{
const dom = freshDom();
const jz = createJuzhen();
jz.render(dom.window.document.body);
const t0 = performance.now();
jz.revert(dom.window.document.body);
jz.render(dom.window.document.body);
times.push(performance.now() - t0);
}
console.log(" revert+render 全樹往返:", median(times).toFixed(2), "ms");
}
-92
View File
@@ -1,92 +0,0 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { JSDOM } from "jsdom";
function openDemo(name)
{
const html = readFileSync(new URL("../demo/" + name, import.meta.url), "utf8");
const dom = new JSDOM(html, { runScripts: "outside-only" });
const { window } = dom;
const observers = [];
window.ResizeObserver = class
{
constructor(callback) { this.callback = callback; this.disconnected = false; observers.push(this); }
observe(root) { this.root = root; }
disconnect() { this.disconnected = true; }
};
// 此處驗證事件刷新與觀察目標;矩形模型不作真實 layout 驗收。
const rect = { top: 0, bottom: 20, left: 0, right: 20, width: 20, height: 20 };
window.Element.prototype.getClientRects = () => [ rect ];
window.Range.prototype.getClientRects = () => [ rect ];
window.eval(readFileSync(new URL("../dist/juzhen.iife.js", import.meta.url), "utf8"));
for (const script of window.document.querySelectorAll("script:not([src])"))
{
window.eval(script.textContent);
}
return { dom, window, document: window.document, observers };
}
function change(window, id, checked)
{
const input = window.document.getElementById(id);
input.checked = checked;
input.dispatchEvent(new window.Event("change"));
}
test("index demo:對齊與寬度明確重排,revert 後開關不重新啟用", () =>
{
const { dom, window, document, observers } = openDemo("index.html");
try
{
for (const [ id, checked ] of [ [ "justify", true ], [ "wide", true ] ])
{
const oldGap = document.querySelector("jz-hws");
const oldObservers = observers.filter((observer) => !observer.disconnected);
assert.ok(oldGap);
change(window, id, checked);
assert.equal(oldGap.isConnected, false, id + " 撤回舊產物");
assert.ok(document.querySelector("jz-hws"), id + " 重新建立產物");
assert.ok(oldObservers.every((observer) => observer.disconnected), id + " 更換 layout 安裝");
}
assert.ok(document.body.classList.contains("justify"));
assert.equal(document.querySelector(".narrow").style.maxWidth, "none");
document.getElementById("revert").click();
const count = observers.length;
assert.equal(document.querySelector("[data-jz]"), null);
change(window, "justify", false);
change(window, "wide", false);
change(window, "force-kaiming", true);
assert.equal(document.querySelector("[data-jz]"), null, "revert 狀態仍保持");
assert.equal(observers.length, count, "停用時不新增 layout 安裝");
document.getElementById("render").click();
assert.ok(document.querySelector("jz-hws"), "手動 render 可重新啟用");
assert.ok(document.getElementById("status").textContent.includes("开明式"));
}
finally { dom.window.close(); }
});
test("resize demo:各拖曳框各自被觀察,切換對齊刷新所有框", () =>
{
const { dom, window, document, observers } = openDemo("resize-check.html");
try
{
const boxes = Array.from(document.querySelectorAll(".box.juzhen"));
const targets = new Set(observers.map((observer) => observer.root));
assert.equal(targets.size, boxes.length);
assert.ok(boxes.every((box) => targets.has(box)));
assert.equal(targets.has(document.body), false);
const oldGaps = boxes.flatMap((box) => Array.from(box.querySelectorAll("jz-hws")));
const oldObservers = observers.slice();
assert.ok(oldGaps.length > 0);
change(window, "t-justify", true);
assert.ok(oldGaps.every((gap) => !gap.isConnected), "所有框撤回舊間隙");
assert.ok(oldObservers.every((observer) => observer.disconnected));
const activeTargets = new Set(observers.filter((observer) => !observer.disconnected)
.map((observer) => observer.root));
assert.equal(activeTargets.size, boxes.length);
assert.ok(boxes.every((box) => activeTargets.has(box)));
assert.ok(document.querySelector("jz-hws"), "新產物仍存在");
}
finally { dom.window.close(); }
});
-35
View File
@@ -1,35 +0,0 @@
// 診斷:印出指定句子 charify 後每個 jz-char 的碼位與 class(ASCII 輸出,
// 不受 Bash 非 ASCII 正規化影響)。非單元測試,手動執行。
import { setupDom } from "./helpers.mjs";
import { createJuzhen } from "../dist/juzhen.mjs";
function dump(label, lang, text)
{
const { document } = setupDom('<p lang="' + lang + '">' + text + "</p>");
createJuzhen({ spacing: false, longWord: false }).render(document.body);
console.log("=== " + label + " (lang=" + lang + ") ===");
document.querySelectorAll("jz-char").forEach((c) =>
{
const cp = "U+" + c.textContent.codePointAt(0).toString(16).toUpperCase().padStart(4, "0");
const cls = Array.from(c.classList).join(" ");
console.log(" " + cp + " [" + cls + "]");
});
}
dump("简体开明", "zh-Hans", "完了……怎么办?!真是的——他竟然走了。");
dump("繁体全角", "zh-Hant", "完了……怎麼辦?!真是的——他竟然走了。");
// #3:200% 是否被拆斷。印出 jinze 後 <p> 的子節點序列。
function dumpKids(label, lang, text)
{
const { document } = setupDom('<p lang="' + lang + '">' + text + "</p>");
createJuzhen({ spacing: true }).render(document.body);
console.log("=== kids " + label + " ===");
document.querySelector("p").childNodes.forEach((n) =>
{
if (n.nodeType === 3) { console.log(" text: " + JSON.stringify(n.data)); }
else { console.log(" <" + n.nodeName.toLowerCase() + "> " + JSON.stringify(n.textContent)); }
});
}
dumpKids("百分号", "zh-Hans", "效率提升了 200%,达到约 3.5 倍。");
-27
View File
@@ -1,27 +0,0 @@
// jsdom 測試輔助:建立 DOM 並把必要全域掛上,再回傳 document。
// 注入結構正確性可於 jsdom 驗證;版面(justify/擠壓量/行端)須瀏覽器目視。
import { JSDOM } from "jsdom";
/**
* 建立一個帶 body 內容之 jsdom,並把 document / NodeFilter / Node 掛到
* globalThis(dom.ts 之 createJz 用全域 document)。回傳 { dom, document }。
*/
export function setupDom(bodyHtml = "")
{
const dom = new JSDOM(
"<!doctype html><html><head></head><body>" + bodyHtml + "</body></html>",
);
globalThis.document = dom.window.document;
globalThis.NodeFilter = dom.window.NodeFilter;
globalThis.Node = dom.window.Node;
globalThis.window = dom.window;
globalThis.MutationObserver = dom.window.MutationObserver; // C2 觀察(jsdom 支持)
return { dom, document: dom.window.document };
}
/** 計算 root 下某 tag(大寫)之元素數。 */
export function countTag(root, tag)
{
return root.querySelectorAll(tag).length;
}
-1613
View File
File diff suppressed because it is too large Load Diff
-92
View File
@@ -1,92 +0,0 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { setupDom } from "./helpers.mjs";
import { createJuzhen } from "../dist/juzhen.mjs";
function layoutHarness(document)
{
const originalResizeObserver = globalThis.ResizeObserver;
const originalRects = window.Element.prototype.getClientRects;
const observers = [];
let measurements = 0;
let resolveFonts;
const ready = new Promise((resolve) => { resolveFonts = resolve; });
Object.defineProperty(document, "fonts", { configurable: true, value: { ready } });
globalThis.ResizeObserver = class
{
constructor(callback) { this.callback = callback; this.disconnected = false; observers.push(this); }
observe() {}
disconnect() { this.disconnected = true; }
fire() { this.callback([], this); }
};
window.Element.prototype.getClientRects = function ()
{
measurements += 1;
return [ { top: 0, bottom: 16, left: 0, right: 16, width: 16, height: 16 } ];
};
return {
observers,
resolveFonts,
count: () => measurements,
restore()
{
window.Element.prototype.getClientRects = originalRects;
if (originalResizeObserver === undefined) { delete globalThis.ResizeObserver; }
else { globalThis.ResizeObserver = originalResizeObserver; }
},
};
}
for (const signal of [ "resize", "fonts" ])
{
test("layout lifecycle:重排後舊 " + signal + " 回調不得量測新產物", async () =>
{
const { dom, document } = setupDom('<p lang="zh-Hant">「甲乙</p>');
const root = document.querySelector("p");
const layout = layoutHarness(document);
const jz = createJuzhen({ spacing: false, orphan: false, jinze: false, longWord: false });
try
{
jz.render(root);
jz.rerender(root);
assert.equal(layout.observers.length, 2);
assert.equal(layout.observers[0].disconnected, true);
const before = layout.count();
if (signal === "resize")
{
layout.observers[0].fire();
assert.equal(layout.count(), before, "已卸載回調不碰目前產物");
layout.observers[1].fire();
assert.equal(layout.count(), before + 1, "目前回調正常執行");
}
else
{
layout.resolveFonts();
await Promise.resolve();
assert.equal(layout.count(), before + 1, "只執行目前安裝的字型回調");
}
}
finally { jz.revert(root); jz.disconnect(); layout.restore(); dom.window.close(); }
});
}
test("layout lifecycle:回退後字型 Promise 不再查詢根子樹", async () =>
{
const { dom, document } = setupDom('<p lang="zh-Hant">「甲乙</p>');
const root = document.querySelector("p");
const layout = layoutHarness(document);
const jz = createJuzhen({ spacing: false, orphan: false, jinze: false, longWord: false });
const nativeQuery = root.querySelectorAll;
let queries = 0;
try
{
jz.render(root);
jz.revert(root);
root.querySelectorAll = function (...args) { queries += 1; return nativeQuery.apply(this, args); };
layout.resolveFonts();
await Promise.resolve();
assert.equal(queries, 0, "卸載後不再進入 layout 工作");
assert.equal(root.innerHTML, "「甲乙");
}
finally { root.querySelectorAll = nativeQuery; jz.disconnect(); layout.restore(); dom.window.close(); }
});
-95
View File
@@ -1,95 +0,0 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
const python = "python3";
const available = spawnSync(python, [ "-c", "import fontTools" ]).status === 0;
const tool = fileURLToPath(new URL("../tools/measure-bias.py", import.meta.url));
const fixture = `
from fontTools.fontBuilder import FontBuilder
from fontTools.pens.ttGlyphPen import TTGlyphPen
from fontTools.feaLib.builder import addOpenTypeFeaturesFromString
import sys
fb = FontBuilder(1000, isTTF=True)
names = ['.notdef', 'comma', 'commaHant', 'fullstop']
fb.setupGlyphOrder(names)
fb.setupCharacterMap({0xff0c: 'comma', 0xff0e: 'fullstop'})
glyphs = {}
for name, left, right in [('.notdef', 0, 0), ('comma', 50, 250),
('commaHant', 450, 550), ('fullstop', 650, 850)]:
p = TTGlyphPen(None)
if right > left:
p.moveTo((left, 0)); p.lineTo((right, 0)); p.lineTo((right, 150))
p.lineTo((left, 150)); p.closePath()
glyphs[name] = p.glyph()
fb.setupGlyf(glyphs)
fb.setupHorizontalMetrics({'.notdef': (1000, 0), 'comma': (1000, 50),
'commaHant': (1000, 450), 'fullstop': (1000, 650)})
fb.setupHorizontalHeader(ascent=800, descent=-200)
fb.setupNameTable({'familyName': 'BiasFixture', 'styleName': 'Regular',
'uniqueFontIdentifier': 'BiasFixture', 'fullName': 'BiasFixture',
'psName': 'BiasFixture'})
fb.setupOS2(sTypoAscender=800, sTypoDescender=-200, usWinAscent=800, usWinDescent=200)
fb.setupPost(); fb.setupMaxp()
features = 'languagesystem hani ZHT; feature locl { script hani; language ZHT; sub comma by commaHant; } locl;'
if sys.argv[2] == 'halt':
features += ' feature halt { sub fullstop by comma; } halt;'
addOpenTypeFeaturesFromString(fb.font, features)
fb.save(sys.argv[1])
`;
function run(args)
{
const result = spawnSync(python, args, { encoding: "utf8" });
assert.equal(result.status, 0, result.stderr || String(result.error));
return result.stdout;
}
function withFont(feature, check)
{
const dir = mkdtempSync(join(tmpdir(), "juzhen-bias-test-"));
try
{
const font = join(dir, "fixture.ttf");
run([ "-c", fixture, font, feature ]);
check(font);
}
finally { rmSync(dir, { recursive: true, force: true }); }
}
function table(output)
{
return JSON.parse(output.slice(output.indexOf("\n{") + 1));
}
test("measure-bias:全角句點入表,lang 僅選鍵且輸出說明 cmap/locl 限制",
{ skip: available ? false : "需工具本身的 Python fonttools 依賴" }, () =>
{
withFont("none", (font) =>
{
for (const lang of [ "zh-Hans", "zh-Hant" ])
{
const output = run([ tool, font, lang ]);
assert.equal(table(output)[lang]["."], "right");
assert.equal(table(output)[lang][","], "left", "仍量預設 cmap,未套 ZHT locl 替代字形");
assert.match(output, /未做 shaping.*未應用 locl/);
assert.match(output, /語言參數僅選 JSON 分類鍵/);
assert.match(output, /瀏覽器實際字形逐字微調/);
}
});
});
test("measure-bias:halt feature 存在只報存在,不承諾逐字覆蓋",
{ skip: available ? false : "需工具本身的 Python fonttools 依賴" }, () =>
{
withFont("halt", (font) =>
{
const output = run([ tool, font ]);
assert.match(output, /halt:有.*不保證涵蓋所有標點/);
assert.equal(table(output)["zh-Hans"]["."], "right", "feature 偵測不改預設輪廓分類");
});
});
-58
View File
@@ -1,58 +0,0 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { setupDom } from "./helpers.mjs";
import { createJuzhen } from "../dist/juzhen.mjs";
for (const minLength of [ 6.5, Infinity, NaN, -Infinity, 1e21 ])
{
test("longWord:無效計數 " + minLength + " 回退六字,不靜默停用", () =>
{
const { dom, document } = setupDom('<p>configuration</p>');
const jz = createJuzhen({ jiya: false, spacing: false, jinze: false, orphan: false,
longWord: { minLength } });
try
{
jz.render(document.body);
assert.equal(jz.options.longWord.minLength, 6);
assert.equal(document.querySelectorAll('[data-jz="longWord"]').length, 1);
assert.equal(document.body.textContent, "configuration");
jz.revert(document.body);
assert.equal(document.querySelector("p").innerHTML, "configuration");
}
finally { jz.disconnect(); dom.window.close(); }
});
}
for (const chars of [ Infinity, NaN, -Infinity ])
{
test("orphan:非有限計數 " + chars + " 回退兩字", () =>
{
const { dom, document } = setupDom('<p>甲乙丙丁</p>');
const jz = createJuzhen({ jiya: false, spacing: false, jinze: false, longWord: false,
orphan: { chars } });
try
{
jz.render(document.body);
assert.equal(jz.options.orphan.chars, 2);
assert.equal(document.querySelector("jz-orphan").textContent, "丙丁");
jz.revert(document.body);
assert.equal(document.querySelector("p").innerHTML, "甲乙丙丁");
}
finally { jz.disconnect(); dom.window.close(); }
});
}
test("有效計數:longWord 整數閾值及 orphan 小數向下取整維持既有行為", () =>
{
const { dom, document } = setupDom('<p>abcdef abcdefg甲乙丙丁</p>');
const jz = createJuzhen({ jiya: false, spacing: false, jinze: false,
longWord: { minLength: 7 }, orphan: { chars: 2.5 } });
try
{
jz.render(document.body);
assert.equal(jz.options.orphan.chars, 2);
assert.deepEqual(Array.from(document.querySelectorAll('[data-jz="longWord"]'), (el) => el.textContent), [ "abcdefg" ]);
assert.equal(document.querySelector("jz-orphan").textContent, "丙丁");
}
finally { jz.revert(document.body); jz.disconnect(); dom.window.close(); }
});
-79
View File
@@ -1,79 +0,0 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { setupDom } from "./helpers.mjs";
import { createJuzhen } from "../dist/juzhen.mjs";
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
for (const scenario of [ "move-added-block", "detached-target-record" ])
{
test("observer scope:" + scenario + " 不排版已移出觀察根的作者內容", async () =>
{
const { dom, document } = setupDom('<main id="root"></main><aside id="outside"></aside>');
const root = document.getElementById("root");
const outside = document.getElementById("outside");
const p = document.createElement("p");
p.innerHTML = "新增ABC,作者文字。";
const jz = createJuzhen();
try
{
if (scenario === "detached-target-record") { root.appendChild(p); }
jz.observe(root);
if (scenario === "move-added-block") { root.appendChild(p); }
outside.appendChild(p);
if (scenario === "detached-target-record") { p.setAttribute("lang", "zh-Hans"); }
const original = p.outerHTML;
await tick();
assert.equal(p.outerHTML, original, "已移出內容不能因尚未派發的記錄被新排版");
const active = document.createElement("p");
active.textContent = "根內ABC,仍處理。";
root.appendChild(active);
await tick();
assert.ok(active.querySelector("jz-char"), "觀察根內後續更新保持正常");
}
finally { jz.revert(root); jz.disconnect(); dom.window.close(); }
});
}
for (const change of [ "text", "attribute", "new-block" ])
{
test("observer detached root:" + change + " 在未掛入 document 的根仍增量更新", async () =>
{
const { dom, document } = setupDom();
const root = document.createElement("main");
root.innerHTML = '<p lang="zh-Hant">原文,甲乙。</p>';
const jz = createJuzhen();
try
{
jz.render(root);
jz.observe(root);
const p = root.querySelector("p");
if (change === "text") { p.textContent = "新文ABC,丙丁。"; }
else if (change === "attribute") { p.setAttribute("data-jz-level", "text"); }
else
{
const added = document.createElement("p");
added.textContent = "追加ABC,戊己。";
root.appendChild(added);
}
await tick();
assert.equal(root.isConnected, false);
if (change === "attribute")
{
assert.equal(p.getAttribute("data-jz-block-level"), "text");
assert.equal(p.querySelector("jz-orphan"), null);
}
else
{
const target = change === "new-block" ? root.lastElementChild : p;
assert.ok(target.querySelector("jz-hws"));
assert.ok(target.querySelector("jz-char"));
}
const currentText = root.textContent;
jz.revert(root);
assert.equal(root.querySelector("[data-jz]"), null);
assert.equal(root.textContent, currentText);
}
finally { jz.revert(root); jz.disconnect(); dom.window.close(); }
});
}
-828
View File
@@ -1,828 +0,0 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { createJuzhen } from "../dist/juzhen.mjs";
import { setupDom } from "./helpers.mjs";
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
function installCountingMutationObserver(
maxCallbacks = Number.POSITIVE_INFINITY,
{ deferDelivery = false, wrapRecords = (records) => records } = {},
)
{
const NativeMutationObserver = globalThis.MutationObserver;
const instances = [];
class CountingMutationObserver
{
constructor(callback)
{
this.callback = callback;
this.observeCount = 0;
this.disconnectCount = 0;
this.callbackCount = 0;
this.pendingRecords = [];
this.suppressed = false;
this.inner = new NativeMutationObserver((records) =>
{
if (deferDelivery) { this.pendingRecords.push(...records); }
else { this.deliver(records); }
});
instances.push(this);
}
deliver(records)
{
this.callbackCount += 1;
if (this.callbackCount > maxCallbacks)
{
this.suppressed = true;
this.inner.disconnect();
return;
}
this.callback(wrapRecords(records), this);
}
observe(target, options)
{
this.observeCount += 1;
if (!this.suppressed) { this.inner.observe(target, options); }
}
disconnect()
{
this.disconnectCount += 1;
this.inner.disconnect();
}
takeRecords()
{
return this.inner.takeRecords();
}
}
globalThis.MutationObserver = CountingMutationObserver;
return {
instances,
restore()
{
globalThis.MutationObserver = NativeMutationObserver;
},
};
}
function installExpectationShiftProbe()
{
const nativeShift = Array.prototype.shift;
let calls = 0;
let work = 0;
Array.prototype.shift = function ()
{
const head = this[0];
if (head && typeof head === "object" && "target" in head
&& "oldValue" in head && "newValue" in head)
{
calls += 1;
work += this.length;
}
return nativeShift.call(this);
};
return {
result: () => ({ calls, work }),
restore()
{
Array.prototype.shift = nativeShift;
},
};
}
function installExpectationSpliceProbe()
{
const nativeSplice = Array.prototype.splice;
let calls = 0;
let removed = 0;
Array.prototype.splice = function (...args)
{
const [ start, deleteCount ] = args;
const head = this[0];
if (start === 0 && head && typeof head === "object" && "target" in head
&& "oldValue" in head && "newValue" in head)
{
calls += 1;
removed += deleteCount;
}
return nativeSplice.apply(this, args);
};
return {
result: () => ({ calls, removed }),
restore()
{
Array.prototype.splice = nativeSplice;
},
};
}
function installLayoutHarness(document, withFonts = false)
{
const NativeResizeObserver = globalThis.ResizeObserver;
const originalGetClientRects = window.Element.prototype.getClientRects;
const instances = [];
let rectsEnabled = true;
class CapturingResizeObserver
{
constructor(callback)
{
this.callback = callback;
this.target = null;
this.disconnected = false;
instances.push(this);
}
observe(target)
{
this.target = target;
}
disconnect()
{
this.disconnected = true;
}
fire()
{
this.callback([ { target: this.target } ], this);
}
}
globalThis.ResizeObserver = CapturingResizeObserver;
window.Element.prototype.getClientRects = function ()
{
if (!rectsEnabled) { return []; }
return [ {
bottom: 16, height: 16, left: 0, right: 16,
top: 0, width: 16, x: 0, y: 0,
} ];
};
if (withFonts)
{
Object.defineProperty(document, "fonts", {
configurable: true,
value: { ready: Promise.resolve() },
});
}
return {
instances,
setRectsEnabled(value)
{
rectsEnabled = value;
},
restore()
{
window.Element.prototype.getClientRects = originalGetClientRects;
if (NativeResizeObserver === undefined) { delete globalThis.ResizeObserver; }
else { globalThis.ResizeObserver = NativeResizeObserver; }
},
};
}
test("observer recovery: synchronous render failure restores later observation", async () =>
{
const { document } = setupDom('<div id="root"><p lang="zh-Hant">原文,內容。</p></div>');
const root = document.getElementById("root");
const paragraph = root.querySelector("p");
const expected = new Error("isSkipped render failure");
let throwNext = false;
const jz = createJuzhen({
isSkipped()
{
if (!throwNext) { return false; }
throwNext = false;
throw expected;
},
});
jz.render(root);
jz.observe(root);
jz.invalidate(root);
throwNext = true;
assert.throws(() => jz.render(root), (error) => error === expected);
paragraph.textContent = "錯誤後,仍可繼續觀察。";
await tick();
await tick();
jz.disconnect();
assert.ok(paragraph.querySelector("jz-char"), "之後的 DOM 變化仍自動處理");
assert.equal(paragraph.textContent, "錯誤後,仍可繼續觀察。");
});
test("observer recovery: mutation callback failure restores a later mutation", async () =>
{
const { document } = setupDom('<div id="root"><p lang="zh-Hant">原文,內容。</p></div>');
const root = document.getElementById("root");
const paragraph = root.querySelector("p");
const expected = new Error("isSkipped mutation failure");
const reported = [];
let throwNext = false;
const onError = (event) =>
{
reported.push(event.error);
event.preventDefault();
};
window.addEventListener("error", onError);
const jz = createJuzhen({
isSkipped()
{
if (!throwNext) { return false; }
throwNext = false;
throw expected;
},
});
try
{
jz.render(root);
jz.observe(root);
jz.invalidate(root);
throwNext = true;
paragraph.textContent = "第一次,觸發錯誤。";
await tick();
assert.ok(reported.includes(expected), "MutationObserver 回調錯誤仍照常對外呈現");
paragraph.textContent = "第二次,恢復處理。";
await tick();
await tick();
assert.ok(paragraph.querySelector("jz-char"), "後續變化仍被觀察");
assert.equal(paragraph.textContent, "第二次,恢復處理。");
}
finally
{
jz.disconnect();
window.removeEventListener("error", onError);
}
});
test("observer depth: nested render does not resume or observe partial outer changes", async () =>
{
const { document } = setupDom('<div id="root"><p lang="zh-Hant">嵌套,渲染。</p></div>');
const counting = installCountingMutationObserver();
const root = document.getElementById("root");
const paragraph = root.querySelector("p");
let reenter = false;
let jz;
try
{
jz = createJuzhen({
isSkipped()
{
if (reenter)
{
reenter = false;
jz.render(paragraph);
}
return false;
},
});
jz.render(root);
jz.observe(root);
assert.equal(counting.instances.length, 1);
jz.invalidate(root);
reenter = true;
jz.rerender(root);
const observer = counting.instances[0];
assert.equal(observer.disconnectCount, 1, "只有最外層 0→1 暫停會 disconnect");
assert.equal(observer.observeCount, 2, "只有最外層 1→0 恢復會 observe");
await tick();
await tick();
assert.equal(observer.callbackCount, 0, "嵌套處理不會觀察半成品或自觸發");
assert.ok(paragraph.querySelector("jz-char"));
}
finally
{
if (jz) { jz.disconnect(); }
counting.restore();
}
});
test("observer depth: observe replacement inside a paused render is deferred coherently", async () =>
{
const { document } = setupDom('<div id="root"><p lang="zh-Hant">重建,觀察。</p></div>');
const counting = installCountingMutationObserver();
const root = document.getElementById("root");
const paragraph = root.querySelector("p");
let replaceObserver = false;
let observeCallsDuringPause = -1;
let jz;
try
{
jz = createJuzhen({
isSkipped()
{
if (replaceObserver)
{
replaceObserver = false;
jz.disconnect();
jz.observe(root);
observeCallsDuringPause = counting.instances.reduce(
(sum, observer) => sum + observer.observeCount, 0,
);
}
return false;
},
});
jz.render(root);
jz.observe(root);
jz.invalidate(root);
replaceObserver = true;
jz.rerender(root);
assert.equal(counting.instances.length, 2, "嵌套 observe 建立新觀察者");
assert.equal(observeCallsDuringPause, 1, "暫停期內新觀察者尚未 observe");
assert.equal(
counting.instances.reduce((sum, observer) => sum + observer.observeCount, 0),
2,
"最外層結束後新觀察者恰恢復一次",
);
paragraph.textContent = "重建後,仍能處理。";
await tick();
await tick();
assert.ok(paragraph.querySelector("jz-char"));
assert.equal(counting.instances[1].callbackCount, 1, "新觀察者接手後續變化");
}
finally
{
if (jz) { jz.disconnect(); }
counting.restore();
}
});
test("observer provenance: asynchronous internal layout classes do not self-trigger", async () =>
{
const { document } = setupDom('<div id="root"><p lang="zh-Hant">(版面回調</p></div>');
const counting = installCountingMutationObserver(8);
const layout = installLayoutHarness(document, true);
const root = document.getElementById("root");
const paragraph = root.querySelector("p");
let jz;
try
{
jz = createJuzhen({ spacing: false, orphan: false });
jz.render(root);
jz.observe(root);
await tick();
await tick();
const observer = counting.instances[0];
assert.equal(layout.instances.length, 1, "未因內部 class 記錄重建 layout observer");
assert.equal(observer.callbackCount, 1, "同批 remove/add provenance 被精確消費");
assert.equal(observer.suppressed, false, "防掛上限未被觸發");
assert.ok(paragraph.querySelector("jz-char.jz-half-le"), "異步行端結果保持");
}
finally
{
if (jz) { jz.disconnect(); }
layout.restore();
counting.restore();
}
});
test("observer provenance: author whitelist attributes still trigger rerender", async () =>
{
const { document } = setupDom('<div id="root"><p lang="zh-Hant">作者,屬性。</p></div>');
const counting = installCountingMutationObserver();
const root = document.getElementById("root");
const paragraph = root.querySelector("p");
const jz = createJuzhen();
try
{
jz.render(root);
jz.observe(root);
paragraph.classList.add("consumer-class");
await tick();
assert.equal(counting.instances[0].callbackCount, 1, "作者 class 觸發 observer");
assert.ok(paragraph.querySelector("jz-char"));
paragraph.setAttribute("data-jz-skip", "");
await tick();
assert.equal(counting.instances[0].callbackCount, 2, "作者非 class 白名單屬性亦觸發");
assert.equal(paragraph.querySelectorAll("[data-jz]").length, 0, "skip 重分類生效");
}
finally
{
jz.disconnect();
counting.restore();
}
});
test("observer provenance: external writes on owned products are not consumed", async () =>
{
const { document } = setupDom('<div id="root"><p lang="zh-Hans">前文<strong>粗体文字</strong>,后续</p></div>');
const counting = installCountingMutationObserver();
const root = document.getElementById("root");
const jz = createJuzhen({ spacing: false, longWord: false, orphan: false });
try
{
jz.render(root);
jz.observe(root);
const atom = root.querySelector("jz-char");
atom.classList.add("consumer-class");
await tick();
assert.equal(counting.instances[0].callbackCount, 1, "owned 元素外部 class 觸發");
assert.equal(atom.isConnected, false, "外部 class 變更進入 rerender");
const nextAtom = root.querySelector("jz-char");
nextAtom.setAttribute("lang", "en");
await tick();
assert.equal(counting.instances[0].callbackCount, 2, "owned 元素外部 lang 觸發");
assert.equal(nextAtom.isConnected, false);
const splitoff = root.querySelector('[data-jz-kind="splitoff"]');
splitoff.classList.add("consumer-splitoff");
await tick();
assert.equal(counting.instances[0].callbackCount, 3, "owned splitoff 外部 class 觸發");
// 外部樣式使片段無法安全併回;rerender 須保留作者元素與字序,不能丟掉 class。
assert.equal(splitoff.isConnected, true);
assert.ok(splitoff.classList.contains("consumer-splitoff"));
assert.equal(splitoff.hasAttribute("data-jz-kind"), false, "舊 splitoff 憑證已撤銷");
assert.equal(root.textContent, "前文粗体文字,后续");
jz.revert(root);
assert.equal(splitoff.isConnected, true);
assert.ok(splitoff.classList.contains("consumer-splitoff"));
assert.equal(root.querySelectorAll("[data-jz]").length, 0);
}
finally
{
jz.disconnect();
counting.restore();
}
});
test("observer provenance: mixed internal and external class records stay distinguishable", async () =>
{
const { document } = setupDom('<div id="root"><p lang="zh-Hant">(混合記錄</p></div>');
const counting = installCountingMutationObserver();
const layout = installLayoutHarness(document);
const root = document.getElementById("root");
const jz = createJuzhen({ spacing: false, orphan: false });
try
{
jz.render(root);
jz.observe(root);
const atom = root.querySelector("jz-char");
atom.classList.add("consumer-before-internal");
layout.instances[0].fire();
await tick();
assert.equal(counting.instances[0].callbackCount, 1, "同批記錄只派發一次");
assert.equal(atom.isConnected, false, "外部 mismatch 後之內部 transition 仍精確消費");
}
finally
{
jz.disconnect();
layout.restore();
counting.restore();
}
});
test(
"observer provenance: threshold compaction preserves a 1024-record tail across callbacks",
{ timeout: 20000 },
async () =>
{
const size = 2048;
const html = Array.from({ length: size }, (_, index) => `<p>(${index}</p>`).join("");
const { document } = setupDom(`<div id="root" lang="zh-Hant">${html}</div>`);
const counting = installCountingMutationObserver(4, { deferDelivery: true });
const layout = installLayoutHarness(document);
const spliceProbe = installExpectationSpliceProbe();
const root = document.getElementById("root");
const jz = createJuzhen({ spacing: false, longWord: false, orphan: false });
try
{
jz.render(root);
jz.observe(root);
layout.setRectsEnabled(false);
layout.instances[0].fire();
await tick();
const observer = counting.instances[0];
const records = observer.pendingRecords.splice(0);
assert.equal(records.length, size, "每個 target 各排入一條 internal remove");
observer.deliver(records.slice(0, 1024));
assert.deepEqual(spliceProbe.result(), { calls: 1, removed: 1024 });
assert.equal(layout.instances.length, 1, "首批觸發 threshold compaction 而不 rerender");
observer.deliver(records.slice(1024));
assert.equal(observer.callbackCount, 2, "第二批由壓縮後 deque 繼續消費");
assert.equal(layout.instances.length, 1, "尾批全消費後 head/reset 正確");
assert.equal(root.querySelectorAll("jz-char.jz-half-le").length, 0);
}
finally
{
jz.disconnect();
spliceProbe.restore();
layout.restore();
counting.restore();
}
},
);
test(
"observer provenance: callback failure compacts the consumed prefix and preserves its tail",
{ timeout: 15000 },
async () =>
{
const size = 1025;
const html = Array.from({ length: size }, (_, index) => `<p>(${index}</p>`).join("");
const { document } = setupDom(`<div id="root" lang="zh-Hant">${html}</div>`);
const counting = installCountingMutationObserver(4, { deferDelivery: true });
const layout = installLayoutHarness(document);
const spliceProbe = installExpectationSpliceProbe();
const root = document.getElementById("root");
const jz = createJuzhen({ spacing: false, longWord: false, orphan: false });
const expected = new Error("mid-callback classification failure");
const throwingRecord = {
type: "characterData",
get target()
{
throw expected;
},
};
try
{
jz.render(root);
jz.observe(root);
layout.setRectsEnabled(false);
layout.instances[0].fire();
await tick();
const observer = counting.instances[0];
const records = observer.pendingRecords.splice(0);
assert.equal(records.length, size);
assert.throws(
() => observer.deliver([ ...records.slice(0, 1024), throwingRecord ]),
(error) => error === expected,
);
assert.deepEqual(
spliceProbe.result(),
{ calls: 1, removed: 1024 },
"拋錯 callback 之 finally 立即壓縮已消費前綴",
);
assert.equal(layout.instances.length, 1, "finally 壓縮已消費前綴而不 rerender");
observer.deliver([ records[1024] ]);
assert.equal(layout.instances.length, 1, "下一 callback 精確消費保留之 tail");
const atom = root.querySelector("jz-char");
atom.classList.add("consumer-after-callback-error");
await tick();
assert.equal(observer.pendingRecords.length, 1, "外部 class 排入獨立 record");
observer.deliver(observer.pendingRecords.splice(0));
assert.equal(observer.callbackCount, 3, "錯誤、tail、外部 record 各交付一次");
assert.equal(atom.isConnected, false, "外部 record 未被殘留 expectation 誤吞");
}
finally
{
jz.disconnect();
spliceProbe.restore();
layout.restore();
counting.restore();
}
},
);
test("observer provenance: foreign-instance product changes still trigger", async () =>
{
const { document } = setupDom('<div id="root"><p lang="zh-Hant">外來,產物。</p></div>');
const foreign = createJuzhen();
const root = document.getElementById("root");
foreign.render(root);
const atom = root.querySelector("jz-char");
const counting = installCountingMutationObserver();
const local = createJuzhen();
try
{
local.observe(root);
atom.classList.add("foreign-change");
await tick();
assert.equal(counting.instances[0].callbackCount, 1, "外來實例無本地 provenance");
}
finally
{
local.disconnect();
counting.restore();
}
});
for (const mutationKind of [ "childList", "characterData" ])
{
test(`observer provenance: owned ${mutationKind} changes still trigger`, async () =>
{
const { document } = setupDom('<div id="root"><p lang="zh-Hant">內容,變更。</p></div>');
const counting = installCountingMutationObserver();
const root = document.getElementById("root");
const jz = createJuzhen();
try
{
jz.render(root);
jz.observe(root);
const atom = root.querySelector("jz-char");
if (mutationKind === "childList") { atom.append("外"); }
else
{
const text = document.createTreeWalker(atom, NodeFilter.SHOW_TEXT).nextNode();
text.data += "外";
}
await tick();
assert.equal(counting.instances[0].callbackCount, 1);
assert.equal(atom.isConnected, false, `${mutationKind} 進入 rerender`);
}
finally
{
jz.disconnect();
counting.restore();
}
});
}
for (const lifecycle of [ "pause", "replace", "disconnect" ])
{
test(`observer provenance: ${lifecycle} clears stale expectations`, async () =>
{
const { document } = setupDom('<div id="root"><p lang="zh-Hant">(生命週期</p></div>');
const counting = installCountingMutationObserver();
const layout = installLayoutHarness(document);
const root = document.getElementById("root");
const jz = createJuzhen({ spacing: false, orphan: false });
try
{
jz.render(root);
jz.observe(root);
layout.instances[0].fire();
if (lifecycle === "pause") { jz.rerender(root); }
else if (lifecycle === "replace") { jz.observe(root); }
else { jz.disconnect(); jz.observe(root); }
const activeObserver = counting.instances[counting.instances.length - 1];
root.querySelector("jz-char").classList.add("consumer-after-lifecycle");
await tick();
assert.equal(activeObserver.callbackCount, 1, "外部 class 未被陳舊 provenance 吞掉");
}
finally
{
jz.disconnect();
layout.restore();
counting.restore();
}
});
}
test("observer provenance: transition inference and queue consumption scale linearly", async (context) =>
{
const reports = [];
for (const size of [ 500, 1000, 2000 ])
{
const html = Array.from({ length: size }, (_, index) => `<p>(${index}</p>`).join("");
const { document } = setupDom(`<div id="root" lang="zh-Hant">${html}</div>`);
let recordAccesses = 0;
let recordCount = 0;
const counting = installCountingMutationObserver(Number.POSITIVE_INFINITY, {
wrapRecords(records)
{
recordCount += records.length;
return records.map((record) => new Proxy({}, {
get(_target, property)
{
recordAccesses += 1;
return record[property];
},
}));
},
});
const layout = installLayoutHarness(document);
const shiftProbe = installExpectationShiftProbe();
const root = document.getElementById("root");
const jz = createJuzhen({ spacing: false, longWord: false, orphan: false });
try
{
jz.render(root);
jz.observe(root);
assert.equal(root.querySelectorAll("jz-char.jz-half-le").length, size);
layout.instances[0].fire();
await tick();
const shift = shiftProbe.result();
assert.equal(recordCount, size * 2, "每 target 恰有 remove/add 兩條 transition");
const report = { size, recordCount, recordAccesses, queueShiftWork: shift.work };
context.diagnostic("linear probe " + JSON.stringify(report));
assert.ok(
recordAccesses <= recordCount * 12 && shift.calls === 0 && shift.work === 0,
"transition 推斷與 queue 消費保持線性:" + JSON.stringify(report),
);
assert.equal(layout.instances.length, 1, "規模回放沒有自觸發 rerender");
reports.push(report);
}
finally
{
jz.disconnect();
shiftProbe.restore();
layout.restore();
counting.restore();
}
}
context.diagnostic("linear probes complete " + JSON.stringify(reports));
});
test("observer:手動局部操作先結算其他兄弟塊的待交付變更", async () =>
{
for (const operation of [ "render", "rerender", "revert" ])
{
const { dom, document } = setupDom('<main><p id="a">原文,甲。</p><p id="b">原文,乙。</p></main>');
const root = document.querySelector("main");
const a = document.getElementById("a");
const b = document.getElementById("b");
const jz = createJuzhen({ jinze: false, orphan: false, longWord: false });
try
{
jz.render(root);
jz.observe(root);
a.textContent = "改後,新文。";
jz[operation](b);
await tick();
assert.equal(a.querySelectorAll("jz-char").length, 2, operation + " 不丟棄兄弟變更");
if (operation === "revert") { assert.equal(b.querySelector("[data-jz]"), null); }
a.textContent = "再次,更新。";
await tick();
assert.equal(a.querySelectorAll("jz-char").length, 2, "結算後繼續觀察");
}
finally { jz.disconnect(); dom.window.close(); }
}
});
test("observer:祖先分類待更新時手動 revert 子塊仍最後生效", async () =>
{
const { dom, document } = setupDom('<main><p id="a">甲文,內容。</p><p id="b">乙文,內容。</p></main>');
const root = document.querySelector("main");
const a = document.getElementById("a");
const b = document.getElementById("b");
const jz = createJuzhen();
try
{
jz.render(root);
jz.observe(root);
root.setAttribute("data-jz-level", "text");
jz.revert(b);
await tick();
assert.equal(a.getAttribute("data-jz-block-level"), "text");
assert.equal(a.querySelector("jz-orphan"), null, "兄弟塊採新祖先級別");
assert.equal(b.querySelector("[data-jz]"), null, "舊祖先記錄不重新 render 已回退子塊");
assert.equal(b.hasAttribute("data-jz-block-level"), false);
}
finally { jz.disconnect(); dom.window.close(); }
});
test("observer:暫停前結算失敗仍非同步報錯,不阻擋手動操作及後續觀察", async () =>
{
const { dom, document } = setupDom('<main><p id="a">甲文,內容。</p><p id="b">乙文,內容。</p></main>');
const root = document.querySelector("main");
const a = document.getElementById("a");
const b = document.getElementById("b");
const expected = new Error("pending mutation failure");
let shouldThrow = false;
const errors = [];
dom.window.addEventListener("error", (event) =>
{
errors.push(event.error);
event.preventDefault();
});
const jz = createJuzhen({
jinze: false, orphan: false, longWord: false,
isSkipped(node)
{
if (shouldThrow && node === a) { shouldThrow = false; throw expected; }
return false;
},
});
try
{
jz.render(root);
jz.observe(root);
a.textContent = "改後,新文。";
shouldThrow = true;
assert.doesNotThrow(() => jz.revert(b), "舊 observer 錯誤不變成同步 API 例外");
assert.equal(errors.length, 0);
await tick();
assert.deepEqual(errors, [ expected ]);
assert.equal(b.querySelector("[data-jz]"), null);
a.textContent = "再次,更新。";
await tick();
assert.equal(a.querySelectorAll("jz-char").length, 2);
}
finally { jz.disconnect(); dom.window.close(); }
});
-247
View File
@@ -1,247 +0,0 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { setupDom } from "./helpers.mjs";
import { createJuzhen } from "../dist/juzhen.mjs";
test("pill:塊本身與其後代塊的內部不進入 standalone pass", () =>
{
for (const html of [
'<main><div class="pill"><p>甲ABC乙,丙。</p></div><p>正常ABC,文字。</p></main>',
'<main><p class="pill">甲ABC乙,丙。</p><p>正常ABC,文字。</p></main>',
])
{
const { dom, document } = setupDom(html);
const original = document.body.innerHTML;
const pill = document.querySelector(".pill");
const before = pill.innerHTML;
const jz = createJuzhen({ pillSelector: ".pill" });
try
{
jz.render(document.body);
assert.equal(pill.innerHTML, before, "pill 內部保持原樣");
assert.ok(document.querySelector("main > p:not(.pill) jz-hws"), "正常兄弟塊仍處理");
jz.revert(document.body);
assert.equal(pill.innerHTML, before);
}
finally { jz.disconnect(); dom.window.close(); }
}
});
const exclusions = [
[ "avoid", "data-jz-skip" ],
[ "text", 'data-jz-level="text"' ],
[ "off", 'data-juzhen-off="orphan jinze"' ],
[ "predicate", 'class="skip-text"' ],
];
const skippedText = (node) => node.nodeType === 3
&& node.parentElement?.classList.contains("skip-text");
test("jinze:排除或關閉段落功能的兩側不建立禁斷綁定", () =>
{
for (const [ label, attr ] of exclusions)
{
for (const side of [ "before-close", "after-open" ])
{
const protectedHtml = '<span ' + attr + '>自管</span>';
const html = side === "before-close"
? '<p>' + protectedHtml + ',正常文字。</p>'
: '<p>正常文字(' + protectedHtml + '</p>';
const { dom, document } = setupDom(html);
const original = document.body.innerHTML;
const protectedEl = document.querySelector("span");
const jz = createJuzhen({ spacing: false, orphan: false, longWord: false,
isSkipped: skippedText });
try
{
jz.render(document.body);
assert.equal(protectedEl.closest("jz-jinze"), null, label + "/" + side);
assert.equal(protectedEl.innerHTML, "自管");
jz.revert(document.body);
assert.equal(document.body.innerHTML, original);
}
finally { jz.disconnect(); dom.window.close(); }
}
}
});
test("jinze:透明元素內的排除內容不可被整元素退化綁定帶入", () =>
{
const html = '<p><a href="#">甲<span data-jz-skip>自管</span>乙</a>,正常。</p>';
const { dom, document } = setupDom(html);
const original = document.body.innerHTML;
const protectedEl = document.querySelector("[data-jz-skip]");
const jz = createJuzhen({ spacing: false, orphan: false, longWord: false });
try
{
jz.render(document.body);
assert.equal(protectedEl.closest("jz-jinze"), null);
jz.revert(document.body);
assert.equal(document.body.innerHTML, original);
}
finally { jz.disconnect(); dom.window.close(); }
});
test("jinze:註解與空 Text 不阻斷禁則,回退保留註解身份和次序", () =>
{
const html = '<p>甲<!--anchor-->,乙。</p>';
const { dom, document } = setupDom(html);
const original = document.body.innerHTML;
const p = document.querySelector("p");
const anchor = p.childNodes[1];
const empty = document.createTextNode("");
p.insertBefore(empty, anchor);
const jz = createJuzhen({ spacing: false, orphan: false, longWord: false });
try
{
jz.render(p);
const group = p.querySelector("jz-jinze");
assert.equal(group.textContent, "甲,");
assert.ok(group.contains(anchor));
assert.ok(group.contains(empty));
jz.revert(p);
assert.equal(p.childNodes[1], anchor);
assert.equal(anchor.previousSibling.textContent, "甲");
assert.equal(anchor.nextSibling.textContent, ",乙。");
assert.equal(document.body.innerHTML, original);
}
finally { jz.disconnect(); dom.window.close(); }
});
test("jinze:行內 render 根不把外部兄弟搬入生成群組", () =>
{
const html = '<p>甲<strong id="root">,乙。</strong>後文</p>';
const { dom, document } = setupDom(html);
const original = document.body.innerHTML;
const root = document.getElementById("root");
const parent = root.parentNode;
const before = root.previousSibling;
const after = root.nextSibling;
const jz = createJuzhen({ spacing: false, orphan: false, longWord: false });
try
{
jz.render(root);
assert.equal(root.parentNode, parent);
assert.equal(root.previousSibling, before);
assert.equal(root.nextSibling, after);
assert.equal(parent.querySelectorAll(":scope > jz-jinze").length, 0);
jz.revert(root);
assert.equal(document.body.innerHTML, original);
}
finally { jz.disconnect(); dom.window.close(); }
});
test("orphan:排除/text/off 的尾部內容不計數,也不包裹", () =>
{
for (const [ label, attr ] of exclusions)
{
const html = '<p>正常文字<span ' + attr + '>自管文字</span></p>';
const { dom, document } = setupDom(html);
const original = document.body.innerHTML;
const jz = createJuzhen({ spacing: false, jiya: false, jinze: false, longWord: false,
isSkipped: skippedText });
try
{
jz.render(document.body);
assert.equal(document.querySelector("jz-orphan"), null, label + " 尾部是綁定邊界");
jz.revert(document.body);
assert.equal(document.body.innerHTML, original);
}
finally { jz.disconnect(); dom.window.close(); }
}
});
test("orphan:只綁最後排除邊界之後的合資格末尾", () =>
{
for (const [ label, attr ] of exclusions)
{
for (const tail of [ "末", "最後" ])
{
const html = '<p>前文<span ' + attr + '>自管文字</span>' + tail + '</p>';
const { dom, document } = setupDom(html);
const original = document.body.innerHTML;
const jz = createJuzhen({ spacing: false, jiya: false, jinze: false, longWord: false,
isSkipped: skippedText });
try
{
jz.render(document.body);
const orphan = document.querySelector("jz-orphan");
assert.equal(orphan?.textContent ?? null, tail === "末" ? null : tail, label + "/" + tail);
assert.equal(document.querySelector("span").closest("jz-orphan"), null);
jz.revert(document.body);
assert.equal(document.body.innerHTML, original);
}
finally { jz.disconnect(); dom.window.close(); }
}
}
});
test("orphan:不可切容器不以整綁繞過排除邊界,可切容器只切合資格末尾", () =>
{
for (const tag of [ 'span', 'a href="#"' ])
{
const closeTag = tag.split(" ")[0];
const html = '<p>前文<' + tag + '><span data-jz-skip>自管</span>最後</' + closeTag + '></p>';
const { dom, document } = setupDom(html);
const original = document.body.innerHTML;
const protectedEl = document.querySelector("[data-jz-skip]");
const jz = createJuzhen({ spacing: false, jiya: false, jinze: false, longWord: false });
try
{
jz.render(document.body);
assert.equal(protectedEl.closest("jz-orphan"), null);
assert.equal(document.querySelector("jz-orphan")?.textContent ?? null,
closeTag === "span" ? "最後" : null);
jz.revert(document.body);
assert.equal(document.body.innerHTML, original);
}
finally { jz.disconnect(); dom.window.close(); }
}
});
test("jinze:完整跨節點 base/VS/surrogate 原子一起綁到閉標點", () =>
{
for (const parts of [ [ "𠀀", "\u{E0100}" ], [ "\uD840", "\uDC00", "\uDB40", "\uDD00" ] ])
{
const { dom, document } = setupDom("<p></p>");
const p = document.querySelector("p");
p.append("前文");
const authors = parts.map((part) =>
{
const span = document.createElement("span");
span.textContent = part;
p.appendChild(span);
return span;
});
p.append(",");
const original = p.innerHTML;
const jz = createJuzhen({ spacing: false, orphan: false, longWord: false });
try
{
jz.render(p);
assert.equal(p.querySelector("jz-jinze").textContent, "𠀀\u{E0100},");
const rendered = p.innerHTML;
jz.render(p);
assert.equal(p.innerHTML, rendered);
jz.revert(p);
assert.equal(p.innerHTML, original);
assert.deepEqual(Array.from(p.querySelectorAll("span")), authors);
}
finally { jz.disconnect(); dom.window.close(); }
}
});
test("orphan:split surrogate 的補充平面字計為一個實義字", () =>
{
const { dom, document } = setupDom('<p>前文<strong>\uD840</strong><em>\uDC00</em>末</p>');
const p = document.querySelector("p");
const original = p.innerHTML;
const jz = createJuzhen({ spacing: false, jiya: false, jinze: false, longWord: false });
try
{
jz.render(p);
assert.equal(p.querySelector("jz-orphan").textContent, "𠀀末");
jz.revert(p);
assert.equal(p.innerHTML, original);
}
finally { jz.disconnect(); dom.window.close(); }
});
-226
View File
@@ -1,226 +0,0 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { setupDom } from "./helpers.mjs";
import { createJuzhen } from "../dist/juzhen.mjs";
const ONLY_JIYA = { spacing: false, jinze: false, orphan: false, longWord: false };
for (const selector of [ "\uFE00", "\u{E0100}" ])
{
for (const full of [ false, true ])
{
test("jiya atom:跨透明節點 VS " + selector.codePointAt(0).toString(16) + " full=" + full, () =>
{
const { dom, document } = setupDom('<p lang="zh-Hant">甲<strong id="base">,</strong><em id="vs">' + selector + '</em>「乙丙」</p>');
const root = document.querySelector("p");
const original = root.innerHTML;
const text = root.textContent;
const base = document.getElementById("base");
const vs = document.getElementById("vs");
let clicks = 0;
vs.addEventListener("click", () => { clicks += 1; });
const jz = createJuzhen(full ? {} : ONLY_JIYA);
try
{
jz.render(root);
const comma = root.querySelector("jz-char.bd-pause");
assert.equal(comma.textContent, "," + selector);
assert.equal(comma.querySelector("jz-inner").textContent, "," + selector);
assert.equal(comma.classList.contains("jz-half"), true);
assert.equal(root.querySelector("jz-char.bd-open").classList.contains("jz-half"), true);
assert.equal(document.getElementById("base"), base);
assert.equal(document.getElementById("vs"), vs);
vs.dispatchEvent(new dom.window.Event("click"));
assert.equal(clicks, 1);
assert.equal(root.textContent, text);
const rendered = root.innerHTML;
jz.render(root);
assert.equal(root.innerHTML, rendered, "重複 render 不增殖");
jz.revert(root);
assert.equal(root.innerHTML, original);
}
finally { jz.revert(root); jz.disconnect(); dom.window.close(); }
});
}
}
test("jiya atom:astral base 與 VS 的四個 surrogate 半字合成一個原子", () =>
{
const { dom, document } = setupDom('<p lang="zh-Hant"><strong></strong><em></em><i></i><b></b>「甲</p>');
const root = document.querySelector("p");
[ "\uD83D", "\uDE00", "\uDB40", "\uDD00" ].forEach((text, i) => { root.children[i].textContent = text; });
const original = root.innerHTML;
const jz = createJuzhen({ ...ONLY_JIYA, charClass: { biaodian: { pause: "😀" } } });
try
{
jz.render(root);
const chars = root.querySelectorAll("jz-char");
assert.equal(chars.length, 2);
assert.equal(chars[0].textContent, "😀\u{E0100}");
assert.equal(chars[0].classList.contains("jz-half"), true);
jz.revert(root);
assert.equal(root.innerHTML, original);
}
finally { jz.revert(root); jz.disconnect(); dom.window.close(); }
});
test("jiya 分類:astral 字集不包含原文中孤立的 surrogate 半字", () =>
{
const { dom, document } = setupDom('<p></p>');
const root = document.querySelector("p");
root.textContent = "\uD83DA\uDE00";
const original = root.textContent;
const jz = createJuzhen({ ...ONLY_JIYA, charClass: { biaodian: { pause: "😀" } } });
try
{
jz.render(root);
assert.equal(root.querySelector("jz-char"), null);
assert.equal(root.textContent, original);
}
finally { jz.revert(root); jz.disconnect(); dom.window.close(); }
});
for (const excluded of [ false, true ])
{
test("jiya 邊界:空 Text excluded=" + excluded, () =>
{
const { dom, document } = setupDom('<p lang="zh-Hant">,<strong></strong>「</p>');
const root = document.querySelector("p");
const empty = document.createTextNode("");
root.querySelector("strong").appendChild(empty);
const jz = createJuzhen({ ...ONLY_JIYA, isSkipped: (node) => excluded && node === empty });
try
{
jz.render(root);
assert.equal(root.querySelectorAll("jz-char.jz-half").length, excluded ? 0 : 2);
}
finally { jz.revert(root); jz.disconnect(); dom.window.close(); }
});
}
for (const side of [ "base", "vs" ])
{
test("jiya 跨原子局部回退:" + side + " 端保持原文及另一端位置", () =>
{
const { dom, document } = setupDom('<p lang="zh-Hant"><strong id="base">,</strong><em id="vs">\uFE00</em>「</p>');
const root = document.querySelector("p");
const jz = createJuzhen(ONLY_JIYA);
try
{
jz.render(root);
jz.revert(document.getElementById(side));
assert.equal(document.getElementById("base").textContent, ",");
assert.equal(document.getElementById("vs").textContent, "\uFE00");
assert.equal(root.textContent, ",\uFE00「");
jz.revert(root);
assert.equal(root.innerHTML, '<strong id="base">,</strong><em id="vs">\uFE00</em>「');
}
finally { jz.revert(root); jz.disconnect(); dom.window.close(); }
});
}
for (const mutation of [ "replace-source", "move-carrier", "move-author-text", "foreign-revert" ])
{
test("jiya 原子回退:" + mutation + " 保留作者外部修改/實例所有權", () =>
{
const { dom, document } = setupDom('<main><p lang="zh-Hant"><strong id="base">,</strong><em id="vs">\uFE00</em>「</p><aside></aside></main>');
const root = document.querySelector("p");
const jz = createJuzhen(ONLY_JIYA);
const other = createJuzhen(ONLY_JIYA);
const aside = document.querySelector("aside");
try
{
jz.render(root);
const comma = root.querySelector("jz-char.bd-pause");
const selector = comma.querySelector("jz-inner").lastChild;
if (mutation === "replace-source") { document.getElementById("vs").textContent = "作者新文"; }
else if (mutation === "move-carrier") { aside.appendChild(comma); }
else if (mutation === "move-author-text") { aside.appendChild(selector); }
else
{
const html = root.innerHTML;
other.revert(root);
assert.equal(root.innerHTML, html);
}
jz.revert(root);
assert.equal(document.getElementById("base").textContent, ",");
if (mutation === "replace-source")
{
assert.equal(document.getElementById("vs").textContent, "作者新文");
assert.equal(root.textContent, ",作者新文「");
}
else if (mutation === "move-author-text") { assert.equal(aside.textContent, "\uFE00"); }
else
{
assert.equal(document.getElementById("vs").textContent, "\uFE00");
assert.equal(root.querySelector("[data-jz]"), null);
assert.equal(aside.querySelector("[data-jz]"), null);
}
}
finally { jz.revert(document.body); jz.disconnect(); other.disconnect(); dom.window.close(); }
});
}
for (const mutation of [ "clone-outer", "clone-inner", "clone-base", "remove-outer" ])
{
test("jiya 原子失效:" + mutation + " 不重複或復活已刪內容", () =>
{
const { dom, document } = setupDom('<p lang="zh-Hant"><strong id="base">,</strong><em id="vs">\uFE00</em>「</p>');
const root = document.querySelector("p");
const jz = createJuzhen(ONLY_JIYA);
try
{
jz.render(root);
const outer = root.querySelector("jz-char.bd-pause");
const inner = outer.querySelector("jz-inner");
const base = document.getElementById("base");
if (mutation === "clone-outer") { outer.replaceWith(outer.cloneNode(true)); }
else if (mutation === "clone-inner") { inner.replaceWith(inner.cloneNode(true)); }
else if (mutation === "clone-base") { base.replaceWith(base.cloneNode(true)); }
else { outer.remove(); }
const before = root.textContent;
jz.revert(root);
assert.equal(root.textContent, before, "回退不能從失效的私有來源補回舊內容");
if (mutation !== "clone-inner")
{
const holder = document.createElement("aside");
holder.appendChild(outer);
jz.revert(holder);
assert.equal(holder.firstChild, outer, "失效舊產物撤銷所有權,重插入也不重新取得");
}
}
finally { jz.revert(root); jz.disconnect(); dom.window.close(); }
});
}
test("jiya 原子失效:來源父被替換不把 VS 放回已脫離的作者舊父", () =>
{
const { dom, document } = setupDom('<p lang="zh-Hant"><strong>,</strong><em id="vs">\uFE00</em>「</p>');
const root = document.querySelector("p");
const source = document.getElementById("vs");
const jz = createJuzhen(ONLY_JIYA);
try
{
jz.render(root);
source.replaceWith(source.cloneNode(true));
jz.revert(root);
assert.equal(source.textContent, "");
assert.equal(root.textContent, ",「");
}
finally { jz.revert(root); jz.disconnect(); dom.window.close(); }
});
test("jiya 原子生命週期:完整 render 根脫離文檔仍精確回退", () =>
{
const { dom, document } = setupDom('<p lang="zh-Hant"><strong>,</strong><em>\uFE00</em>「</p>');
const root = document.querySelector("p");
const original = root.innerHTML;
const jz = createJuzhen(ONLY_JIYA);
try
{
jz.render(root);
root.remove();
jz.revert(root);
assert.equal(root.innerHTML, original);
}
finally { jz.disconnect(); dom.window.close(); }
});
-325
View File
@@ -1,325 +0,0 @@
// createJuzhen 實例所有權:revert 只得復原本實例產物。
import assert from "node:assert/strict";
import { test } from "node:test";
import { setupDom } from "./helpers.mjs";
import { createJuzhen } from "../dist/juzhen.mjs";
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
test("所有權:作者 data-jz 結構不可作為 revert 憑證", () =>
{
const html = '<section data-jz="spacing"><jz-hws data-jz="spacing" '
+ 'data-jz-kind="wrap">author</jz-hws></section>';
const { document } = setupDom(html);
const before = document.body.innerHTML;
createJuzhen().revert(document.body);
assert.equal(document.body.innerHTML, before);
});
test("所有權:實例 B 不得還原實例 A 之結構或 root 屬性", () =>
{
const { document } = setupDom('<p lang="zh-Hans">用Neovim寫,很好。</p>');
const before = document.body.innerHTML;
const a = createJuzhen({ jiya: { halfWidth: "margin" }, justifyAtoms: false });
const b = createJuzhen();
a.render(document.body);
const rendered = document.body.innerHTML;
assert.equal(document.body.getAttribute("data-jz-halfwidth"), "margin");
assert.equal(document.body.getAttribute("data-jz-atoms"), "inline");
b.revert(document.body);
assert.equal(document.body.innerHTML, rendered);
assert.equal(document.body.getAttribute("data-jz-halfwidth"), "margin");
assert.equal(document.body.getAttribute("data-jz-atoms"), "inline");
a.revert(document.body);
assert.equal(document.body.innerHTML, before);
});
test("所有權:兩實例同值屬性之早到者先 revert 不移除後到者聲明", () =>
{
const { document } = setupDom('<p lang="zh-Hans">純文字</p>');
const opts = {
spacing: false,
jiya: { halfWidth: "margin" },
jinze: false,
orphan: false,
longWord: false,
justifyAtoms: false,
};
const a = createJuzhen(opts);
const b = createJuzhen(opts);
a.render(document.body);
b.render(document.body);
a.revert(document.body);
assert.equal(document.body.getAttribute("data-jz-halfwidth"), "margin");
assert.equal(document.body.getAttribute("data-jz-atoms"), "inline");
assert.equal(document.querySelector("p").getAttribute("data-jz-block-level"), "paragraph");
b.revert(document.body);
assert.equal(document.body.hasAttribute("data-jz-halfwidth"), false);
assert.equal(document.body.hasAttribute("data-jz-atoms"), false);
assert.equal(document.querySelector("p").hasAttribute("data-jz-block-level"), false);
});
test("所有權:屬性寫者亂序 revert 還原最近活躍值或原值", () =>
{
const options = {
spacing: false, jiya: false, jinze: false, orphan: false, longWord: false,
};
const first = setupDom("<p>純文字</p>");
const a = createJuzhen(options);
const b = createJuzhen({ ...options, level: { text: "p" } });
a.render(first.document.body);
b.render(first.document.body);
a.revert(first.document.body);
assert.equal(first.document.querySelector("p").getAttribute("data-jz-block-level"), "text");
b.revert(first.document.body);
assert.equal(first.document.querySelector("p").hasAttribute("data-jz-block-level"), false);
const second = setupDom("<p>純文字</p>");
const c = createJuzhen(options);
const d = createJuzhen({ ...options, level: { text: "p" } });
c.render(second.document.body);
d.render(second.document.body);
d.revert(second.document.body);
assert.equal(second.document.querySelector("p").getAttribute("data-jz-block-level"), "paragraph");
c.revert(second.document.body);
assert.equal(second.document.querySelector("p").hasAttribute("data-jz-block-level"), false);
});
test("所有權:外部改寫屬性後 revert fail closed,新寫者可以當前值重建基線", () =>
{
const options = {
spacing: false, jiya: false, jinze: false, orphan: false, longWord: false,
justifyAtoms: false,
};
const { document } = setupDom("<p>純文字</p>");
const a = createJuzhen(options);
a.render(document.body);
document.body.setAttribute("data-jz-atoms", "external");
document.querySelector("p").removeAttribute("data-jz-block-level");
const b = createJuzhen(options);
b.render(document.body);
assert.equal(document.body.getAttribute("data-jz-atoms"), "inline");
assert.equal(document.querySelector("p").getAttribute("data-jz-block-level"), "paragraph");
a.revert(document.body);
assert.equal(document.body.getAttribute("data-jz-atoms"), "inline");
assert.equal(document.querySelector("p").getAttribute("data-jz-block-level"), "paragraph");
b.revert(document.body);
assert.equal(document.body.getAttribute("data-jz-atoms"), "external");
assert.equal(document.querySelector("p").hasAttribute("data-jz-block-level"), false);
});
test("所有權:子根 render 後對祖先 revert 完整清理", () =>
{
const { document } = setupDom('<div><p lang="zh-Hans">用Neovim寫,很好。</p></div>');
const before = document.body.innerHTML;
const p = document.querySelector("p");
const jz = createJuzhen({ jiya: { halfWidth: "margin" }, justifyAtoms: false });
jz.render(p);
assert.equal(p.getAttribute("data-jz-halfwidth"), "margin");
assert.ok(p.querySelector("[data-jz]"));
jz.revert(document.body);
assert.equal(document.body.innerHTML, before);
});
test("所有權:局部 revert 完整撤回祖根與獨立子根的混合產物", () =>
{
const { document } = setupDom('<main><section id="part">'
+ '<p id="a">甲文,內容。</p><p id="b">乙文,內容。</p></section></main>');
const main = document.querySelector("main");
const part = document.getElementById("part");
const a = document.getElementById("a");
const b = document.getElementById("b");
const before = part.innerHTML;
const jz = createJuzhen({ jiya: { halfWidth: "margin" }, justifyAtoms: false });
jz.render(main);
jz.render(b);
assert.ok(a.querySelector("jz-char"));
assert.ok(b.querySelector("jz-char"));
assert.equal(b.getAttribute("data-jz-halfwidth"), "margin");
jz.revert(part);
assert.equal(part.innerHTML, before, "祖根寫入的兄弟產物與子根屬性皆撤回");
assert.equal(main.getAttribute("data-jz-halfwidth"), "margin", "請求範圍外祖根屬性保留");
jz.render(part);
assert.ok(a.querySelector("jz-char"), "還原後可再次渲染");
jz.revert(main);
assert.equal(part.innerHTML, before);
});
test("所有權:混合根局部 revert 保留作者與其他實例的產物", () =>
{
const { document } = setupDom('<main><section id="part">'
+ '<p id="a" data-jz-block-level="author-level">甲文,內容。</p>'
+ '<p id="b">乙文,內容。</p><p id="foreign">他文,內容。</p>'
+ '<span data-jz-skip><jz-hws data-jz="spacing" data-jz-kind="wrap">作者</jz-hws></span>'
+ '</section></main>');
const part = document.getElementById("part");
const foreign = document.getElementById("foreign");
const peer = createJuzhen();
peer.render(foreign);
const before = part.innerHTML;
const foreignBefore = foreign.innerHTML;
const author = part.querySelector("span > jz-hws");
const local = createJuzhen({ scope: { avoid: "#foreign" } });
local.render(document.querySelector("main"));
local.render(document.getElementById("b"));
local.revert(part);
assert.equal(part.innerHTML, before, "本實例全清且作者/peer 產物完整保留");
assert.equal(foreign.innerHTML, foreignBefore);
assert.equal(part.querySelector("span > jz-hws"), author, "作者元素身份保留");
assert.equal(document.getElementById("a").getAttribute("data-jz-block-level"), "author-level");
assert.equal(foreign.getAttribute("data-jz-block-level"), "paragraph");
peer.revert(foreign);
});
test("所有權:本實例完整還原 wrapper 與 splitoff", () =>
{
const html = '<p lang="zh-Hans">前文<strong>粗体文字</strong>,后续</p>';
const { document } = setupDom(html);
const before = document.body.innerHTML;
const jz = createJuzhen({ spacing: false, longWord: false, orphan: false });
jz.render(document.body);
assert.ok(document.querySelector('[data-jz-kind="wrap"]'));
assert.ok(document.querySelector('[data-jz-kind="splitoff"]'));
jz.revert(document.body);
assert.equal(document.body.innerHTML, before);
});
test("所有權:已有實例重複 render 仍冪等", () =>
{
const { document } = setupDom('<p lang="zh-Hans">用Neovim寫,很好。</p>');
const jz = createJuzhen();
jz.render(document.body);
const once = document.body.innerHTML;
jz.render(document.body);
assert.equal(document.body.innerHTML, once);
});
test("所有權:已還原 wrapper 之舊憑證即時撤銷", () =>
{
const { document } = setupDom("<p>中A文</p>");
const jz = createJuzhen({ jiya: false, longWord: false, jinze: false, orphan: false });
jz.render(document.body);
const gap = document.querySelector("jz-hws");
assert.ok(gap);
jz.revert(document.body);
document.querySelector("p").appendChild(gap);
const stale = gap.outerHTML;
jz.revert(document.body);
assert.ok(document.body.contains(gap));
assert.equal(gap.outerHTML, stale);
});
test("所有權:已知 tag 改成無生產者之 marker kind 亦 fail closed", () =>
{
const { document } = setupDom("<p>中A文</p>");
const jz = createJuzhen({ jiya: false, longWord: false, jinze: false, orphan: false });
jz.render(document.body);
const gap = document.querySelector("jz-hws");
assert.ok(gap);
gap.setAttribute("data-jz-kind", "marker");
const malformed = gap.outerHTML;
jz.revert(document.body);
assert.ok(document.body.contains(gap));
assert.equal(gap.outerHTML, malformed);
});
test("所有權:observe 移除獨立 render 根時清理脫離子樹與佈局觀察者", async () =>
{
const previousResizeObserver = globalThis.ResizeObserver;
const observers = [];
class MockResizeObserver
{
constructor()
{
this.targets = [];
this.disconnectCount = 0;
observers.push(this);
}
observe(target)
{
this.targets.push(target);
}
disconnect()
{
this.disconnectCount += 1;
}
}
globalThis.ResizeObserver = MockResizeObserver;
try
{
const { document } = setupDom('<div id="parent"></div>');
const parent = document.getElementById("parent");
const jz = createJuzhen({ jiya: { halfWidth: "margin" }, justifyAtoms: false });
jz.observe(parent);
const block = document.createElement("p");
block.lang = "zh-Hans";
block.innerHTML = "用Neovim寫,很好。";
const original = block.innerHTML;
parent.appendChild(block);
await tick();
const blockObservers = observers.filter((ro) => ro.targets.includes(block));
assert.equal(blockObservers.length, 2, "lineEdge 與 gapTrim 各持一觀察者");
assert.ok(block.querySelector("[data-jz]"));
assert.equal(block.getAttribute("data-jz-halfwidth"), "margin");
assert.equal(block.getAttribute("data-jz-atoms"), "inline");
block.setAttribute("data-jz-atoms", "external");
block.remove();
await tick();
assert.ok(blockObservers.every((ro) => ro.disconnectCount === 1));
assert.equal(block.querySelector("[data-jz]"), null);
assert.equal(block.innerHTML, original);
assert.equal(block.hasAttribute("data-jz-halfwidth"), false);
assert.equal(block.getAttribute("data-jz-atoms"), "external", "外部改寫 fail closed");
parent.appendChild(block);
await tick();
assert.ok(block.querySelector("[data-jz]"), "脫離根清理後可再插入重新 render");
jz.revert(parent);
jz.disconnect();
assert.equal(block.getAttribute("data-jz-atoms"), "external");
}
finally
{
if (previousResizeObserver === undefined) { delete globalThis.ResizeObserver; }
else { globalThis.ResizeObserver = previousResizeObserver; }
}
});
test("所有權:observe 移除非 render 根之行內內容不過度清理", async () =>
{
const { document } = setupDom('<div id="parent"><p>前<span>中A文</span>後</p></div>');
const parent = document.getElementById("parent");
const jz = createJuzhen({ jiya: false, jinze: false, orphan: false, longWord: false });
jz.render(parent);
jz.observe(parent);
const span = document.querySelector("span");
const rendered = span.innerHTML;
assert.ok(span.querySelector("jz-hws"));
span.remove();
await tick();
assert.equal(span.innerHTML, rendered, "非獨立 render 根之脫離內容保持原渲染結構");
assert.ok(span.querySelector("jz-hws"));
document.querySelector("p").appendChild(span);
await tick();
jz.revert(parent);
jz.disconnect();
});
-52
View File
@@ -1,52 +0,0 @@
import { spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
if (process.platform !== "darwin")
{
console.error("test:wkwebview requires macOS, Xcode, and an active GUI session.");
process.exit(1);
}
const testDir = dirname(fileURLToPath(import.meta.url));
const projectRoot = resolve(testDir, "..");
const temporaryRoot = mkdtempSync(join(tmpdir(), "cjk-autospace-wkwebview-"));
const moduleCache = join(temporaryRoot, "ModuleCache");
const binary = join(temporaryRoot, "wkwebview-e2e");
mkdirSync(moduleCache);
function run(command, args)
{
const result = spawnSync(command, args, {
cwd: projectRoot,
encoding: "utf8",
stdio: "inherit",
});
if (result.error) { throw result.error; }
if (result.status !== 0) { process.exitCode = result.status ?? 1; }
return result.status === 0;
}
try
{
const compiled = run("xcrun", [
"swiftc",
"-parse-as-library",
"-swift-version", "5",
"-module-cache-path", moduleCache,
"-framework", "AppKit",
"-framework", "WebKit",
join(testDir, "wkwebview-e2e.swift"),
"-o", binary,
]);
if (compiled)
{
run(binary, [ join(projectRoot, "demo/e2e.html"), projectRoot ]);
}
}
finally
{
rmSync(temporaryRoot, { recursive: true, force: true });
}
-189
View File
@@ -1,189 +0,0 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { setupDom } from "./helpers.mjs";
import { createJuzhen } from "../dist/juzhen.mjs";
function sameLine(document)
{
const view = document.defaultView;
const previous = globalThis.ResizeObserver;
const elementRects = view.Element.prototype.getClientRects;
const rangeRects = view.Range.prototype.getClientRects;
const rect = { top: 0, bottom: 20, left: 0, right: 20, width: 20, height: 20 };
globalThis.ResizeObserver = class { observe() {} disconnect() {} };
view.Element.prototype.getClientRects = () => [ rect ];
view.Range.prototype.getClientRects = () => [ rect ];
return () =>
{
view.Element.prototype.getClientRects = elementRects;
view.Range.prototype.getClientRects = rangeRects;
if (previous === undefined) { delete globalThis.ResizeObserver; }
else { globalThis.ResizeObserver = previous; }
};
}
for (const orphan of [ false, true ])
{
test("line edge:同行 orphan 不製造行首 orphan=" + orphan, () =>
{
const { dom, document } = setupDom('<main lang="zh-Hant"><p>前甲「乙丙」</p></main>');
const restore = sameLine(document);
const jz = createJuzhen({ orphan, spacing: false, longWord: false });
try
{
jz.render(document.querySelector("main"));
assert.equal(document.querySelector(".bd-open").classList.contains("jz-half-le"), false);
}
finally { jz.revert(); restore(); dom.window.close(); }
});
}
for (const display of [ "inline", "inline-block", "contents", "block" ])
{
test("line edge:自訂隔離元素按實際物理盒攀爬 display=" + display, () =>
{
const { dom, document } = setupDom('<p lang="zh-Hant">前甲<x-box style="display:'
+ display + '">「乙」</x-box>丙</p>');
const restore = sameLine(document);
const jz = createJuzhen({ isolateSelector: "x-box", orphan: false, spacing: false, longWord: false });
try
{
jz.render();
for (const char of document.querySelectorAll("jz-char"))
{
assert.equal(char.classList.contains("jz-half-le"), display === "block");
}
}
finally { jz.revert(); restore(); dom.window.close(); }
});
}
for (const hidden of [ false, true ])
{
test("line edge:BR 為強制行緣,隱藏時可穿越 hidden=" + hidden, () =>
{
const { dom, document } = setupDom('<p lang="zh-Hant">甲」<br'
+ (hidden ? ' style="display:none"' : '') + '>「乙</p>');
const restore = sameLine(document);
const jz = createJuzhen({ orphan: false, spacing: false, longWord: false });
try
{
jz.render();
for (const char of document.querySelectorAll("jz-char"))
{
assert.equal(char.classList.contains("jz-half-le"), !hidden);
}
}
finally { jz.revert(); restore(); dom.window.close(); }
});
}
for (const action of [ "insert", "replace-source", "edit-fragment", "edit-source" ])
{
test("splitoff:回退須驗來源身份並保留外部樣式 action=" + action, () =>
{
const { dom, document } = setupDom('<p><strong class="original">甲乙丙</strong>,丁</p>');
const p = document.querySelector("p");
const source = p.querySelector("strong");
const jz = createJuzhen({ orphan: false, spacing: false, longWord: false });
try
{
jz.render(p);
const fragment = p.querySelector('[data-jz-kind="splitoff"]');
assert.ok(fragment);
if (action === "insert")
{
const added = document.createElement("strong");
added.className = "external";
added.textContent = "新";
fragment.before(added);
}
else if (action === "replace-source")
{
const replacement = source.cloneNode(false);
replacement.textContent = "替";
source.replaceWith(replacement);
}
else if (action === "edit-fragment") { fragment.className = "edited"; }
else { source.className = "edited"; }
jz.revert(p);
assert.equal(fragment.parentNode, p, "來源失效時保留片段當前父與樣式");
assert.equal(fragment.textContent, "丙");
assert.equal(fragment.className, action === "edit-fragment" ? "edited" : "original");
assert.equal(p.querySelector("[data-jz]"), null);
if (action === "insert")
{
assert.equal(p.querySelector(".external").textContent, "新");
assert.equal(p.textContent, "甲乙新丙,丁");
}
if (action === "replace-source") { assert.equal(p.textContent, "替丙,丁"); }
jz.revert(p);
assert.equal(fragment.textContent, "丙", "撤回所有權後再次 revert 不碰片段");
}
finally { jz.revert(p); dom.window.close(); }
});
}
test("splitoff:同來源多層切分仍完整還原作者結構", () =>
{
const { dom, document } = setupDom('<p>「<strong><em>甲乙丙丁</em></strong>」</p>');
const p = document.querySelector("p");
const original = p.innerHTML;
const jz = createJuzhen();
try { jz.render(p); jz.revert(p); assert.equal(p.innerHTML, original); }
finally { jz.revert(p); dom.window.close(); }
});
for (const [ html, defaultLang, half ] of [
[ '<main lang="zh-Hans"><p lang="">甲,乙</p></main>', "zh-Hans", false ],
[ '<main><p>甲,乙</p></main>', "", false ],
[ '<main lang="zh-Hant"><p lang="zh-Hans-x-hant">甲,乙</p></main>', "zh-Hant", true ],
[ '<main lang="zh-Hans"><p lang="zh-TW-x-hans">甲,乙</p></main>', "zh-Hans", false ],
])
{
test("locale:空值及私用內容不冒充繼承語言 " + html, () =>
{
const { dom, document } = setupDom(html);
const jz = createJuzhen({ lang: { default: defaultLang }, jinze: false, orphan: false });
try
{
jz.render(document.querySelector("main"));
assert.equal(document.querySelector("jz-char").classList.contains("jz-half"), half);
}
finally { jz.revert(); dom.window.close(); }
});
}
test("splitoff:多實例六種回退順序恢復原結構,36 組合", () =>
{
const fixtures = [
'<strong>甲乙丙丁</strong>,戊己',
'「<strong>甲乙丙丁</strong>」',
'<strong><em>甲乙丙丁</em></strong>,戊己',
'<strong class="author" style="color:red">甲乙丙丁</strong>,戊己',
'<strong>甲乙𠀀\uDB40\uDD00丙丁</strong>,戊己',
'<strong data-jz="author" data-jz-kind="author">甲乙丙丁</strong>,戊己',
];
const orders = [ [ 0, 1, 2 ], [ 0, 2, 1 ], [ 1, 0, 2 ], [ 1, 2, 0 ], [ 2, 0, 1 ], [ 2, 1, 0 ] ];
for (const html of fixtures)
{
for (const order of orders)
{
const { dom, document } = setupDom('<p lang="zh-Hant">' + html + '</p>');
const p = document.querySelector("p");
const original = p.innerHTML;
const owners = [
createJuzhen({ jiya: false, spacing: false, orphan: false, longWord: false }),
createJuzhen({ jiya: false, spacing: false, jinze: false, longWord: false }),
createJuzhen({ spacing: false, orphan: false, longWord: false }),
];
try
{
owners.forEach((owner) => owner.render(p));
order.forEach((index) => owners[index].revert(p));
assert.equal(p.innerHTML, original, html + " order=" + order.join(","));
}
finally { owners.forEach((owner) => owner.disconnect()); dom.window.close(); }
}
}
});
-524
View File
@@ -1,524 +0,0 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
import Ajv2020 from "ajv/dist/2020.js";
import { build } from "esbuild";
import { JSDOM } from "jsdom";
const TEST_DIR = dirname(fileURLToPath(import.meta.url));
const ROOT = dirname(TEST_DIR);
const RULES_DIR = join(ROOT, "rules");
async function readJson(path)
{
return JSON.parse(await readFile(path, "utf8"));
}
const [ rules, rulesSchema, vectorsDoc, vectorsSchema, packageJson, spacingCss ] = await Promise.all([
readJson(join(RULES_DIR, "juzhen-rules.json")),
readJson(join(RULES_DIR, "juzhen-rules.schema.json")),
readJson(join(RULES_DIR, "golden-vectors.json")),
readJson(join(RULES_DIR, "golden-vectors.schema.json")),
readJson(join(ROOT, "package.json")),
readFile(join(ROOT, "src/css/_spacing.css"), "utf8"),
]);
const ajv = new Ajv2020({ allErrors: true, strict: true });
const validateRules = ajv.compile(rulesSchema);
const validateVectors = ajv.compile(vectorsSchema);
async function bundleSourceAdapter()
{
const source = [
'export * from "./src/core/unicode.ts";',
'export * from "./src/core/locale.ts";',
'export { createJuzhen, normalizeOptions } from "./src/index.ts";',
'export { jiyaPass, jiyaAdjacencyPass } from "./src/typeset/jiya.ts";',
'export { longWordPass } from "./src/typeset/longword.ts";',
'export { levelMarkPass } from "./src/typeset/levelmark.ts";',
'export { jinzePass } from "./src/typeset/jinze.ts";',
'export { spacingPass } from "./src/typeset/spacing.ts";',
'export { orphanPass } from "./src/typeset/orphan.ts";',
'export { lineEdgePass, gapTrimPass } from "./src/typeset/lineedge.ts";',
].join("\n");
const result = await build({
stdin: {
contents: source,
loader: "ts",
resolveDir: ROOT,
sourcefile: "shared-rules-adapter.ts",
},
bundle: true,
format: "esm",
platform: "node",
target: "es2022",
write: false,
logLevel: "silent",
});
const bundled = result.outputFiles[0].text + "\n//# sourceURL=shared-rules-adapter.mjs\n";
const encoded = Buffer.from(bundled).toString("base64");
return import("data:text/javascript;base64," + encoded);
}
const adapter = await bundleSourceAdapter();
const vectors = vectorsDoc.vectors;
function vectorsOf(category)
{
return vectors.filter((vector) => vector.category === category);
}
function renderText(text, options = {})
{
const dom = new JSDOM("<!doctype html><body><p></p></body>");
const paragraph = dom.window.document.querySelector("p");
paragraph.textContent = text;
const instance = adapter.createJuzhen({
spacing: false,
jiya: false,
jinze: false,
orphan: false,
longWord: false,
...options,
});
const globals = {
document: globalThis.document,
NodeFilter: globalThis.NodeFilter,
};
globalThis.document = dom.window.document;
globalThis.NodeFilter = dom.window.NodeFilter;
try
{
instance.render(paragraph);
}
finally
{
if (globals.document === undefined) { delete globalThis.document; }
else { globalThis.document = globals.document; }
if (globals.NodeFilter === undefined) { delete globalThis.NodeFilter; }
else { globalThis.NodeFilter = globals.NodeFilter; }
}
return { dom, paragraph, instance };
}
function punctuationCharacter(punctuationClass)
{
return {
stop: "。",
pause: ",",
open: "「",
close: "」",
middle: "·",
liga: "…",
}[punctuationClass];
}
function entryBounds(entry)
{
const start = Number.parseInt(entry.codePoint || entry.start, 16);
const end = Number.parseInt(entry.codePoint || entry.end, 16);
return { start, end };
}
function assertOrderedCodePointSet(name, entries)
{
let previousEnd = -1;
for (const entry of entries)
{
const { start, end } = entryBounds(entry);
assert.ok(start >= 0 && end <= 0x10FFFF, name + " Unicode bounds");
assert.ok(start <= end, name + " closed range order");
assert.ok(start > previousEnd, name + " ranges must be sorted and non-overlapping");
previousEnd = end;
}
}
function webEscape(codePoint)
{
const hex = codePoint.toString(16).toUpperCase();
return codePoint <= 0xFFFF
? "\\u" + hex.padStart(4, "0")
: "\\u{" + hex + "}";
}
function rangesToWebFragment(entries)
{
return entries.map((entry) =>
{
const { start, end } = entryBounds(entry);
return start === end
? webEscape(start)
: webEscape(start) + "-" + webEscape(end);
}).join("");
}
function assertEquivalentMembership(name, entries, sourceFragment)
{
assertOrderedCodePointSet(name, entries);
const canonical = new RegExp("^[" + rangesToWebFragment(entries) + "]$", "u");
const source = new RegExp("^[" + sourceFragment + "]$", "u");
for (let codePoint = 0; codePoint <= 0x10FFFF; codePoint += 1)
{
const character = String.fromCodePoint(codePoint);
assert.equal(
canonical.test(character),
source.test(character),
name + " differs at U+" + codePoint.toString(16).toUpperCase(),
);
}
}
function canonicalCodePoints(entries)
{
const out = [];
for (const entry of entries)
{
const { start, end } = entryBounds(entry);
for (let codePoint = start; codePoint <= end; codePoint += 1) { out.push(codePoint); }
}
return out;
}
test("shared rules: Ajv 2020 strict validation, rejection cases, exports, and coverage", () =>
{
assert.equal(rulesSchema.$schema, "https://json-schema.org/draft/2020-12/schema");
assert.equal(vectorsSchema.$schema, "https://json-schema.org/draft/2020-12/schema");
assert.equal(rules.schemaVersion, 1);
assert.equal(rules.rulesetVersion, "1.0.1");
assert.equal(rules.unicodeProfile.version, "17.0.0");
assert.equal(vectorsDoc.schemaVersion, 1);
assert.equal(vectorsDoc.rulesetVersion, rules.rulesetVersion);
assert.equal(validateRules(rules), true, ajv.errorsText(validateRules.errors));
assert.equal(validateVectors(vectorsDoc), true, ajv.errorsText(validateVectors.errors));
const extraRules = structuredClone(rules);
extraRules.unexpected = true;
assert.equal(validateRules(extraRules), false, "rules additionalProperties must be rejected");
const bogusJinze = structuredClone(vectorsDoc);
const jinzeIndex = bogusJinze.vectors.findIndex((vector) => vector.category === "jinze");
assert.notEqual(jinzeIndex, -1, "valid fixture must contain a jinze vector");
bogusJinze.vectors[jinzeIndex] = {
id: "bogus-jinze",
category: "jinze",
input: {},
expected: { reason: "bogus" },
};
assert.equal(validateVectors(bogusJinze), false, "malformed jinze vector must be rejected");
assert.equal(
validateVectors.errors?.some((error) => error.keyword === "minItems"),
false,
"bogus jinze rejection must not rely on document-wide vector count",
);
const extraVectorField = structuredClone(vectorsDoc);
extraVectorField.vectors[0].input.unexpected = true;
assert.equal(
validateVectors(extraVectorField),
false,
"category input additionalProperties must be rejected",
);
assert.ok(vectors.length >= 20, `expected at least 20 vectors, got ${vectors.length}`);
const ids = vectors.map((vector) => vector.id);
assert.equal(new Set(ids).size, ids.length, "golden vector ids must be unique");
for (const category of [ "jinze", "orphan" ])
{
assert.ok(
vectors.some((vector) => vector.category === category && vector.expected.atoms),
`${category} must retain at least one cross-feature atom vector`,
);
}
const requiredCategories = [
"language-classification",
"punctuation-classification",
"punctuation-style",
"punctuation-adjacency",
"spacing",
"ivs-atom",
"jinze",
"orphan",
];
for (const category of requiredCategories)
{
assert.ok(vectorsOf(category).length > 0, `missing vector category ${category}`);
}
assert.deepEqual(packageJson.exports["./rules"], "./rules/juzhen-rules.json");
assert.deepEqual(packageJson.exports["./rules/schema"], "./rules/juzhen-rules.schema.json");
assert.deepEqual(packageJson.exports["./rules/vectors"], "./rules/golden-vectors.json");
assert.deepEqual(
packageJson.exports["./rules/vectors-schema"],
"./rules/golden-vectors.schema.json",
);
assert.ok(packageJson.files.includes("rules"));
});
test("shared rules: canonical character classes equal the Web source constants", () =>
{
const canonical = rules.characterClasses;
assert.equal(canonical.representation, "unicode-code-point-ranges");
assertEquivalentMembership("cjk", canonical.cjk, adapter.CJK);
assertEquivalentMembership("ansAfterCjk", canonical.ansAfterCjk, adapter.ANS_AFTER_CJK);
assertEquivalentMembership("ansBeforeCjk", canonical.ansBeforeCjk, adapter.ANS_BEFORE_CJK);
assertEquivalentMembership(
"variationSelectors",
canonical.variationSelectors,
adapter.VARIATION_SELECTORS,
);
assert.deepEqual(canonical.biaodian.classificationOrder, [
"stop", "pause", "open", "close", "middle", "liga",
]);
for (const punctuationClass of canonical.biaodian.classificationOrder)
{
const entries = canonical.biaodian.classes[punctuationClass];
assertOrderedCodePointSet("biaodian." + punctuationClass, entries);
assert.deepEqual(
canonicalCodePoints(entries),
Array.from(adapter.DEFAULT_RULESET.biaodian[punctuationClass], (character) =>
character.codePointAt(0)).sort((a, b) => a - b),
"biaodian." + punctuationClass,
);
}
const extended = adapter.resolveRuleset({
cjk: "〇",
western: "★",
biaodian: { stop: "‼" },
});
assert.equal(extended.cjk, adapter.CJK + "〇");
assert.equal(extended.ansAfter, adapter.ANS_AFTER_CJK + "★");
assert.equal(extended.ansBefore, adapter.ANS_BEFORE_CJK + "★");
assert.equal(extended.biaodian.stop, adapter.DEFAULT_RULESET.biaodian.stop + "‼");
assert.equal(canonical.overrideSemantics.mode, "append");
assert.equal(canonical.overrideSemantics.removalAllowed, false);
assert.equal(canonical.overrideSemantics.reclassificationAllowed, false);
});
test("shared rules: explicit script identifiers match whole subtags", () =>
{
for (const [ lang, expected ] of [
[ "zh-CN-x-shant", "zh-Hans" ],
[ "zh-TW-x-hansford", "zh-Hant" ],
[ "ZH-CN-X-EHANT", "zh-Hans" ],
[ "zh-Hant-CN", "zh-Hant" ],
[ "zh-Hans-TW", "zh-Hans" ],
])
{
assert.equal(adapter.classify(lang), expected, lang);
}
});
test("Web locale: own lang and explicit unknown stop inheritance", () =>
{
const dom = new JSDOM('<main lang="zh-Hans"><p lang=""><span>甲</span></p></main>');
const paragraph = dom.window.document.querySelector("p");
assert.equal(adapter.effectiveLang(paragraph, "ja"), "");
assert.equal(adapter.effectiveLang(paragraph.firstChild.firstChild, "ja"), "");
assert.equal(adapter.effectiveLang(dom.window.document.querySelector("main"), "ja"), "zh-hans");
paragraph.removeAttribute("lang");
assert.equal(adapter.effectiveLang(paragraph, "ja"), "zh-hans");
dom.window.close();
});
test("shared rules: locale defaults and all language vectors match the Web adapter", () =>
{
const config = { default: "zh-Hant", policy: {} };
for (const [ langClass, expectedStyle ] of Object.entries(rules.locales.defaultPolicies))
{
assert.equal(adapter.styleFor(langClass, config), expectedStyle, langClass);
}
for (const vector of vectorsOf("language-classification"))
{
assert.equal(
adapter.classify(vector.input.lang),
vector.expected.langClass,
vector.id,
);
}
assert.equal(adapter.classify("zhx"), "other", "BCP-47 boundary must reject zhx");
});
test("shared rules: punctuation classification and style vectors match Web behavior", () =>
{
for (const vector of vectorsOf("punctuation-classification"))
{
const actual = adapter.classifyBiaodian(vector.input.character);
const expected = vector.expected.punctuationClass;
assert.equal(actual, expected === null ? null : "bd-" + expected, vector.id);
}
for (const vector of vectorsOf("punctuation-style"))
{
const halfClasses = rules.punctuationStyles.baselineHalfWidthClasses[vector.input.style];
assert.equal(
halfClasses.includes(vector.input.punctuationClass),
vector.expected.halfWidth,
vector.id + " canonical",
);
const character = punctuationCharacter(vector.input.punctuationClass);
const { paragraph } = renderText(character, {
jiya: true,
lang: { default: "zh-Hant", style: vector.input.style },
});
const char = paragraph.querySelector("jz-char");
assert.ok(char, vector.id + " must be classified by Web adapter");
assert.equal(char.classList.contains("jz-half"), vector.expected.halfWidth, vector.id);
}
});
test("shared rules: punctuation adjacency vectors match Web behavior", () =>
{
for (const vector of vectorsOf("punctuation-adjacency"))
{
const leftCharacter = punctuationCharacter(vector.input.left);
const rightCharacter = punctuationCharacter(vector.input.right);
const { paragraph } = renderText(leftCharacter + rightCharacter, {
jiya: true,
lang: { default: "zh-Hant", style: "quanjiao" },
});
const chars = Array.from(paragraph.querySelectorAll("jz-char"));
assert.equal(chars.length, 2, vector.id);
assert.equal(
chars[0].classList.contains("jz-half"),
vector.expected.squeezeLeft,
vector.id + " left",
);
assert.equal(
chars[1].classList.contains("jz-half"),
vector.expected.squeezeRight,
vector.id + " right",
);
}
});
test("shared rules: spacing vectors match Web decisions and author-space normalization", () =>
{
assert.equal(rules.spacing.gapEm, 0.25);
const cssGap = /jz-hws\s*\{[^}]*margin-right:\s*([0-9.]+)em\s*;/s.exec(spacingCss);
assert.ok(cssGap, "Web adapter spacing CSS must declare jz-hws margin-right in em");
assert.equal(Number.parseFloat(cssGap[1]), rules.spacing.gapEm, "Web CSS gap drift");
for (const vector of vectorsOf("spacing"))
{
const { paragraph } = renderText(vector.input.text, { spacing: true });
assert.equal(
paragraph.querySelectorAll("jz-hws").length,
vector.expected.managedGaps,
vector.id + " gaps",
);
assert.equal(paragraph.textContent, vector.expected.normalizedText, vector.id + " text");
if (Object.hasOwn(vector.expected, "gapEm"))
{
assert.equal(vector.expected.gapEm, rules.spacing.gapEm, vector.id + " gap width");
}
}
});
test("shared rules: IVS vectors enforce base plus at most one selector", () =>
{
assert.equal(rules.atoms.maxVariationSelectors, 1);
for (const vector of vectorsOf("ivs-atom"))
{
assert.deepEqual(
adapter.splitVariationSequences(vector.input.text),
vector.expected.atoms,
vector.id,
);
}
});
test("Web adapter only: pass groups, orders, levels, and compatibility no-ops do not drift", () =>
{
const actual = {
charify: [ adapter.jiyaPass, adapter.longWordPass ],
standalone: [
adapter.levelMarkPass,
adapter.jiyaAdjacencyPass,
adapter.jinzePass,
adapter.spacingPass,
adapter.orphanPass,
adapter.lineEdgePass,
adapter.gapTrimPass,
],
};
assert.deepEqual(
actual.charify.map(({ name, order, level }) => ({ name, order, level })),
[
{ name: "jiya", order: 20, level: "text" },
{ name: "longWord", order: 60, level: "paragraph" },
],
"Web charify group",
);
assert.deepEqual(
actual.standalone.map(({ name, order, level }) => ({ name, order, level })),
[
{ name: "levelMark", order: 10, level: "text" },
{ name: "jiyaAdjacency", order: 25, level: "text" },
{ name: "jinze", order: 40, level: "paragraph" },
{ name: "spacing", order: 50, level: "text" },
{ name: "orphan", order: 70, level: "paragraph" },
{ name: "jiyaLineEdge", order: 90, level: "paragraph" },
{ name: "spacingGapTrim", order: 92, level: "paragraph" },
],
"Web standalone group",
);
const passNames = new Set([ ...actual.charify, ...actual.standalone ].map((pass) => pass.name));
for (const option of [ "hanging", "biaodian", "emphasis", "ruby" ])
{
assert.equal(passNames.has(option), false, option + " must remain unregistered");
}
});
test("shared rules: jinze and orphan semantic vectors match Web behavior", () =>
{
for (const vector of vectorsOf("jinze"))
{
if (vector.expected.atoms)
{
assert.deepEqual(
adapter.splitVariationSequences(vector.input.left + vector.input.right),
vector.expected.atoms,
vector.id + " atoms",
);
}
const { paragraph } = renderText(vector.input.left + vector.input.right, {
jiya: true,
jinze: true,
lang: { default: "zh-Hant", style: "quanjiao" },
});
assert.equal(
paragraph.querySelector("jz-jinze") !== null,
vector.expected.prohibitedBreak,
vector.id,
);
}
const anyCjk = new RegExp(
"[" + rangesToWebFragment(rules.characterClasses.cjk) + "]",
"u",
);
const punctuation = new Set(
Object.values(rules.characterClasses.biaodian.classes)
.flatMap((entries) => canonicalCodePoints(entries)),
);
for (const vector of vectorsOf("orphan"))
{
const atoms = adapter.splitVariationSequences(vector.input.text);
if (vector.expected.atoms)
{
assert.deepEqual(atoms, vector.expected.atoms, vector.id + " atoms");
}
const meaningful = atoms.filter((atom) =>
anyCjk.test(atom) && !punctuation.has(atom.codePointAt(0)));
assert.equal(meaningful.length, vector.expected.meaningfulCjkCount, vector.id + " count");
const { paragraph } = renderText(vector.input.text, {
orphan: { chars: vector.input.minimum },
});
const orphan = paragraph.querySelector("jz-orphan");
assert.equal(orphan ? orphan.textContent : null, vector.expected.protectedSuffix, vector.id);
}
});
-519
View File
@@ -1,519 +0,0 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { createJuzhen } from "../dist/juzhen.mjs";
import { setupDom } from "./helpers.mjs";
const TEXT_ONLY = { jiya: false, jinze: false, orphan: false, longWord: false };
for (const cp of [ 0xE000, 0xE001, 0xE002, 0xE003 ])
{
test(`spacing source: U+${cp.toString(16).toUpperCase()} remains author text`, () =>
{
const text = "漢" + String.fromCodePoint(cp) + "A中";
const { dom, document } = setupDom("<p></p>");
const paragraph = document.querySelector("p");
paragraph.textContent = text;
const jz = createJuzhen(TEXT_ONLY);
try
{
for (let cycle = 0; cycle < 3; cycle += 1)
{
jz.render(paragraph);
assert.equal(paragraph.textContent, text);
assert.deepEqual(Array.from(paragraph.querySelectorAll("jz-hws"), (gap) => gap.textContent), [ "A" ]);
const rendered = paragraph.innerHTML;
jz.render(paragraph);
assert.equal(paragraph.innerHTML, rendered, "repeat render stays idempotent");
jz.revert(paragraph);
assert.equal(paragraph.textContent, text);
assert.equal(paragraph.querySelector("[data-jz]"), null);
}
}
finally { jz.revert(paragraph); dom.window.close(); }
});
}
for (const fakeIndex of [ "0", "9" ])
{
test(`spacing source: fake compound placeholder ${fakeIndex} does not collide with GPT-4`, () =>
{
const text = "漢\uE001" + fakeIndex + "\uE002 GPT-4中";
const { dom, document } = setupDom("<p></p>");
const paragraph = document.querySelector("p");
paragraph.textContent = text;
const jz = createJuzhen(TEXT_ONLY);
try
{
jz.render(paragraph);
assert.equal(paragraph.textContent, text);
assert.deepEqual(Array.from(paragraph.querySelectorAll("jz-hws"), (gap) => gap.textContent), [ "4" ]);
jz.revert(paragraph);
assert.equal(paragraph.textContent, text);
}
finally { jz.revert(paragraph); dom.window.close(); }
});
}
test("spacing source: reserved characters across transparent nodes preserve IVS and space normalization", () =>
{
const { dom, document } = setupDom('<p><strong>漢\uE000\uE001</strong>'
+ '<em>0\uE002\uE003\uE004</em> GPT-4<span>\u{20000}</span>'
+ '<i>\u{E0100}</i> A<span>甲</span> <b>乙</b></p>');
const paragraph = document.querySelector("p");
const source = paragraph.textContent;
const expected = source.replace("\u{E0100} A", "\u{E0100}A").replace("甲 乙", "甲乙");
const authors = Array.from(paragraph.querySelectorAll("strong,em,span,i,b"));
const jz = createJuzhen(TEXT_ONLY);
try
{
for (let cycle = 0; cycle < 3; cycle += 1)
{
jz.render(paragraph);
assert.equal(paragraph.textContent, expected, "only contracted author spaces are removed");
assert.deepEqual(Array.from(paragraph.querySelectorAll("jz-hws"), (gap) => gap.textContent),
[ "4", "\u{20000}\u{E0100}", "A" ]);
jz.revert(paragraph);
assert.equal(paragraph.textContent, expected);
assert.equal(paragraph.querySelector("[data-jz]"), null);
assert.deepEqual(Array.from(paragraph.querySelectorAll("strong,em,span,i,b")), authors);
}
}
finally { jz.revert(paragraph); dom.window.close(); }
});
test("spacing source: candidate markers respect custom CJK and western classes", () =>
{
const { dom, document } = setupDom("<p>漢A中</p>");
const paragraph = document.querySelector("p");
const jz = createJuzhen({ ...TEXT_ONLY, charClass: {
cjk: "\\uE000-\\uE003", western: "\\uE004-\\uE007",
} });
try
{
jz.render(paragraph);
assert.equal(paragraph.textContent, "漢A中");
assert.deepEqual(Array.from(paragraph.querySelectorAll("jz-hws"), (gap) => gap.textContent), [ "漢", "A" ]);
jz.revert(paragraph);
assert.equal(paragraph.textContent, "漢A中");
}
finally { jz.revert(paragraph); dom.window.close(); }
});
test("spacing source: supplementary markers keep source PUA and DOM atom offsets intact", () =>
{
const { dom, document } = setupDom("<p>漢\u{F0000}A中</p>");
const paragraph = document.querySelector("p");
const text = paragraph.textContent;
const jz = createJuzhen({ ...TEXT_ONLY, charClass: { cjk: "\\uE000-\\uF8FF" } });
try
{
jz.render(paragraph);
assert.equal(paragraph.textContent, text);
assert.deepEqual(Array.from(paragraph.querySelectorAll("jz-hws"), (gap) => gap.textContent), [ "A" ]);
jz.revert(paragraph);
assert.equal(paragraph.textContent, text);
}
finally { jz.revert(paragraph); dom.window.close(); }
});
test("spacing source: exhausted marker candidates fail before author text playback", () =>
{
const { dom, document } = setupDom("<p>漢 A中</p>");
const paragraph = document.querySelector("p");
const before = paragraph.innerHTML;
const jz = createJuzhen({ ...TEXT_ONLY, charClass: { cjk: "\\u0000-\\u{10FFFF}" } });
try
{
assert.throws(() => jz.render(paragraph), /已佔用全部間隙標記候選/);
assert.equal(paragraph.innerHTML, before, "no source atom or author space was replayed");
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, before);
}
finally { jz.revert(paragraph); dom.window.close(); }
});
for (const selector of [ "code", "code span" ])
{
test(`spacing root: directly rendering ${selector} preserves pill contents`, () =>
{
const { dom, document } = setupDom('<p>外<code><span>漢A</span> 字B</code>文</p>');
const root = document.querySelector(selector);
const before = document.body.innerHTML;
const jz = createJuzhen(TEXT_ONLY);
try
{
jz.render(root);
assert.equal(root.querySelector("jz-hws"), null);
jz.render(root);
jz.revert(root);
assert.equal(document.body.innerHTML, before);
}
finally { jz.revert(root); dom.window.close(); }
});
}
test("spacing pill: a single author space across transparent nodes normalizes on both sides", () =>
{
const { dom, document } = setupDom('<p><span>漢</span><em> </em><code>x</code>'
+ '<i> </i><strong>文</strong></p>');
const paragraph = document.querySelector("p");
const authors = Array.from(paragraph.querySelectorAll("span,em,code,i,strong"));
const expected = paragraph.innerHTML.replace("<em> </em>", "<em></em>").replace("<i> </i>", "<i></i>");
const jz = createJuzhen(TEXT_ONLY);
try
{
for (let cycle = 0; cycle < 3; cycle += 1)
{
jz.render(paragraph);
assert.equal(paragraph.textContent, "漢x文");
assert.deepEqual(Array.from(paragraph.querySelectorAll("jz-hws"), (gap) => gap.textContent), [ "漢", "x" ]);
const rendered = paragraph.innerHTML;
jz.render(paragraph);
assert.equal(paragraph.innerHTML, rendered);
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, expected);
assert.deepEqual(Array.from(paragraph.querySelectorAll("span,em,code,i,strong")), authors);
}
}
finally { jz.revert(paragraph); dom.window.close(); }
});
for (const base of [ "漢", "\u{20000}" ])
{
for (const withSpace of [ false, true ])
{
test(`spacing pill: split ${base.codePointAt(0).toString(16)} IVS with space=${withSpace} stays one atom`, () =>
{
const vs = "\u{E0100}";
const space = withSpace ? " " : "";
const { dom, document } = setupDom(`<p><strong>${base}</strong><em>${vs}</em>`
+ `<span>${space}</span><code>x</code><i>${space}</i><b>${base}</b><u>${vs}</u></p>`);
const paragraph = document.querySelector("p");
const expected = paragraph.innerHTML.replace("<span> </span>", "<span></span>").replace("<i> </i>", "<i></i>");
const authors = Array.from(paragraph.children);
const jz = createJuzhen(TEXT_ONLY);
try
{
for (let cycle = 0; cycle < 3; cycle += 1)
{
jz.render(paragraph);
assert.equal(paragraph.textContent, base + vs + "x" + base + vs);
assert.deepEqual(Array.from(paragraph.querySelectorAll("jz-hws"), (gap) => gap.textContent), [ base + vs, "x" ]);
const rendered = paragraph.innerHTML;
jz.render(paragraph);
assert.equal(paragraph.innerHTML, rendered);
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, expected, "cross-node selector returns to its author element");
assert.deepEqual(Array.from(paragraph.children), authors);
}
}
finally { jz.revert(paragraph); dom.window.close(); }
});
}
}
for (const whitespace of [ " ", "\n", "\t", " \n" ])
{
test(`spacing pill: whitespace ${JSON.stringify(whitespace)} is a boundary, not a single removable space`, () =>
{
const { dom, document } = setupDom(`<p><span>漢</span><i>${whitespace}</i>`
+ `<code>x</code><em>${whitespace}</em><span>文</span></p>`);
const paragraph = document.querySelector("p");
const before = paragraph.innerHTML;
const jz = createJuzhen(TEXT_ONLY);
try
{
jz.render(paragraph);
assert.equal(paragraph.querySelector("jz-hws"), null);
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, before);
}
finally { jz.revert(paragraph); dom.window.close(); }
});
}
for (const barrier of [
'<mark data-juzhen-off="spacing"></mark>',
'<code data-juzhen-off="spacing"></code>',
'<span data-jz-skip></span>',
"<br>",
"<x-opaque></x-opaque>",
])
{
test(`spacing pill: empty ${barrier} stops lookup beyond one space on both sides`, () =>
{
const { dom, document } = setupDom(`<p><span>漢</span>${barrier}<i> </i>`
+ `<code>x</code><em> </em>${barrier}<span>文</span></p>`);
const paragraph = document.querySelector("p");
const before = paragraph.innerHTML;
const jz = createJuzhen(TEXT_ONLY);
try
{
jz.render(paragraph);
assert.equal(paragraph.querySelector("jz-hws"), null);
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, before);
}
finally { jz.revert(paragraph); dom.window.close(); }
});
}
test("spacing pill: out-of-scope neighbors stop space normalization", () =>
{
const { dom, document } = setupDom('<p><span>漢</span><i class="prose"> </i>'
+ '<code class="prose">x</code><em class="prose"> </em><span>文</span></p>');
const paragraph = document.querySelector("p");
const before = paragraph.innerHTML;
const jz = createJuzhen({ ...TEXT_ONLY, scope: { include: ".prose" } });
try
{
jz.render(paragraph);
assert.equal(paragraph.querySelector("jz-hws"), null);
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, before);
}
finally { jz.revert(paragraph); dom.window.close(); }
});
test("spacing pill: local render cannot move an outside IVS base or delete an inside trailing space", () =>
{
const { dom, document } = setupDom('<p><strong>\u{20000}</strong><span id="root">'
+ '<em>\u{E0100}</em><code>x</code><i> </i></span><b>文</b></p>');
const root = document.getElementById("root");
const before = document.body.innerHTML;
const jz = createJuzhen(TEXT_ONLY);
try
{
jz.render(root);
assert.equal(document.body.innerHTML, before);
jz.render(root);
jz.revert(root);
assert.equal(document.body.innerHTML, before);
}
finally { jz.revert(root); dom.window.close(); }
});
test("spacing pill: a local transparent root encloses only its own neighbor", () =>
{
const { dom, document } = setupDom('<p>外<span id="root"><strong>漢</strong>'
+ '<code>x</code></span> 文</p>');
const root = document.getElementById("root");
const before = document.body.innerHTML;
const jz = createJuzhen(TEXT_ONLY);
try
{
jz.render(root);
assert.deepEqual(Array.from(root.querySelectorAll("jz-hws"), (gap) => gap.textContent), [ "漢" ]);
assert.equal(document.querySelector("p").lastChild.data, " 文", "root-external space remains author text");
jz.revert(root);
assert.equal(document.body.innerHTML, before);
}
finally { jz.revert(root); dom.window.close(); }
});
function appendTextChunks(parent, chunks)
{
const tags = [ "strong", "em", "i", "span", "b", "u" ];
return chunks.map((chunk, index) =>
{
const element = parent.ownerDocument.createElement(tags[index % tags.length]);
element.textContent = chunk;
parent.appendChild(element);
return element;
});
}
const SPLIT_CODEPOINTS = [
[ "\uD840", "\uDC00" ],
[ "\uD840", "\uDC00", "\uDB40", "\uDD00" ],
[ "\uD840", "\uDC00\uDB40", "\uDD00" ],
[ "\u{20000}", "\uDB40", "\uDD00" ],
[ "漢", "\uDB40", "\uDD00" ],
];
for (const [index, chunks] of SPLIT_CODEPOINTS.entries())
{
test(`spacing source: split surrogate codepoint case ${index} maps to one complete gap`, () =>
{
const { dom, document } = setupDom("<p></p>");
const paragraph = document.querySelector("p");
const authors = appendTextChunks(paragraph, [ ...chunks, "A" ]);
const before = paragraph.innerHTML;
const atom = chunks.join("");
const jz = createJuzhen(TEXT_ONLY);
try
{
for (let cycle = 0; cycle < 3; cycle += 1)
{
jz.render(paragraph);
assert.equal(paragraph.textContent, atom + "A");
assert.deepEqual(Array.from(paragraph.querySelectorAll("jz-hws"), (gap) => gap.textContent), [ atom ]);
const rendered = paragraph.innerHTML;
jz.render(paragraph);
assert.equal(paragraph.innerHTML, rendered);
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, before);
assert.deepEqual(Array.from(paragraph.children), authors);
}
}
finally { jz.revert(paragraph); dom.window.close(); }
});
for (const withSpace of [ false, true ])
{
test(`spacing pill: split surrogate case ${index}, space=${withSpace} remains complete on both sides`, () =>
{
const { dom, document } = setupDom("<p></p>");
const paragraph = document.querySelector("p");
appendTextChunks(paragraph, chunks);
if (withSpace) { appendTextChunks(paragraph, [ " " ]); }
const code = document.createElement("code");
code.textContent = "x";
paragraph.appendChild(code);
if (withSpace) { appendTextChunks(paragraph, [ " " ]); }
appendTextChunks(paragraph, chunks);
const authors = Array.from(paragraph.children);
const before = paragraph.innerHTML.replaceAll("<strong> </strong>", "<strong></strong>");
const atom = chunks.join("");
const jz = createJuzhen(TEXT_ONLY);
try
{
for (let cycle = 0; cycle < 3; cycle += 1)
{
jz.render(paragraph);
assert.equal(paragraph.textContent, atom + "x" + atom);
assert.deepEqual(Array.from(paragraph.querySelectorAll("jz-hws"), (gap) => gap.textContent), [ atom, "x" ]);
const rendered = paragraph.innerHTML;
jz.render(paragraph);
assert.equal(paragraph.innerHTML, rendered);
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, before);
assert.deepEqual(Array.from(paragraph.children), authors);
}
}
finally { jz.revert(paragraph); dom.window.close(); }
});
}
}
for (const barrier of [
'<mark data-juzhen-off="spacing"></mark>',
'<code data-juzhen-off="spacing"></code>',
'<span data-jz-skip></span>',
"<br>",
"<x-opaque></x-opaque>",
])
{
test(`spacing surrogate: ${barrier} prevents joining adjacent high and low halves`, () =>
{
const { dom, document } = setupDom("<p></p>");
const paragraph = document.querySelector("p");
appendTextChunks(paragraph, [ "\uD840" ]);
paragraph.insertAdjacentHTML("beforeend", barrier);
appendTextChunks(paragraph, [ "\uDC00" ]);
const code = document.createElement("code");
code.textContent = "x";
paragraph.appendChild(code);
appendTextChunks(paragraph, [ "\uD840" ]);
paragraph.insertAdjacentHTML("beforeend", barrier);
appendTextChunks(paragraph, [ "\uDC00", "A" ]);
const before = paragraph.innerHTML;
const text = paragraph.textContent;
const jz = createJuzhen(TEXT_ONLY);
try
{
jz.render(paragraph);
assert.equal(paragraph.textContent, text);
assert.equal(paragraph.querySelector("jz-hws"), null);
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, before);
}
finally { jz.revert(paragraph); dom.window.close(); }
});
}
test("spacing surrogate: actual isolated UTF-16 halves stay author text", () =>
{
const { dom, document } = setupDom("<p></p>");
const paragraph = document.querySelector("p");
appendTextChunks(paragraph, [ "\uD840", "A" ]);
const code = document.createElement("code");
code.textContent = "x";
paragraph.appendChild(code);
appendTextChunks(paragraph, [ "\uDC00" ]);
const text = paragraph.textContent;
const before = paragraph.innerHTML;
const jz = createJuzhen(TEXT_ONLY);
try
{
jz.render(paragraph);
assert.equal(paragraph.textContent, text);
assert.deepEqual(Array.from(paragraph.querySelectorAll("jz-hws"), (gap) => gap.textContent), [ "A" ]);
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, before);
}
finally { jz.revert(paragraph); dom.window.close(); }
});
test("spacing surrogate: a local root cannot join either outside surrogate half", () =>
{
const { dom, document } = setupDom('<p><strong></strong><span id="root">'
+ '<em></em><code>x</code><i></i></span><b></b></p>');
document.querySelector("strong").textContent = "\uD840";
document.querySelector("em").textContent = "\uDC00";
document.querySelector("i").textContent = "\uD840";
document.querySelector("b").textContent = "\uDC00";
const root = document.getElementById("root");
const before = document.body.innerHTML;
const jz = createJuzhen(TEXT_ONLY);
try
{
jz.render(root);
assert.equal(document.body.innerHTML, before);
jz.revert(root);
assert.equal(document.body.innerHTML, before);
}
finally { jz.revert(root); dom.window.close(); }
});
test("spacing surrogate: an excluded Text is a run boundary even when empty", () =>
{
const { dom, document } = setupDom("<p></p>");
const paragraph = document.querySelector("p");
appendTextChunks(paragraph, [ "\uD840" ]);
const excluded = document.createTextNode("");
paragraph.appendChild(excluded);
appendTextChunks(paragraph, [ "\uDC00", "A" ]);
const before = paragraph.innerHTML;
const jz = createJuzhen({ ...TEXT_ONLY, isSkipped: (node) => node === excluded });
try
{
jz.render(paragraph);
assert.equal(paragraph.querySelector("jz-hws"), null);
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, before);
}
finally { jz.revert(paragraph); dom.window.close(); }
});
test("spacing surrogate: complete codepoints across source scope boundaries are not joined", () =>
{
const { dom, document } = setupDom('<p><strong></strong><em class="prose"></em>'
+ '<code class="prose">x</code><i class="prose"></i><b></b></p>');
document.querySelector("strong").textContent = "\uD840";
document.querySelector("em").textContent = "\uDC00";
document.querySelector("i").textContent = "\uD840";
document.querySelector("b").textContent = "\uDC00";
const paragraph = document.querySelector("p");
const before = paragraph.innerHTML;
const jz = createJuzhen({ ...TEXT_ONLY, scope: { include: ".prose" } });
try
{
jz.render(paragraph);
assert.equal(paragraph.querySelector("jz-hws"), null);
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, before);
}
finally { jz.revert(paragraph); dom.window.close(); }
});
-120
View File
@@ -1,120 +0,0 @@
// 針對性驗證:v1 之「行內塊(pill)邊界空格」與「原檔多餘空格收斂」是否
// 在新引擎落地(回應使用者提問)。
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { setupDom, countTag } from "./helpers.mjs";
import { createJuzhen } from "../dist/juzhen.mjs";
const SPACING_ONLY = { jiya: false, longWord: false, jinze: false };
// ----- 原檔多餘空格收斂(STRIP_CJK_SPACE_ANS / STRIP_ANS_SPACE_CJK)-----
test("strip:CJK↔Latin 間之作者空格被移除(間隙改由 margin,複製緊湊)", () =>
{
const { document } = setupDom("<p>用 Neovim 寫</p>");
createJuzhen(SPACING_ONLY).render(document.body);
const p = document.querySelector("p");
assert.equal(p.textContent, "用Neovim寫", "作者空格移除,間隙改由 jz-hws margin(copy 緊湊)");
assert.equal(countTag(p, "jz-hws"), 2);
});
test("strip:間隙 copy-clean(無注入空白,textContent 不含中西間空白)", () =>
{
const { document } = setupDom("<p>裝 fzf 工具</p>");
createJuzhen(SPACING_ONLY).render(document.body);
const p = document.querySelector("p");
assert.equal(p.textContent, "裝fzf工具", "中西間隙不入 textContent(純 copy-clean)");
assert.equal(countTag(p, "jz-hws"), 2);
});
test("strip:全流程(含 charify)下作者空格仍收斂、不雙空格", () =>
{
const { document } = setupDom('<p lang="zh-Hans">用 TypeScript 寫,很好</p>');
createJuzhen({}).render(document.body);
const p = document.querySelector("p");
assert.ok(!/ {2,}/.test(p.textContent), "無雙空格:" + JSON.stringify(p.textContent));
});
// ----- 行內塊(pill)邊界空格(Pass B)-----
test("pill:code 與 CJK 緊鄰兩側各插一 jz-hws", () =>
{
const { document } = setupDom("<p>用<code>vim</code>編輯</p>");
createJuzhen(SPACING_ONLY).render(document.body);
assert.equal(countTag(document.querySelector("p"), "jz-hws"), 2);
});
test("pill:code 與 Latin 緊鄰亦插間隙(PILL_NEIGHBOR 含 A-Za-z0-9)", () =>
{
const { document } = setupDom("<p>看<code>x</code>嗎</p>");
createJuzhen(SPACING_ONLY).render(document.body);
assert.equal(countTag(document.querySelector("p"), "jz-hws"), 2);
});
test("pill:已有作者空格時收斂為單一 jz-hws(不雙空格)", () =>
{
const { document } = setupDom("<p>用 <code>vim</code> 編輯</p>");
createJuzhen(SPACING_ONLY).render(document.body);
const p = document.querySelector("p");
assert.ok(!/ {2,}/.test(p.textContent), "無雙空格:" + JSON.stringify(p.textContent));
assert.equal(countTag(p, "jz-hws"), 2);
});
test("pill:標點與 code 之間不強插間隙(鄰字非 PILL_NEIGHBOR)", () =>
{
const { document } = setupDom("<p>他說:<code>vim</code>很好</p>");
createJuzhen(SPACING_ONLY).render(document.body);
// : 非 PILL_NEIGHBOR → 左側不插;code|很 右側插 1。
assert.equal(countTag(document.querySelector("p"), "jz-hws"), 1);
});
// ----- 間隔號 ·(U+00B7)不觸發中西間隙 -----
test("間隔號:姓名 · 兩側不插 jz-hws(· 屬 CJK 標點,非西文)", () =>
{
const { document } = setupDom("<p>卡尔·马克思</p>");
createJuzhen(SPACING_ONLY).render(document.body);
assert.equal(countTag(document.querySelector("p"), "jz-hws"), 0, "· 兩側皆不應有間隙");
});
// ----- ASCII 左括號(Safari 16 無 lookbehind 路徑)-----
test("左括號:普通英數字後補間隙,連續樣式不漏匹配", () =>
{
const { document } = setupDom("<p>A(b[c{中</p>");
createJuzhen(SPACING_ONLY).render(document.body);
assert.equal(countTag(document.querySelector("p"), "jz-hws"), 3);
});
test("左括號:dot-prefixed 英數字串段不補間隙", () =>
{
const { document } = setupDom("<p>.foo(中</p><p>x.bar[文</p><p>.baz{字</p>");
createJuzhen(SPACING_ONLY).render(document.body);
for (const p of document.querySelectorAll("p"))
{
assert.equal(countTag(p, "jz-hws"), 0);
}
});
test("左括號:點號不在當前英數字 suffix 前時仍補間隙", () =>
{
const { document } = setupDom("<p>.foo-bar(中</p>");
createJuzhen(SPACING_ONLY).render(document.body);
assert.equal(countTag(document.querySelector("p"), "jz-hws"), 1);
});
test("Safari 16 產物:spacing 源碼與發佈 JS 不得含 lookbehind 語法", () =>
{
const lookbehindSyntax = /\(\?<([=!])/;
for (const path of [
new URL("../src/typeset/spacing.ts", import.meta.url),
new URL("../dist/juzhen.mjs", import.meta.url),
new URL("../dist/juzhen.iife.js", import.meta.url),
])
{
assert.doesNotMatch(readFileSync(path, "utf8"), lookbehindSyntax, path.pathname);
}
});
-326
View File
@@ -1,326 +0,0 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { execFileSync } from "node:child_process";
import { setupDom } from "./helpers.mjs";
import { createJuzhen } from "../dist/juzhen.mjs";
const ONLY_SPACING = { jiya: false, jinze: false, orphan: false, longWord: false };
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
for (const emptied of [ false, true ])
{
test("revert provenance:重用已撤回容器 emptied=" + emptied + " 不循環或丟字", () =>
{
// 有界子進程使來源鏈循環的回歸失敗可終止,不能卡住整套測試。
const script = `
import assert from 'node:assert/strict';
import { setupDom } from ${JSON.stringify(new URL("./helpers.mjs", import.meta.url).href)};
import { createJuzhen } from ${JSON.stringify(new URL("../dist/juzhen.mjs", import.meta.url).href)};
const {dom,document}=setupDom('<p></p>');const root=document.querySelector('p');
for(const t of ['甲','「','禰','\\uFE00','A'])root.append(document.createTextNode(t));
const B=createJuzhen({spacing:false,orphan:false,longWord:false});
const C=createJuzhen({spacing:false,jiya:false,jinze:false,longWord:false});
const A=createJuzhen({jiya:false,jinze:false,orphan:false,longWord:false});
B.render(root);const oldJ=root.querySelector('jz-jinze');
C.render(root);const oldO=root.querySelector('jz-orphan');A.render(root);
B.revert(root);oldJ.append(oldO);C.revert(oldJ);
let request=oldJ;
if(${emptied}){root.append(oldJ.querySelector('jz-hws'));oldJ.replaceChildren();request=root;}
const text=request.textContent;A.revert(request);assert.equal(request.textContent,text);
A.disconnect();B.disconnect();C.disconnect();dom.window.close();
console.log('bounded revert PASS');
`;
const output = execFileSync(process.execPath, [ "--input-type=module", "-e", script ],
{ encoding: "utf8", timeout: 5000 });
assert.match(output, /bounded revert PASS/);
});
}
for (const [name, attrs, options] of [
[ "level", 'data-jz-level="text"', {} ],
[ "off", 'data-juzhen-off="jiya orphan jinze longWord spacing"', {} ],
[ "avoid", "data-jz-skip", {} ],
[ "lang", 'lang="zh-Hans"', {} ],
[ "pill", 'class="pill"', { pillSelector: ".pill" } ],
])
{
test("observer relocate:" + name + " 的增量結果與完整重排一致", async () =>
{
const { dom, document } = setupDom('<main><section id="from"><p>文字ABCDEF,甲乙。</p></section>'
+ '<section id="to" ' + attrs + '></section></main>');
const root = document.querySelector("main");
const paragraph = root.querySelector("p");
const jz = createJuzhen(options);
try
{
jz.render(root);
jz.observe(root);
document.getElementById("to").append(paragraph);
await tick();
jz.disconnect();
const incremental = paragraph.outerHTML;
jz.rerender(root);
assert.equal(incremental, paragraph.outerHTML);
assert.equal(paragraph.textContent, "文字ABCDEF,甲乙。");
}
finally { jz.revert(root); jz.disconnect(); dom.window.close(); }
});
}
test("observer relocate:舊 avoid 快取不帶入新父範圍", async () =>
{
const { dom, document } = setupDom('<main><section data-jz-skip><p>文字ABC,甲乙。</p></section><section id="to"></section></main>');
const root = document.querySelector("main");
const paragraph = root.querySelector("p");
const jz = createJuzhen();
try
{
jz.render(root);
jz.observe(root);
document.getElementById("to").append(paragraph);
await tick();
jz.disconnect();
const incremental = paragraph.outerHTML;
jz.rerender(root);
assert.equal(incremental, paragraph.outerHTML);
assert.ok(paragraph.querySelector("jz-char"));
}
finally { jz.revert(root); jz.disconnect(); dom.window.close(); }
});
for (const [name, marker, options] of [
[ "off", 'data-juzhen-off="spacing"', {} ],
[ "whitelist", 'data-juzhen="jiya"', {} ],
[ "blocks", 'class="disabled"', { blocks: [ { selector: ".disabled", features: [ "jiya" ] } ] } ],
])
{
test("spacing inline feature:" + name + " 保留禁用內容及作者空格", () =>
{
const { dom, document } = setupDom('<p>外ABC<span ' + marker + '>甲 ABC 乙</span>文XYZ</p>');
const root = document.querySelector("p");
const disabled = root.querySelector("span");
const original = root.textContent;
const jz = createJuzhen({ ...ONLY_SPACING, ...options });
try
{
jz.render(root);
assert.equal(disabled.innerHTML, "甲 ABC 乙");
assert.equal(root.textContent, original);
assert.equal(root.querySelectorAll("jz-hws").length, 2, "外部啟用區仍留隙");
jz.revert(root);
assert.equal(root.textContent, original);
}
finally { jz.revert(root); dom.window.close(); }
});
}
for (const marker of [ 'data-juzhen-off="spacing"', 'data-juzhen="jiya"' ])
{
test("spacing feature:禁用塊內最近子白名單可重新啟用 " + marker, () =>
{
const { dom, document } = setupDom('<p ' + marker + '>甲 ABC<span data-juzhen="spacing">乙 DEF</span>丙 GHI</p>');
const root = document.querySelector("p");
const jz = createJuzhen(ONLY_SPACING);
try
{
jz.render(root);
assert.equal(root.textContent, "甲 ABC乙DEF丙 GHI");
assert.equal(root.querySelectorAll("jz-hws").length, 1);
assert.equal(root.querySelector("span jz-hws").textContent, "乙");
}
finally { jz.revert(root); dom.window.close(); }
});
}
for (const side of [ "left", "right" ])
{
test("spacing pill feature:" + side + " 鄰字禁用時不刪空格或造隙", () =>
{
const html = side === "left" ? '<span data-juzhen-off="spacing">甲 </span><code>x</code>'
: '<code>x</code><span data-juzhen-off="spacing"> 甲</span>';
const { dom, document } = setupDom("<p>" + html + "</p>");
const root = document.querySelector("p");
const jz = createJuzhen(ONLY_SPACING);
try { jz.render(root); assert.equal(root.innerHTML, html); }
finally { jz.revert(root); dom.window.close(); }
});
}
for (const separator of [ '<span data-juzhen-off="spacing"></span>', '<span data-juzhen=""></span>' ])
{
test("spacing feature:空禁用元素中斷 run " + separator, () =>
{
const { dom, document } = setupDom("<p>甲" + separator + "ABC</p>");
const root = document.querySelector("p");
const jz = createJuzhen(ONLY_SPACING);
try { jz.render(root); assert.equal(root.querySelector("jz-hws"), null); }
finally { jz.revert(root); dom.window.close(); }
});
}
for (const full of [ false, true ])
{
for (const mutation of [ "clone-gap", "clone-base", "remove-gap", "replace-source-parent" ])
{
test("spacing carrier integrity:" + mutation + " full=" + full, () =>
{
const { dom, document } = setupDom('<p><strong id="base">禰</strong><em id="vs">\u{E0100}</em>A</p>');
const root = document.querySelector("p");
const source = root.querySelector("em");
const jz = createJuzhen(full ? {} : ONLY_SPACING);
try
{
jz.render(root);
const gap = root.querySelector("jz-hws");
const base = document.getElementById("base");
if (mutation === "clone-gap") { gap.replaceWith(gap.cloneNode(true)); }
else if (mutation === "clone-base") { base.replaceWith(base.cloneNode(true)); }
else if (mutation === "remove-gap") { gap.remove(); }
else { source.replaceWith(source.cloneNode(true)); }
const before = root.textContent;
jz.revert(root);
assert.equal(root.textContent, mutation === "replace-source-parent" ? "禰A" : before);
if (mutation === "replace-source-parent") { assert.equal(source.textContent, ""); }
}
finally { jz.revert(root); dom.window.close(); }
});
}
}
for (const order of [ "binding-first", "spacing-first" ])
{
test("spacing multi-owner:" + order + " 回退受控父包裹不丟 VS", () =>
{
const { dom, document } = setupDom("<p></p>");
const root = document.querySelector("p");
for (const value of [ "「", "禰", "\uFE00", "A" ]) { root.appendChild(document.createTextNode(value)); }
const binding = createJuzhen({ spacing: false, orphan: false, longWord: false });
const spacing = createJuzhen(ONLY_SPACING);
try
{
binding.render(root);
spacing.render(root);
if (order === "binding-first") { binding.revert(root); spacing.revert(root); }
else { spacing.revert(root); binding.revert(root); }
assert.equal(root.textContent, "「禰\uFE00A");
assert.equal(root.querySelector("[data-jz]"), null);
}
finally { binding.revert(root); spacing.revert(root); dom.window.close(); }
});
}
for (const side of [ "left", "right" ])
{
test("spacing pill feature:空禁用元素在 " + side + " 阻斷鄰字查詢", () =>
{
const barrier = '<span data-juzhen-off="spacing"></span>';
const html = side === "left" ? "甲" + barrier + "<code>x</code>"
: "<code>x</code>" + barrier + "甲";
const { dom, document } = setupDom("<p>" + html + "</p>");
const root = document.querySelector("p");
const jz = createJuzhen(ONLY_SPACING);
try { jz.render(root); assert.equal(root.innerHTML, html); }
finally { jz.revert(root); dom.window.close(); }
});
}
for (const order of [ [0,1,2], [1,0,2], [0,2,1], [1,2,0], [2,0,1], [2,1,0] ])
{
test("spacing multi-owner:傳遞解包鏈 " + order.join("→") + " 保留來源原子", () =>
{
const { dom, document } = setupDom("<p></p>");
const root = document.querySelector("p");
for (const value of [ "甲", "「", "禰", "\uFE00", "A" ]) { root.appendChild(document.createTextNode(value)); }
const instances = [
createJuzhen({ spacing: false, orphan: false, longWord: false }),
createJuzhen({ spacing: false, jiya: false, jinze: false, longWord: false }),
createJuzhen(ONLY_SPACING),
];
try
{
for (const instance of instances) { instance.render(root); }
for (const index of order) { instances[index].revert(root); }
assert.equal(root.textContent, "甲「禰\uFE00A");
assert.equal(root.querySelector("[data-jz]"), null);
}
finally { for (const instance of instances) { instance.revert(root); } dom.window.close(); }
});
}
for (const order of [ "punctuation-first", "spacing-first" ])
{
test("spacing multi-owner:跨 Text 標點父解包 " + order + " 保留 VS", () =>
{
const { dom, document } = setupDom('<p><strong>,</strong><em>\uFE00</em>A</p>');
const root = document.querySelector("p");
const punctuation = createJuzhen({ spacing: false, jinze: false, orphan: false, longWord: false });
const spacing = createJuzhen({ ...ONLY_SPACING, charClass: { cjk: "," } });
try
{
punctuation.render(root);
spacing.render(root);
if (order === "punctuation-first") { punctuation.revert(root); spacing.revert(root); }
else { spacing.revert(root); punctuation.revert(root); }
assert.equal(root.textContent, ",\uFE00A");
assert.equal(root.querySelector("[data-jz]"), null);
}
finally { punctuation.revert(root); spacing.revert(root); dom.window.close(); }
});
}
for (const mode of [ "all", "spacing-only" ])
{
test("spacing include:行內 opt-in " + mode + " 只處理選中子樹", () =>
{
const html = '<p>外ABC<span class="prose">甲DEF<span class="prose">乙GHI</span>丙</span>文XYZ</p>';
const { dom, document } = setupDom(html);
const root = document.querySelector("p");
const jz = createJuzhen({ ...(mode === "all" ? {} : ONLY_SPACING), scope: { include: ".prose" } });
try
{
jz.render(root);
const gaps = Array.from(root.querySelectorAll("jz-hws"));
assert.deepEqual(gaps.map((gap) => gap.textContent), [ "甲", "F", "乙", "I" ]);
assert.equal(root.firstChild.textContent, "外ABC");
assert.equal(root.lastChild.textContent, "文XYZ");
const rendered = root.innerHTML;
jz.render(root);
assert.equal(root.innerHTML, rendered);
jz.revert(root);
assert.equal(root.outerHTML, html);
}
finally { jz.revert(root); dom.window.close(); }
});
}
for (const marker of [ 'data-juzhen-off="jiya"', 'data-juzhen="spacing"' ])
{
test("jiya feature:空禁用元素中斷標點鄰接 " + marker, () =>
{
const { dom, document } = setupDom('<p lang="zh-Hant">,<span ' + marker + '></span>「</p>');
const root = document.querySelector("p");
const jz = createJuzhen({ spacing: false, orphan: false, jinze: false, longWord: false });
try
{
jz.render(root);
assert.equal(root.querySelectorAll("jz-char").length, 2);
assert.equal(root.querySelectorAll("jz-char.jz-half").length, 0);
}
finally { jz.revert(root); dom.window.close(); }
});
}
test("jiya feature:跨 Text 原子不越空禁用元素搬移 VS", () =>
{
const { dom, document } = setupDom('<p lang="zh-Hant"><strong>,</strong><span data-juzhen-off="jiya"></span><em>\uFE00</em>「</p>');
const root = document.querySelector("p");
const jz = createJuzhen({ spacing: false, orphan: false, jinze: false, longWord: false });
try
{
jz.render(root);
assert.equal(root.querySelector("jz-char").textContent, ",");
assert.equal(root.querySelector("em").textContent, "\uFE00");
assert.equal(root.querySelector("jz-char.jz-half"), null);
}
finally { jz.revert(root); dom.window.close(); }
});
-196
View File
@@ -1,196 +0,0 @@
import AppKit
import Darwin
import Foundation
import WebKit
@MainActor
final class WkWebViewHarness: NSObject, WKNavigationDelegate
{
private let app: NSApplication
private let window: NSWindow
private let webView: WKWebView
private var finished = false
init(app: NSApplication, pagePath: String, readRoot: String)
{
self.app = app
let configuration = WKWebViewConfiguration()
configuration.websiteDataStore = .nonPersistent()
webView = WKWebView(
frame: NSRect(x: 0, y: 0, width: 1224, height: 768),
configuration: configuration
)
window = NSWindow(
contentRect: webView.frame,
styleMask: [ .titled, .closable, .resizable ],
backing: .buffered,
defer: false
)
super.init()
webView.navigationDelegate = self
window.title = "Juzhen WKWebView E2E"
window.contentView = webView
window.center()
window.makeKeyAndOrderFront(nil)
let pageUrl = URL(fileURLWithPath: pagePath)
let readUrl = URL(fileURLWithPath: readRoot, isDirectory: true)
webView.loadFileURL(pageUrl, allowingReadAccessTo: readUrl)
DispatchQueue.main.asyncAfter(deadline: .now() + 20)
{ [weak self] in
self?.finish(code: 2, reason: "timeout")
}
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!)
{
evaluate("navigator.userAgent")
{ value in
print("WKWEBVIEW_USER_AGENT=\(value)")
}
click(buttonId: "run-core")
pollTitle(expectedPrefix: "CORE PASS", timeoutAt: Date().addingTimeInterval(8))
{ [weak self] coreTitle in
guard let self else { return }
guard coreTitle.hasPrefix("CORE PASS") else
{
self.collectAndFinish(code: 3, reason: "core title: \(coreTitle)")
return
}
self.click(buttonId: "run-observer")
self.pollTitle(expectedPrefix: "PASS", timeoutAt: Date().addingTimeInterval(8))
{ [weak self] finalTitle in
guard let self else { return }
let success = finalTitle.hasPrefix("PASS")
self.collectAndFinish(
code: success ? 0 : 4,
reason: "final title: \(finalTitle)"
)
}
}
}
func webView(
_ webView: WKWebView,
didFail navigation: WKNavigation!,
withError error: Error
)
{
finish(code: 5, reason: "navigation failed: \(error)")
}
func webView(
_ webView: WKWebView,
didFailProvisionalNavigation navigation: WKNavigation!,
withError error: Error
)
{
finish(code: 6, reason: "provisional navigation failed: \(error)")
}
private func click(buttonId: String)
{
let script = "document.getElementById('\(buttonId)')?.click(); document.title;"
evaluate(script)
{ value in
print("WKWEBVIEW_CLICK_\(buttonId)=\(value)")
}
}
private func pollTitle(
expectedPrefix: String,
timeoutAt: Date,
completion: @escaping (String) -> Void
)
{
evaluate("document.title")
{ [weak self] value in
guard let self else { return }
if value.hasPrefix(expectedPrefix) || value.hasPrefix("FAIL") || Date() >= timeoutAt
{
completion(value)
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1)
{ [weak self] in
self?.pollTitle(
expectedPrefix: expectedPrefix,
timeoutAt: timeoutAt,
completion: completion
)
}
}
}
private func collectAndFinish(code: Int32, reason: String)
{
let script = """
JSON.stringify({
title: document.title,
summary: document.getElementById('summary-text')?.textContent || '',
failures: Array.from(document.querySelectorAll('#results li[data-state="fail"]'))
.map((item) => item.textContent),
results: Array.from(document.querySelectorAll('#results li'))
.map((item) => item.textContent)
})
"""
evaluate(script)
{ [weak self] value in
print("WKWEBVIEW_RESULT=\(value)")
self?.finish(code: code, reason: reason)
}
}
private func evaluate(_ script: String, completion: @escaping (String) -> Void)
{
webView.evaluateJavaScript(script)
{ value, error in
if let error
{
completion("EVALUATION_ERROR: \(error)")
return
}
completion(String(describing: value ?? "nil"))
}
}
private func finish(code: Int32, reason: String)
{
guard !finished else { return }
finished = true
print("WKWEBVIEW_EXIT_REASON=\(reason)")
fflush(stdout)
window.close()
app.stop(nil)
exit(code)
}
}
@main
struct Main
{
@MainActor
private static var harness: WkWebViewHarness?
@MainActor
static func main()
{
guard CommandLine.arguments.count == 3 else
{
fputs("usage: harness <e2e.html> <read-root>\n", stderr)
exit(64)
}
let app = NSApplication.shared
app.setActivationPolicy(.accessory)
harness = WkWebViewHarness(
app: app,
pagePath: CommandLine.arguments[1],
readRoot: CommandLine.arguments[2]
)
app.run()
}
}
-103
View File
@@ -1,103 +0,0 @@
#!/usr/bin/env python3
"""量測字型之標點 glyph 墨色位置,產出聚珍 margin 後備模式之偏側建議表。
用途:消費端字型缺 OpenType `halt`(如方正書宋 GBK、多數系統字型)時,margin 後備
模式需知每個標點之「墨色偏側」以於 0.5em 半形盒內正確定位字身。本工具讀字型輪廓,
量各標點 glyph 之 ink bounding box,依墨色中心建議 left/center/right/full,並印出
可貼入 `options.jiya.bias` 之 JSON 片段。
只量預設 cmap glyph,未做 shaping、未應用 `locl`;語言參數僅選 JSON 的語言分類鍵,
不切換字型的語言字形。`halt` feature 存在也不保證涵蓋所有標點。建議表須按瀏覽器
實際字形逐字微調,尤其是會依語言替換 glyph 的字型。
依賴:fonttools(`pip install fonttools`)。
用法:python3 tools/measure-bias.py /path/to/font.ttf [zh-Hans|zh-Hant|ja|other]
輸出之偏側語義(決定字身於 0.5em 盒內之左移;見 README「halt/margin 後備」):
left 墨偏左 → 不移(字身左對齊,墨在盒內、右側空白溢出)
center 墨居中 → 左移 0.25em
right 墨偏右 → 左移 0.5em(右半入盒,左側空白溢出)
full 無 glyph/空白 glyph → 不收、維持全形
門檻:ink 中心 < 0.375 → left;0.375–0.625 → center;> 0.625 → right。
判讀後仍建議於瀏覽器目視微調(字身側鬚、字面留白因字型而異)。
"""
import json
import sys
from fontTools.pens.boundsPen import BoundsPen
from fontTools.ttLib import TTFont
# 聚珍 margin 模式會收半形之標點(句末點號僅半角式收,此處一併量供參考)。
PUNCT = {
"pause_stop": ",、。.;:!?",
"open": "「『(《〈【〖〔[{“‘",
"close": "」』)》〉】〗〕]}”’",
}
def has_feature(font, tag):
for t in ("GSUB", "GPOS"):
if t in font and font[t].table.FeatureList:
for fr in font[t].table.FeatureList.FeatureRecord:
if fr.FeatureTag == tag:
return True
return False
def bias_of(font, upm, cmap, gs, ch):
cp = ord(ch)
if cp not in cmap:
return "full", None
g = gs[cmap[cp]]
pen = BoundsPen(gs)
g.draw(pen)
if pen.bounds is None:
return "full", None
x_min, _, x_max, _ = pen.bounds
center = ((x_min + x_max) / 2) / upm
if center < 0.375:
b = "left"
elif center > 0.625:
b = "right"
else:
b = "center"
return b, center
def main():
if len(sys.argv) < 2:
print(__doc__)
sys.exit(1)
path = sys.argv[1]
lang = sys.argv[2] if len(sys.argv) > 2 else "zh-Hans"
font = TTFont(path)
upm = font["head"].unitsPerEm
cmap = font.getBestCmap()
gs = font.getGlyphSet()
halt = has_feature(font, "halt")
print(f"# 字型:{path}")
print(f"# OpenType halt:{'有(feature 存在不保證涵蓋所有標點,須逐字確認)' if halt else '無 → 可用 margin 後備模式'}")
print(f"# unitsPerEm={upm}")
print("# 量測預設 cmap glyph,未做 shaping/未應用 locl;語言參數僅選 JSON 分類鍵。")
print("# 請按瀏覽器實際字形逐字微調建議表。")
print()
table = {}
for group, chars in PUNCT.items():
print(f"# {group}")
for ch in chars:
b, center = bias_of(font, upm, cmap, gs, ch)
cstr = f"{center:.2f}" if center is not None else " - "
print(f"# {ch} inkcenter={cstr} -> {b}")
table[ch] = b
print()
print("// 貼入 options.jiya.bias(依瀏覽器目視再微調):")
print(json.dumps({lang: table}, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
-20
View File
@@ -1,20 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": [ "ES2020", "DOM", "DOM.Iterable" ],
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"exactOptionalPropertyTypes": false,
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "dist",
"rootDir": "src",
"skipLibCheck": true,
"verbatimModuleSyntax": true,
"isolatedModules": true
},
"include": [ "src" ]
}