Compare commits
4 Commits
main
..
docs-legacy
| Author | SHA1 | Date | |
|---|---|---|---|
|
69f34413c9
|
|||
|
b5ac89b687
|
|||
|
bda7795c85
|
|||
|
3e5e45fb89
|
@@ -1,8 +0,0 @@
|
|||||||
.DS_Store
|
|
||||||
node_modules/
|
|
||||||
*.log
|
|
||||||
.scratch/
|
|
||||||
|
|
||||||
# 文檔(HTML 文檔+changelog)只存於 docs 分支;此處為 main 上之未追蹤本地鏡像,
|
|
||||||
# 唯讀,請勿在此編輯——所有文檔操作須於 docs 分支進行。
|
|
||||||
.docs/
|
|
||||||
-1151
File diff suppressed because it is too large
Load Diff
+4475
File diff suppressed because it is too large
Load Diff
@@ -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><wbr></code> 每 6 字元亂插斷詞機會,產生兩個觀感問題:(1)位置與音節邊界無關,斷在怪位;(2)<code><wbr></code> 為 zero-width,斷處不顯連字符,讀者無法辨認「上行末是被斷開的詞」。</p>
|
||||||
|
|
||||||
|
<p>Pass C 走另一條路:把 <code>[A-Za-z]{N,}</code>(預設 <code>N=6</code>)之長英文段包進 <code><span lang="en"></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><span lang></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><script></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><strong>是</strong> 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>
|
||||||
@@ -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.
|
|
||||||
@@ -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
@@ -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 項。
|
|
||||||
真實下游頁面仍須自行驗證其字型與版面。
|
|
||||||
@@ -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
@@ -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">甲𠀀</strong>󠄀A</p>
|
|
||||||
<p id="ivs-forward-no-orphan" data-juzhen-off="orphan"><strong>甲𠀀</strong>󠄀A</p>
|
|
||||||
<p id="ivs-forward-same-node" data-juzhen-off="orphan">𠀀󠄀A</p>
|
|
||||||
<p id="ivs-forward-same-node-prefix" data-juzhen-off="orphan">甲𠀀󠄀A</p>
|
|
||||||
<p id="ivs-reverse">A<strong id="author-reverse">𠀀</strong>󠄀乙</p>
|
|
||||||
<p id="ivs-line-break" data-juzhen-off="orphan" style="width:2.5em;text-align:justify"><strong>甲𠀀</strong>󠄀A</p>
|
|
||||||
<p id="ivs-nowrap" data-juzhen-off="orphan" style="width:1em;white-space:nowrap"><strong>甲𠀀</strong>󠄀A</p>
|
|
||||||
<p id="ivs-lineedge-open" lang="zh-Hant">「𠀀󠄀甲</p>
|
|
||||||
<p id="ivs-lineedge-close" lang="zh-Hant">甲𠀀󠄀」</p>
|
|
||||||
<p id="observer-case">观察初始内容</p>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<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">漢0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>𠀀</strong><em>󠄀</em> <code>x</code> <span>文</span></p>
|
|
||||||
<p id="jinze-ivs" data-jz-level="paragraph">前文<strong>𠀀</strong><em>󠄀</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">︀</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>
|
|
||||||
@@ -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
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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">邊界:段落實義字 < N → 不綁。單行段仍可能建立尾段綁定,但不需拉字換行、效果無害。極窄容器(<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>
|
|
||||||
Vendored
-6
@@ -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>;
|
|
||||||
Vendored
-37
@@ -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 {};
|
|
||||||
Vendored
-154
@@ -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;
|
|
||||||
}
|
|
||||||
Vendored
-41
@@ -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;
|
|
||||||
Vendored
-5
@@ -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;
|
|
||||||
Vendored
-14
@@ -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;
|
|
||||||
Vendored
-99
@@ -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;
|
|
||||||
Vendored
-27
@@ -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;
|
|
||||||
Vendored
-109
@@ -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 */
|
|
||||||
Vendored
-3890
File diff suppressed because it is too large
Load Diff
Vendored
-3864
File diff suppressed because it is too large
Load Diff
Vendored
-192
@@ -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;
|
|
||||||
Vendored
-2
@@ -1,2 +0,0 @@
|
|||||||
import type { StandalonePass } from "../types.js";
|
|
||||||
export declare const jinzePass: StandalonePass;
|
|
||||||
Vendored
-3
@@ -1,3 +0,0 @@
|
|||||||
import type { CharifyPass, StandalonePass } from "../types.js";
|
|
||||||
export declare const jiyaPass: CharifyPass;
|
|
||||||
export declare const jiyaAdjacencyPass: StandalonePass;
|
|
||||||
Vendored
-2
@@ -1,2 +0,0 @@
|
|||||||
import type { StandalonePass } from "../types.js";
|
|
||||||
export declare const levelMarkPass: StandalonePass;
|
|
||||||
Vendored
-3
@@ -1,3 +0,0 @@
|
|||||||
import type { StandalonePass } from "../types.js";
|
|
||||||
export declare const lineEdgePass: StandalonePass;
|
|
||||||
export declare const gapTrimPass: StandalonePass;
|
|
||||||
Vendored
-2
@@ -1,2 +0,0 @@
|
|||||||
import type { CharifyPass } from "../types.js";
|
|
||||||
export declare const longWordPass: CharifyPass;
|
|
||||||
Vendored
-2
@@ -1,2 +0,0 @@
|
|||||||
import type { StandalonePass } from "../types.js";
|
|
||||||
export declare const orphanPass: StandalonePass;
|
|
||||||
Vendored
-2
@@ -1,2 +0,0 @@
|
|||||||
import type { StandalonePass } from "../types.js";
|
|
||||||
export declare const spacingPass: StandalonePass;
|
|
||||||
+4693
File diff suppressed because it is too large
Load Diff
+356
@@ -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><span lang></code> 包裝」(給瀏覽器原生 <code>hyphens: auto</code> 用)。工廠函式設計,由消費端注入 pill 選擇器、樣式 / 段落 / skip 標籤集等配置。輸出 <code><span class="cjk-autospace" aria-hidden="true">(空白)</span></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 <script> / 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><strong>是</strong> 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><p>本資料夾<strong>不是</strong> Neovim 使用者目錄。</p></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><strong></code> 之 nextSibling 處)</li>
|
||||||
|
<li>位置 2:原文「m」與「 」之間,「 」被 strip → marker 插在「m」後(同一 text node 內,splitText 後插入)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>最終 DOM(簡化):<code>本資料夾<strong>不是</strong><span class="cjk-autospace"> </span>Neovim<span class="cjk-autospace"> </span>使用者目錄。</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>命令 <code>git</code> 更新</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>命令<span class="cjk-autospace"> </span><code>git</code><span class="cjk-autospace"> </span>更新</code></p>
|
||||||
|
|
||||||
|
<h2>Pass C — long English word wrap(預設啟用)</h2>
|
||||||
|
|
||||||
|
<p>解 CJK 兩端對齊正文中「行末長英文詞」之斷行問題:原靠 <code><wbr></code> 每 N 字元亂插之路徑會產生「斷點位置與音節無關 + 斷處不顯連字符」兩個觀感問題。Pass C 改採 <code><span lang="en"></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><span lang></code> → skip)</li>
|
||||||
|
<li>對通過篩選之 node:用 <code>[A-Za-z]{minLength,}</code> 收所有匹配,把每個匹配位置之子串裹進 <code><span lang="en"></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><span class="cjk-autospace" aria-hidden="true"> </span></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 '<script>\n'
|
||||||
|
cat "$LIB/cjk-autospace.js" # 工廠函式
|
||||||
|
printf '\n'
|
||||||
|
sed '$d' "$RUNTIME/runtime.js" # 應用程式 runtime
|
||||||
|
cat "$MODE_BEHAVIOR_JS"
|
||||||
|
printf '\n})();\n'
|
||||||
|
printf '</script>\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 = "<script>" .. lib .. "\n" .. entry .. "</script>"
|
||||||
|
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><text></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><p>是</p><p>Neovim</p></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>
|
||||||
Generated
-1412
File diff suppressed because it is too large
Load Diff
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 必須公開
|
|
||||||
同名選項或未來功能介面已定案。
|
|
||||||
@@ -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" ]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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,上方規則)。 */
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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
@@ -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;
|
|
||||||
@@ -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);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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,隨其解包清除。
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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); }
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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
@@ -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) + "×");
|
|
||||||
@@ -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>
|
|
||||||
@@ -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");
|
|
||||||
}
|
|
||||||
@@ -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(); }
|
|
||||||
});
|
|
||||||
@@ -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 倍。");
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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(); }
|
|
||||||
});
|
|
||||||
@@ -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 偵測不改預設輪廓分類");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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(); }
|
|
||||||
});
|
|
||||||
@@ -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(); }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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(); }
|
|
||||||
});
|
|
||||||
@@ -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(); }
|
|
||||||
});
|
|
||||||
@@ -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(); }
|
|
||||||
});
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
@@ -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(); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -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(); }
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -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(); }
|
|
||||||
});
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
@@ -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" ]
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user