Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
c4f0ee02d4
|
|||
|
fdad106980
|
|||
|
cda0903089
|
|||
|
738c917a5e
|
|||
|
5e74818153
|
|||
|
648605ac53
|
|||
|
f75382c9b5
|
|||
|
4b4505654f
|
|||
|
445716e34e
|
|||
|
bbda4cb3d5
|
|||
|
38a1cbaace
|
|||
|
43ac591d6a
|
|||
|
309bc358a8
|
|||
|
0f49ba8ecc
|
|||
|
e4d1dac315
|
|||
|
e9b956ca4e
|
|||
|
92c37cf63e
|
@@ -1,8 +1,3 @@
|
||||
.DS_Store
|
||||
node_modules/
|
||||
*.log
|
||||
.scratch/
|
||||
|
||||
# 文檔(HTML 文檔+changelog)只存於 docs 分支;此處為 main 上之未追蹤本地鏡像,
|
||||
# 唯讀,請勿在此編輯——所有文檔操作須於 docs 分支進行。
|
||||
.docs/
|
||||
|
||||
-1011
File diff suppressed because it is too large
Load Diff
@@ -1,542 +0,0 @@
|
||||
# 聚珍(Juzhen)
|
||||
|
||||
> 現代簡/繁中文網頁排版套件。Han.css 之現代化重寫,沿其功能版圖,並針對
|
||||
> 數種情況作調整:**複製乾淨**(中西間隙不入剪貼板)、**依 `lang` 全角/開明
|
||||
> 自動切換**、**`justify` 下句末標點只佔一份間距**、**跨瀏覽器一致**
|
||||
> (Chrome/Edge/Safari/WebKit/Firefox)。
|
||||
|
||||
倉庫名仍為 `cjk-autospace`;公開 API 為 `createJuzhen` / 全域 `Juzhen`;注入之
|
||||
自訂元素前綴 `jz-`。完整設計見 [`ARCHITECTURE.md`](./ARCHITECTURE.md)。
|
||||
|
||||
> 本版(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 正確性測試
|
||||
```
|
||||
|
||||
產物(`dist/`,已入庫):
|
||||
|
||||
| 檔 | 用途 |
|
||||
|---|---|
|
||||
| `dist/juzhen.mjs` | ESM,供打包器/TypeScript |
|
||||
| `dist/juzhen.iife.js` | IIFE,全域 `Juzhen`,原生 `<script>` 直跑 |
|
||||
| `dist/juzhen.css` | 合併樣式(**必須引入**,視覺尺寸全在此) |
|
||||
| `dist/index.d.ts` | TypeScript 型別宣告 |
|
||||
|
||||
---
|
||||
|
||||
## 用法
|
||||
|
||||
### 原生 `<script>`
|
||||
|
||||
```html
|
||||
<link rel="stylesheet" href="dist/juzhen.css">
|
||||
<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" } });
|
||||
jz.render(document.querySelector("main"));
|
||||
```
|
||||
|
||||
**JS 與 CSS 缺一不可**:JS 只注入 `jz-*` 結構,所有視覺尺寸(間隙寬度、擠壓量、
|
||||
`justify` 原子化、字體堆疊)都在 `juzhen.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)。
|
||||
- **`invalidate(root?)`**:清該子樹之內部快取。**`revert` 已自動失效其子樹**,故僅在「改了既有
|
||||
元素之分類相關屬性(`class`/`data-jz-*`/`lang` 等)**但不走 `revert`**」時才需顯式調用,以
|
||||
免下次 render 沿用陳舊分類。內容(子節點/文本)變化不改分類、無需 `invalidate`(但須
|
||||
`revert`+`render` 該塊方能重處理)。
|
||||
|
||||
> **元素變化之正確性**:實例內部對「元素分類(透明/pill/隔離/avoid)/功能級別/功能開關」
|
||||
> 快取以加速跨 render 復用(快取隨實例持久、非每次 render 重建)。**改變既有元素之上列屬性後,
|
||||
> 須經 `revert`/`rerender`/`invalidate`/`observe` 任一使該子樹快取失效**,否則沿用舊分類。
|
||||
> 新增/移除元素與純內容編輯無此顧慮(新元素重新計算、移除者自動回收)。
|
||||
|
||||
> **`revert` 之範圍**:`revert` 乾淨移除聚珍注入之全部結構(`jz-*`/`data-jz-*`),DOM 回到**邏輯
|
||||
> 等價**態;但**不還原** `spacing` 依契約規範化之贅餘作者空格(CJK↔西文吸收為 margin 間隙、
|
||||
> CJK↔CJK 剝除;見上表「中西間隙」)。即 `render→revert` 對含 CJK↔西文作者空格之源,還原後語義
|
||||
> 一致、無 `jz-*` 殘留,唯該空格已正規化(此差異穩定、不隨反覆 render/revert 累積)。若消費端需
|
||||
> 保留原始作者空格以供編輯,應自存原文、勿以 `revert` 當「還原到未處理源碼」。
|
||||
|
||||
---
|
||||
|
||||
## 切片 1 已實作功能
|
||||
|
||||
| 功能 | 說明 |
|
||||
|---|---|
|
||||
| **中西間隙** `spacing` | CJK↔西文邊界插入 copy-clean 之 `jz-hws`(`user-select:none`,剪貼板跳過)。「西文」涵蓋拉丁(含擴充 A/B,拼音聲調 `ā ǎ ē`)、希臘、西里爾、數字、貨幣(`€ ₩ ₿`)、數學運算符等;CJK 標點(`。,「」()……` 等)不誤判為西文、不生隙。含跨樣式行內邊界與 pill/隔離元素邊界(`code`/`kbd`/`.katex` 與 `mark`,兩端補隙口徑一致;pill 緊鄰標點時穿越 `jz-jinze` 仍補間隙;pill 跨行右隙、間隙落行末之自我應驗皆已修)。**pill 之邏輯鄰字為標點時(如 `漢字。<code>`)不誤加間隙**(標點非 pill 鄰字;`jz-char`/`jz-inner` 透明穿越,與 `collectRuns` 一致)。贅餘作者空格一律正規化:CJK↔西文吸收為受管間隙、CJK↔CJK(含跨 inline 標籤)剝除不留隙。 |
|
||||
| **長英文詞** `longWord` | `[A-Za-z]{6,}` 包入 `<span lang="en">`,配合 CSS `hyphens:auto` 原生斷詞(DOM 無連字符 → 複製不污染)。 |
|
||||
| **標點擠壓** `jiya` | 標點 charify 為 `jz-char`+`bd-*` class。三機制:①baseline 政策(全角式不擠/開明式擠句內點號+括號引號/半角式全擠);②行內連續擠壓(相鄰標點**雙側獨立**收——各標點若其有空白之側緊鄰另一標點即收半,故 `,「` 全角式與開明式皆 1em、`。」` 全角式 1.5em/開明式 1em;**唯句末點號接開類為句界**——`。「`/`?「` 保留句末點號右空白、僅收開類,全角式與開明式皆 1.5em;透明穿越 `strong`/`em` 等行內邊界);③行端擠壓(`lineedge` layout pass)。半形預設以字型 `halt` 渲染(按字形正確定位,居中字形如 `!?`/繁體 `。,` 不重疊;不依賴 `text-spacing-trim`)。**字型缺 `halt` 時可切 `margin` 後備模式**(見下節)。 |
|
||||
| **禁則** `jinze` | 行首行末避頭尾:閉類/點號綁前一字(不置行首)、開類綁後一字(不置行末),只隔離邊界字故長引文中段仍可斷。亦為 `justify` 綁定載體。邊界字落於**透明**行內樣式元素(`strong`/`em` 等)內時**克隆切分**只綁邊界詞元(避免整個元素入 `nowrap` 致行末整體掉行、上行留巨大空隙),切出之克隆標 `data-jz-kind="splitoff"`、`revert` 時自動併回原元素;不可切元素(`a`/`abbr`/`ruby`/帶 `id`,以及 **pill/隔離/avoid**——見「行內元素三分類」)退化為**整元素綁定**——pill(如行內公式 `.katex`)即作為原子鄰字單位整體綁入 `jz-jinze`,絕不下潛肢解其內部。 |
|
||||
| **垂懸字避免** `orphan` | 段末行 CJK 實義字 ≥ N(預設 2;標點/符號不計)。方案 C(斷行層、無測量):把段末「第 N 個實義字起至段末」包入 `jz-orphan`(`white-space:nowrap`),整體不可斷 → 末行恒含之 ⇒ 末行實義字 ≥ N,任意寬度/字型成立。單行段無害。邊界字落於行內元素內時同樣**克隆切分**(只綁末 N 字、避免整個長元素入 `nowrap` 溢出容器;機制同禁則)。**預設開**(`orphan:false` 關,`{chars:n}` 調)。段落級功能。 |
|
||||
| ~~**標點懸掛** `hanging`~~ | **已移除**(§6.5.6):純負 margin 機制為機制級死路(寬度守恆矛盾,CSS 接觸不到斷行器),原生 `hanging-punctuation` 為唯一正解但僅 Safari 支援、且與本庫 inline-block 原子互斥。`hanging` 選項仍被接受(API 相容)但**無任何效果**;相關程式碼/CSS 已全數刪除,原生路徑屬遠期「原生管線」新功能。 |
|
||||
| **功能分級** `level` | 功能分文本級(中西間隙、標點擠壓——任意文本片段適用)與段落級(禁則、垂懸字、懸掛、長詞——需行/段布局)。塊級元素預設全功能;`data-jz-level="text"` 子樹或選項 `level:{text:"選擇器"}`(如標題/按鈕)僅跑文本級。 |
|
||||
| **justify 單份間距** | 每個 `jz-char` 為 `inline-block` 原子項,句末標點只貢獻一份對齊伸縮量。 |
|
||||
|
||||
後續(架構已預留,不返工):標點修正(簡↔繁)、著重號、注音/Ruby。
|
||||
|
||||
---
|
||||
|
||||
## 選項
|
||||
|
||||
```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 關
|
||||
// hanging:已移除、無效果(見「標點懸掛」一節)
|
||||
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` | `Record<lang, style>` | `{}` | 逐 lang 政策 |
|
||||
| `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"}`) | 長英文詞斷字 |
|
||||
| `jiya` | bool|`{halfWidth,bias}` | `true` | 標點擠壓(*詳見「標點半形」*) |
|
||||
| `jinze` | bool | `true` | 禁則避頭尾 |
|
||||
| `orphan` | bool|`{chars}` | `true`(`{chars: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` | 標籤名[] | 27 個標準行內標籤 | **透明穿越白名單**(未列之行內標籤=邊界) |
|
||||
| `blockTags` | 標籤名[] | 36 個塊標籤 | 塊級邊界集 |
|
||||
| `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` | 逐子樹半形渲染模式 |
|
||||
|
||||
**預留、當前無效果**:`hanging`(已移除,見下);`biaodian`/`emphasis`/`ruby`(架構預留切片
|
||||
#7/#8)。注意頂層 `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` > 內建逐語言預設。屬性值非法(拼錯)則忽略、回退
|
||||
lang。半角式渲染沿用既有 `halt`/`margin` 機制(見下節),本功能只做「按元素選樣式」、無新增
|
||||
渲染邏輯。
|
||||
|
||||
### 行內元素三分類:透明/隔離/整體
|
||||
|
||||
行內元素對排版有且僅有三種口徑。**邏輯文本流之遍歷原語**(中西間隙、擠壓相鄰、禁則綁定、
|
||||
垂懸字)**共用同一判定** `isTransparent`;未在 `styleInlines` 之**未知行內標籤**(自訂元素/
|
||||
web component 等)採**嚴格白名單**、一律當**邊界**(語義同隔離、內部自成脈絡),透明須顯式
|
||||
登記 `styleInlines`。(另兩層各有其正當之邊界、刻意不併入:結構克隆切分之安全白名單
|
||||
`canSplit`、行端物理版面測量 `lineedge`,詳見 `ARCHITECTURE.md`。)
|
||||
|
||||
| 分類 | 選擇器 | 邊界 | 內部 | 典型 |
|
||||
|---|---|---|---|---|
|
||||
| **透明** | `styleInlines`(預設 27 個常見樣式標籤;**未列之行內標籤=邊界**) | 穿越——間隙/相鄰/run 皆延續 | 照常處理;禁則可**克隆切分**(最小綁定) | `strong`/`em`/`b`/`i`/`a`/`span`(自訂元素須自行登記) |
|
||||
| **隔離** | `isolateSelector`(預設 `"mark"`) | 斷 run/斷相鄰/不可切分;**兩端**與 CJK/西文相鄰時補 `jz-hws` 間隙 | **照常處理**(自成脈絡:內部標點照擠、內部中西照補隙) | 帶背景色之 `<mark>`、badge 類盒樣式行內 |
|
||||
| **整體** | `pillSelector`(預設 `"code, kbd"`) | 同隔離(邊界斷、兩端補隙) | **完全不可見**——不 charify、不插間隙、不切分、不下潛 | `code`/`kbd`、行內公式 `.katex`、引用標 `.cite-ref` |
|
||||
|
||||
要點:
|
||||
|
||||
- **分類優先序(單一判定 `finder.category`)**:一元素恰屬其一,多選擇器同命中時依序短路定案——
|
||||
`avoid > pill > isolate > block > transparent > opaque`(`opaque`=未登記之未知行內標籤,
|
||||
當隔離式邊界)。故帶 `data-jz-skip` 之 pill/isolate 判為 avoid;同命中 pill 與 isolate 判為 pill。
|
||||
邏輯文本流各原語(`collectRuns`/`jiyaAdjacency`/`adjacentLogicalChar`)皆據此**同一分類**分派。
|
||||
- **判定以元素為準、非標籤名**:KaTeX 全樹皆 `<span>`,但 `.katex` 命中 `pillSelector`
|
||||
即為整體邊界——舊版按標籤名判透明曾整棵下潛、把公式內側末字連同殼層克隆切出(肢解)。
|
||||
- **pill 行為覆蓋 isolate**:兩者邊界口徑完全相同(兩端補隙、斷 run/斷相鄰、不可切分,
|
||||
同一段邊界處理代碼),差異僅在內部;同一元素同時命中兩選擇器時 **pill 勝出**(內部不可見)。
|
||||
- **禁則之整體綁定**:pill 緊鄰禁則標點時(`…$公式$,`),整個 pill 作為原子鄰字單位
|
||||
綁入 `jz-jinze`(公式本不可斷,無額外代價);隔離元素同樣整綁(切分背景盒有可見接縫)。
|
||||
- **avoid(排除)之七種來源**,任一命中即邊界、內外皆不處理:`isSkipped` 謂詞/`skipTags`
|
||||
標籤集/SVG/`contentEditable`/`data-jz-skip` 屬性/`skipAttribute`(自訂屬性名)/
|
||||
`scope.avoid` 選擇器。**以自身判定**(元素自身命中即是,非僅查祖先);一次性檢查用
|
||||
`isAvoided`(自身或祖先之組合判定)。**`skipTags` 預設不含 `CODE`/`KBD`**——二者已由
|
||||
`pillSelector` 以 pill 涵蓋(三分類互斥;若把 `code` 移出 `pillSelector`,須自行加入
|
||||
`skipTags`/`scope.avoid`,否則其內部將被當普通文本處理)。
|
||||
- **行內公式/夾注引用宜列為 pill**:`pillSelector: "code, kbd, .katex, .cite-ref"`——
|
||||
獨立行內單位以 pill 邊界邏輯統一處理(兩側補隙、吸收作者空格、整體綁定)。
|
||||
|
||||
### 分頁器(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 屬性,就近繼承)
|
||||
|
||||
```html
|
||||
<p data-juzhen="spacing jiya">…</p> <!-- 白名單:僅此二功能 -->
|
||||
<p data-juzhen-off="jiya">…</p> <!-- 黑名單:停用 jiya,其餘繼承 -->
|
||||
<div lang="zh-Hans">…</div> <!-- lang 驅動全角/開明 -->
|
||||
```
|
||||
|
||||
### 功能分級:文本級 vs 段落級(`level`/`data-jz-level`)
|
||||
|
||||
功能分兩級:
|
||||
|
||||
- **文本級**(中西間隙 `spacing`、標點擠壓 `jiya`):僅依字符鄰接,**任意文本片段**(單行
|
||||
標題、按鈕、單一行內元素)皆適用且想要。
|
||||
- **段落級**(禁則 `jinze`、垂懸字 `orphan`、標點懸掛 `hanging`、長詞 `longWord`):依賴
|
||||
行/段布局(換行、行端、段末),對單行/非段落文本無意義甚至有害。
|
||||
|
||||
塊級元素(`p`/`div`/`li`/`h1`…)**預設段落級(全功能)**。把標題/按鈕/UI 文字標為
|
||||
**文本級**(要間隙與標點半形、不要懸掛/避孤字/避頭尾):
|
||||
|
||||
```html
|
||||
<h1 data-jz-level="text">標題:只跑中西間隙與標點擠壓</h1>
|
||||
<p data-jz-level="paragraph">…</p> <!-- 顯式全功能(巢狀於 text 子樹內可覆寫,最近祖先勝) -->
|
||||
```
|
||||
|
||||
```ts
|
||||
createJuzhen({ level: { text: "h1,h2,h3,button,.ui" } }); // 選擇器一鍵標記
|
||||
```
|
||||
|
||||
### 標點懸掛(`hanging`)— 已移除
|
||||
|
||||
原計劃之標點懸掛(行端標點凸出版心)經實測為**機制級死路、已移除**(負 margin 程式碼與
|
||||
CSS 皆刪)。`hanging` 選項仍被接受(API 相容)但**無任何效果**——`hanging:true`/
|
||||
`data-juzhen`/`blocks` 任一要求皆被靜默忽略,行端僅做半形擠壓。
|
||||
|
||||
**為何機制級不可行**(純 CSS 之寬度守恆矛盾):行端標點盒若保持非零寬,則「墨色出去多少、
|
||||
版心內就空出多少」(行末實義字無法齊版心右緣);若收為零寬,則釋放一整字寬、斷行器把下一字
|
||||
拉上本行與標點重疊(且 `justifyAtoms` 預設之 inline-block 下負 margin 被 justify 吸收)。
|
||||
**墨色出去多少版心內就空多少;想不空就得讓斷行器重排這塊寬度,而 CSS hack 接觸不到斷行器**。
|
||||
補償墊片、兩階段試錯都改變不了此恒等式。
|
||||
|
||||
唯一正解為原生 CSS `hanging-punctuation`,惟 **Chrome/Firefox 至今不支援**(僅 Safari),且
|
||||
本庫之 `jz-char` 為 inline-block 原子、非文本,原生懸掛不作用——未來若做,須是繞開 `jz-char`
|
||||
的「原生管線」新功能(`text-spacing-trim` + `hanging-punctuation`,按能力門控),與已移除之
|
||||
程式碼無共享。詳見 `ARCHITECTURE.md §6.5.6`。
|
||||
|
||||
---
|
||||
|
||||
## 標點半形:`halt` 預設與 `margin` 後備
|
||||
|
||||
開明/半角式把標點收成「半形」,有兩種渲染機制:
|
||||
|
||||
| 機制 | 設定 | 原理 | 適用 |
|
||||
|---|---|---|---|
|
||||
| **`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` 屬性所在子樹**決定(CSS 後代選擇器),故粒度極靈活:
|
||||
|
||||
- **全域**:`jiya: { halfWidth: "margin" }` → `render()` 於處理 root 自動掛屬性。
|
||||
- **逐區塊/逐語言**:在 HTML 任一祖先手寫 `data-jz-halfwidth="margin"`(或
|
||||
`"halt"`),即令該子樹用對應機制——無需另建實例。常見如「全頁 `halt`,某用
|
||||
無 `halt` 字型之區塊掛 `margin`」,或反之(`margin` 頁中嵌一塊 `halt` 島)。
|
||||
|
||||
```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`** → 直接用預設(`jiya: true`,halt 模式),無需配字,到此為止。
|
||||
- **無 `halt`**(多數系統字型、GBK 老字型)→ 用 `margin` 後備,續下。
|
||||
2. **取偏側建議**:同一工具同時量各標點 glyph 之墨色中心、印出建議偏側表
|
||||
(`left|center|right|full`)與可貼入 `jiya.bias` 之 JSON。
|
||||
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`。
|
||||
3. **作用域類(用 `scope.include` 時)**:若設 `scope.include: ".juzhen"`,須給內容
|
||||
容器加對應 class(如 `class="juzhen"`);`juzhen.css` 之 normalize 亦收斂於此域,
|
||||
不外溢。
|
||||
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`
|
||||
關閉 Blink 原生 `text-spacing-trim`/`text-autospace`(避免與託管擠壓重複作用),
|
||||
並令注入間隙 `user-select: none`(複製乾淨)。
|
||||
8. **段落屬性照常設、原子透明**:聚珍把標點拆成 inline-block 原子,但已確保這些原子對
|
||||
消費端段落樣式透明——可在段落正常設 `text-indent`(首行縮排)、`hanging-punctuation`、
|
||||
`text-decoration`(下劃線)等;庫內已歸零會被原子重複套用之繼承屬性、並令原子承接段落
|
||||
裝飾,故首行縮排正常、下劃線連續不斷,無需任何額外配置(機制見 ARCHITECTURE「原子透明
|
||||
原則」)。
|
||||
|
||||
---
|
||||
|
||||
## 字型與 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`)
|
||||
|
||||
每個字符的分類(中文/西文、全寬標點各子類)抽象為**規則集**:內置默認規則集即聚珍
|
||||
的分類表,消費端可經 `options.charClass` **擴充**,以覆寫 `spacing`/`jiya`/`jinze`
|
||||
等規則對特定字符之判定。語義為**追加**——把當前未分類之字符、或新符號歸入對應類。
|
||||
|
||||
```js
|
||||
createJuzhen({
|
||||
charClass: {
|
||||
cjk: "〇", // 追加為中文(CJK):與西文邊界補中西間隙、禁則/justify 視為單字
|
||||
western: "★☆", // 追加為西文(半寬標點亦歸此):與 CJK 邊界補間隙
|
||||
biaodian: { // 追加為全寬標點,按子類(jiya charify/擠壓、禁則避頭尾)
|
||||
stop: ".", // 句末(。!?類)
|
||||
pause: "", // 句內(,、;:類)
|
||||
open: "", close: "", // 開/閉括號引號
|
||||
middle: "", liga: "", // 間隔號/省略破折
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**典型用例**:英文全角句號 `.`(U+FF0E,有別於中文句號 `。` U+3002)預設**未分類**
|
||||
——不被 charify、不挤壓、與 CJK 間不補隙。若內容用 `.` 作句末標點,以
|
||||
`charClass: { biaodian: { stop: "." } }` 歸為全寬句末標點,即比照 `。` 處理(開明式挤半形、
|
||||
全角式佔全形、行首避頭尾)。
|
||||
|
||||
要點:
|
||||
|
||||
- **向下兼容**:不傳 `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/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),成品自含、不依賴其執行期程式碼。
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
@@ -1,84 +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
|
||||
(含 normalize 之 text-spacing-trim:space-all)。否則 A 之原生標點被 space-all
|
||||
撐開、顯得比 B 鬆,造成「B 似乎有效」之假象(實則 B 全形未擠、與真原文等寬)。 */
|
||||
/* 字型設於容器即可——聚珍不管字型(無 _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">
|
||||
此字型僅含 <code>vert</code>、<b>無 <code>halt</code></b>,故 halt 模式之開明式擠壓「完全無效」(標點仍佔全形)。
|
||||
margin 後備模式以負 margin 依字形偏側收半形——本字型簡體點號(,、。;:!?)墨色皆偏左下,故收右側空白。
|
||||
白底行盒寬度即直接反映效果: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;整句
|
||||
// 行寬 halt 24.0em → margin 19.5em(10 標點各收 0.5em),可於瀏覽器 devtools 量。
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
-181
@@ -1,181 +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 statusEl = document.getElementById("status");
|
||||
function render()
|
||||
{
|
||||
jz.revert(document.body); // 先還原(重套政策時清除上次產物;首次為 no-op)
|
||||
jz = makeJz();
|
||||
jz.render(document.body);
|
||||
statusEl.textContent = "已 render(" + document.querySelectorAll("jz-hws").length + " 间隙、" + document.querySelectorAll("jz-char").length + " 标点原子;繁体政策:"
|
||||
+ (forceKaiming ? "开明式" : "全角式") + ")";
|
||||
}
|
||||
function revert()
|
||||
{
|
||||
jz.revert(document.body);
|
||||
statusEl.textContent = "已 revert(DOM 还原)";
|
||||
}
|
||||
document.getElementById("force-kaiming").addEventListener("change", function (e)
|
||||
{
|
||||
forceKaiming = e.target.checked;
|
||||
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);
|
||||
});
|
||||
document.getElementById("wide").addEventListener("change", function (e)
|
||||
{
|
||||
document.querySelectorAll(".narrow").forEach(function (el)
|
||||
{
|
||||
el.style.maxWidth = e.target.checked ? "none" : "";
|
||||
});
|
||||
});
|
||||
render();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,98 +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>
|
||||
// 全頁一次渲染(quanjiao 全角式,與下游回饋之政策一致);各框由其 lang 屬性
|
||||
// 驅動簡/繁字形。(標點懸掛已移除,見 ARCHITECTURE §6.5.6。)
|
||||
Juzhen.createJuzhen({ scope: { include: ".juzhen" }, lang: { style: "quanjiao" } })
|
||||
.render(document.body);
|
||||
|
||||
document.getElementById("t-justify").addEventListener("change", function (e)
|
||||
{
|
||||
document.body.classList.toggle("justify", e.target.checked);
|
||||
});
|
||||
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
-25
@@ -1,25 +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 = "marker" | "wrap";
|
||||
export interface CreateOptions {
|
||||
/** 追加之 class(空白分隔或陣列)。 */
|
||||
classes?: string | string[];
|
||||
/** 文本內容。 */
|
||||
text?: string;
|
||||
}
|
||||
/** 造一個帶 pass 標記之 jz-* 元素。 */
|
||||
export declare function createJz(tag: JzTag, pass: string, kind: JzKind, opts?: CreateOptions): HTMLElement;
|
||||
/**
|
||||
* 還原某 pass 之全部產物(I6)。以 data-jz 定址,逆文件序處理(先內層後
|
||||
* 外層),marker 移除、wrap 解包,最後 normalize 受影響父節點合併文本。
|
||||
*
|
||||
* splitoff(克隆切分之邊界詞元,見 core/split.ts)須在 wrap 全部解包**之後**才併回:
|
||||
* 否則其前兄弟可能仍是 wrap 殼(如 jz-jinze)而非原元素。故主迴圈跳過 splitoff,於
|
||||
* 解包後第二階段以文件序逐一併回前兄弟(同標籤元素),還原為單一元素(I6 一致)。
|
||||
*/
|
||||
export declare function revertPass(pass: string, root: ParentNode): 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
-140
@@ -1,140 +0,0 @@
|
||||
import type { FeatureLevel, ResolvedOptions } from "../types.js";
|
||||
/** 邏輯 run 中之單一字元來源位置。 */
|
||||
export interface CharPos {
|
||||
textNode: Text;
|
||||
offset: number;
|
||||
}
|
||||
/** 一段邏輯文本:剝離樣式後之連續字元,及其逐字回映射。 */
|
||||
export interface LogicalRun {
|
||||
text: string;
|
||||
map: CharPos[];
|
||||
}
|
||||
/** pill 鄰接之邏輯鄰字。 */
|
||||
export interface AdjacentChar {
|
||||
textNode: Text;
|
||||
offset: number;
|
||||
ch: string;
|
||||
}
|
||||
/** 行內元素對排版之口徑分類(§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
|
||||
* 隨 makeCtx 每次 render 重建 → 快取僅存活單次 render,無跨 render 失效問題(持久化屬 C1,
|
||||
* 另議)。
|
||||
*/
|
||||
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)。 */
|
||||
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。回傳 text.length >= 2 之 run。
|
||||
*/
|
||||
collectRuns(block: Element): LogicalRun[];
|
||||
/**
|
||||
* pill 之邏輯鄰字(架構 §4.1):經樣式鏈遞迴,遇 pill/block/avoid/間隙
|
||||
* marker 即止。direction = -1 取前鄰字,+1 取後鄰字。
|
||||
*/
|
||||
adjacentLogicalChar(el: Element, direction: -1 | 1): 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
-40
@@ -1,40 +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 屬性之祖先,回傳小寫值;無則回傳 fallback。
|
||||
*/
|
||||
export declare function effectiveLang(node: Node, fallback: string): string;
|
||||
/**
|
||||
* 將 BCP-47 語言標籤歸類。前綴以子標籤邊界比對('zh-tw' 命中 zh-Hant,
|
||||
* 'zhx' 不命中 zh)。香港/台灣繁體歸 zh-Hant;含 Hant/Hans 子標籤者優先。
|
||||
*/
|
||||
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): Element;
|
||||
/**
|
||||
* 把 el 之**指定切點**(精確字位)起至末尾剝入克隆並回傳(供 orphan:自實義字綁至塊末)。
|
||||
* 不可切或切點在最起點 → 回 null(呼叫端綁整個直接子)。
|
||||
*/
|
||||
export declare function splitElementAt(el: Element, textNode: Text, offset: number, pass: string, finder: Finder): Element | null;
|
||||
Vendored
-82
@@ -1,82 +0,0 @@
|
||||
import type { LangClass } from "./locale.js";
|
||||
/** CJK 表意文字/假名/注音等範圍(沿用並擴充 v1 之 PANGU_CJK)。 */
|
||||
export declare const CJK: 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)。 */
|
||||
cjk?: string;
|
||||
/** 追加為西文(半寬標點亦歸此,按西文處理間隙);併入 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
-104
@@ -1,104 +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.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: "locl" 1, "halt" 1;
|
||||
-webkit-font-feature-settings: "locl" 1, "halt" 1;
|
||||
}
|
||||
[data-jz-halfwidth=margin] jz-char.jz-half jz-inner,
|
||||
[data-jz-halfwidth=margin] jz-char.jz-half-le jz-inner {
|
||||
font-feature-settings: normal;
|
||||
-webkit-font-feature-settings: normal;
|
||||
}
|
||||
[data-jz-halfwidth=margin] jz-char.jz-half.jz-ink-left,
|
||||
[data-jz-halfwidth=margin] jz-char.jz-half.jz-ink-center,
|
||||
[data-jz-halfwidth=margin] jz-char.jz-half.jz-ink-right,
|
||||
[data-jz-halfwidth=margin] jz-char.jz-half-le.jz-ink-left,
|
||||
[data-jz-halfwidth=margin] jz-char.jz-half-le.jz-ink-center,
|
||||
[data-jz-halfwidth=margin] jz-char.jz-half-le.jz-ink-right {
|
||||
width: 0.5em;
|
||||
overflow: visible;
|
||||
}
|
||||
[data-jz-halfwidth=margin] jz-char.jz-half.jz-ink-left jz-inner,
|
||||
[data-jz-halfwidth=margin] jz-char.jz-half.jz-ink-center jz-inner,
|
||||
[data-jz-halfwidth=margin] jz-char.jz-half.jz-ink-right jz-inner,
|
||||
[data-jz-halfwidth=margin] jz-char.jz-half-le.jz-ink-left jz-inner,
|
||||
[data-jz-halfwidth=margin] jz-char.jz-half-le.jz-ink-center jz-inner,
|
||||
[data-jz-halfwidth=margin] jz-char.jz-half-le.jz-ink-right jz-inner {
|
||||
display: inline-block;
|
||||
}
|
||||
[data-jz-halfwidth=margin] jz-char.jz-ink-center jz-inner {
|
||||
margin-left: -0.25em;
|
||||
}
|
||||
[data-jz-halfwidth=margin] jz-char.jz-ink-right jz-inner {
|
||||
margin-left: -0.5em;
|
||||
}
|
||||
[data-jz-halfwidth=margin] jz-char jz-inner {
|
||||
text-decoration: inherit;
|
||||
}
|
||||
[data-jz-halfwidth=halt] jz-char.jz-half jz-inner,
|
||||
[data-jz-halfwidth=halt] jz-char.jz-half-le jz-inner {
|
||||
font-feature-settings: "locl" 1, "halt" 1;
|
||||
-webkit-font-feature-settings: "locl" 1, "halt" 1;
|
||||
margin-left: 0;
|
||||
}
|
||||
[data-jz-halfwidth=halt] jz-char.jz-half,
|
||||
[data-jz-halfwidth=halt] jz-char.jz-half-le {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* 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
-2442
File diff suppressed because it is too large
Load Diff
Vendored
-2416
File diff suppressed because it is too large
Load Diff
Vendored
-183
@@ -1,183 +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 {
|
||||
minLength?: number;
|
||||
lang?: string;
|
||||
}
|
||||
/** 垂懸字避免設定(§6.5.1)。布林 true 等同 { chars: 2 }。 */
|
||||
export interface OrphanOptions {
|
||||
/** 段末行至少保留之 CJK 實義字數(標點/符號不計;預設 2)。 */
|
||||
chars?: number;
|
||||
}
|
||||
/** 標點擠壓設定(架構 §6.3)。布林 true 等同 { halfWidth: "halt" }。 */
|
||||
export interface JiyaOptions {
|
||||
/** 半形渲染機制:
|
||||
* - "halt"(預設)— 用字型 OpenType `halt`,由字型按字形正確定位,需字型支援;
|
||||
* - "margin" — 負 margin 後備模式,供缺 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"` 標記(屬性優先、最近祖先勝)。
|
||||
* 預設:塊級元素(blockTags)→ 段落級(全功能)。 */
|
||||
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 為唯一正解但僅 Safari 支援、
|
||||
* 且與本庫 inline-block 原子互斥。此選項仍被接受以保 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;
|
||||
}
|
||||
/**
|
||||
* 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";
|
||||
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
-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;
|
||||
+7086
File diff suppressed because one or more lines are too long
@@ -0,0 +1,463 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-Hant">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>變更紀錄(Changelog)</title>
|
||||
<meta name="generated-at" content="2026-07-11">
|
||||
<meta name="source" content="cjk-autospace · 變更紀錄">
|
||||
<!-- RUNTIME_CSS -->
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="page">
|
||||
<div class="meta">
|
||||
<h1>變更紀錄(Changelog)</h1>
|
||||
<p class="subtitle">聚珍(Juzhen)/cjk-autospace 之版本變更紀錄。</p>
|
||||
<p class="timestamp"><time>2026-07-11</time> · <span>cjk-autospace · 變更紀錄</span></p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<!-- ACTIONS -->
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- BODY -->
|
||||
<p class="lede">聚珍(Juzhen)/cjk-autospace 變更紀錄。<a href="./index.html">← 回文檔</a></p>
|
||||
|
||||
<h2>效能第二/三層:掃描/佈局提速 + 增量恢復(Tier3) <span class="badge accent">2026-07-12</span></h2>
|
||||
<p>承第一層(S2+S3)。本批含掃描/佈局提速與**增量恢復**(動態頁面局部變化免全樹重掃),全程獨立複驗+4 份真人撰寫真實文檔驗收。單元測試 130→140 全綠。</p>
|
||||
<h3>掃描 / 佈局提速</h3>
|
||||
<ul>
|
||||
<li><strong>祖先游走記憶化(中間發現+S4)</strong>:<code>insidePill</code>/<code>isAvoided</code> 之「逐文本節點沿祖先鏈」以迭代回填記憶化(每元素恰算一次、兄弟共用),O(文本×depth)→O(元素)。掃描層 <code>el.matches()</code> 累計約降五成;深嵌套 render 進程隔離墻鐘 pre-Tier1 707ms→254ms(<strong>2.8×</strong>)。</li>
|
||||
<li><strong>S1 gapTrim 對齊門</strong>:尾隨 margin 僅在右緣 flush 之對齊可見,唯 <code>left</code>/<code>start</code>(ltr) 之 ragged 右緣可整批跳過,省其 O(gaps) 次同步重排。非兩端對齊內容 gapTrim 由數千 ms 降至數十 ms;justify/center/right/RTL 保留完整算法、行為不變(真瀏覽器差分:trim 集指紋逐位相同)。</li>
|
||||
</ul>
|
||||
<h3>增量恢復(第三層,向下兼容 API)</h3>
|
||||
<ul>
|
||||
<li><strong>C1 持久化 Finder</strong>:跨 render 復用元素鍵快取。正確性靠失效——新增 <code>invalidate(el)</code> 清整棵子樹快取(祖先屬性變則後代快取變脏),<code>revert</code> 自動失效其子樹。</li>
|
||||
<li><strong>C3 <code>rerender(root)</code></strong>=revert+render(局部恢復,實測編輯 400 段中一段 <strong>766×</strong>、matches 1441×)。</li>
|
||||
<li><strong>C2 <code>observe</code>/<code>disconnect</code></strong>:MutationObserver 自動增量——內容/分類屬性變化重處理最近塊、新增之塊直接 render;render/revert 期間暫停觀察,自身注入絕不自觸發。</li>
|
||||
<li><strong>append 修復</strong>(獨立差分複驗 IV-3 發現、單測漏掉):向葉塊(帶 <code>jz-orphan</code>)追加子塊使其轉非葉,須撤容器陳舊 orphan。</li>
|
||||
</ul>
|
||||
<h3>獨立複驗 + 真實文檔驗收</h3>
|
||||
<ul>
|
||||
<li>5 種獨立方法(差分/往返/增量≡全量/進程隔離測速/真瀏覽器 S1),皆與既有單測/基準不同源——<strong>抓到並修復 append 真 bug</strong>。</li>
|
||||
<li>4 份真人撰寫真實文檔(繁體技術/簡體評論/文學對話/混排):非空白字元多重集守恆、無字元改/增/漏/亂序,<code>revert</code> 淨移除結構、真瀏覽器整條管線零報錯。</li>
|
||||
</ul>
|
||||
<h3>文檔(I1/I6 收緊)</h3>
|
||||
<ul>
|
||||
<li>驗收浮現:<code>render→revert</code> 不還原 <code>spacing</code> 依契約規範化之贅餘作者空格(<code>在 Neovim 中</code>→<code>在Neovim中</code>)——固有、非本批引入。<strong>I1</strong> 明示此為刻意常態例外(語義不變、確刪空格且不還原);<strong>I6</strong> 明示「邏輯等價≠逐字節」之唯一差異即被規範化之空格。README 補「<code>revert</code> 之範圍」注。</li>
|
||||
</ul>
|
||||
|
||||
<h2>效能第一層:掃描提速(category 記憶化 + orphan 葉塊預計算) <span class="badge accent">2026-07-11</span></h2>
|
||||
<p>下游效能訴求(掃描疑 O(n²))之第一層——無狀態、保行為、無公開 API 變更之確定收益。單元測試 129→130 全綠、輸出不變。</p>
|
||||
<h3>優化</h3>
|
||||
<ul>
|
||||
<li><strong>S3 category 記憶化</strong>:<code>finder.category</code> 以 <code>WeakMap<Element, InlineCategory></code> 記憶化,砍除 <code>collectRuns</code>/<code>jiyaAdjacency</code>/<code>jinze</code>/<code>split</code> 跨 pass 對同一元素之重複 <code>el.matches()</code>(pill/isolate/avoid 選擇器)。實測 pill/isolate 密集頁面 <code>el.matches()</code> 8204→5842(約降三成,命中率 55%)。安全:單次 render 內一元素之分類恆定、且 Finder 隨 <code>makeCtx</code> 每次 render 重建 → 快取僅存活單次 render、無跨 render 失效(持久化屬另議)。</li>
|
||||
<li><strong>S2 orphan 葉塊預計算</strong>:以一趟上攀預計算「非葉塊」Set 取代逐塊 <code>block.querySelector(sel)</code> 掃整棵子樹之 O(Σ塊子樹)(深塊嵌套下 O(n²));某塊為另一塊之最近塊祖先 ⟺ 含塊級後代,與原 querySelector 同口徑、行為等價。</li>
|
||||
</ul>
|
||||
<h3>亲驗與待議</h3>
|
||||
<ul>
|
||||
<li>亲驗:暫禁 category 快取則 <code>computeCategory</code> 由 1041 增至 2322、<code>el.matches()</code> 由 5842 增至 8204——證快取真減負而非空操作(初次插桩誤打 <code>HTMLBodyElement.prototype</code> 而漏計,改補 <code>Element.prototype.matches</code> 後為準)。</li>
|
||||
<li>新發現(下一機會):剩餘 <code>el.matches()</code> 之最大來源為 <code>insidePill</code> 之「逐文本節點沿祖先鏈 <code>pillMatches</code>」(<code>eachTextNode</code> 每文本節點一趟、未記憶化)。</li>
|
||||
</ul>
|
||||
|
||||
<h2>splitTreeAfter 攀升對稱守衛(opaque 中間祖先不再被克隆肢解) <span class="badge accent">2026-07-11</span></h2>
|
||||
<p>下游 <code>.selection-bar-message</code> 病例:垂懸字(orphan)之段末實義字落於 opaque <code><button></code>(巢於可切 <code><span></code>)時,<code>splitElementAt</code> 僅驗頂層 span 可切;<code>splitTreeAfter</code> 逐層克隆「文本節點→top」祖先鏈時,中間 opaque button 從未複查即被克隆肢解(複製出 <code>splitoff</code> button、破壞互動元素)。<code>firstText</code>/<code>lastText</code> 守下潛(遇非可切回 <code>null</code>),攀升卻無對稱守衛——此即真正病根,下游 per-instance 迴避僅治標。</p>
|
||||
<h3>修復</h3>
|
||||
<ul>
|
||||
<li><strong>攀升守衛</strong>:<code>splitTreeAfter</code> 切分前先驗「<code>textNode.parentNode</code>…top(含 top)」整條祖先鏈皆 <code>canSplit</code>,任一層 opaque/pill/isolate/帶 id 即放棄、回退整綁(退化但安全,與下潛守衛對稱)。純新增守衛、無新克隆邏輯;jinze 之 <code>isolateBoundaryToken</code> 因下潛已守,行為不變。附帶亦杜絕中間祖先帶 <code>id</code> 之克隆重複 id。</li>
|
||||
<li><strong>不變量</strong>:<code>canSplit</code> 須對「切點 textNode 至 top 之<strong>整條</strong>祖先鏈」成立、非僅 top——下潛(<code>firstText</code>/<code>lastText</code>)+攀升(<code>splitTreeAfter</code> 守衛)雙向守。</li>
|
||||
<li>單元測試 127→129 全綠(+2):末實義字在 opaque button(巢於可切 span)→ 綁整 span、無 <code>splitoff</code>、button 完整;退化路徑 render→revert 還原一致。亲驗——暫禁守衛則該測試於「無 splitoff」斷言失敗(證明非空通過、病根屬實)。</li>
|
||||
</ul>
|
||||
|
||||
<h2>逐元素標點樣式覆寫(data-jz-style/stylePolicy) <span class="badge accent">2026-07-11</span></h2>
|
||||
<p>下游需求:非段落/特定範圍元素用半角式(banjiao),段落用全角/開明。原標點樣式僅由 <code>lang</code> 決定(<code>lang.policy</code>/<code>lang.style</code>),無逐元素覆寫。新增與 <code>data-jz-level</code>/<code>level.text</code> 同構之逐元素樣式機制(<code>banjiao</code> 渲染早已就緒、此為純路由,無新壓縮邏輯/CSS)。單元測試 121→127 全綠。</p>
|
||||
<h3>設置格式</h3>
|
||||
<ul>
|
||||
<li><strong>HTML 屬性</strong>:<code>data-jz-style="banjiao|kaiming|quanjiao"</code>——標於元素或任一祖先,該子樹用此樣式。</li>
|
||||
<li><strong>選項</strong>:<code>lang: { stylePolicy: [{ selector, style }] }</code>——如 <code>[{ selector: "h1, h2, button, .ui", style: "banjiao" }]</code>。</li>
|
||||
<li><strong>優先序</strong>:逐元素覆寫(近祖先勝、同層屬性優先於選擇器)> <code>lang.policy</code> > <code>lang.style</code> > 內建逐語言預設。屬性值非法(拼錯)則忽略、回退 lang。</li>
|
||||
</ul>
|
||||
|
||||
<h2>專案整理:架構一致性 + 確認缺陷修復 + 文檔重整 <span class="badge accent">2026-07-07</span></h2>
|
||||
<p>四路並行審計(core/typeset+CSS/設置 API/文檔)後之兩階段整理。核心:消除「透明穿越」判定之兩套口徑分裂,並修四個審計缺陷中之確認項(另一證偽、一延後)。單元測試 116→121 全綠。</p>
|
||||
<h3>階段一 · 確認缺陷修復 + 清理</h3>
|
||||
<ul>
|
||||
<li><strong>jinze 冪等</strong>:二次 render(未 revert)曾產生巢狀 <code>jz-jinze</code>(違反 I6,實測 3→6)——守衛改判 <code>isJz(parent, PASS)</code>。</li>
|
||||
<li><strong>pill 自身 avoid 漏檢</strong>:<code>processPills</code> 補 <code>avoidsSelf</code>——自身命中 avoid(如 <code><code data-jz-skip></code>)之 pill 曾仍被補間隙。連帶:<strong><code>CODE</code>/<code>KBD</code> 自 <code>skipTags</code> 預設移除</strong>(已由 <code>pillSelector</code> 以 pill 涵蓋;再列 skipTags 違反三分類互斥、且會抹掉普通 <code>code</code> 之 pill 兩端間隙)。若把 <code>code</code> 移出 <code>pillSelector</code>,須自行加入 <code>skipTags</code>/<code>scope.avoid</code>。</li>
|
||||
<li>刪零引用死代碼(<code>createMarker</code>/<code>ANY_CJK</code>/<code>BD_ALL</code>/<code>BD_CLASS_NAMES</code>/<code>CreateOptions.attrs</code>);死旗標 <code>biaodian</code>/<code>emphasis</code>/<code>ruby</code> 補 <code>@reserved</code> 註記。</li>
|
||||
</ul>
|
||||
<h3>階段二 · 架構一致性</h3>
|
||||
<ul>
|
||||
<li><strong>透明判定單一真相源</strong>:<code>transparentInline</code> 提升為公開 <code>isTransparent</code>,邏輯文本流之遍歷原語(<code>collectRuns</code>/<code>adjacentLogicalChar</code>/<code>jiyaAdjacency</code>)共用之。<strong>未在 <code>styleInlines</code> 之未知行內標籤(如 <code><button></code>)採嚴格白名單、一律當邊界</strong>(用戶抉擇;透明須顯式登記)。分層澄清:結構切分安全 <code>canSplit</code>、行端物理版面 <code>lineedge</code> 各有正當之不同邊界、刻意不併入。</li>
|
||||
<li><strong>跨透明元素邊界避頭尾</strong>(修 #3):避頭類標點落於 <code><strong></code> 邊緣(<code>你好<strong>。世界</strong></code> 之句末點號位於 strong 首位)時,<code>forbiddenBreak</code> 經 <code>edgeCharEl</code> 穿越透明元素下探定位、render 沿透明邊緣上攀處理外層 parent,不再漏綁、誤置行首。</li>
|
||||
<li><strong>avoid 排除歸一</strong>:<code>isAvoided</code> 改單一組合入口(自身或祖先),免除「<code>avoidsSelf ‖ isAvoided</code>」易漏一半之誤用(階段一 pill 漏檢即此類)。</li>
|
||||
<li>小項:<code>scope.include</code> 型別去多餘 <code>| null</code>;<code>gapTrim</code> 補逐節點功能閘;<code>PASSES</code> 陣列補註執行序。</li>
|
||||
<li><strong>元素分類單一入口 <code>finder.category</code></strong>(收尾):散落各遍歷原語各自短路之分類優先序,收斂為單一分類器——一元素恰屬 avoid/pill/isolate/block/transparent/opaque 之一,優先序 <code>avoid > pill > isolate > block > transparent > opaque</code>(帶 <code>data-jz-skip</code> 之 pill → avoid;同命中 pill 與 isolate → pill)。<code>isTransparent</code> = <code>category==="transparent"</code>;<code>collectRuns</code>/<code>jiyaAdjacency</code> 改 <code>switch(category)</code>;<code>canSplit</code> = <code>SPLITTABLE ∩ transparent</code>。行為等價(121 全綠)。</li>
|
||||
</ul>
|
||||
<h3>復驗後重新定性(不照單全收)</h3>
|
||||
<ul>
|
||||
<li><strong>lineedge 攀穿 isolate——非缺陷</strong>:<code>edgeRect</code> 為物理版面測量(<code>getClientRects</code>),isolate 斷的是邏輯 run/相鄰,物理行首行尾判定須跨 <code><mark></code> 找真實相鄰內容;初評誤判、已還原。</li>
|
||||
<li><strong>lineEdge/gapTrim 雙 ResizeObserver 派發序——確認非缺陷</strong>:Chrome headless 實測回調按 observer <strong>創建順序</strong>派發(ResizeObserver 規範遍歷 observers 插入序,非 observe 序;實測 seq=12);<code>lineEdge</code>(order 90)之 observer 先於 <code>gapTrim</code>(order 92)建、故 resize 時恆先派發,<code>gapTrim</code> 之行末去隙恆基於已套用之半形版面(非陳舊態)。以註解鎖為不變量、不需改碼。</li>
|
||||
<li><strong>五處幂等收斂——評估後不做</strong>:各檢查不同位置關係(祖先/父/子),無單一 <code>isJz</code> 形狀可全覆蓋,強行歸一徒增風險。</li>
|
||||
</ul>
|
||||
<h3>文檔(本次)</h3>
|
||||
<ul>
|
||||
<li>README 新增「全部選項速查」表——突出被埋沒之 <code>lang.style</code>/<code>styleInlines</code>/<code>blockTags</code>/<code>skipTags</code>(標籤名陣列)/<code>skipAttribute</code>/<code>isSkipped</code>;三分類改分層表述、avoid 改七源+組合入口。</li>
|
||||
<li>ARCHITECTURE §3.1 <code>scope.avoid</code> 過時示例、§4.1 透明分層與 avoid 七源、§4.2 標點六子類(舊列 <code>bd-cop</code>/<code>bd-sep</code>/<code>quote</code> 與實作不符)修正。</li>
|
||||
</ul>
|
||||
|
||||
<h2>標點擠壓句界例外(句末點號接開類保留右空白) <span class="badge accent">2026-06-13</span></h2>
|
||||
<p>行內連續擠壓(機制 A)新增<strong>句界例外</strong>:句末點號(<code>。.!?</code>)後接開類引號/括號(<code>。「</code>/<code>?「</code>)時保留句末點號右側空白、僅收開類左側,全角式與開明式皆合占 <strong>1.5em</strong>(對稱於 <code>。」</code>)——下游回饋之語感修正。單元測試 113→116 全綠。</p>
|
||||
<h3>變更</h3>
|
||||
<ul>
|
||||
<li><strong>句界例外</strong>:原機制 A 對相鄰標點對 <code>(L, R)</code> 兩側獨立收——<code>L</code> 有右側空白即收、<strong>不看 R 為何類</strong>,致句末點號接開類兩側皆收、合占 1em,丟失句界視覺(句末點號收尾後緊接開類起新引文,本應保留句界間距)。修為:<code>L</code> 為句末點號(<code>bd-stop</code>)且 <code>R</code> 為開類(<code>bd-open</code>)時不收 <code>L</code>、僅收 <code>R</code> 左側 → 1.5em。</li>
|
||||
<li><strong>例外僅限句末點號+開類</strong>:句內點號 <code>,「</code>(屬同句)與雙括號 <code>」「</code>/<code>》《</code>(貼合相連)<strong>不</strong>屬句界,仍兩側皆收 1em;<code>。」</code>(句末接閉類,閉類左為墨無左空白)本就只收句末點號、合占 1.5em,未變。</li>
|
||||
<li><strong>半角式不回歸</strong>:半角式句末點號由 baseline(機制 C)已收半形,句界守衛僅跳過 adjacency 之重複追加、<strong>不移除</strong> baseline 之 <code>jz-half</code>;32 個可壓縮標點於半角式仍全半形(實測 0 回歸)。非可壓縮 <code>·・‧</code>/<code>…—</code> 各式皆全寬,屬設計。</li>
|
||||
</ul>
|
||||
<h3>範圍</h3>
|
||||
<ul>
|
||||
<li>唯一改動點 <code>jiyaAdjacencyPass.squeeze()</code>(<code>src/typeset/jiya.ts</code>)加 <code>stopBeforeOpen</code> 守衛;行端擠壓 <code>lineedge</code>、baseline 政策、<code>jinze</code>/<code>orphan</code>/<code>spacing</code> 皆不波及(純視覺寬度、無結構變更)。</li>
|
||||
</ul>
|
||||
|
||||
<h2>行內元素三分類(KaTeX 肢解修復)+ v1 相容層移除 <span class="badge accent">2026-06-12</span></h2>
|
||||
<p>下游問題六(KaTeX 公式被禁則綁定肢解)修復:確立行內元素<strong>三分類</strong>口徑(透明/隔離/整體),所有遍歷原語共用同一判定。v1 相容層隨最後一批消費端遷移完成而移除。單元測試 101→112 全綠。</p>
|
||||
<h3>修復(下游問題六)</h3>
|
||||
<ul>
|
||||
<li><strong>Bug:jinze 透明下潛肢解 pill</strong>。行內公式緊鄰禁則標點(<code>…$x$,</code>)時,jinze 為綁鄰字下潛 <code>.katex</code>——KaTeX 全樹皆 <code><span></code>,舊版<strong>僅憑標籤名判透明</strong>,把公式內側末字連同完整殼層逐層克隆切出(<code>splitoff</code>),上標漂移錯位;<code>pillSelector</code>/<code>skipAttribute</code>/<code>scope.avoid</code> 三層防護全被刺穿。復現另揭兩向量:相鄰兩 pill 時 <code>descend</code> 刺穿致 <code>jz-hws</code> 插入公式內部;pill 內部標點被 jiya charify。</li>
|
||||
<li><strong>根因一:判透明以標籤名而非元素</strong>。<code>transparentInline(el)</code> 改以元素判定——先排除 pill/isolate/avoid 再查標籤集;<code>core/split</code> 之 <code>canSplit</code> 諮詢 finder(pill/isolate/avoid 一律不可切 → 退化<strong>整元素綁定</strong>——pill 即「作為原子鄰字單位整體綁入 <code>jz-jinze</code>」之期望行為)。</li>
|
||||
<li><strong>根因二:avoid 僅查祖先</strong>。新增 <code>avoidsSelf(el)</code> 自身判定——元素自身命中 <code>scope.avoid</code>/<code>skipAttribute</code>/<code>data-jz-skip</code> 時即為邊界(舊版被當透明穿越,防護形同虛設)。</li>
|
||||
<li><strong>根因三:descend 越界跳找</strong>。<code>adjacentLogicalChar</code> 之 <code>descend</code> 改三態(邊界字/無內容續找兄弟/<strong>blocked</strong> 終止)——遇 pill/isolate/avoid 不可跳過續找更遠之字。</li>
|
||||
<li><strong>pill 內部全面不可見</strong>:<code>eachTextNode</code> 過濾 <code>insidePill</code>——pill 內部不 charify、不插間隙、不做長詞包裝(舊版消費端自加之 pill 如 <code>.katex</code> 內部曾被直接變更)。</li>
|
||||
</ul>
|
||||
<h3>新增:行內元素三分類(§4.1)</h3>
|
||||
<ul>
|
||||
<li><strong>透明</strong>(<code>styleInlines</code> + <code>jz-*</code>):穿越、run 延續、禁則可克隆切分——<code>strong</code>/<code>em</code> 等純樣式。</li>
|
||||
<li><strong>隔離</strong>(<code>isolateSelector</code>,<strong>新增選項</strong>,預設 <code>"mark"</code>,<code>""</code> 停用):邊界斷 run/斷相鄰/不可切分、<strong>兩端補隙</strong>(同 pill),<strong>內部照常處理</strong>(自成脈絡)——帶背景色之 <code><mark></code> 等盒樣式行內(切分背景盒有可見接縫,<code>MARK</code> 並自可切白名單移除)。</li>
|
||||
<li><strong>整體</strong>(<code>pillSelector</code>):內部對一切 pass 不可見,禁則時整體綁入——<code>code</code>/<code>kbd</code>、行內公式 <code>.katex</code>、引用標 <code>.cite-ref</code> 等宜納入。</li>
|
||||
<li>spacing Pass B 邊界選擇器擴為 <strong>pill ∪ isolate</strong>;jiyaAdjacency 對 isolate 斷相鄰而內部自成脈絡;orphan 補 avoid 塊不綁(舊漏判)。</li>
|
||||
</ul>
|
||||
<h3>移除:v1 相容層</h3>
|
||||
<ul>
|
||||
<li>最後一批 v1 消費端遷移 v2 API 完成,<code>src/compat/v1.ts</code>(<code>createCjkAutospace</code>)、IIFE 全域別名、死選項 <code>autospaceClass</code>(經查從未被任何 pass 讀取)一併移除;不變量 I8 退役。v1 原始碼保存於 <code>v1-autospace</code> 分支。</li>
|
||||
</ul>
|
||||
|
||||
<h2>行內排版透明性修復 + 標點懸掛移除 <span class="badge accent">2026-06-11</span></h2>
|
||||
<p>修復兩個下游 Bug(同源於「行內元素對排版透明」原則在各遍歷原語口徑不一)+三項同根隱患審計;並把上一輪「停用」之標點懸掛<strong>正式移除</strong>(負 margin 程式碼/CSS/型別一併刪)。單元測試 90→101 全綠。</p>
|
||||
<h3>修復</h3>
|
||||
<ul>
|
||||
<li><strong>Bug:pill 前標點誤加間隙</strong>(如 <code>漢字。<code></code>)。根因:<code>finder.adjacentLogicalChar</code> 之 <code>descend</code> 遇不透明之 <code>jz-char</code> 既不進入亦不終止、而<strong>跳過</strong>它越過標點取到更前之漢字,誤補 <code>jz-hws</code>。修復=<code>isTransparentInline</code> 補 <code>JZ-CHAR</code>/<code>JZ-INNER</code>,<code>descend</code> 遂回傳標點(非 pill 鄰字、不補隙),與 <code>collectRuns</code> 之既有透明遞迴一致。</li>
|
||||
<li><strong>Bug:行內樣式元素整體掉行</strong>(<code><strong>很長一串</strong>,</code> 把整串粗體塞進 <code>nowrap</code> inline-block,行末放不下即整體掉行、上行留巨大空隙)。新增 <code>core/split.ts</code> 之克隆切分:沿祖先鏈把邊界詞元剝入淺克隆(保留標籤/class/style/lang、去 id;標 <code>data-jz-kind="splitoff"</code>),禁則/垂懸字只綁該克隆(最小綁定),原元素餘部留原位、渲染無縫。<code>revertPass</code> 增延後階段把 splitoff 併回原元素(先解包 wrap、後併 splitoff),<code>render→revert</code> 還原一致。純樣式標籤白名單可切,<code>a</code>/<code>abbr</code>/<code>ruby</code>/帶 id 退化整綁。</li>
|
||||
<li><strong>審計(同根隱患)</strong>:T4 <code>lineedge</code> 之行端攀爬集改由 <code>options.finder.styleInlines</code> <strong>派生</strong>(棄硬編碼子集,免遺漏 <code>ins</code>/<code>del</code>/<code>sub</code> 等致行端誤判);T5 <code>jiyaAdjacency</code> 遇 pill 斷相鄰、不穿越;T6 <code>spacing</code> processPills 補 per-node 功能閘(<code>data-juzhen-off="spacing"</code> 區域內不補隙)。</li>
|
||||
</ul>
|
||||
<h3>標點懸掛:實作後停用 → 正式移除</h3>
|
||||
<p>純 CSS 手段(負 margin/相對位移/零寬盒)經實測為<strong>機制級死路</strong>,已移除(負 margin 之 <code>lineedge</code> 程式碼/<code>jz-hang-*</code> CSS/<code>LineEndMode</code>/<code>lineEnd</code>/<code>features.hanging</code> 一併刪)。行端僅餘半形擠壓(<code>jz-half-le</code>)。</p>
|
||||
<ul>
|
||||
<li><strong>寬度守恆論證</strong>:行端標點盒若保持非零寬,則墨色出去多少、版心內就空出多少(行末實義字無法齊版心右緣);若收為零寬,則釋放一整字寬、斷行器把下一字拉上本行與標點重疊(且 <code>justifyAtoms</code> 預設之 inline-block 下負 margin 被 justify 吸收)。<strong>CSS hack 接觸不到斷行器,此恒等式無解。</strong></li>
|
||||
<li><strong>唯一正解=原生</strong> <code>hanging-punctuation</code>,惟 Chrome/Firefox 至今不支援(僅 Safari),且 <code>jz-char</code> 為 inline-block 原子非文本、原生懸掛不作用之。未來若做須是繞開 jz-char 的「原生管線」新功能(<code>text-spacing-trim</code> + <code>hanging-punctuation</code>,按能力門控),與已移除碼無共享,故不保留休眠碼。</li>
|
||||
<li><code>hanging</code> 公開選項保留為 <strong>@deprecated no-op</strong>(API 相容;測試覆蓋 option/<code>data-juzhen</code>/<code>blocks</code> 各路徑皆不產生 <code>jz-hang-*</code> 且輸出與不傳一致)。詳見 <code>ARCHITECTURE §6.5.6</code>。</li>
|
||||
</ul>
|
||||
|
||||
<h2>功能分級 + 垂懸字(標點懸掛停用) <span class="badge accent">2026-06-09</span></h2>
|
||||
<p>ARCHITECTURE §6.5:功能分級、垂懸字(方案 C)落地工作良好;原計劃之標點懸掛經實測<strong>確認不可行、全面停用</strong>。共 90 單元測試全綠(含懸掛防逃逸覆蓋)。</p>
|
||||
<h3>新增</h3>
|
||||
<ul>
|
||||
<li><strong>功能分級(文本級 vs 段落級)</strong>:把已實作功能切為兩級——<strong>文本級</strong>(中西間隙 <code>spacing</code>、標點擠壓 <code>jiya</code>,任意文本片段適用)與<strong>段落級</strong>(禁則 <code>jinze</code>、垂懸字 <code>orphan</code>、長詞 <code>longWord</code>,需行/段布局)。塊級元素預設全功能;<code>data-jz-level="text"</code> 子樹(最近祖先勝)或選項 <code>level:{text:"選擇器"}</code> 標記之元素(標題/按鈕/UI)僅跑文本級。實作為 <code>Finder.levelFor</code>/<code>levelAllows</code> + 各 pass <code>level</code> 元數據;段落級 pass(含 charify 之 longWord)逐元素受閘。<code>spacing</code> 補單元素 root 支援(行內/非塊 root 亦處理)。</li>
|
||||
<li><strong>垂懸字避免 <code>orphan</code>(方案 C,斷行層無測量,order 70)</strong>:段末行 CJK 實義字 ≥ N(預設 2;標點/符號不計)。把段末「第 N 個實義字起至段末」(含尾隨標點)包入 <code>jz-orphan</code>(<code>white-space:nowrap</code>),整體不可斷 → 末行恒含之 ⇒ 末行實義字 ≥ N,任意寬度/字型成立、無需量測。邊界字落於行內元素內時綁定整個塊級直接子(跨 <code>jz-jinze</code> 攀爬、不切割)。<strong>預設開 chars=2</strong>(<code>orphan:false</code> 關);段落級,<code>data-jz-level="text"</code> 跳過。</li>
|
||||
<li><strong>行端模式重構 + 行歸屬判定 bug 修正</strong>:lineedge 行端處理抽象為可配模式(<code>squeeze</code>/hang/none),現有行端半形=<code>squeeze</code>、保留並改善。修「行歸屬判定」bug:<code>jz-char</code> 為 inline-block,其行盒 <code>top</code> 與相鄰純文本字形 <code>top</code> 因基線差約 5px、遠小於行高卻超舊容差(EPS=4px),致同行誤判為下一行;改判「垂直區間」(下一行=<code>b.top ≥ a.bottom</code>、上一行=<code>b.bottom ≤ a.top</code>,容 2px)。</li>
|
||||
</ul>
|
||||
<h3>標點懸掛:實作後全面停用</h3>
|
||||
<p>原計劃之標點懸掛(行端標點凸出版心)經 headless + 真實瀏覽器實測<strong>確認純負 <code>margin</code> 機制不可行</strong>,已全面停用(<code>hanging</code> 選項仍被接受但無任何效果)。</p>
|
||||
<ul>
|
||||
<li><strong>不可行原因</strong>:① 句號墨色偏字框左下角,安全凸出量(< 1 個 CJK 字寬 1em)只把空白半框推出版心、墨色仍在內(視覺不懸掛);② 凸出滿 1em 釋放一整字寬、把下一字拉上本行致<strong>重疊</strong>;③ 與 <code>inline-block</code> 對齊原子互斥(<code>justifyAtoms:true</code> 下負 margin 被 justify 吸收);④ <code>ResizeObserver</code> 重排時兩階段標記錯亂。</li>
|
||||
<li><strong>三重防逃逸</strong>:① <code>index</code> 鎖 <code>lineEnd:"squeeze"</code>(忽略 <code>hanging</code> 選項);② <code>modeForChar</code> 無條件降 squeeze;③ <code>applyEdge</code> 之 <code>HANG_ENABLED=false</code> 總開關。縱使下游以 <code>hanging</code>/<code>data-juzhen</code>/<code>blocks</code> 任何方式要求亦不啟用(單元測試覆蓋各路徑皆不產生 <code>jz-hang-*</code>)。</li>
|
||||
<li><strong>未來路徑</strong>:原生 CSS <code>hanging-punctuation</code> 為正解,待 Chrome 支援(現僅 Safari)並解決與 inline-block 原子衝突。hang 之程式碼、CSS、型別保留休眠。詳見 <code>ARCHITECTURE §6.5.6</code>。</li>
|
||||
</ul>
|
||||
<h3>工具</h3>
|
||||
<ul>
|
||||
<li><code>demo/showcase.html</code>(新增):定寬文本框並排對照,逐場景演示功能分級與垂懸字(關閉/開啟孤字、chars=3、標點不計入 N)。</li>
|
||||
<li><code>demo/resize-check.html</code>:增垂懸字(<code>jz-orphan</code> 描框 + 關閉對照)拖曳檢查區。兩頁均不含懸掛(已停用)。</li>
|
||||
</ul>
|
||||
|
||||
<h2>標點挤压一致性 <span class="badge accent">2026-06-04</span></h2>
|
||||
<h3>新增/修復</h3>
|
||||
<ul>
|
||||
<li><strong>全角句點 <code>.</code> 納入句末標點</strong>:<code>BD_STOP</code> 由 <code>。!?</code> 擴為 <code>。.!?</code>——納入全角句點 <code>.</code>(U+FF0E,異於表意句號 <code>。</code> U+3002)。實測 Noto CJK SC/TC 之 <code>halt</code> 皆覆蓋、挤压收半形 advance 0.5em,與 <code>。</code> 一致;<code>inkBias</code> 同步補(簡 left/繁 center)。政策行為同 <code>。!?</code>:<strong>全角式與開明式句末保持全角 1em</strong>,banjiao(或鄰接被收)挤半。設計原則:<code>halt</code> 即字型之「挤压意願」,字型不提供某標點 <code>halt</code>(字形占滿、不宜挤)則信任字型、保持全角不強挤。</li>
|
||||
<li><strong>連續標點挤压規則:雙側獨立 + 透明穿越</strong>(修正既有不一致)。規則:相鄰兩標點,<strong>對每一標點</strong>獨立判斷——若其「有空白之側」緊鄰另一標點即收半。原為「二選一只收一側」,致 <code>,「</code> 全角式誤為 1.5em;修後 <code>,「</code> <strong>全角式與開明式皆 1em</strong>、<code>。」</code> <strong>全角式 1.5em/開明式 1em</strong>、<code>·—</code> 兩式皆 2em(不宜挤)。並改「同 parent 直接兄弟」掃描為<strong>從 root 之透明遍歷</strong>(穿越 <code>strong</code>/<code>em</code> 等行內邊界、block/avoid 斷相鄰),使跨 inline 邊界之連續標點亦正確配對(原漏致達 2em)。</li>
|
||||
</ul>
|
||||
|
||||
<h2>字符分類規則集 <span class="badge accent">2026-06-04</span></h2>
|
||||
<h3>新增</h3>
|
||||
<ul>
|
||||
<li><strong>可配置字符分類(<code>charClass</code>)</strong>:把散落各 pass 之硬編碼分類(中文 CJK/西文 ANS/全寬標點 BD_* 子類)統一抽象為<strong>規則集</strong>,內置默認即現狀值,消費端經 <code>options.charClass</code>(<code>{ cjk?, western?, biaodian?:{stop,pause,open,close,middle,liga} }</code>)<strong>追加擴充</strong>,覆寫 <code>spacing</code>/<code>jiya</code>/<code>jinze</code> 之分類判定。動機:未分類符號(如二點省略 <code>‥</code> U+2025)——不 charify/不挤;以 <code>charClass:{biaodian:{stop:"‥"}}</code> 即可歸入對應類。<strong>向下兼容</strong>:不傳則行為與內置完全一致、各實例隔離(覆寫不污染默認)。v1 為追加語義(不重分類/移除內置);結構性 ASCII 不開放。<code>spacing</code> 之間隙正則改由規則集 per-instance 編譯。測試 66 通過(含 9 條 charClass)。</li>
|
||||
</ul>
|
||||
|
||||
<h2>下游回饋修復批次 <span class="badge accent">2026-06-03</span></h2>
|
||||
<p>聚焦 Peek.nvim 等下游回饋之原子透明與中西間隙正確性,皆以真實 Chromium 量測驗證(jsdom 之 TreeWalker/offset 失真,不可信)。</p>
|
||||
|
||||
<h3>修復</h3>
|
||||
<ul>
|
||||
<li><strong>原子透明(段落繼承屬性漏溢)</strong>:<code>jz-*</code> inline-block 原子對消費端段落樣式透明——歸零會被原子重複套用之 <code>text-indent</code>/<code>hanging-punctuation</code>(過套用),並令原子 <code>text-decoration: inherit</code> 以接續段落下劃線(欠套用)。段落首行縮排正常、下劃線連續不斷,消費端無需配置。</li>
|
||||
<li><strong>中西間隙 · <code>gapTrim</code> 自我應驗</strong>:行末去隙原依「後字是否在更低行」判定,而後字落點受間隙自身(及下游間隙)之 <code>margin</code> 推擠,致行中間隙被誤 trim——表現為容器寬相關之「最後一個間隙消失」、行內 <code>code</code> pill 跨行時右側間隙消失。改為「起始全部 trim、由左至右逐一定案」,量測不受任何 <code>margin</code> 污染。</li>
|
||||
<li><strong>中西間隙 · 西文字集完整化</strong>:補入貨幣符號(<code>€</code> 等 U+20A0–20BF)、拉丁擴充 A/B(拼音聲調 ā ǎ ē)、西里爾、數學運算符,並把希臘補全至含 α-ω。鄰接 CJK 之手工空格遂正規化、邊界補隙。列舉法以 <code>\uXXXX</code> 表示、無需 regex flag,相容 2016/Windows 10 時代瀏覽器。</li>
|
||||
<li><strong>標點誤隙</strong>:全角省略號 …… 後接 CJK 不再誤補隙(<code>DOTS_CJK</code> 移除 <code>|…</code>);收尾彎引號 ” 後接拉丁/數字不再補隙(移除 pangu <code>QUOTE_AN</code> 偏例,與 」』)》】〕 等所有收尾標點一致)。全部常用中文標點 × 簡繁共 104 例稽核,CJK 間皆 0 誤隙。</li>
|
||||
</ul>
|
||||
|
||||
<h3>變更</h3>
|
||||
<ul>
|
||||
<li><strong>字型移出聚珍管理</strong>:字型屬消費端領域,聚珍不再內建任何 <code>font-family</code>/字型堆疊(移除 <code>_lang.css</code>,<code>dist/juzhen.css</code> 零字型規則)。逐語言字型完全由消費端 CSS 設定(範式見文檔「字型與 locl」)。聚珍僅守三項非管理性本分:<code>jz-* { font: inherit }</code> 使原子繼承消費端字型、不強加;半形 <code>font-feature-settings</code> 顯式併入 <code>"locl" 1</code>(連同 <code>halt</code>)確保泛 CJK 字型之地域標點字形不因設 halt 而失;render 只讀不改 <code>lang</code>。halt/margin 兩模式皆已驗證字型繼承與 locl 正常。</li>
|
||||
</ul>
|
||||
|
||||
<h3>工具</h3>
|
||||
<ul>
|
||||
<li><strong><code>demo/resize-check.html</code></strong>:可拖曳容器檢查頁——拖動方框寬度,驗證中西間隙在各寬度下恆穩、標點無誤隙;可切 <code>justify</code> 與「顯示間隙」描邊。</li>
|
||||
</ul>
|
||||
|
||||
<h2>v2 切片 1 · 增補 <span class="badge accent">2026-06</span></h2>
|
||||
<p>切片 1 之上的修補與強化,聚焦標點半形對缺 <code>halt</code> 字型之支援,及下游回饋之邊界/分頁器修復。</p>
|
||||
|
||||
<h3>新增</h3>
|
||||
<ul>
|
||||
<li><strong>標點半形 <code>margin</code> 後備模式</strong>(缺 OpenType <code>halt</code> 之字型):<code>jiya.halfWidth: "halt"</code>(預設)|<code>"margin"</code>。<code>margin</code> 以 <code>width:0.5em</code> +字身依墨色偏側溢出定位,於 CSS 盒模型重現 <code>halt</code> 之半形 advance(真 0.5em;相鄰標點各 0.5em、合佔 1em,無負 margin 之鄰接疊加/單側重疊)。模式由 <code>data-jz-halfwidth</code> 子樹屬性決定,可逐區塊/逐 <code>lang</code>(含 halt-island 反置)。</li>
|
||||
<li><strong>字形偏側表 <code>inkBias</code></strong>:依字身墨色中心給 <code>left/center/right/full</code>;彎引號/角括號/尖括號偏一側、圓方括號近中。參考表據方正書宋 GBK 實測,可經 <code>jiya.bias</code> 逐 <code>lang</code> 逐字覆寫。</li>
|
||||
<li><strong>字型適配工具與工作流</strong>:<code>tools/measure-bias.py</code>(量 glyph ink 中心、產偏側建議表)+文檔之「字型適配工作流」。<code>demo/fallback.html</code>:方正書宋 GBK 對照 halt 失效 vs margin 生效。</li>
|
||||
<li><strong><code>justifyAtoms</code> 選項</strong>:預設 <code>true</code>;設 <code>false</code> 時 <code>jz-char</code>/<code>jz-jinze</code> 改 <code>display:inline</code>,相容分頁器(Paged.js)之對齊引擎(<code>halt</code> 半形與禁則 <code>nowrap</code> 保留)。</li>
|
||||
</ul>
|
||||
|
||||
<h3>修復</h3>
|
||||
<ul>
|
||||
<li><strong>字型不覆寫消費端</strong>:<code>_lang.css</code> 之 Noto 字型堆疊改為純後備——全包 <code>:where()</code> 零特異度、只命中帶 <code>lang</code> 屬性之元素、不直接覆寫標點字面容器 <code>jz-inner</code>。消費端 <code>font-family</code> 恆優先,標點隨之以消費端字型渲染(修正先前標點被強制換成 Noto 之缺陷)。</li>
|
||||
<li><strong>pill 緊鄰標點之左緣間隙</strong>:<code>adjacentLogicalChar</code> 透明穿越 <code>jz-jinze</code>(同 <code>jz-hws</code>/樣式行內)→ pill 後接 CJK 標點時,左緣 <code>jz-hws</code> 與作者空格剝除復原。</li>
|
||||
<li><strong>CJK↔CJK 贅餘作者空格</strong>:含跨 inline 標籤者一律剝除、不留隙,與 CJK↔西文之邊界正規化一致(全形空格 U+3000 不動)。</li>
|
||||
</ul>
|
||||
|
||||
<h3>已知限制</h3>
|
||||
<ul>
|
||||
<li><code>margin</code> 後備半形依賴 <code>inline-block</code>(<code>width:0.5em</code>),與 <code>justifyAtoms:false</code> 不可兼得——分頁器場景請用含 <code>halt</code> 之字型。</li>
|
||||
<li><code>margin</code> 偏側為三檔量化(0/0.25/0.5em)之近似;字形極端者須以 <code>jiya.bias</code> 逐字微調。</li>
|
||||
</ul>
|
||||
|
||||
<h2>v2.0.0 <span class="badge accent">切片 1</span> · 從零重寫</h2>
|
||||
<p>依 <code>ARCHITECTURE.md</code> 契約,從零重寫為「現代簡/繁中文網頁排版套件」。倉庫名 <code>cjk-autospace</code> 不變;公開 API 為 <code>createJuzhen</code> /全域 <code>Juzhen</code>;自訂元素前綴 <code>jz-</code>。v1 完整保留於 <code>v1-autospace</code> 分支。</p>
|
||||
|
||||
<h3>新增</h3>
|
||||
<ul>
|
||||
<li><strong>核心引擎</strong>:<code>finder</code>(遍歷/avoid/<code>scope.include</code>/邏輯 run/pill 鄰字/分塊閘 <code>featureEnabledFor</code>)、<code>unicode</code>(字元表+標點子類+字形側位)、<code>locale</code>(語言分類+全角/開明政策)、<code>dom</code>(<code>jz-*</code> 工廠+可還原標記)、<code>pass</code> 聚合管線。</li>
|
||||
<li><strong>中西間隙</strong>(margin 模型):包裹邊界左側字、以 <code>margin-right</code> 留隙,<strong>不注入空白字元</strong> → <code>textContent</code> 完全不變、複製緊湊、<code>justify</code> 不暴增、不撐框、行首乾淨。<code>gapTrim</code> layout pass 行末去隙以齊右。</li>
|
||||
<li><strong>標點擠壓</strong>:全角式/開明式/半角式,三機制(baseline 政策+行內連續擠壓+行端擠壓)。半形以字型 OpenType <code>halt</code> 渲染。全域 <code>lang.style</code> +逐 <code>lang</code> <code>policy</code>。</li>
|
||||
<li><strong>禁則(避頭尾)</strong>:閉類/點號綁前一字、開類綁後一字,以詞元為邊界、長引文中段可斷;兼作 <code>justify</code> 綁定。</li>
|
||||
<li><strong>長英文詞</strong>:<code><span lang></code> + <code>hyphens</code> 原生斷詞。</li>
|
||||
<li><strong>justify 句末單份間距</strong>:<code>jz-char</code> inline-block 原子化。</li>
|
||||
<li><strong>v1 相容層</strong>:<code>createCjkAutospace().apply()</code> 映射;IIFE 全域別名。</li>
|
||||
<li><strong>構建/測試</strong>:esbuild(ESM/IIFE/CSS)+ tsc(d.ts);jsdom 測試;<code>demo/</code> 簡繁並列+justify+標點寬度量測。</li>
|
||||
</ul>
|
||||
|
||||
<h3>相對 v1 之改進</h3>
|
||||
<ul>
|
||||
<li>中西間隙複製乾淨(<code>textContent</code> 不變;v1 之 <code><span></code> 真實空格會被選取)。</li>
|
||||
<li>擠壓由「<code>halt</code> 套整段=半角式」進化為依 <code>lang</code> 之全角/開明/半角,且加入行內連續與行端擠壓。</li>
|
||||
<li>新增禁則避頭尾、justify 句末單份間距、跨瀏覽器一致策略與顯式禁用層。</li>
|
||||
</ul>
|
||||
|
||||
<h2>v1(cjk-autospace) · v1-autospace 分支</h2>
|
||||
<p>Pangu.js v7.2.1 風格之 CJK↔Latin 邊界自動間距 shim,工廠 <code>createCjkAutospace(options).apply(root)</code>。</p>
|
||||
<ul>
|
||||
<li>Pass A:跨樣式邊界中西間隙(marker span 含真實空格 + <code>user-select:none</code>)。</li>
|
||||
<li>Pass B:pill(<code>code</code>/<code>kbd</code>)邊界間隙。</li>
|
||||
<li>Pass C:長英文詞 <code><span lang></code> + <code>hyphens</code>。</li>
|
||||
<li>Pass D:開明式標點擠壓(句內點號 <code>font-feature-settings:"halt"</code> 半形)。</li>
|
||||
</ul>
|
||||
|
||||
</main>
|
||||
|
||||
<footer class="page">
|
||||
<p>本頁由聚珍排版工程記錄。所有文檔操作均於 docs 分支進行;main 僅存一份未追蹤之本地鏡像。</p>
|
||||
</footer>
|
||||
|
||||
<!-- RUNTIME_SPRITE -->
|
||||
|
||||
<script type="text/markdown" id="md-source">
|
||||
# 變更紀錄(Changelog)— 聚珍(Juzhen)/cjk-autospace
|
||||
|
||||
## 效能第二/三層:掃描/佈局提速 + 增量恢復(Tier3)(2026-07-12)
|
||||
|
||||
承第一層(S2+S3)。含掃描/佈局提速與增量恢復(動態頁面局部變化免全樹重掃),全程獨立複驗+4 份真人撰寫真實文檔驗收。測試 130→140 全綠。
|
||||
|
||||
### 掃描 / 佈局提速
|
||||
- 祖先游走記憶化(中間發現+S4):insidePill/isAvoided 之「逐文本節點沿祖先鏈」迭代回填記憶化,O(文本×depth)→O(元素)。el.matches() 約降五成;深嵌套 render 進程隔離墻鐘 707ms→254ms(2.8×)。
|
||||
- S1 gapTrim 對齊門:尾隨 margin 僅右緣 flush 之對齊可見,唯 left/start(ltr) 可整批跳過、省 O(gaps) 次重排。非兩端對齊 gapTrim 數千 ms→數十 ms;justify/center/right/RTL 行為不變(真瀏覽器差分 trim 集逐位相同)。
|
||||
|
||||
### 增量恢復(第三層,向下兼容 API)
|
||||
- C1 持久化 Finder:跨 render 復用元素鍵快取;正確性靠失效——invalidate(el) 清整棵子樹快取,revert 自動失效其子樹。
|
||||
- C3 rerender(root)=revert+render(編輯 400 段中一段 766×、matches 1441×)。
|
||||
- C2 observe/disconnect:MutationObserver 自動增量,內容/分類屬性變化重處理最近塊、新增之塊直接 render;render/revert 期間暫停觀察、不自觸發。
|
||||
- append 修復(IV-3 差分複驗發現、單測漏掉):向葉塊(帶 jz-orphan)追加子塊使其轉非葉,須撤容器陳舊 orphan。
|
||||
|
||||
### 獨立複驗 + 真實文檔驗收
|
||||
- 5 種獨立方法(差分/往返/增量≡全量/進程隔離測速/真瀏覽器 S1),與既有單測/基準不同源——抓到並修復 append 真 bug。
|
||||
- 4 份真人撰寫真實文檔:多重集守恆、無字元改/增/漏/亂序,revert 淨移除結構、真瀏覽器整條管線零報錯。
|
||||
|
||||
### 文檔(I1/I6 收緊)
|
||||
- 驗收浮現 render→revert 不還原 spacing 規範化之贅餘作者空格(在 Neovim 中→在Neovim中)——固有、非本批引入。I1 明示此為刻意常態例外;I6 明示「邏輯等價≠逐字節」之唯一差異即被規範化之空格。README 補「revert 之範圍」注。
|
||||
|
||||
## 效能第一層:掃描提速(category 記憶化 + orphan 葉塊預計算)(2026-07-11)
|
||||
|
||||
下游效能訴求(掃描疑 O(n²))之第一層——無狀態、保行為、無公開 API 變更。測試 129→130 全綠、輸出不變。
|
||||
|
||||
### 優化
|
||||
- S3 category 記憶化:finder.category 以 WeakMap<Element,InlineCategory> 記憶化,砍除各遍歷原語跨 pass
|
||||
對同一元素之重複 el.matches()。實測 pill/isolate 密集頁 el.matches() 8204→5842(約降三成、命中率 55%)。
|
||||
安全:單次 render 內分類恆定、Finder 每次 render 重建 → 快取僅存活單次 render、無跨 render 失效。
|
||||
- S2 orphan 葉塊預計算:以一趟上攀預計算「非葉塊」Set 取代逐塊 block.querySelector(sel) 掃子樹之
|
||||
O(Σ塊子樹)(深塊嵌套 O(n²));某塊為另一塊之最近塊祖先 ⟺ 含塊級後代,與原 querySelector 同口徑、等價。
|
||||
|
||||
### 亲驗與待議
|
||||
- 亲驗:暫禁 category 快取則 computeCategory 1041→2322、el.matches() 5842→8204(初次插桩誤打
|
||||
HTMLBodyElement.prototype 漏計,改補 Element.prototype.matches 後為準)。
|
||||
- 新發現(下一機會):剩餘 el.matches() 最大來源為 insidePill 之逐文本節點沿祖先鏈 pillMatches(未記憶化)。
|
||||
|
||||
## splitTreeAfter 攀升對稱守衛(opaque 中間祖先不再被克隆肢解)(2026-07-11)
|
||||
|
||||
下游 .selection-bar-message 病例:垂懸字段末實義字落於 opaque `<button>`(巢於可切 `<span>`)時,splitElementAt 僅驗頂層 span,splitTreeAfter 逐層克隆「文本節點→top」祖先鏈時中間 opaque button 從未複查即被克隆肢解(複製出 splitoff button)。firstText/lastText 守下潛,攀升卻無對稱守衛——真正病根,下游 per-instance 迴避僅治標。
|
||||
|
||||
### 修復
|
||||
- 攀升守衛:splitTreeAfter 切分前先驗「textNode.parentNode…top(含 top)」整條祖先鏈皆 canSplit,任一層 opaque/pill/isolate/帶 id 即放棄、回退整綁(與下潛守衛對稱)。純新增守衛、無新克隆邏輯;jinze 之 isolateBoundaryToken 因下潛已守、行為不變。附帶杜絕中間祖先帶 id 之克隆重複 id。
|
||||
- 不變量:canSplit 須對「切點 textNode 至 top 之整條祖先鏈」成立、非僅 top——下潛(firstText/lastText)+攀升(splitTreeAfter 守衛)雙向守。
|
||||
- 測試 127→129 全綠(+2):末實義字在 opaque button(巢於可切 span)→ 綁整 span、無 splitoff、button 完整;退化路徑 render→revert 還原一致。亲驗暫禁守衛則該測試於「無 splitoff」斷言失敗。
|
||||
|
||||
## 逐元素標點樣式覆寫(data-jz-style/stylePolicy)(2026-07-11)
|
||||
|
||||
下游需求:非段落/特定範圍元素用半角式,段落用全角/開明。原樣式僅由 lang 決定,新增逐元素覆寫(與 data-jz-level 同構、純路由,banjiao 渲染早已就緒)。測試 121→127 全綠。
|
||||
|
||||
### 設置格式
|
||||
- HTML 屬性 data-jz-style="banjiao|kaiming|quanjiao"(標於元素或任一祖先,該子樹生效)。
|
||||
- 選項 lang.stylePolicy: [{ selector, style }](如 [{selector:"h1,h2,button,.ui", style:"banjiao"}])。
|
||||
- 優先序:逐元素覆寫(近祖先勝、屬性優先於選擇器)> lang.policy > lang.style > 內建;非法屬性值回退 lang。
|
||||
|
||||
## 專案整理:架構一致性 + 確認缺陷修復 + 文檔重整(2026-07-07)
|
||||
|
||||
四路並行審計後之兩階段整理。核心:消除「透明穿越」判定之兩套口徑分裂。測試 116→121 全綠。
|
||||
|
||||
### 階段一 · 確認缺陷 + 清理
|
||||
- jinze 冪等:二次 render 曾巢狀 jz-jinze(違反 I6,3→6)——守衛改判 isJz(parent, PASS)。
|
||||
- pill 自身 avoid 漏檢:processPills 補 avoidsSelf。連帶 CODE/KBD 自 skipTags 預設移除(已由 pillSelector 以 pill 涵蓋;再列 skipTags 違反三分類互斥、會抹掉普通 code 之 pill 間隙)。若把 code 移出 pillSelector 須自行加入 skipTags/scope.avoid。
|
||||
- 刪死代碼 createMarker/ANY_CJK/BD_ALL/BD_CLASS_NAMES/CreateOptions.attrs;死旗標 biaodian/emphasis/ruby 補 @reserved。
|
||||
|
||||
### 階段二 · 架構一致性
|
||||
- 透明判定單一真相源:transparentInline 提升為公開 isTransparent,邏輯文本流三原語(collectRuns/adjacentLogicalChar/jiyaAdjacency)共用之。未在 styleInlines 之未知行內標籤採嚴格白名單、一律當邊界(用戶抉擇)。分層:canSplit(切分安全)、lineedge(物理版面)各有正當之不同邊界、不併入。
|
||||
- 跨透明元素邊界避頭尾(修 #3):句末點號落於 <strong> 首位(你好<strong>。世界</strong>)時 forbiddenBreak 經 edgeCharEl 下探、render 沿透明邊緣上攀處理外層 parent,不再漏綁。
|
||||
- avoid 排除歸一:isAvoided 改組合入口(自身或祖先),免「avoidsSelf ‖ isAvoided」漏一半。
|
||||
- 小項:scope.include 去多餘 |null;gapTrim 補功能閘;PASSES 補註執行序。
|
||||
- 元素分類單一入口 finder.category(收尾):分類優先序收斂為單一分類器(avoid>pill>isolate>block>transparent>opaque);isTransparent=category==="transparent"、collectRuns/jiyaAdjacency switch(category)、canSplit=SPLITTABLE∩transparent。行為等價。
|
||||
|
||||
### 復驗後重新定性(不照單全收)
|
||||
- lineedge 攀穿 isolate——非缺陷:edgeRect 為物理測量,須跨 mark 找真實相鄰;初評誤判、已還原。
|
||||
- lineEdge/gapTrim 雙 ResizeObserver 派發序——確認非缺陷:Chrome 實測按 observer 創建序派發(規範插入序,非 observe 序);lineEdge(order 90)先於 gapTrim(order 92)建、resize 恆先派發、不會用陳舊半形態;註解鎖不變量、不改碼。
|
||||
- 五處幂等收斂——不做:各檢查不同位置關係,無單一 isJz 形狀可全覆蓋,強行歸一徒增風險。
|
||||
|
||||
### 文檔
|
||||
- README 新增「全部選項速查」表(突出 lang.style/styleInlines/skipTags/skipAttribute/isSkipped);三分類改分層、avoid 改七源。
|
||||
- ARCHITECTURE §3.1 scope.avoid 過時示例、§4.1 透明分層、§4.2 標點六子類修正。
|
||||
|
||||
## 標點擠壓句界例外(句末點號接開類保留右空白)(2026-06-13)
|
||||
|
||||
### 變更
|
||||
- 句界例外:機制 A 原對相鄰標點對 (L, R) 兩側獨立收——L 有右空白即收、不看 R 為何類,致句末點號接開類(。「/?「)兩側皆收、合占 1em、丟失句界視覺。修為:L 為句末點號(bd-stop)且 R 為開類(bd-open)時不收 L、僅收 R 左側 → 全角式與開明式皆 1.5em(對稱於 。」)。
|
||||
- 例外僅限句末點號+開類:句內點號 ,「 與雙括號 」「/》《 仍兩側皆收 1em;。」(句末接閉類)本就只收句末點號、合占 1.5em,未變。
|
||||
- 半角式不回歸:句末點號於半角式由 baseline 已收半形,守衛僅跳過 adjacency 之重複追加、不移除 baseline 之 jz-half(實測 32 可壓縮標點全半形、0 回歸)。·・‧/…— 各式皆全寬屬設計。
|
||||
|
||||
### 範圍
|
||||
- 唯一改動點 jiyaAdjacencyPass.squeeze() 加 stopBeforeOpen 守衛;lineedge/baseline/jinze/orphan/spacing 不波及。測試 113→116 全綠。
|
||||
|
||||
## 行內元素三分類(KaTeX 肢解修復)+ v1 相容層移除(2026-06-12)
|
||||
|
||||
### 修復(下游問題六)
|
||||
- jinze 透明下潛肢解 pill:行內公式緊鄰禁則標點時,KaTeX 全樹皆 span、舊版僅憑標籤名判透明,公式內側末字連殼層被克隆切出(splitoff)、上標漂移;pillSelector/skipAttribute/scope.avoid 三層防護全被刺穿。另修:相鄰 pill 時 descend 刺穿致 jz-hws 插入公式內部;pill 內部標點被 jiya charify。
|
||||
- 根因修復:transparentInline 以元素判定(先排除 pill/isolate/avoid);canSplit 諮詢 finder(不可切→整元素綁定,pill 即整體綁入 jz-jinze);avoidsSelf 自身判定(舊僅查祖先);descend 三態(blocked 終止、不越界跳找);eachTextNode 過濾 pill 內部。
|
||||
|
||||
### 新增:行內元素三分類(§4.1)
|
||||
- 透明(styleInlines+jz-*):穿越、run 延續、可克隆切分。
|
||||
- 隔離(isolateSelector 新增選項,預設 "mark","" 停用):邊界斷 run/斷相鄰/不可切分、兩端補隙,內部照常處理。MARK 自可切白名單移除(背景盒切分有可見接縫)。
|
||||
- 整體(pillSelector):內部對一切 pass 不可見,禁則時整體綁入。spacing Pass B 邊界選擇器擴為 pill ∪ isolate。
|
||||
|
||||
### 移除:v1 相容層
|
||||
- createCjkAutospace/IIFE 全域別名/死選項 autospaceClass 一併刪(消費端已全數遷移 v2 API);I8 退役。測試 101→112 全綠。
|
||||
|
||||
## 行內排版透明性修復 + 標點懸掛移除(2026-06-11)
|
||||
|
||||
### 修復
|
||||
- pill 前標點誤加間隙(漢字。\<code\>):descend 越過 jz-char 取更前字誤補 jz-hws;isTransparentInline 補 JZ-CHAR/JZ-INNER。
|
||||
- 行內樣式元素整體掉行:core/split 克隆切分(splitoff),禁則/垂懸字只綁邊界詞元、revert 延後階段併回。
|
||||
- 審計 T4 lineedge 攀爬集由 styleInlines 派生;T5 jiyaAdjacency 遇 pill 斷相鄰;T6 spacing processPills 補 per-node 功能閘。
|
||||
|
||||
### 移除
|
||||
- 標點懸掛由停用轉正式移除(寬度守恆矛盾,CSS 接觸不到斷行器;負 margin 程式碼/CSS/型別一併刪;hanging 選項保留為 no-op)。
|
||||
|
||||
## 功能分級 + 垂懸字(2026-06-09)
|
||||
|
||||
- 功能分級:文本級(spacing/jiya)vs 段落級(jinze/orphan/longWord);data-jz-level/level:{text} 標記。
|
||||
- 垂懸字避免 orphan(方案 C,斷行層無測量,預設開 chars=2)。
|
||||
- lineedge 行端半形(squeeze)+行歸屬判定改垂直區間比較;標點懸掛實作後確認不可行、全面停用。
|
||||
|
||||
## 標點挤压一致性(2026-06-04)
|
||||
|
||||
### 新增/修復
|
||||
- 全角句點 . 納入句末標點:BD_STOP 由 。!? 擴為 。.!?(. U+FF0E 異於 。 U+3002)。Noto CJK 有 halt、挤压收 0.5em,與 。一致;inkBias 補(簡 left/繁 center)。政策同 。!?:全角式與開明式句末保持全角 1em,banjiao 挤半。原則:halt 即字型挤压意願,字型不給某標點 halt(不宜挤)則信任字型不強挤。
|
||||
- 連續標點挤压:雙側獨立 + 透明穿越。規則:相鄰兩標點,對每一標點獨立判斷,若其有空白之側緊鄰另一標點即收半。修後 ,「 全角式與開明式皆 1em、。」 全角式 1.5em/開明式 1em、·— 兩式皆 2em。並改同 parent 掃描為從 root 透明遍歷,跨 strong/em 邊界之連續標點亦正確挤(原漏致 2em)。
|
||||
|
||||
## 字符分類規則集(2026-06-04)
|
||||
|
||||
### 新增
|
||||
- 可配置字符分類 charClass:硬編碼分類(中文/西文/全寬標點子類)統一抽象為規則集,內置默認,消費端經 options.charClass(cjk/western/biaodian{stop,pause,open,close,middle,liga})追加擴充,覆寫 spacing/jiya/jinze 判定。例:未分類符號(如 ‥ U+2025)可經 biaodian.stop 歸全寬句末標點。向下兼容(不傳則行為不變、實例隔離);v1 追加語義、不重分類內置;結構性 ASCII 不開放。spacing 間隙正則改由規則集 per-instance 編譯。測試 66 通過。
|
||||
|
||||
## 下游回饋修復批次(2026-06-03)
|
||||
|
||||
皆以真實 Chromium 量測驗證(jsdom 不可信)。
|
||||
|
||||
### 修復
|
||||
- 原子透明(段落繼承屬性漏溢):jz-* inline-block 原子對段落樣式透明——歸零 text-indent/hanging-punctuation(過套用),令原子 text-decoration:inherit 接續下劃線(欠套用)。
|
||||
- 中西間隙 gapTrim 自我應驗:行末去隙原依「後字是否在更低行」判定,後字落點受間隙自身及下游間隙 margin 推擠致行中間隙誤 trim(容器寬相關「最後一個間隙消失」、pill 跨行右隙消失)。改「起始全 trim、左→右逐一定案」。
|
||||
- 中西間隙西文集完整化:補貨幣(€ 等)、拉丁擴充 A/B(拼音 ā ǎ ē)、西里爾、數學運算符,希臘補全至 α-ω。列舉法 \uXXXX、無需 flag、相容 2016。
|
||||
- 標點誤隙:…… 後接 CJK 不誤補(DOTS_CJK 去 |…);收尾彎引號 ” 後接拉丁/數字不補(移除 pangu QUOTE_AN 偏例)。中文標點 × 簡繁 104 例稽核皆 0 誤隙。
|
||||
|
||||
### 變更
|
||||
- 字型移出聚珍管理:不再內建 font-family/字型堆疊(移除 _lang.css);逐語言字型交消費端 CSS(範式見文檔「字型與 locl」)。聚珍僅守:jz-* font:inherit 繼承消費端字型、半形 font-feature 顯式併 locl(連同 halt)、render 不改 lang。halt/margin 兩模式字型繼承與 locl 皆驗證正常。
|
||||
|
||||
### 工具
|
||||
- demo/resize-check.html:可拖曳容器檢查頁(各寬度間隙穩定性、標點無誤隙)。
|
||||
|
||||
## v2 切片 1 · 增補(2026-06)
|
||||
|
||||
### 新增
|
||||
- 標點半形 margin 後備模式(缺 halt 字型):jiya.halfWidth "halt"|"margin";margin 以 width:0.5em+字身偏側溢出定位重現 halt。模式由 data-jz-halfwidth 子樹屬性決定。
|
||||
- 字形偏側表 inkBias(left/center/right/full),jiya.bias 逐 lang 逐字覆寫。
|
||||
- tools/measure-bias.py 字型偏側量測+字型適配工作流;demo/fallback.html。
|
||||
- justifyAtoms 選項(false → jz-char/jz-jinze 改 inline,相容 Paged.js)。
|
||||
|
||||
### 修復
|
||||
- _lang.css 字型堆疊改純後備(:where 零特異度、只命中 [lang] 元素、不覆寫 jz-inner)→ 消費端字型恆優先。
|
||||
- adjacentLogicalChar 透明穿越 jz-jinze → pill 緊鄰標點之左緣間隙與作者空格剝除復原。
|
||||
- CJK↔CJK 贅餘作者空格(含跨 inline 標籤)剝除不留隙。
|
||||
|
||||
### 已知限制
|
||||
- margin 後備與 justifyAtoms:false 不可兼得(分頁器場景用 halt 字型)。
|
||||
|
||||
## v2.0.0 切片 1 · 從零重寫
|
||||
|
||||
依 ARCHITECTURE.md 從零重寫。公開 API createJuzhen/全域 Juzhen,前綴 jz-。
|
||||
|
||||
- 核心引擎 finder/unicode/locale/dom/pass。
|
||||
- 中西間隙(margin 模型,copy-clean)+gapTrim 行末去隙。
|
||||
- 標點擠壓 全角/開明/半角,三機制,半形 halt。
|
||||
- 禁則避頭尾+justify 綁定;長英文詞;justify 句末單份間距;v1 相容層。
|
||||
|
||||
## v1(cjk-autospace,v1-autospace 分支)
|
||||
|
||||
Pangu 風格 CJK↔Latin 間距 shim。Pass A 中西間隙/B pill/C 長詞/D 開明擠壓。
|
||||
</script>
|
||||
|
||||
<!-- RUNTIME_JS -->
|
||||
|
||||
</body>
|
||||
</html>
|
||||
+6736
File diff suppressed because one or more lines are too long
@@ -0,0 +1,271 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-Hant">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>聚珍(Juzhen)</title>
|
||||
<meta name="generated-at" content="2026-06-02">
|
||||
<meta name="source" content="cjk-autospace · 使用文檔">
|
||||
<!-- RUNTIME_CSS -->
|
||||
</head>
|
||||
<body data-toc="1">
|
||||
|
||||
<header class="page">
|
||||
<div class="meta">
|
||||
<h1>聚珍(Juzhen)</h1>
|
||||
<p class="subtitle">現代簡/繁中文網頁排版套件——Han.css 之現代化重寫,複製乾淨、依 lang 全角/開明切換、justify 句末標點單份間距、跨瀏覽器一致。</p>
|
||||
<p class="timestamp"><time>2026-06-02</time> · <span>cjk-autospace · 使用文檔</span></p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<!-- ACTIONS -->
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- BODY -->
|
||||
<p class="lede">聚珍(Juzhen)是現代簡/繁中文網頁排版套件,Han.css 之現代化重寫,沿其功能版圖,並針對四點作調整:複製乾淨(中西間隙不入剪貼板)、依 <code>lang</code> 全角/開明自動切換、<code>justify</code> 下句末標點只佔一份間距、跨瀏覽器一致。倉庫名仍為 <code>cjk-autospace</code>;公開 API 為 <code>createJuzhen</code> /全域 <code>Juzhen</code>;注入之自訂元素前綴 <code>jz-</code>。</p>
|
||||
|
||||
<p>本版(2.x)為從零重寫。v1 之 <code>createCjkAutospace</code> 相容層<strong>已移除</strong>(最後一批 v1 消費端已遷移 v2 API);v1 原始碼保存於 <code>v1-autospace</code> 分支。設計契約見 <code>ARCHITECTURE.md</code>。</p>
|
||||
|
||||
<h2>安裝與構建</h2>
|
||||
<pre><code>npm install
|
||||
npm run build # esbuild → dist/juzhen.{mjs,iife.js,css};tsc → dist/index.d.ts
|
||||
npm test # jsdom DOM 正確性測試</code></pre>
|
||||
<p>產物(<code>dist/</code>,已入庫):<code>juzhen.mjs</code>(ESM,供打包器/TypeScript)、<code>juzhen.iife.js</code>(IIFE,全域 <code>Juzhen</code>,原生 <code><script></code> 直跑)、<code>juzhen.css</code>(合併樣式,<strong>必須引入</strong>)、<code>index.d.ts</code>(型別宣告)。</p>
|
||||
|
||||
<h2>用法</h2>
|
||||
<h3>原生 <code><script></code></h3>
|
||||
<pre><code><link rel="stylesheet" href="dist/juzhen.css">
|
||||
<script src="dist/juzhen.iife.js"></script>
|
||||
<script>
|
||||
const jz = Juzhen.createJuzhen({ scope: { include: ".prose" } });
|
||||
jz.render(); // 對 document.body 套用(僅命中 .prose 子樹)
|
||||
// jz.revert(); // 還原
|
||||
</script></code></pre>
|
||||
<h3>ESM/TypeScript</h3>
|
||||
<pre><code>import { createJuzhen } from "cjk-autospace";
|
||||
import "cjk-autospace/css";
|
||||
|
||||
createJuzhen({ lang: { default: "zh-Hant" } }).render(document.querySelector("main"));</code></pre>
|
||||
<div class="callout info"><span class="label">JS 與 CSS 缺一不可</span> JS 只注入 <code>jz-*</code> 結構,所有視覺尺寸(間隙寬度、擠壓量、<code>justify</code> 原子化、字體堆疊)都在 <code>juzhen.css</code>。缺 CSS 則全部失效。</div>
|
||||
|
||||
<h2>已實作功能</h2>
|
||||
<table>
|
||||
<thead><tr><th>功能</th><th>說明</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>中西間隙 <code>spacing</code></td><td>CJK↔西文邊界,<strong>margin 模型</strong>:包裹左側字、以 <code>margin-right</code> 留隙,不注入空白字元 → <code>textContent</code> 完全不變、複製緊湊、<code>justify</code> 不暴增、行首乾淨;行末由 <code>gapTrim</code> layout pass 去隙以齊右。含跨樣式邊界與 pill/隔離元素邊界(<code>code</code>/<code>kbd</code>/<code>.katex</code> 與 <code>mark</code>,兩端補隙口徑一致;pill 緊鄰標點時穿越 <code>jz-jinze</code> 仍補間隙)。贅餘作者空格一律正規化:CJK↔西文吸收為受管間隙、CJK↔CJK(含跨 inline 標籤)剝除不留隙。</td></tr>
|
||||
<tr><td>長英文詞 <code>longWord</code></td><td><code>[A-Za-z]{6,}</code> 包入 <code><span lang="en"></code>,配合消費端 CSS <code>hyphens:auto</code> 原生斷詞(DOM 無連字符 → 複製不污染)。</td></tr>
|
||||
<tr><td>標點擠壓 <code>jiya</code></td><td>標點 charify 為 <code>jz-char</code> + <code>bd-*</code> class。三機制:baseline 政策(全角式不擠/開明式擠句內點號+括號引號/半角式全擠)+行內連續擠壓+行端擠壓。半形預設以字型 OpenType <code>halt</code> 渲染;缺 <code>halt</code> 字型可切 <code>margin</code> 後備模式(見下)。</td></tr>
|
||||
<tr><td>禁則 <code>jinze</code></td><td>行首行末避頭尾:閉類/點號綁前一字(不置行首)、開類綁後一字(不置行末);只隔離邊界詞元,長引文中段仍可斷。邊界字落於<strong>透明</strong>行內樣式元素(<code>strong</code>/<code>em</code> 等)內時<strong>克隆切分</strong>只綁邊界詞元(避免整串入 <code>nowrap</code> 致行末整體掉行),克隆標 <code>data-jz-kind="splitoff"</code>、<code>revert</code> 時併回;pill/隔離/avoid 不可切、<strong>整元素綁定</strong>——pill(如行內公式 <code>.katex</code>)作為原子鄰字單位整體綁入 <code>jz-jinze</code>,絕不下潛肢解(見「行內元素三分類」)。亦為 <code>justify</code> 綁定載體。</td></tr>
|
||||
<tr><td>垂懸字避免 <code>orphan</code></td><td>段末行 CJK 實義字 ≥ N(預設 2;標點不計)。方案 C(斷行層無測量):段末末 N 實義字+尾隨標點包入 <code>jz-orphan</code>(<code>white-space:nowrap</code>),整體不可斷 → 末行恒含之 ⇒ 末行 ≥ N。邊界字落於行內元素內時同樣<strong>克隆切分</strong>(避免長元素溢出容器)。<strong>預設開</strong>;段落級。</td></tr>
|
||||
<tr><td><s>標點懸掛 <code>hanging</code></s></td><td><strong>已移除</strong>:純 CSS 機制為機制級死路(寬度守恆矛盾——墨色出去多少版心內就空多少,CSS 接觸不到斷行器)。原生 <code>hanging-punctuation</code> 為唯一正解但僅 Safari 支援、且與 inline-block 原子互斥。<code>hanging</code> 選項保留為 no-op(無效果)。詳見 ARCHITECTURE §6.5.6。</td></tr>
|
||||
<tr><td>功能分級 <code>level</code></td><td>文本級(間隙、擠壓——任意文本適用)vs 段落級(禁則、垂懸字、長詞——需行/段布局)。塊級預設全功能;<code>data-jz-level="text"</code> 子樹或 <code>level:{text:"選擇器"}</code>(標題/按鈕)僅跑文本級。</td></tr>
|
||||
<tr><td>justify 單份間距</td><td>每個 <code>jz-char</code> 為 <code>inline-block</code> 原子,句末標點只貢獻一份對齊伸縮量。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>後續(架構已預留,不返工):標點修正(簡↔繁)、著重號、注音/Ruby。</p>
|
||||
|
||||
<h2>選項</h2>
|
||||
<pre><code>createJuzhen({
|
||||
lang: {
|
||||
default: "zh-Hant", // 無 lang 祖先時之預設語言標籤
|
||||
style: "kaiming", // 全域擠壓預設:quanjiao|kaiming|banjiao
|
||||
policy: { "zh-Hant": "quanjiao" }, // 逐 lang 覆寫
|
||||
stylePolicy: [ { selector: "h1,button,.ui", style: "banjiao" } ], // 逐元素樣式(優先於 lang)
|
||||
},
|
||||
scope: { root: document.body, include: ".prose", avoid: "blockquote.raw" },
|
||||
spacing: true,
|
||||
longWord: { minLength: 6, lang: "en" },
|
||||
jiya: true, // 或物件:{ halfWidth: "halt"|"margin", bias }
|
||||
jinze: true,
|
||||
orphan: true, // 垂懸字避免(預設開);或 { chars: 2 };false 關
|
||||
// hanging:已移除、無效果(純 CSS 機制級死路,詳見 §6.5.6)
|
||||
level: { text: "h1,h2,h3,button,.ui" }, // 此選擇器子樹僅跑文本級功能
|
||||
justifyAtoms: true, // 分頁器下設 false 改 inline(見下)
|
||||
blocks: [ { selector: ".code-comment", features: [ "spacing" ] } ],
|
||||
});</code></pre>
|
||||
<p>擠壓政策優先序:<strong>逐元素 <code>data-jz-style</code>/<code>stylePolicy</code> > 逐 lang <code>policy</code> > 全域 <code>style</code> > 內建預設</strong>(繁→全角、簡→開明)。<strong>逐元素覆寫</strong>供「非段落/UI 元素用半角式、段落用全角/開明」等:屬性 <code>data-jz-style="banjiao|kaiming|quanjiao"</code>(標於元素或祖先)或選擇器 <code>stylePolicy</code>,近祖先勝、屬性優先於選擇器、非法值回退 lang。</p>
|
||||
<h3>分塊功能選擇(HTML 屬性,就近繼承)</h3>
|
||||
<pre><code><p data-juzhen="spacing jiya">…</p> <!-- 白名單:僅此二功能 -->
|
||||
<p data-juzhen-off="jiya">…</p> <!-- 黑名單:停用 jiya,其餘繼承 -->
|
||||
<div lang="zh-Hans">…</div> <!-- lang 驅動全角/開明 --></code></pre>
|
||||
|
||||
<h2>標點半形:<code>halt</code> 預設與 <code>margin</code> 後備</h2>
|
||||
<p>開明/半角式把標點收成「半形」,有兩種渲染機制:</p>
|
||||
<table>
|
||||
<thead><tr><th>機制</th><th>設定</th><th>原理</th><th>適用</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>halt</code>(預設)</td><td><code>jiya: true</code></td><td>字型 OpenType <code>halt</code>,由字型按字形正確定位、不重疊</td><td>字型含 <code>halt</code>(Noto CJK/思源系列)</td></tr>
|
||||
<tr><td><code>margin</code>(後備)</td><td><code>jiya: { halfWidth: "margin" }</code></td><td><code>width:0.5em</code> +字身依墨色偏側溢出定位(以 CSS 重現 halt)</td><td>字型<strong>缺</strong> <code>halt</code>(多數系統字型、GBK 老字型如方正書宋 GBK、PingFang)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="callout warn"><span class="label">為何需要後備</span> <code>halt</code> 缺席時開明式擠壓「完全無效」(標點仍佔全形)。實測<strong>方正書宋 GBK 僅含 <code>vert</code>、無 <code>halt</code></strong> → halt 模式不擠;切 <code>margin</code> 後備後(開明式 zh-Hans),同句行寬 <code>24.0em → 19.7em</code>、單一句內點號 <code>1.0em → 0.5em</code>,半形生效。</div>
|
||||
<div class="callout"><span class="label">句末標點與連續挤压</span> 句末點號為 <code>。.!?</code>——含<strong>全角句點 <code>.</code></strong>(U+FF0E,異於表意句號 <code>。</code> U+3002,Noto CJK 有 <code>halt</code>);全角式與開明式句末皆<strong>保持全角 1em</strong>,banjiao 挤半。<strong>連續標點</strong>對每一標點獨立判斷——若其有空白之側緊鄰另一標點即收半形:故 <code>,「</code> <strong>全角式與開明式皆 1em</strong>、<code>。」</code> <strong>全角式 1.5em/開明式 1em</strong>;並透明穿越 <code>strong</code>/<code>em</code> 等行內邊界。<strong>句界例外</strong>:句末點號接開類(<code>。「</code>/<code>?「</code>)為句界,保留句末點號右側空白、僅收開類左側 → 全角式與開明式皆 <strong>1.5em</strong>(對稱於 <code>。」</code>);例外僅限句末點號+開類,句內點號 <code>,「</code> 與雙括號 <code>」「</code>/<code>》《</code> 仍兩側皆收 1em。半角式句末點號由 baseline 已半形,故仍 1em(不回歸全寬)。</div>
|
||||
|
||||
<h3>激活與粒度</h3>
|
||||
<p>模式由 <strong><code>data-jz-halfwidth</code> 屬性所在子樹</strong>決定(CSS 後代選擇器),故粒度極靈活:</p>
|
||||
<ul>
|
||||
<li><strong>全域</strong>:<code>jiya: { halfWidth: "margin" }</code> → <code>render()</code> 於處理 root 自動掛屬性。</li>
|
||||
<li><strong>逐區塊/逐語言</strong>:在 HTML 任一祖先手寫 <code>data-jz-halfwidth="margin"</code>(或 <code>"halt"</code>),即令該子樹用對應機制,無需另建實例。常見如「全頁 <code>halt</code>,某用無 <code>halt</code> 字型之區塊掛 <code>margin</code>」,或反之(<code>margin</code> 頁中嵌一塊 <code>halt</code> 島)。</li>
|
||||
</ul>
|
||||
<pre><code><!-- 全頁 halt(預設),此塊改用無 halt 字型 → 局部 margin 後備 -->
|
||||
<section data-jz-halfwidth="margin" style="font-family: '方正书宋_GBK'">…</section></code></pre>
|
||||
|
||||
<h3>機制與字形偏側表(<code>margin</code> 模式專用,可覆寫)</h3>
|
||||
<p><code>margin</code> 模式把標點 <code>jz-char</code> 之 inline-block 寬收為 <strong>0.5em(真半形 advance)</strong>,字身(<code>jz-inner</code>)以 <code>overflow</code> 溢出該盒,再依<strong>墨色偏側</strong>左移、使墨色落於 0.5em 盒內、空白溢向外側——即以 CSS 盒模型重現 <code>halt</code> 之「收字身寬+按字形定位」。相鄰兩標點各 0.5em、合佔 1em,無鄰接疊加或單側重疊問題。</p>
|
||||
<p>偏側依每字墨色中心。聚珍內建<strong>參考表</strong>(依方正書宋 GBK 實測之 glyph ink 位):簡體點號 <code>,、。;:!?</code> 偏左;繁體點號居中;彎引號 <code>“”‘’</code>、角括號 <code>「」</code>、尖括號 <code>〈〉</code> 墨色強偏一側(開→<code>right</code>、閉→<code>left</code>);圓/方/書名等括號 <code>()【】《》…</code> 墨色近中→<code>center</code>。可逐 <code>lang</code> 逐字覆寫:</p>
|
||||
<pre><code>createJuzhen({
|
||||
jiya: {
|
||||
halfWidth: "margin",
|
||||
bias: {
|
||||
"zh-Hans": { ",": "center", "(": "right" }, // 覆寫參考預設
|
||||
"zh-Hant": { "。": "center" },
|
||||
},
|
||||
},
|
||||
});
|
||||
// 偏側值(決定字身於 0.5em 盒內之左移):
|
||||
// left 墨偏左 → 不移(字身左對齊)
|
||||
// center 墨居中 → 左移 0.25em
|
||||
// right 墨偏右 → 左移 0.5em(右半入盒)
|
||||
// full 無可壓縮空白 → 不收、維持全形</code></pre>
|
||||
|
||||
<h3>字型適配工作流(margin 模式配字)</h3>
|
||||
<p>換用新字型時,依下列步驟確認半形渲染:</p>
|
||||
<ol>
|
||||
<li><strong>驗 <code>halt</code></strong>:<code>python3 tools/measure-bias.py <font.ttf></code>(需 <code>pip install fonttools</code>)。首行報該字型是否含 OpenType <code>halt</code>。有則直接用預設(halt 模式),到此為止;無則用 <code>margin</code> 後備,續下。</li>
|
||||
<li><strong>取偏側建議</strong>:同一工具量各標點 glyph 之墨色中心、印出建議偏側表與可貼入 <code>jiya.bias</code> 之 JSON。</li>
|
||||
<li><strong>套用</strong>:<code>createJuzhen({ jiya: { halfWidth: "margin", bias: <貼入 JSON> } })</code>;多數宋體系 GBK 字型與內建參考表相符,未必需覆寫。</li>
|
||||
<li><strong>目視微調</strong>:與原文對照逐字檢查——標點與鄰字<strong>重疊</strong>則偏側往「不移」方向調(<code>right→center→left</code>);標點與內容<strong>仍有空隙</strong>則往「多移」方向調(<code>left→center→right</code>);某標點無可壓空白則設 <code>full</code>。</li>
|
||||
<li><strong>記錄</strong>:把該字型之 <code>bias</code> 覆寫存入專案設定,作為該字型之適配結果。</li>
|
||||
</ol>
|
||||
<p>偏側是「字身於 0.5em 盒內左移多少」的三檔量化(0/0.25/0.5em);量測值近門檻時以目視為準。<code>halt</code> 字型由字型精確定位,無此配字需求——<code>margin</code> 配字僅為缺 <code>halt</code> 字型之務實近似。</p>
|
||||
|
||||
<h2>CSS 基礎設定(消費端須知)</h2>
|
||||
<p>JS 只注入 <code>jz-*</code> 結構,<strong>所有視覺尺寸在 <code>juzhen.css</code></strong>。消費端須備妥:</p>
|
||||
<ol>
|
||||
<li><strong>引入樣式(必須)</strong>:<code><link rel="stylesheet" href="dist/juzhen.css"></code>(或 ESM <code>import "cjk-autospace/css"</code>)。</li>
|
||||
<li><strong>設定 <code>lang</code>(強烈建議)</strong>:內容容器標 <code>lang="zh-Hans"</code>/<code>"zh-Hant"</code>/<code>"ja"</code>,驅動全角/開明政策與字形 <code>locl</code>。</li>
|
||||
<li><strong>作用域類</strong>:若設 <code>scope.include: ".juzhen"</code>,須給內容容器加對應 class;<code>juzhen.css</code> 之 normalize 亦收斂於此域。</li>
|
||||
<li><strong>長英文詞斷字</strong>:須由消費端 CSS 提供 <code>hyphens: auto</code>(含 <code>-webkit-hyphens</code>)。</li>
|
||||
<li><strong><code>justify</code>(選用)</strong>:欲兩端對齊則自設 <code>text-align: justify</code>;句末標點單份間距與行末去隙由聚珍處理。</li>
|
||||
<li><strong>字型由消費端負責</strong>:聚珍<strong>不</strong>內建任何 <code>font-family</code>/字型堆疊——逐語言字型完全由消費端 CSS 決定(推薦範式:逐 <code>lang</code> 設於 <code>.juzhen</code> 或帶 <code>lang</code> 之容器,<strong>勿</strong>直接設 <code>jz-inner</code>)。聚珍僅以 <code>jz-* { font: inherit }</code> 使原子繼承消費端字型、不強加,並顯式保留 <code>locl</code>(不改寫 <code>lang</code>、不關閉 locl,故正確標 <code>lang</code> + 用支援 locl 之字型即正常)。半形擠壓依字型 OpenType <code>halt</code>;缺 <code>halt</code> 之字型須用 <code>jiya.halfWidth:"margin"</code> +按字型以 <code>jiya.bias</code> 微調。</li>
|
||||
<li><strong>自動處理</strong>:<code>juzhen.css</code> 已於 <code>.juzhen</code> 範圍內以 <code>@supports</code> 關閉 Blink 原生 <code>text-spacing-trim</code>/<code>text-autospace</code>,並令注入間隙 <code>user-select: none</code>(複製乾淨)。</li>
|
||||
<li><strong>段落屬性照常設、原子透明</strong>:聚珍把標點拆成 <code>inline-block</code> 原子,但已確保原子對消費端段落樣式透明——段落可正常設 <code>text-indent</code>(首行縮排)、<code>hanging-punctuation</code>、<code>text-decoration</code>(下劃線)等。庫內已歸零會被原子重複套用之繼承屬性、並令原子承接段落裝飾,故首行縮排正常、下劃線連續不斷,<strong>無需任何額外配置</strong>。</li>
|
||||
</ol>
|
||||
|
||||
<h2>行內元素三分類:透明/隔離/整體</h2>
|
||||
<p>行內元素對排版有且僅有三種口徑。<strong>邏輯文本流之遍歷原語共用單一判定</strong> <code>isTransparent</code>(中西間隙、擠壓相鄰、禁則綁定、垂懸字);<strong>未在 <code>styleInlines</code> 之未知行內標籤(自訂元素等)採嚴格白名單、一律當邊界</strong>,透明須顯式登記。多選擇器同命中時依單一分類器 <code>finder.category</code> 之<strong>優先序</strong> <code>avoid > pill > isolate > block > transparent > opaque</code> 定案(如 <code>data-jz-skip</code> 之 pill → avoid;pill+isolate → pill)。判定<strong>以元素為準、非標籤名</strong>——KaTeX 全樹皆 <code><span></code>,但 <code>.katex</code> 命中 <code>pillSelector</code> 即為整體邊界。結構克隆切分 <code>canSplit</code>、行端物理版面 <code>lineedge</code> 為正當之獨立層(邊界各異,見架構)。</p>
|
||||
<table>
|
||||
<thead><tr><th>分類</th><th>選擇器</th><th>邊界</th><th>內部</th><th>典型</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><strong>透明</strong></td><td><code>styleInlines</code>(<strong>未列之行內標籤=邊界</strong>)</td><td>穿越——間隙/相鄰/run 皆延續</td><td>照常處理;禁則可<strong>克隆切分</strong>(最小綁定)</td><td><code>strong</code>/<code>em</code>/<code>a</code>/<code>span</code></td></tr>
|
||||
<tr><td><strong>隔離</strong></td><td><code>isolateSelector</code>(預設 <code>"mark"</code>;<code>""</code> 停用)</td><td>斷 run/斷相鄰/不可切分;<strong>兩端</strong>補 <code>jz-hws</code> 間隙</td><td><strong>照常處理</strong>(自成脈絡)</td><td>帶背景色之 <code><mark></code>、badge 類</td></tr>
|
||||
<tr><td><strong>整體</strong></td><td><code>pillSelector</code>(預設 <code>"code, kbd"</code>)</td><td>同隔離</td><td><strong>完全不可見</strong>——不 charify、不插間隙、不切分、不下潛</td><td><code>code</code>/<code>kbd</code>、行內公式 <code>.katex</code>、引用標 <code>.cite-ref</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>禁則遇 pill(<code>…$公式$,</code>):整個 pill 作為原子鄰字單位<strong>整體綁入</strong> <code>jz-jinze</code>(公式本不可斷,無額外代價);隔離元素同樣整綁(切分背景盒有可見接縫)。<strong>avoid 七源</strong>(<code>isSkipped</code>/<code>skipTags</code>/SVG/<code>contentEditable</code>/<code>data-jz-skip</code>/<code>skipAttribute</code>/<code>scope.avoid</code>)以<strong>自身</strong>判定、命中即邊界;一次性檢查用組合入口 <code>isAvoided</code>(自身或祖先)。<strong><code>skipTags</code> 預設不含 <code>code</code>/<code>kbd</code></strong>——二者由 <code>pillSelector</code> 以 pill 涵蓋(三分類互斥)。</p>
|
||||
<div class="callout"><span class="label">建議</span> 行內公式/夾注引用等獨立行內單位宜列為 pill:<code>pillSelector: "code, kbd, .katex, .cite-ref"</code>——兩側補隙、吸收作者空格、整體綁定,統一處理。</div>
|
||||
|
||||
<h2>分頁器(Paged.js 等)相容:<code>justifyAtoms</code></h2>
|
||||
<p>預設 <code>jz-char</code>/<code>jz-jinze</code> 為 <code>inline-block</code>,作 <code>justify</code> 之單一對齊原子。但<strong>分頁器</strong>(如 Paged.js)之對齊引擎會把每個 inline-block 邊界當伸縮點、與 CJK inter-ideograph 疊加,於窄欄混排時字距失控、整段散架。此時設 <code>justifyAtoms: false</code>——<code>jz-char</code>/<code>jz-jinze</code> 改 <code>display:inline</code>(半形 <code>halt</code> 與禁則 <code>nowrap</code> 綁定均保留)。常規流式版面無需設定。</p>
|
||||
<div class="callout warn"><span class="label">注意</span> <code>margin</code> 後備半形依賴 <code>inline-block</code>(<code>width:0.5em</code>),故 <code>justifyAtoms:false</code> 與 <code>margin</code> 模式不可兼得——分頁器場景請用含 <code>halt</code> 之字型。</div>
|
||||
|
||||
<h2>自定義字符分類:<code>charClass</code></h2>
|
||||
<p>每個字符之分類(中文/西文、全寬標點各子類)抽象為<strong>規則集</strong>:內置默認即聚珍分類表,消費端經 <code>options.charClass</code> <strong>擴充</strong>,以覆寫 <code>spacing</code>/<code>jiya</code>/<code>jinze</code> 對特定字符之判定。語義為<strong>追加</strong>——把當前未分類字符或新符號歸入對應類。</p>
|
||||
<pre><code>createJuzhen({
|
||||
charClass: {
|
||||
cjk: "〇", // 追加為中文:與西文邊界補間隙、禁則/justify 視為單字
|
||||
western: "★☆", // 追加為西文(半寬標點亦歸此):與 CJK 邊界補間隙
|
||||
biaodian: { // 追加為全寬標點,按子類:jiya charify/擠壓、禁則避頭尾
|
||||
stop: ".", // 句末(。!?類) pause 句內(,、;:類)
|
||||
open: "", close: "", middle: "", liga: "",
|
||||
},
|
||||
},
|
||||
});</code></pre>
|
||||
<p><strong>典型用例</strong>:英文全角句號 <code>.</code>(U+FF0E,異於中文句號 <code>。</code> U+3002)預設<strong>未分類</strong>——不 charify、不挤壓、與 CJK 間不補隙。以 <code>charClass:{ biaodian:{ stop:"." } }</code> 歸為全寬句末標點,即比照 <code>。</code> 處理(開明式挤半形、全角式佔全形、行首避頭尾)。</p>
|
||||
<div class="callout"><span class="label">要點</span> <strong>向下兼容</strong>:不傳 <code>charClass</code> 時分類與行為與內置完全一致、且各實例隔離(覆寫不污染默認)。<strong>追加語義(v1)</strong>:擴充內置類,不支援重分類/移除內置字符。結構性 ASCII(字母數字/運算符)為固定集、不開放。<code>margin</code> 模式下新增開/閉括號之墨色偏側可一併以 <code>jiya.bias</code> 指定。</div>
|
||||
|
||||
<h2>v1 向下相容 — 已移除</h2>
|
||||
<p>v1 相容層(<code>createCjkAutospace</code> 導出、IIFE 之全域別名、<code>autospaceClass</code> 選項)<strong>已隨最後一批 v1 消費端遷移完成而移除</strong>。請改用 <code>createJuzhen(options).render(root)</code> v2 API;v1 原始碼保存於 <code>v1-autospace</code> 分支。</p>
|
||||
|
||||
<h2>目視驗證</h2>
|
||||
<p><code>demo/index.html</code> 載入 <code>dist/</code> 產物,簡繁並列、可切 <code>justify</code>、附 copy-clean 測試區。<code>demo/fallback.html</code> 以方正書宋 GBK(無 <code>halt</code>)對照 <code>halt</code> 失效 vs <code>margin</code> 後備生效。<code>demo/resize-check.html</code> 為可拖曳容器檢查頁(驗中西間隙在各寬度恆穩、行末去隙、pill 跨行右隙)。<code>demo/showcase.html</code> 以定寬文本框並列,逐場景演示功能分級與垂懸字。版面相關結論(justify 單份間距、擠壓視覺量、行端、<code>inline-block</code> 基線、間隙隨寬度之穩定性)須於 Chrome/Safari/Firefox 三引擎目視核對——jsdom 僅驗證 DOM 結構正確性。</p>
|
||||
|
||||
</main>
|
||||
|
||||
<footer class="page">
|
||||
<p>本頁為聚珍(Juzhen)使用文檔。所有文檔操作均於 docs 分支進行;main 僅存一份未追蹤之本地鏡像。MIT 授權;借鑑 Han.css/Heti 之思路與分類表,成品自含。</p>
|
||||
</footer>
|
||||
|
||||
<!-- RUNTIME_SPRITE -->
|
||||
|
||||
<script type="text/markdown" id="md-source">
|
||||
# 聚珍(Juzhen)
|
||||
|
||||
現代簡/繁中文網頁排版套件。Han.css 之現代化重寫:複製乾淨、依 `lang` 全角/開明切換、`justify` 句末標點單份間距、跨瀏覽器一致。公開 API `createJuzhen` /全域 `Juzhen`,自訂元素前綴 `jz-`。
|
||||
|
||||
## 用法
|
||||
|
||||
```html
|
||||
<link rel="stylesheet" href="dist/juzhen.css">
|
||||
<script src="dist/juzhen.iife.js"></script>
|
||||
<script>
|
||||
Juzhen.createJuzhen({ scope: { include: ".prose" } }).render();
|
||||
</script>
|
||||
```
|
||||
|
||||
JS 只注入 `jz-*` 結構,所有視覺尺寸在 juzhen.css,缺一不可。
|
||||
|
||||
## 已實作功能
|
||||
|
||||
- spacing 中西間隙(margin 模型,copy-clean;含 pill/隔離元素邊界與作者空格正規化)【文本級】
|
||||
- longWord 長英文詞包裝(`<span lang>` + hyphens:auto)【段落級】
|
||||
- jiya 標點擠壓(全角/開明/半角;半形 halt 預設、margin 後備)【文本級】
|
||||
- jinze 禁則(避頭尾、justify 綁定;透明元素克隆切分最小綁定,pill/隔離/avoid 整元素綁定)【段落級】
|
||||
- orphan 垂懸字避免(方案 C,末行 CJK 實義字 ≥ N;預設開 chars=2)【段落級】
|
||||
- hanging 標點懸掛:已移除(純 CSS 機制級死路:寬度守恆矛盾,CSS 接觸不到斷行器;原生 hanging-punctuation 為唯一正解但僅 Safari 支援、且與 inline-block 原子互斥;選項保留為 no-op;詳見 §6.5.6)
|
||||
- level 功能分級(文本級 vs 段落級;data-jz-level="text"/level:{text:選擇器} 標記標題等僅跑文本級)
|
||||
- justify 句末標點單份間距(jz-char inline-block 原子)
|
||||
|
||||
## 標點半形:halt 預設與 margin 後備
|
||||
|
||||
- halt(預設,`jiya: true`):字型 OpenType halt 定位,需字型支援。
|
||||
- margin(`jiya: { halfWidth: "margin" }`):width:0.5em+字身依墨色偏側溢出定位,以 CSS 重現 halt;供缺 halt 字型(如方正書宋 GBK,僅含 vert)。
|
||||
- 模式由 `data-jz-halfwidth` 子樹屬性決定,可逐區塊/lang。
|
||||
- 偏側 inkBias 給 left/center/right/full,可 `jiya.bias` 逐 lang 逐字覆寫。
|
||||
- 句末點號 。.!?(含全角句點 . U+FF0E,異於 。 U+3002):全角式與開明式句末皆保持全角 1em,banjiao 挤半。
|
||||
- 連續標點:對每一標點獨立判斷,若其有空白之側緊鄰另一標點即收半。,「 全角式與開明式皆 1em;。」 全角式 1.5em/開明式 1em;透明穿越 strong/em 等行內邊界。句界例外:句末點號接開類(。「/?「)保留句末點號右空白、僅收開類 → 全角式與開明式皆 1.5em(對稱於 。」);僅限句末點號+開類,,「 與 」「/》《 仍 1em。半角式句末點號 baseline 已半、仍 1em(不回歸)。
|
||||
|
||||
### 字型適配工作流
|
||||
|
||||
1. `python3 tools/measure-bias.py <font.ttf>` 驗 halt、量偏側。
|
||||
2. 有 halt → 用預設;無 halt → margin 後備+偏側表。
|
||||
3. 套用 jiya.bias,瀏覽器目視微調(重疊→往不移;有隙→往多移)。
|
||||
|
||||
## CSS 基礎設定
|
||||
|
||||
引入 juzhen.css(必須);設 lang;scope class;hyphens:auto;text-align:justify(選用)。字型由消費端負責:聚珍不內建 font-family,逐語言字型自設於 .juzhen 或帶 lang 之容器(含 halt 字型,缺則 margin 後備+bias);聚珍以 font:inherit 繼承消費端字型、顯式保留 locl(正確標 lang + 支援 locl 之字型即正常)。段落屬性照常設、原子透明:text-indent/hanging-punctuation/text-decoration 等已由庫內統一處理(歸零過套用、令原子承接段落裝飾),首行縮排正常、下劃線連續,無需額外配置。
|
||||
|
||||
## 行內元素三分類
|
||||
|
||||
行內元素對排版三種口徑,邏輯文本流之遍歷原語共用單一判定 isTransparent(以元素為準、非標籤名);未在 styleInlines 之未知行內標籤採嚴格白名單、一律當邊界(透明須顯式登記):透明(styleInlines——穿越、run 延續、可克隆切分);隔離(isolateSelector,預設 "mark"——邊界斷 run/斷相鄰/不可切分、兩端補隙,內部照常處理);整體(pillSelector——內部對一切 pass 不可見,禁則時作為原子鄰字單位整體綁入 jz-jinze)。結構克隆切分 canSplit、行端物理版面 lineedge 為正當之獨立層。多命中依單一分類器 finder.category 之優先序 avoid>pill>isolate>block>transparent>opaque 定案(data-jz-skip 之 pill→avoid;pill+isolate→pill)。avoid 七源(isSkipped/skipTags/SVG/contentEditable/data-jz-skip/skipAttribute/scope.avoid)以自身判定、命中即邊界(一次性用組合 isAvoided);skipTags 預設不含 code/kbd(由 pill 涵蓋、三分類互斥)。行內公式/夾注引用宜列為 pill:`pillSelector: "code, kbd, .katex, .cite-ref"`。
|
||||
|
||||
## 分頁器相容
|
||||
|
||||
`justifyAtoms: false` → jz-char/jz-jinze 改 display:inline,相容 Paged.js。margin 後備與 justifyAtoms:false 不可兼得。
|
||||
|
||||
## 自定義字符分類 charClass
|
||||
|
||||
字符分類(中/西文、全寬標點各子類)抽象為規則集,內置默認,消費端經 options.charClass 追加擴充,覆寫 spacing/jiya/jinze 之判定。`charClass: { cjk?, western?, biaodian?:{stop,pause,open,close,middle,liga} }`。例:英文全角句號 .(U+FF0E)預設未分類,以 biaodian.stop:"." 歸為全寬句末標點即比照 。處理。向下兼容(不傳則行為不變、實例隔離);v1 為追加語義,不重分類內置;結構性 ASCII 不開放。
|
||||
|
||||
## v1 相容 — 已移除
|
||||
|
||||
v1 相容層(createCjkAutospace/IIFE 全域別名/autospaceClass)已隨最後一批 v1 消費端遷移完成而移除;請改用 createJuzhen(options).render(root)。v1 原始碼保存於 v1-autospace 分支。
|
||||
|
||||
MIT。借鑑 Han.css/Heti 思路與分類表,成品自含。
|
||||
</script>
|
||||
|
||||
<!-- RUNTIME_JS -->
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Generated
-1335
File diff suppressed because it is too large
Load Diff
@@ -1,43 +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"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"ARCHITECTURE.md",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "node build.mjs && tsc --emitDeclarationOnly",
|
||||
"test": "node build.mjs && node --test test/*.test.mjs"
|
||||
},
|
||||
"keywords": [
|
||||
"cjk",
|
||||
"chinese",
|
||||
"typography",
|
||||
"autospace",
|
||||
"han.css",
|
||||
"clreq",
|
||||
"punctuation"
|
||||
],
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"esbuild": "^0.24.0",
|
||||
"jsdom": "^25.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
-153
@@ -1,153 +0,0 @@
|
||||
// jz-* 元素工廠與還原(架構 §4.4/§5)。
|
||||
//
|
||||
// 所有注入結構皆帶 data-jz="<pass>" 標記(冪等 + 可定址還原,I6),並以
|
||||
// data-jz-kind 區分「marker(純注入,還原時移除)」與「wrap(包裝原內容,
|
||||
// 還原時解包)」。視覺尺寸一律在 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 之 kind:marker(純注入,還原時移除)/wrap(包裝原內容,還原時解包)。
|
||||
// data-jz-kind 另有第三種值 "splitoff"(克隆切分之邊界詞元),由 core/split.ts 直接以
|
||||
// setAttribute 設定、不經 createJz;revertPass 於 wrap 全解包後之第二階段併回(見下)。
|
||||
type JzKind = "marker" | "wrap";
|
||||
|
||||
export interface CreateOptions
|
||||
{
|
||||
/** 追加之 class(空白分隔或陣列)。 */
|
||||
classes?: string | string[];
|
||||
/** 文本內容。 */
|
||||
text?: string;
|
||||
}
|
||||
|
||||
const KIND_ATTR = "data-jz-kind";
|
||||
const PASS_ATTR = "data-jz";
|
||||
|
||||
function doc(): Document
|
||||
{
|
||||
if (typeof document === "undefined")
|
||||
{
|
||||
throw new Error("juzhen: 無 document(需於瀏覽器或 jsdom 環境執行)。");
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
/** 造一個帶 pass 標記之 jz-* 元素。 */
|
||||
export function createJz(
|
||||
tag: JzTag,
|
||||
pass: string,
|
||||
kind: JzKind,
|
||||
opts: CreateOptions = {},
|
||||
): HTMLElement
|
||||
{
|
||||
const el = doc().createElement(tag);
|
||||
el.setAttribute(PASS_ATTR, pass);
|
||||
el.setAttribute(KIND_ATTR, kind);
|
||||
if (opts.classes)
|
||||
{
|
||||
const list = Array.isArray(opts.classes) ? opts.classes : [ opts.classes ];
|
||||
for (const c of list) { if (c) { el.classList.add(c); } }
|
||||
}
|
||||
if (opts.text !== undefined) { el.textContent = opts.text; }
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* 還原某 pass 之全部產物(I6)。以 data-jz 定址,逆文件序處理(先內層後
|
||||
* 外層),marker 移除、wrap 解包,最後 normalize 受影響父節點合併文本。
|
||||
*
|
||||
* splitoff(克隆切分之邊界詞元,見 core/split.ts)須在 wrap 全部解包**之後**才併回:
|
||||
* 否則其前兄弟可能仍是 wrap 殼(如 jz-jinze)而非原元素。故主迴圈跳過 splitoff,於
|
||||
* 解包後第二階段以文件序逐一併回前兄弟(同標籤元素),還原為單一元素(I6 一致)。
|
||||
*/
|
||||
export function revertPass(pass: string, root: ParentNode): void
|
||||
{
|
||||
const nodes = Array.from(
|
||||
root.querySelectorAll<HTMLElement>('[' + PASS_ATTR + '="' + pass + '"]'),
|
||||
);
|
||||
// 逆序:子元素先於父元素處理,避免解包父後子引用失效。
|
||||
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 (kind === "splitoff") { continue; } // 延後階段處理。
|
||||
touched.add(parent);
|
||||
if (kind === "marker")
|
||||
{
|
||||
parent.removeChild(el);
|
||||
}
|
||||
else
|
||||
{
|
||||
// wrap:把子節點搬出後移除外殼。
|
||||
while (el.firstChild)
|
||||
{
|
||||
parent.insertBefore(el.firstChild, el);
|
||||
}
|
||||
parent.removeChild(el);
|
||||
}
|
||||
}
|
||||
// 第二階段:splitoff 併回(此時 wrap 已全解包,前兄弟即原元素)。文件序,使同一
|
||||
// 原元素之多個 splitoff 依序併入。
|
||||
const splitoffs = Array.from(
|
||||
root.querySelectorAll<HTMLElement>(
|
||||
'[' + PASS_ATTR + '="' + pass + '"][' + KIND_ATTR + '="splitoff"]',
|
||||
),
|
||||
);
|
||||
for (const so of splitoffs)
|
||||
{
|
||||
const parent = so.parentNode;
|
||||
if (!parent) { continue; }
|
||||
touched.add(parent);
|
||||
const prev = so.previousSibling;
|
||||
if (prev && prev.nodeType === 1 && (prev as Element).nodeName === so.nodeName)
|
||||
{
|
||||
// 併入前兄弟(同標籤原元素)尾部。
|
||||
while (so.firstChild) { prev.appendChild(so.firstChild); }
|
||||
parent.removeChild(so);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 前兄弟非預期同標籤元素(罕見,結構曾被外力改動):原地解包,仍移除標記。
|
||||
while (so.firstChild) { parent.insertBefore(so.firstChild, so); }
|
||||
parent.removeChild(so);
|
||||
}
|
||||
}
|
||||
for (const p of touched)
|
||||
{
|
||||
if ((p 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,572 +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";
|
||||
|
||||
/** 邏輯 run 中之單一字元來源位置。 */
|
||||
export interface CharPos
|
||||
{
|
||||
textNode: Text;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
/** 一段邏輯文本:剝離樣式後之連續字元,及其逐字回映射。 */
|
||||
export interface LogicalRun
|
||||
{
|
||||
text: string;
|
||||
map: CharPos[];
|
||||
}
|
||||
|
||||
/** pill 鄰接之邏輯鄰字。 */
|
||||
export interface AdjacentChar
|
||||
{
|
||||
textNode: Text;
|
||||
offset: number;
|
||||
ch: string;
|
||||
}
|
||||
|
||||
/** 行內元素對排版之口徑分類(§4.1)——恰屬其一,優先序見 Finder.category。 */
|
||||
export type InlineCategory =
|
||||
| "avoid" | "pill" | "isolate" | "block" | "transparent" | "opaque";
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
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(元素數)——兄弟節點共用
|
||||
// 祖先鏈快取。同 categoryCache:Finder 隨 render 重建 → 僅存活單次 render、無跨 render 失效。
|
||||
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
|
||||
* 隨 makeCtx 每次 render 重建 → 快取僅存活單次 render,無跨 render 失效問題(持久化屬 C1,
|
||||
* 另議)。
|
||||
*/
|
||||
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)。 */
|
||||
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.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。回傳 text.length >= 2 之 run。
|
||||
*/
|
||||
collectRuns(block: Element): LogicalRun[]
|
||||
{
|
||||
const runs: LogicalRun[] = [];
|
||||
let curText = "";
|
||||
let curMap: CharPos[] = [];
|
||||
|
||||
const flush = (): void =>
|
||||
{
|
||||
if (curText.length >= 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)) { return; }
|
||||
const data = t.data;
|
||||
for (let i = 0; i < data.length; i += 1)
|
||||
{
|
||||
curText += data.charAt(i);
|
||||
curMap.push({ textNode: t, offset: i });
|
||||
}
|
||||
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。
|
||||
recurse();
|
||||
return;
|
||||
}
|
||||
default: // "isolate" | "opaque"(未知行內,嚴格白名單)
|
||||
{
|
||||
// 斷 run,內部自成獨立 run(照常處理、不與外部相鄰)。
|
||||
flush();
|
||||
recurse();
|
||||
flush();
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let c = block.firstChild;
|
||||
while (c) { visit(c); c = c.nextSibling; }
|
||||
flush();
|
||||
return runs;
|
||||
}
|
||||
|
||||
/**
|
||||
* pill 之邏輯鄰字(架構 §4.1):經樣式鏈遞迴,遇 pill/block/avoid/間隙
|
||||
* marker 即止。direction = -1 取前鄰字,+1 取後鄰字。
|
||||
*/
|
||||
adjacentLogicalChar(el: Element, direction: -1 | 1): AdjacentChar | null
|
||||
{
|
||||
const pickEnd = (tn: Text): AdjacentChar =>
|
||||
{
|
||||
if (direction === -1)
|
||||
{
|
||||
return {
|
||||
textNode: tn,
|
||||
offset: tn.data.length - 1,
|
||||
ch: tn.data.charAt(tn.data.length - 1),
|
||||
};
|
||||
}
|
||||
return { textNode: tn, offset: 0, ch: tn.data.charAt(0) };
|
||||
};
|
||||
|
||||
// descend 三態:邊界字/null(子樹無內容,續找兄弟)/"blocked"(遇 pill/
|
||||
// isolate/avoid/block 邊界——邏輯鄰居非「字」,終止整個查找)。不可把邊界
|
||||
// 當無內容跳過續找更遠之字:那是「越過 jz-char 取更前字 → 誤隙」之同型錯誤。
|
||||
const descend = (into: Element): AdjacentChar | "blocked" | null =>
|
||||
{
|
||||
let n: Node | null = direction === -1 ? into.lastChild : into.firstChild;
|
||||
while (n)
|
||||
{
|
||||
if (n.nodeType === 3 && (n as Text).data.length > 0)
|
||||
{
|
||||
return pickEnd(n as Text);
|
||||
}
|
||||
if (n.nodeType === 1)
|
||||
{
|
||||
if (!this.isTransparent(n as Element)) { return "blocked"; }
|
||||
const r = descend(n as Element);
|
||||
if (r) { return r; } // 字或 blocked 皆上拋
|
||||
}
|
||||
n = direction === -1 ? n.previousSibling : n.nextSibling;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
let n: Node | null = direction === -1 ? el.previousSibling : el.nextSibling;
|
||||
let p: Node | null = el.parentNode;
|
||||
for (;;)
|
||||
{
|
||||
while (n)
|
||||
{
|
||||
if (n.nodeType === 3)
|
||||
{
|
||||
if ((n as Text).data.length > 0) { return pickEnd(n as Text); }
|
||||
n = direction === -1 ? n.previousSibling : n.nextSibling;
|
||||
continue;
|
||||
}
|
||||
if (n.nodeType === 1)
|
||||
{
|
||||
const e = n as Element;
|
||||
if (!this.isTransparent(e))
|
||||
{
|
||||
return null; // pill/isolate/avoid/block — 邊界,止。
|
||||
}
|
||||
const r = descend(e);
|
||||
if (r === "blocked") { return null; }
|
||||
if (r) { return r; }
|
||||
n = direction === -1 ? e.previousSibling : e.nextSibling;
|
||||
continue;
|
||||
}
|
||||
n = direction === -1 ? n.previousSibling : n.nextSibling;
|
||||
}
|
||||
if (!p || p.nodeType !== 1 || !this.isTransparent(p as Element))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
const pe = p as Element;
|
||||
n = direction === -1 ? pe.previousSibling : pe.nextSibling;
|
||||
p = pe.parentNode;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 節點之功能級別(§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,132 +0,0 @@
|
||||
// 語言與全角/開明政策(架構 §4.3)。
|
||||
//
|
||||
// 解析節點之有效 lang,歸類,並依政策決定標點寬度(全角式/開明式/半角式)。
|
||||
// CSS 端以 :lang() 落實同一政策(I2/I3);本模組供 JS pass 在需要時查詢。
|
||||
|
||||
/** 標點寬度政策。 */
|
||||
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 屬性之祖先,回傳小寫值;無則回傳 fallback。
|
||||
*/
|
||||
export function effectiveLang(node: Node, fallback: string): string
|
||||
{
|
||||
let p: Node | null = node.parentNode;
|
||||
while (p && p.nodeType === 1)
|
||||
{
|
||||
const el = p as Element;
|
||||
const v = el.getAttribute("lang");
|
||||
if (v) { return v.toLowerCase(); }
|
||||
p = p.parentNode;
|
||||
}
|
||||
return (fallback || "").toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 將 BCP-47 語言標籤歸類。前綴以子標籤邊界比對('zh-tw' 命中 zh-Hant,
|
||||
* 'zhx' 不命中 zh)。香港/台灣繁體歸 zh-Hant;含 Hant/Hans 子標籤者優先。
|
||||
*/
|
||||
export function classify(lang: string): LangClass
|
||||
{
|
||||
const l = (lang || "").toLowerCase();
|
||||
if (l === "ja" || l.indexOf("ja-") === 0) { return "ja"; }
|
||||
if (l === "zh" || l.indexOf("zh-") === 0 || l.indexOf("zh") === 0)
|
||||
{
|
||||
// 顯式子標籤優先。
|
||||
if (l.indexOf("hant") >= 0) { return "zh-Hant"; }
|
||||
if (l.indexOf("hans") >= 0) { return "zh-Hans"; }
|
||||
// 地區推斷:台灣/香港/澳門 → 繁體;其餘(含 cn/sg)→ 簡體。
|
||||
if (/-(tw|hk|mo)\b/.test(l)) { 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,97 +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
|
||||
{
|
||||
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); }
|
||||
}
|
||||
|
||||
/**
|
||||
* 把單一文本節點依 requests 重建為 fragment:間隙以原文,命中段以 build()
|
||||
* 之元素。requests 互不重疊;防禦性地丟棄與已採用段重疊者。
|
||||
*/
|
||||
function applyRequests(node: Text, requests: WrapRequest[]): 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)));
|
||||
}
|
||||
frag.appendChild(req.build(data.slice(req.start, req.end)));
|
||||
cursor = req.end;
|
||||
}
|
||||
if (cursor === 0) { return; } // 無有效段。
|
||||
if (cursor < data.length)
|
||||
{
|
||||
frag.appendChild(ownerDoc.createTextNode(data.slice(cursor)));
|
||||
}
|
||||
parent.replaceChild(frag, node);
|
||||
}
|
||||
@@ -1,242 +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";
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/** 文本節點之首詞元結束位置(CJK 字=1;連續非 CJK 非空白=整詞)。 */
|
||||
function leadingTokenEnd(d: string, anyCjk: RegExp): number
|
||||
{
|
||||
if (anyCjk.test(d.charAt(0))) { return 1; }
|
||||
let e = 1;
|
||||
while (e < d.length && isTokenChar(d.charAt(e), anyCjk)) { e += 1; }
|
||||
return e;
|
||||
}
|
||||
|
||||
/** 文本節點之末詞元起始位置。 */
|
||||
function trailingTokenStart(d: string, anyCjk: RegExp): number
|
||||
{
|
||||
const last = d.length - 1;
|
||||
if (anyCjk.test(d.charAt(last))) { return last; }
|
||||
let s = last;
|
||||
while (s > 0 && isTokenChar(d.charAt(s - 1), anyCjk)) { s -= 1; }
|
||||
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,
|
||||
): 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");
|
||||
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,
|
||||
): 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);
|
||||
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,
|
||||
): Element | null
|
||||
{
|
||||
if (!canSplit(el, finder)) { return null; }
|
||||
return splitTreeAfter(el, textNode, offset, pass, finder);
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
// 字元分類與規則字集(架構 §4.2)。
|
||||
//
|
||||
// 集中所有 Unicode 範圍與標點子類,供各 pass 共用,杜絕分類散落不一致。
|
||||
// CJK/西文字集沿用 v1(cjk-autospace)已驗證之 Pangu 範圍;標點子類為新增,
|
||||
// 對應 CSS class(jz-char 之 bd-* 標記)。
|
||||
//
|
||||
// 這些常數以「regex 字元類片段」形式給出(已就 RegExp 字元類做必要轉義),
|
||||
// 故可直接 `new RegExp("[" + X + "]")`。
|
||||
|
||||
import type { LangClass } from "./locale.js";
|
||||
|
||||
/** CJK 表意文字/假名/注音等範圍(沿用並擴充 v1 之 PANGU_CJK)。 */
|
||||
export const CJK = (
|
||||
"⺀-⼀-" // CJK 部首補充 + 康熙部首
|
||||
+ "-ゟ゠-ヺー-ヿ" // 平假名 + 片假名
|
||||
+ "-ㄯ㈀-㋿" // 注音符號 + 圈號 CJK
|
||||
+ "㐀-䶿一-鿿豈-" // 擴展 A + 統一表意 + 相容
|
||||
);
|
||||
|
||||
/** 西文字母與數字。 */
|
||||
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)。 */
|
||||
cjk?: string;
|
||||
/** 追加為西文(半寬標點亦歸此,按西文處理間隙);併入 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
|
||||
{
|
||||
for (const sub of BD_ORDER)
|
||||
{
|
||||
if (biaodian[sub].indexOf(ch) >= 0) { return "bd-" + sub; }
|
||||
}
|
||||
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,125 +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: "locl" 1, "halt" 1;
|
||||
-webkit-font-feature-settings: "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)不收、維持全形。 */
|
||||
[data-jz-halfwidth="margin"] jz-char.jz-half jz-inner,
|
||||
[data-jz-halfwidth="margin"] jz-char.jz-half-le jz-inner
|
||||
{
|
||||
font-feature-settings: normal;
|
||||
-webkit-font-feature-settings: normal;
|
||||
}
|
||||
|
||||
[data-jz-halfwidth="margin"] jz-char.jz-half.jz-ink-left,
|
||||
[data-jz-halfwidth="margin"] jz-char.jz-half.jz-ink-center,
|
||||
[data-jz-halfwidth="margin"] jz-char.jz-half.jz-ink-right,
|
||||
[data-jz-halfwidth="margin"] jz-char.jz-half-le.jz-ink-left,
|
||||
[data-jz-halfwidth="margin"] jz-char.jz-half-le.jz-ink-center,
|
||||
[data-jz-halfwidth="margin"] jz-char.jz-half-le.jz-ink-right
|
||||
{
|
||||
width: 0.5em;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
[data-jz-halfwidth="margin"] jz-char.jz-half.jz-ink-left jz-inner,
|
||||
[data-jz-halfwidth="margin"] jz-char.jz-half.jz-ink-center jz-inner,
|
||||
[data-jz-halfwidth="margin"] jz-char.jz-half.jz-ink-right jz-inner,
|
||||
[data-jz-halfwidth="margin"] jz-char.jz-half-le.jz-ink-left jz-inner,
|
||||
[data-jz-halfwidth="margin"] jz-char.jz-half-le.jz-ink-center jz-inner,
|
||||
[data-jz-halfwidth="margin"] jz-char.jz-half-le.jz-ink-right jz-inner
|
||||
{
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
[data-jz-halfwidth="margin"] jz-char.jz-ink-center jz-inner
|
||||
{
|
||||
margin-left: -0.25em;
|
||||
}
|
||||
|
||||
[data-jz-halfwidth="margin"] jz-char.jz-ink-right jz-inner
|
||||
{
|
||||
margin-left: -0.5em;
|
||||
}
|
||||
|
||||
/* margin 模式 jz-inner 為 inline-block,會被段落裝飾盒跳過致下劃線於盒內斷缺
|
||||
(見 _normalize.css「原子透明之另一面」)。令其承段落裝飾自繪、接續下劃線。
|
||||
halt 模式 jz-inner 為 inline、裝飾自然穿透,故僅此模式需要。 */
|
||||
[data-jz-halfwidth="margin"] jz-char jz-inner
|
||||
{
|
||||
text-decoration: inherit;
|
||||
}
|
||||
|
||||
/* 粒度控制:模式由 data-jz-halfwidth **屬性所在子樹** 決定(CSS 後代選擇器),
|
||||
故可掛於任意祖先——root(JS 全域預設)、某 lang 容器、單一段落皆可,無需重設
|
||||
實例。下列 halt-reset 規則置於 margin 規則之後:當 margin 頁面中**巢狀**一塊
|
||||
data-jz-halfwidth="halt"(如該塊改用含 halt 之字型)時,憑來源順序勝出,於該
|
||||
島內復原 halt、撤銷半形盒。常見情境(halt 頁中嵌 margin 塊、或 margin 頁中嵌
|
||||
halt 島)皆成立。 */
|
||||
[data-jz-halfwidth="halt"] jz-char.jz-half jz-inner,
|
||||
[data-jz-halfwidth="halt"] jz-char.jz-half-le jz-inner
|
||||
{
|
||||
font-feature-settings: "locl" 1, "halt" 1;
|
||||
-webkit-font-feature-settings: "locl" 1, "halt" 1;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
[data-jz-halfwidth="halt"] jz-char.jz-half,
|
||||
[data-jz-halfwidth="halt"] jz-char.jz-half-le
|
||||
{
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* 註:原標點懸掛(jz-hang-* 負 margin 凸出版心)已移除——純 CSS 機制為機制級死路
|
||||
(寬度守恆矛盾,見 ARCHITECTURE §6.5.6)。行端僅做半形擠壓(jz-half-le,上方規則)。 */
|
||||
@@ -1,62 +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:同屬區塊容器首/末行機制,每個原子各自懸掛自身標點。
|
||||
Chrome 未實作(測不出),Safari 會觸發;且與本庫規劃之「標點懸掛」相衝,故一併
|
||||
歸零(不支援處為 no-op,支援處既擋原子重套、亦不礙段落級懸掛)。
|
||||
不歸零者(經 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、同遭跳過,故於該模式
|
||||
補一條(halt 模式 jz-inner 為 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,20 +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 四分之一字寬 */
|
||||
}
|
||||
|
||||
/* 行末去隙(由 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";
|
||||
-362
@@ -1,362 +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 { runPasses, revertPasses } from "./core/pass.js";
|
||||
import { jiyaAdjacencyPass, jiyaPass } from "./typeset/jiya.js";
|
||||
import { jinzePass } from "./typeset/jinze.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 群)。故此處排列僅供閱讀分組,
|
||||
// 真實序為 order:jiya(20)→jiyaAdjacency(25)→jinze(40)→spacing(50)→longWord(60)→
|
||||
// orphan(70)→lineEdge(90)→gapTrim(92)。
|
||||
const PASSES: Pass[] = [
|
||||
jiyaPass, jiyaAdjacencyPass, longWordPass, spacingPass, jinzePass,
|
||||
orphanPass, lineEdgePass, gapTrimPass,
|
||||
];
|
||||
|
||||
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" }; }
|
||||
const minLength = typeof v.minLength === "number" && 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" && 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 || "zh-Hant",
|
||||
...(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",
|
||||
];
|
||||
|
||||
/** 建立一個聚珍實例。 */
|
||||
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);
|
||||
|
||||
function makeCtx(root: Element): RenderContext
|
||||
{
|
||||
return { root, options, finder };
|
||||
}
|
||||
|
||||
function renderImpl(el: Element): void
|
||||
{
|
||||
runPasses(PASSES, makeCtx(el));
|
||||
// margin 後備模式以 root 屬性作 CSS 開關(見 css/_jiya.css);halt 模式不設。
|
||||
if (options.features.jiya && options.jiyaConfig.halfWidth === "margin")
|
||||
{
|
||||
el.setAttribute("data-jz-halfwidth", "margin");
|
||||
}
|
||||
// justifyAtoms:false → jz-char/jz-jinze 改 inline(分頁器相容,見 css)。
|
||||
if (!options.justifyAtoms)
|
||||
{
|
||||
el.setAttribute("data-jz-atoms", "inline");
|
||||
}
|
||||
}
|
||||
|
||||
function revertImpl(el: Element): void
|
||||
{
|
||||
revertPasses(PASSES, makeCtx(el));
|
||||
el.removeAttribute("data-jz-halfwidth");
|
||||
el.removeAttribute("data-jz-atoms");
|
||||
// 失效(安全預設):清 el 子樹之元素鍵快取,使後續 render 對「屬性已變」之元素重新分類。
|
||||
// 內容變化雖不改分類(此清屬多餘),但廉價且無害,且 render 本就將重掃該子樹。
|
||||
finder.invalidate(el);
|
||||
}
|
||||
|
||||
// ----- C2 觀察狀態 -----
|
||||
let observer: MutationObserver | null = null;
|
||||
let observeRoot: Element | null = null;
|
||||
const observeConfig: MutationObserverInit = {
|
||||
subtree: true, childList: true, characterData: true,
|
||||
attributes: true, attributeFilter: OBSERVE_ATTRS.slice(),
|
||||
};
|
||||
|
||||
// render/revert 期間暫停觀察 → juzhen 自身之 DOM 變更不入觀察隊列、絕不自觸發。
|
||||
function pauseObserver(): void { if (observer) { observer.disconnect(); } }
|
||||
function resumeObserver(): void
|
||||
{
|
||||
if (observer && observeRoot)
|
||||
{
|
||||
observer.takeRecords(); // 丟棄暫停期間之殘留(保險)
|
||||
observer.observe(observeRoot, observeConfig);
|
||||
}
|
||||
}
|
||||
|
||||
/** node 之最近塊祖先(含自身若為塊);達 observeRoot 仍無塊 → 回 observeRoot。 */
|
||||
function nearestBlock(node: Node): Element | 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)
|
||||
for (const m of records)
|
||||
{
|
||||
if (m.type === "childList")
|
||||
{
|
||||
m.addedNodes.forEach((n) =>
|
||||
{
|
||||
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)
|
||||
{
|
||||
const b = nearestBlock(m.target);
|
||||
if (b) { rerenderBlocks.add(b); }
|
||||
}
|
||||
}
|
||||
else // attributes(白名單)/characterData(文本變化)
|
||||
{
|
||||
const b = nearestBlock(m.target);
|
||||
if (b) { rerenderBlocks.add(b); }
|
||||
}
|
||||
}
|
||||
if (!rerenderBlocks.size && !renderNew.length) { return; }
|
||||
|
||||
pauseObserver();
|
||||
// 去嵌套:只保留極大塊(移除為他者後代者),免重複重處理(重複亦正確、僅浪費)。
|
||||
const all = Array.from(rerenderBlocks).filter((b) => b.isConnected);
|
||||
const blocks = all.filter((b) => !all.some((o) => o !== b && o.contains(b)));
|
||||
for (const b of blocks) { revertImpl(b); renderImpl(b); }
|
||||
for (const el of renderNew)
|
||||
{
|
||||
if (!el.isConnected) { continue; } // 同批又被移除
|
||||
if (blocks.some((b) => b.contains(el))) { continue; } // 已由塊重處理涵蓋
|
||||
renderImpl(el);
|
||||
}
|
||||
resumeObserver();
|
||||
}
|
||||
|
||||
return {
|
||||
options,
|
||||
render(root?: Element | string): void
|
||||
{
|
||||
const el = resolveRoot(root, options);
|
||||
if (!el) { return; }
|
||||
pauseObserver(); renderImpl(el); resumeObserver();
|
||||
},
|
||||
revert(root?: Element | string): void
|
||||
{
|
||||
const el = resolveRoot(root, options);
|
||||
if (!el) { return; }
|
||||
pauseObserver(); revertImpl(el); resumeObserver();
|
||||
},
|
||||
rerender(root?: Element | string): void
|
||||
{
|
||||
const el = resolveRoot(root, options);
|
||||
if (!el) { return; }
|
||||
pauseObserver(); revertImpl(el); renderImpl(el); resumeObserver();
|
||||
},
|
||||
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; }
|
||||
if (observer) { observer.disconnect(); }
|
||||
observeRoot = el;
|
||||
observer = new MutationObserver(onMutations);
|
||||
observer.observe(el, observeConfig);
|
||||
},
|
||||
disconnect(): void
|
||||
{
|
||||
if (observer) { observer.disconnect(); observer = null; observeRoot = null; }
|
||||
},
|
||||
};
|
||||
}
|
||||
-219
@@ -1,219 +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
|
||||
{
|
||||
minLength?: number;
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
/** 垂懸字避免設定(§6.5.1)。布林 true 等同 { chars: 2 }。 */
|
||||
export interface OrphanOptions
|
||||
{
|
||||
/** 段末行至少保留之 CJK 實義字數(標點/符號不計;預設 2)。 */
|
||||
chars?: number;
|
||||
}
|
||||
|
||||
/** 標點擠壓設定(架構 §6.3)。布林 true 等同 { halfWidth: "halt" }。 */
|
||||
export interface JiyaOptions
|
||||
{
|
||||
/** 半形渲染機制:
|
||||
* - "halt"(預設)— 用字型 OpenType `halt`,由字型按字形正確定位,需字型支援;
|
||||
* - "margin" — 負 margin 後備模式,供缺 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"` 標記(屬性優先、最近祖先勝)。
|
||||
* 預設:塊級元素(blockTags)→ 段落級(全功能)。 */
|
||||
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 為唯一正解但僅 Safari 支援、
|
||||
* 且與本庫 inline-block 原子互斥。此選項仍被接受以保 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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";
|
||||
collect: (node: Text, ctx: RenderContext) => WrapRequest[];
|
||||
}
|
||||
|
||||
/** 自跑走訪/版面後處理之 pass。 */
|
||||
export interface StandalonePass extends BasePass
|
||||
{
|
||||
kind: "standalone";
|
||||
render: (ctx: RenderContext) => void;
|
||||
}
|
||||
|
||||
export type Pass = CharifyPass | StandalonePass;
|
||||
@@ -1,257 +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 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; }
|
||||
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; }
|
||||
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;
|
||||
}
|
||||
|
||||
/** 相鄰兩節點之間是否禁止斷行。由「邊界 jz-char」之類別決定;邊界字可落於透明行內
|
||||
* 元素之邊緣(跨 <strong> 等、與外部內容不同 DOM parent),故經 edgeCharEl 下探定位,
|
||||
* 非僅認直接 jz-char——否則「你好<strong>。世界</strong>」之句末點號漏綁、誤置行首
|
||||
* (下游審計 #3)。綁定端由 processParent 之 isolateBoundaryToken 落實(可切則最小綁定、
|
||||
* 不可切則整綁),與此偵測同以透明判定為據。 */
|
||||
function forbiddenBreak(a: Node, b: Node, finder: Finder): boolean
|
||||
{
|
||||
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
|
||||
{
|
||||
if (anyCjk.test(d.charAt(0))) { return 1; }
|
||||
let e = 1;
|
||||
while (e < d.length && isTokenChar(d.charAt(e), anyCjk)) { e += 1; }
|
||||
return e;
|
||||
}
|
||||
|
||||
function trailingTokenStart(d: string, anyCjk: RegExp): number
|
||||
{
|
||||
const last = d.length - 1;
|
||||
if (anyCjk.test(d.charAt(last))) { return last; }
|
||||
let s = last;
|
||||
while (s > 0 && isTokenChar(d.charAt(s - 1), anyCjk)) { s -= 1; }
|
||||
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): 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[] = [];
|
||||
for (let i = 0; i < n - 1; i += 1)
|
||||
{
|
||||
forbidden.push(forbiddenBreak(units[i]!, units[i + 1]!, finder));
|
||||
}
|
||||
|
||||
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/avoid,由 canSplit 諮詢 finder 判定)退化整綁——pill 即「作為原子
|
||||
// 鄰字單位整體綁入 jz-jinze」之期望行為(下游問題六)。
|
||||
const el = u as Element;
|
||||
if (needLast) { isolateBoundaryToken(el, "tail", anyCjk, PASS, finder); }
|
||||
if (needFirst) { isolateBoundaryToken(el, "head", anyCjk, PASS, finder); }
|
||||
}
|
||||
}
|
||||
|
||||
// 階段 2:重掃當前子節點,把禁斷相連之相鄰原子併成 jz-jinze。
|
||||
const kids: Node[] = [];
|
||||
for (let c = parent.firstChild; c; c = c.nextSibling) { kids.push(c); }
|
||||
|
||||
let i = 0;
|
||||
while (i < kids.length)
|
||||
{
|
||||
const run: Node[] = [ kids[i]! ];
|
||||
while (i + 1 < kids.length && forbiddenBreak(kids[i]!, kids[i + 1]!, finder))
|
||||
{
|
||||
run.push(kids[i + 1]!);
|
||||
i += 1;
|
||||
}
|
||||
if (run.length >= 2)
|
||||
{
|
||||
const group = createJz("jz-jinze", PASS, "wrap");
|
||||
parent.insertBefore(group, run[0]!);
|
||||
for (const node of run) { group.appendChild(node); }
|
||||
}
|
||||
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 + "]");
|
||||
const parents = new Set<Node>();
|
||||
ctx.root.querySelectorAll("jz-char").forEach((c) =>
|
||||
{
|
||||
// 功能分級閘(§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 (!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); }
|
||||
},
|
||||
revert(ctx: RenderContext): void
|
||||
{
|
||||
revertPass(PASS, ctx.root);
|
||||
},
|
||||
};
|
||||
@@ -1,198 +0,0 @@
|
||||
// 標點擠壓(架構 §6.3)。完整 clreq 擠壓之「行內」部分——三條規則並集,
|
||||
// 以方向性 jz-cl(收左半形空白)/jz-cr(收右)落地,CSS 以負 margin 渲染,
|
||||
// 跨瀏覽器一致(I4),不依賴 halt/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 { classify, effectiveLang, resolveStyle } from "../core/locale.js";
|
||||
import type { LangClass, PunctStyle } from "../core/locale.js";
|
||||
import { blankSides, classifyBiaodian, inkBias } 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,
|
||||
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; i += 1)
|
||||
{
|
||||
const ch = data.charAt(i);
|
||||
const bdClass = classifyBiaodian(ch, biaodian);
|
||||
if (!bdClass) { 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(ch, langClass, biasOverrides);
|
||||
if (bias) { classes.push("jz-ink-" + bias); }
|
||||
out.push({
|
||||
start: i,
|
||||
end: i + 1,
|
||||
pass: PASS,
|
||||
build: (text) =>
|
||||
{
|
||||
const outer = createJz("jz-char", PASS, "wrap", { classes });
|
||||
const inner = createJz("jz-inner", PASS, "wrap", { text });
|
||||
outer.appendChild(inner);
|
||||
return outer;
|
||||
},
|
||||
});
|
||||
}
|
||||
return out;
|
||||
},
|
||||
revert(ctx: RenderContext): void
|
||||
{
|
||||
revertPass(PASS, ctx.root);
|
||||
},
|
||||
};
|
||||
|
||||
// ----- 行內連續擠壓(機制 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)) { 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 ((c as Text).data.length > 0) { prev = null; } // 文本斷相鄰
|
||||
continue;
|
||||
}
|
||||
if (c.nodeType !== 1) { continue; } // 註解等:略過、不斷
|
||||
const el = c as Element;
|
||||
const nm = el.nodeName;
|
||||
if (nm === "JZ-CHAR")
|
||||
{
|
||||
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":
|
||||
{
|
||||
visit(el); // 穿越、相鄰性延續
|
||||
break;
|
||||
}
|
||||
default: // isolate/block/opaque(未知行內)
|
||||
{
|
||||
prev = null; visit(el); prev = null; // 斷相鄰、內部自成脈絡
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(ctx.root);
|
||||
},
|
||||
revert(): void
|
||||
{
|
||||
// 無獨立產物:class 掛於 jiya charify 之 jz-char,隨其解包清除。
|
||||
},
|
||||
};
|
||||
@@ -1,331 +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(僅
|
||||
// Safari 支援、且與本庫 inline-block 原子互斥),屬遠期「原生管線」新功能、與此處無共享。
|
||||
//
|
||||
// ⚠ 純版面、無法於 jsdom 驗證(getClientRects 回傳 0);故無 ResizeObserver
|
||||
// 之環境(jsdom)直接跳過,僅於真實瀏覽器生效(架構 §12/R1/R2)。須跨
|
||||
// Chrome/Safari/Firefox 目視核對。
|
||||
|
||||
import { bdClassOf } from "../core/dom.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<string, ResizeObserver>>();
|
||||
|
||||
function hasLayout(): boolean
|
||||
{
|
||||
return typeof ResizeObserver !== "undefined";
|
||||
}
|
||||
|
||||
/** 安裝一個 layout 重算:立即跑一次,掛 ResizeObserver,字型載入後再跑。 */
|
||||
function installLayout(root: Element, key: string, run: () => void): void
|
||||
{
|
||||
run();
|
||||
let m = observers.get(root);
|
||||
if (!m) { m = new Map(); observers.set(root, m); }
|
||||
const existing = m.get(key);
|
||||
if (existing) { existing.disconnect(); }
|
||||
const ro = new ResizeObserver(() => run());
|
||||
ro.observe(root);
|
||||
m.set(key, ro);
|
||||
const fonts = (root.ownerDocument as Document & { fonts?: { ready?: Promise<unknown> } }).fonts;
|
||||
if (fonts && fonts.ready && typeof fonts.ready.then === "function")
|
||||
{
|
||||
fonts.ready.then(run, () => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function uninstallLayout(root: Element, key: string): void
|
||||
{
|
||||
const m = observers.get(root);
|
||||
if (!m) { return; }
|
||||
const ro = m.get(key);
|
||||
if (ro) { ro.disconnect(); m.delete(key); }
|
||||
}
|
||||
|
||||
// 行內包裹集:edgeRect 攀爬時須跨越「不中斷行內流」之元素找相鄰內容(jz-char 可能被
|
||||
// jz-jinze 包住,其流鄰居在群組外)。遇 block 邊界即止(回 null,視為行端)。
|
||||
// **由 options.finder.styleInlines 派生**+聚珍自身之 jz 包裹標籤——舊版硬編碼僅含
|
||||
// SPAN/STRONG/EM/A/B/I/U/MARK 子集,遇未列之樣式行內(S/INS/DEL/SUB/SUP/SMALL/
|
||||
// ABBR/CITE/DFN/Q/VAR/SAMP/TIME/BDO/BDI/RUBY 等)會提前止步、誤判行端而錯加行端
|
||||
// 半形(透明性不一致之同根隱患 T4)。
|
||||
const JZ_WRAP_TAGS = [ "JZ-JINZE", "JZ-CHAR", "JZ-INNER", "JZ-HWS" ];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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" ? t.data.length - 1 : 0;
|
||||
range.setStart(t, at);
|
||||
range.setEnd(t, at + 1);
|
||||
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 邊界回 null(視為行端)。 */
|
||||
// 註:edgeRect 為**版面**測量(getClientRects 之物理相鄰),非邏輯文本流。故攀爬邊界
|
||||
// 僅依 wrapSet(行內包裹之標籤集),**刻意不**排除 isolate(<mark>)——isolate 斷的是
|
||||
// 邏輯 run/相鄰,其內部字元之物理行首行尾判定仍須跨 mark 找真實相鄰內容(如
|
||||
// 「文字<mark>「引」</mark>」之 「 物理上接在「文字」之後、非行首,須攀出 mark 取「文字」
|
||||
// 之 rect,否則誤判行首而錯收左半)。pill 之內部無 jz-char(eachTextNode 已濾),且 pill
|
||||
// 標籤(code/kbd)不在 styleInlines、自然不在 wrapSet,攀爬遇之即止,無需另判。
|
||||
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)
|
||||
{
|
||||
const r = rectOf(n, side);
|
||||
if (r) { return r; }
|
||||
n = side === "prev" ? n.previousSibling : n.nextSibling;
|
||||
}
|
||||
// 本層無內容:若父為行內包裹,攀出續找其鄰居。
|
||||
if (!parentRef || parentRef.nodeType !== 1) { return null; }
|
||||
if (!wrapSet.has((parentRef as Element).nodeName)) { 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(root: Element): void
|
||||
{
|
||||
for (const cls of ALL_EDGE)
|
||||
{
|
||||
root.querySelectorAll("jz-char." + cls).forEach((e) => e.classList.remove(cls));
|
||||
}
|
||||
}
|
||||
|
||||
interface EdgeDecision { el: Element; }
|
||||
|
||||
/**
|
||||
* 行端偵測 + 半形套用(行首開類收左、行尾閉類收右)。
|
||||
*
|
||||
* **兩階段免自我應驗**:先 clearEdge 清除全部行端半形,再於「自然(無行端效果)佈局」下
|
||||
* 量測**全部**標點之行端狀態(階段 1,不變更 DOM),最後一次性套用 jz-half-le(階段 2)。
|
||||
* 如此每次量測皆見無污染之自然佈局——不受自身或他者之 halt 收縮影響(避免迴圈內逐一 add
|
||||
* class 致下游量測偏移,同 gapTrim 之雙向污染教訓)。ResizeObserver 僅觀 root 尺寸、不因
|
||||
* 套用後內部重排再觸發,故終態穩定。 */
|
||||
function relayout(root: Element, finder: Finder, options: ResolvedOptions): void
|
||||
{
|
||||
clearEdge(root);
|
||||
const wrapSet = buildWrapSet(options);
|
||||
|
||||
const chars = Array.from(root.querySelectorAll("jz-char")).filter(
|
||||
(c) => finder.inScope(c) && !finder.isAvoided(c),
|
||||
);
|
||||
|
||||
const decisions: EdgeDecision[] = [];
|
||||
for (const el of chars)
|
||||
{
|
||||
// 行端為段落級(§6.5.0a):text-level 子樹(單行標題等)不處理行端。
|
||||
if (!finder.levelAllows(el, "paragraph")) { continue; }
|
||||
if (!finder.featureEnabledFor(el, "jiya")) { continue; }
|
||||
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) { d.el.classList.add(LE); }
|
||||
}
|
||||
|
||||
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, "lineEdge", () => relayout(ctx.root, ctx.finder, ctx.options));
|
||||
},
|
||||
revert(ctx: RenderContext): void
|
||||
{
|
||||
uninstallLayout(ctx.root, "lineEdge");
|
||||
clearEdge(ctx.root);
|
||||
},
|
||||
};
|
||||
|
||||
// ----- 中西間隙之行末去隙(機制: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(root: Element, finder: Finder, wrapSet: Set<string>): void
|
||||
{
|
||||
const alignCache = new Map<Element, boolean>();
|
||||
const gaps = Array.from(root.querySelectorAll("jz-hws")).filter(
|
||||
(g) => finder.inScope(g) && !finder.isAvoided(g)
|
||||
&& finder.levelAllows(g, "paragraph")
|
||||
&& finder.featureEnabledFor(g, "spacing")
|
||||
&& !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) => g.classList.add(TRIM));
|
||||
for (const g of gaps)
|
||||
{
|
||||
const r = lastRect(g);
|
||||
const next = r ? edgeRect(g.nextSibling, "next", g, wrapSet) : null;
|
||||
const atLineEnd = !r || !next || next.top > r.top + EPS;
|
||||
if (!atLineEnd) { g.classList.remove(TRIM); }
|
||||
}
|
||||
}
|
||||
|
||||
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, "gapTrim", () => trimGaps(ctx.root, ctx.finder, wrapSet));
|
||||
},
|
||||
revert(ctx: RenderContext): void
|
||||
{
|
||||
uninstallLayout(ctx.root, "gapTrim");
|
||||
ctx.root.querySelectorAll("jz-hws." + TRIM).forEach((e) => e.classList.remove(TRIM));
|
||||
},
|
||||
};
|
||||
@@ -1,63 +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;
|
||||
return span;
|
||||
},
|
||||
});
|
||||
}
|
||||
return out;
|
||||
},
|
||||
revert(ctx: RenderContext): void
|
||||
{
|
||||
revertPass(PASS, ctx.root);
|
||||
},
|
||||
};
|
||||
@@ -1,182 +0,0 @@
|
||||
// 垂懸字避免(架構 §6.5.1,方案 C:斷行層、無測量)。standalone 段落級 pass。
|
||||
//
|
||||
// 目標:段末行之 CJK 實義字數 ≥ N(預設 2;標點/符號不計)。單行段天然無害。
|
||||
// 機制:自段末向前數 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;
|
||||
}
|
||||
|
||||
/** 收集 block 內各 CJK 實義字之定位(文件序);descend 透明行內,遇塊/pill/avoid 即止。 */
|
||||
function collectSubst(
|
||||
block: Element,
|
||||
finder: Finder,
|
||||
blockSet: Set<string>,
|
||||
anyCjk: RegExp,
|
||||
biaodian: Biaodian,
|
||||
): SubstPos[]
|
||||
{
|
||||
const out: SubstPos[] = [];
|
||||
|
||||
const visit = (node: Node, directChild: Node): void =>
|
||||
{
|
||||
if (node.nodeType === 3)
|
||||
{
|
||||
const t = node as Text;
|
||||
const d = t.data;
|
||||
for (let i = 0; i < d.length; i += 1)
|
||||
{
|
||||
const ch = d.charAt(i);
|
||||
// 實義字:屬 CJK 字集且非標點(標點/符號不計)。
|
||||
if (anyCjk.test(ch) && !classifyBiaodian(ch, biaodian))
|
||||
{
|
||||
out.push({ directChild, textNode: t, offset: i });
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (node.nodeType !== 1) { return; }
|
||||
const el = node as Element;
|
||||
// avoid 以自身判定(舊版僅查祖先,自身命中 avoid 之元素被下潛);pill 整體
|
||||
// 不計不下探;isolate 內部為正常文本,照常下探計數(綁定時不可切、整綁)。
|
||||
if (finder.avoidsSelf(el)) { return; }
|
||||
if (blockSet.has(el.nodeName) || finder.pillMatches(el)) { return; } // 邊界:不計、不下探
|
||||
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 out;
|
||||
}
|
||||
|
||||
function processBlock(
|
||||
block: Element,
|
||||
finder: Finder,
|
||||
blockSet: Set<string>,
|
||||
anyCjk: RegExp,
|
||||
biaodian: Biaodian,
|
||||
chars: number,
|
||||
): void
|
||||
{
|
||||
if (hasOrphan(block)) { return; }
|
||||
|
||||
const subst = 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,
|
||||
);
|
||||
if (clone) { startChild = clone; }
|
||||
}
|
||||
|
||||
// 把 startChild 及其後全部直接子搬入 jz-orphan。
|
||||
const orphan = createJz("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 + "]");
|
||||
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);
|
||||
});
|
||||
},
|
||||
revert(ctx: RenderContext): void
|
||||
{
|
||||
revertPass(PASS, ctx.root);
|
||||
},
|
||||
};
|
||||
@@ -1,354 +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 } from "../core/unicode.js";
|
||||
import { createJz, revertPass } from "../core/dom.js";
|
||||
import type { AdjacentChar, Finder } from "../core/finder.js";
|
||||
import type { RenderContext, Ruleset, StandalonePass } from "../types.js";
|
||||
|
||||
const PASS = "spacing";
|
||||
|
||||
// 內部標記字元(PUA,不撞正常文本字形)。
|
||||
const MARK = "";
|
||||
const PH_OPEN = "";
|
||||
const PH_CLOSE = "";
|
||||
|
||||
// ----- Pangu 規則正則(沿用 v1)-----
|
||||
const OPS_HYPHEN = "\\+\\*=&\\-";
|
||||
const OPS_GRADE = "\\+\\-\\*";
|
||||
const QUOTES_CLS = "`\"״";
|
||||
const LBR_BASIC = "\\(\\[\\{";
|
||||
const RBR_BASIC = "\\)\\]\\}";
|
||||
const LBR_EXT = "\\(\\[\\{<>";
|
||||
const RBR_EXT = "\\)\\]\\}<>";
|
||||
|
||||
// 純刪除哨符(U+E003):標記「此處原作者空格應刪除、不留隙」(CJK↔CJK,問題 1b)。
|
||||
// 與空格 1:1 替換以保持 spaced/text 對齊(processBlock 據此刪空格而不插 jz-hws)。
|
||||
const DEL = String.fromCharCode(0xE003);
|
||||
|
||||
/** 由規則集編譯之 per-instance 間隙正則集。 */
|
||||
interface Rules
|
||||
{
|
||||
anyCjk: RegExp; pillNeighbor: 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;
|
||||
return {
|
||||
anyCjk: new RegExp("[" + CJK + "]"),
|
||||
pillNeighbor: new RegExp("[" + ALNUM + CJK + "]"),
|
||||
STRIP_CJK_SPACE_ANS: new RegExp("([" + CJK + "]) ([" + ANS_AFTER + "])", "g"),
|
||||
STRIP_ANS_SPACE_CJK: new RegExp("([" + ANS_BEFORE + "]) ([" + CJK + "])", "g"),
|
||||
STRIP_CJK_SPACE_CJK: new RegExp("([" + CJK + "]) (?=[" + CJK + "])", "g"),
|
||||
DOTS_CJK: new RegExp("([\\.]{2,})([" + CJK + "])", "g"),
|
||||
CJK_PUNCTUATION: new RegExp("([" + CJK + "])([!;,\\?:]+)(?=[" + CJK + ALNUM + "])", "g"),
|
||||
AN_PUNCTUATION_CJK: new RegExp("([" + ALNUM + "])([!;,\\?]+)([" + CJK + "])", "g"),
|
||||
CJK_TILDE: new RegExp("([" + CJK + "])(~+)(?!=)(?=[" + CJK + ALNUM + "])", "g"),
|
||||
CJK_TILDE_EQUALS: new RegExp("([" + CJK + "])(~=)", "g"),
|
||||
CJK_PERIOD: new RegExp("([" + CJK + "])(\\.)(?![" + ALNUM + "\\./])(?=[" + CJK + ALNUM + "])", "g"),
|
||||
AN_PERIOD_CJK: new RegExp("([" + ALNUM + "])(\\.)([" + CJK + "])", "g"),
|
||||
AN_COLON_CJK: new RegExp("([" + ALNUM + "])(:)([" + CJK + "])", "g"),
|
||||
CJK_QUOTE: new RegExp("([" + CJK + "])([" + QUOTES_CLS + "])", "g"),
|
||||
QUOTE_CJK: new RegExp("([" + QUOTES_CLS + "])([" + CJK + "])", "g"),
|
||||
CJK_QUOTE_AN: new RegExp("([" + CJK + "])(\")([" + ALNUM + "])", "g"),
|
||||
CJK_HASH: new RegExp("([" + CJK + "])(#([^ ]))", "g"),
|
||||
HASH_CJK: new RegExp("(([^ ])#)([" + CJK + "])", "g"),
|
||||
SINGLE_LETTER_GRADE: new RegExp("\\b([" + ALPHA + "])([" + OPS_GRADE + "])([" + CJK + "])", "g"),
|
||||
CJK_OPERATOR_ANS: new RegExp("([" + CJK + "])([" + OPS_HYPHEN + "])([" + ALNUM + "])", "g"),
|
||||
ANS_OPERATOR_CJK: new RegExp("([" + ALNUM + "])([" + OPS_HYPHEN + "])([" + CJK + "])", "g"),
|
||||
CJK_LESS_THAN: new RegExp("([" + CJK + "])(<)([" + ALNUM + "])", "g"),
|
||||
LESS_THAN_CJK: new RegExp("([" + ALNUM + "])(<)([" + CJK + "])", "g"),
|
||||
CJK_GREATER_THAN: new RegExp("([" + CJK + "])(>)([" + ALNUM + "])", "g"),
|
||||
GREATER_THAN_CJK: new RegExp("([" + ALNUM + "])(>)([" + CJK + "])", "g"),
|
||||
CJK_LEFT_BRACKET: new RegExp("([" + CJK + "])([" + LBR_EXT + "])", "g"),
|
||||
RIGHT_BRACKET_CJK: new RegExp("([" + RBR_EXT + "])([" + CJK + "])", "g"),
|
||||
AN_LEFT_BRACKET: new RegExp("([" + ALNUM + "])(?<!\\.[" + ALNUM + "]*)([" + LBR_BASIC + "])", "g"),
|
||||
RIGHT_BRACKET_AN: new RegExp("([" + RBR_BASIC + "])([" + ALNUM + "])", "g"),
|
||||
CJK_ANS: new RegExp("([" + CJK + "])([" + ANS_AFTER + "])", "g"),
|
||||
ANS_CJK: new RegExp("([" + ANS_BEFORE + "])([" + CJK + "])", "g"),
|
||||
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 PH_PATTERN = new RegExp(PH_OPEN + "(\\d+)" + PH_CLOSE, "g");
|
||||
|
||||
function panguSpace(text: string, R: Rules): string
|
||||
{
|
||||
if (text.length <= 1 || !R.anyCjk.test(text)) { return text; }
|
||||
|
||||
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" + MARK + "$2");
|
||||
s = s.replace(R.RIGHT_BRACKET_AN, "$1" + MARK + "$2");
|
||||
|
||||
s = s.replace(R.CJK_ANS, "$1" + MARK + "$2");
|
||||
s = s.replace(R.ANS_CJK, "$1" + MARK + "$2");
|
||||
s = s.replace(R.S_A, "$1" + MARK + "$2");
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
interface SpaceAction
|
||||
{
|
||||
before: { textNode: Text; offset: number } | undefined;
|
||||
space: { textNode: Text; offset: number } | null;
|
||||
}
|
||||
|
||||
// 把一個節點(已隔離之單字文本,或元素如 pill)包進 jz-hws——間隙由 CSS 之
|
||||
// margin-right 提供。不注入空白字元:故 justify 不把間隙當詞間空白放大(邊界
|
||||
// 只吃均勻字間量),margin 尾隨左側內容(換行時落行尾、永不落行首),且
|
||||
// textContent 完全不變(內容仍在)。冪等:已在 jz-hws 內則跳過。
|
||||
function wrapNodeInGap(node: Node): void
|
||||
{
|
||||
const parent = node.parentNode;
|
||||
if (!parent || parent.nodeName === "JZ-HWS") { return; }
|
||||
const gap = createJz("jz-hws", PASS, "wrap");
|
||||
parent.insertBefore(gap, node);
|
||||
gap.appendChild(node);
|
||||
}
|
||||
|
||||
// 隔離 pos 處之單一字元並包入 jz-hws(左側字 + margin-right 留隙)。
|
||||
function wrapGapLeft(pos: { textNode: Text; offset: number }): void
|
||||
{
|
||||
let tn = pos.textNode;
|
||||
if (!tn.parentNode || tn.parentNode.nodeName === "JZ-HWS") { return; }
|
||||
if (pos.offset > 0) { tn = tn.splitText(pos.offset); }
|
||||
if (tn.data.length > 1) { tn.splitText(1); }
|
||||
wrapNodeInGap(tn);
|
||||
}
|
||||
|
||||
function processBlock(block: Element, finder: Finder, R: Rules): void
|
||||
{
|
||||
const runs = finder.collectRuns(block);
|
||||
for (const run of runs)
|
||||
{
|
||||
const { text, map } = run;
|
||||
const spaced = panguSpace(text, R);
|
||||
if (spaced === text) { continue; }
|
||||
|
||||
const actions: SpaceAction[] = [];
|
||||
let origIdx = 0;
|
||||
let spacedIdx = 0;
|
||||
while (spacedIdx < spaced.length)
|
||||
{
|
||||
const sc = spaced.charAt(spacedIdx);
|
||||
if (sc === DEL)
|
||||
{
|
||||
// CJK↔CJK 贅餘空格:純刪除、不留隙(before 留空)。
|
||||
actions.push({ before: undefined, space: map[origIdx]! });
|
||||
origIdx += 1;
|
||||
spacedIdx += 1;
|
||||
continue;
|
||||
}
|
||||
if (sc === MARK)
|
||||
{
|
||||
const oc = origIdx < text.length ? text.charAt(origIdx) : null;
|
||||
if (oc === " ")
|
||||
{
|
||||
actions.push({ before: map[origIdx - 1], space: map[origIdx]! });
|
||||
origIdx += 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
actions.push({ before: map[origIdx - 1], space: null });
|
||||
}
|
||||
spacedIdx += 1;
|
||||
continue;
|
||||
}
|
||||
origIdx += 1;
|
||||
spacedIdx += 1;
|
||||
}
|
||||
|
||||
// 倒序執行,避免前面動作之 offset 被後面變更影響。先刪空格(若有)、再包左字
|
||||
// 留隙(若有 before)。刪空格在較高 offset、不擾動更前之 before。
|
||||
for (let i = actions.length - 1; i >= 0; i -= 1)
|
||||
{
|
||||
const act = actions[i]!;
|
||||
if (act.space)
|
||||
{
|
||||
const tn = act.space.textNode;
|
||||
tn.data = tn.data.slice(0, act.space.offset) + tn.data.slice(act.space.offset + 1);
|
||||
}
|
||||
if (act.before) { wrapGapLeft(act.before); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// boundarySelector = pill(整體)∪ isolate(隔離):兩端補隙之口徑一致(§4.1
|
||||
// 三分類);差異僅在內部(pill 不可見、isolate 照常處理),不在此 pass。
|
||||
function processPills(root: Element, finder: Finder, boundarySelector: string, pillNeighbor: RegExp): void
|
||||
{
|
||||
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);
|
||||
if (prev) { handlePillSide(prev, pill, -1, pillNeighbor); }
|
||||
|
||||
const next = finder.adjacentLogicalChar(pill, 1);
|
||||
if (next) { handlePillSide(next, pill, 1, pillNeighbor); }
|
||||
});
|
||||
}
|
||||
|
||||
// dir -1:間隙在 adj 字(左)與 pill(右)之間 → margin 掛 adj 字(包裝該字)。
|
||||
// dir +1:間隙在 pill(左)與 adj 字(右)之間 → margin 掛 pill(包裝 pill)。
|
||||
function handlePillSide(adj: AdjacentChar, pill: Element, dir: -1 | 1, pillNeighbor: RegExp): void
|
||||
{
|
||||
if (dir === -1)
|
||||
{
|
||||
if (pillNeighbor.test(adj.ch))
|
||||
{
|
||||
wrapGapLeft({ textNode: adj.textNode, offset: adj.offset });
|
||||
return;
|
||||
}
|
||||
if (adj.ch === " " && adj.offset >= 1)
|
||||
{
|
||||
const tn = adj.textNode;
|
||||
const bc = tn.data.charAt(adj.offset - 1);
|
||||
if (bc !== " " && pillNeighbor.test(bc))
|
||||
{
|
||||
tn.data = tn.data.slice(0, adj.offset) + tn.data.slice(adj.offset + 1);
|
||||
wrapGapLeft({ textNode: tn, offset: adj.offset - 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (pillNeighbor.test(adj.ch))
|
||||
{
|
||||
wrapNodeInGap(pill);
|
||||
return;
|
||||
}
|
||||
if (adj.ch === " " && adj.textNode.data.length >= 2)
|
||||
{
|
||||
const ac = adj.textNode.data.charAt(1);
|
||||
if (ac !== " " && pillNeighbor.test(ac))
|
||||
{
|
||||
adj.textNode.data = adj.textNode.data.slice(1);
|
||||
wrapNodeInGap(pill);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 處理(功能閘以 block 為粒度)。
|
||||
ctx.finder.eachBlock(ctx.root, (block) =>
|
||||
{
|
||||
if (!finder.featureEnabledFor(block, PASS)) { return; }
|
||||
processBlock(block, finder, R);
|
||||
});
|
||||
// 文本級功能須支持單元素(§6.5.0a):root 為行內/單一非塊元素(如消費端對
|
||||
// <span> 標題呼叫 render)時 eachBlock 不含之,補處理 root 自身(collectRuns
|
||||
// 遇巢狀 block 仍會 flush,故與上面逐 block 不重複;行內 root 通常無 block 子)。
|
||||
if (!options.finder.blockTags.has(ctx.root.nodeName)
|
||||
&& finder.featureEnabledFor(ctx.root, PASS)
|
||||
&& finder.inScope(ctx.root) && !finder.isAvoided(ctx.root))
|
||||
{
|
||||
processBlock(ctx.root, finder, R);
|
||||
}
|
||||
// Pass B:pill/isolate 邊界。
|
||||
const boundarySelector = [ options.finder.pillSelector, options.finder.isolateSelector ]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
if (boundarySelector)
|
||||
{
|
||||
processPills(ctx.root, finder, boundarySelector, R.pillNeighbor);
|
||||
}
|
||||
},
|
||||
revert(ctx: RenderContext): void
|
||||
{
|
||||
revertPass(PASS, ctx.root);
|
||||
},
|
||||
};
|
||||
@@ -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,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,80 +0,0 @@
|
||||
// 針對性驗證:v1 之「行內塊(pill)邊界空格」與「原檔多餘空格收斂」是否
|
||||
// 在新引擎落地(回應使用者提問)。
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
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, "· 兩側皆不應有間隙");
|
||||
});
|
||||
@@ -1,97 +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 片段。
|
||||
|
||||
依賴: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:{'有(建議直接用 halt 預設模式,無需 margin 後備)' if halt else '無 → 需 margin 後備模式'}")
|
||||
print(f"# unitsPerEm={upm}")
|
||||
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