Files
cjk-autospace/ARCHITECTURE.md
T
admin 7b4a051a1f feat: 標點半形 margin 後備模式+下游回饋修復
margin 後備(供缺 OpenType halt 之字型):
- jiya.halfWidth: "halt"(預設)|"margin"。margin 以 width:0.5em+字身依墨色
  偏側溢出定位,於 CSS 盒模型重現 halt 之半形 advance(真 0.5em;相鄰標點各
  0.5em、合佔 1em,無負 margin 之鄰接疊加/單側重疊)。模式由 data-jz-halfwidth
  子樹屬性決定(可逐區塊/逐 lang,含 halt-island 反置)。
- 偏側 inkBias 依字身墨色中心給 left/center/right/full;參考表據方正書宋 GBK
  實測,consumer 可經 jiya.bias 逐 lang 逐字覆寫。
- _lang.css 字型堆疊僅作後備(全包 :where 零特異度、只命中帶 lang 屬性之元素、
  不直接覆寫 jz-inner)→ 消費端 font-family 恆優先,標點隨之以消費端字型渲染。
- 新增 tools/measure-bias.py(量 glyph ink 中心、產偏側建議表)+ README
  「字型適配工作流」。demo/fallback.html:方正書宋 GBK 對照 halt 失效 vs margin。

下游回饋修復:
- 1a:adjacentLogicalChar 透明穿越 jz-jinze(同 jz-hws/樣式行內)→ pill 緊鄰
  CJK 標點時,左緣間隙與作者空格剝除復原(jinze 先於 spacing 之交互)。
- 1b:CJK↔CJK 贅餘作者空格(含跨 inline 標籤)剝除、不留隙,與 CJK↔Latin 之
  邊界正規化一致(DEL 哨符,全形空格 U+3000 不動)。
- 問題二:新增 justifyAtoms 選項;false → jz-char/jz-jinze 改 display:inline,
  相容分頁器(Paged.js)對齊引擎(halt 半形與禁則 nowrap 保留)。

測試 43→49 全通過;dist 重建;README/ARCHITECTURE 同步更新契約。
2026-06-02 01:21:52 +08:00

633 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 聚珍(Juzhen)架構文檔
> 現代簡/繁中文網頁排版套件。Han.css 之現代化重寫,沿其功能版圖,並針對
> 我們關注的數種情況(複製乾淨、依 `lang` 全角/開明切換、justify 句末標點
> 單份間距、跨瀏覽器一致)作調整。
>
> 倉庫名 `cjk-autospace` 不變;公開 API 為 `createJuzhen` / 全域 `Juzhen`
> 自訂元素前綴 `jz-`。本文件先於任何程式碼,作為實作契約——後續每個功能點
> 皆按此落地,以避免不一致與重大返工。
---
## 1. 目標與範圍
1. **第一垂直切片 = v1cjk-autospace)全部功能**,在新引擎上重寫:中西間隙
(Pass A/B)、長英文詞斷詞包裝(Pass C)、開明式標點擠壓(Pass D)。
2. **架構須預留完整復刻 Han.css 的能力**:標點修正、擠壓、懸掛、禁則、著重號、
注音/ruby、樣式正常化、字體偵測、計數器等,皆能在不改動切片 1 既有程式碼
的前提下,以「新增一個 pass」的方式加入。
3. **跨瀏覽器一致**ChromeEdgeSafariWebKitFirefox):一致性關鍵的版面
行為,只用普遍支援的 CSS 原語;原生新特性僅作非必要增強。
非目標:不依賴 Han.css/Heti 等既有庫之執行期程式碼(MIT 條款下可借鑑其思路與
分類表,但成品自含)。
---
## 2. 設計不變量(所有 pass 必須遵守)
這些不變量是「避免返工」的核心。任何新功能點都不得違反。
- **I1 複製乾淨(copy-clean)**:注入之間隙與標記一律 `user-select: none`,且不
改變邏輯 `textContent` 的語義;若某 pass 必須改寫文字(如標點替換),則須為
顯式 opt-in 且可 `revert` 還原。預設「只包裝、不改寫文字」。
- **I2 JSCSS 職責分離**JS 只注入**結構**(`jz-*` 元素、class、`data-jz-*`
屬性);**所有視覺尺寸(marginletter-spacing/寬度/懸掛位移)一律寫在
`juzhen.css`**。同一段 DOM 在 justify 與否、主題與否下都成立。此分離是擴充性
與跨瀏覽器調校的關鍵。
- **I3 依 `lang` 行為**:每個 pass 解析節點的有效 `lang`(最近祖先),行為與
CSS 皆以 `:lang()` 與 locale 政策驅動。全角式/開明式之別即由此決定。
- **I4 跨瀏覽器一致**:間隙、擠壓等一致性關鍵層只用 `margin``letter-spacing`
`inline-block``user-select` 等普遍原語。`text-spacing-trim`
`hanging-punctuation``text-emphasis` 等僅作漸進增強,絕不作為唯一機制。**且當
某原生原語在各引擎支援不一致、會與我們託管之版面衝突或重複作用時,須在
`.juzhen` 範圍內以 `@supports` 顯式禁用之**(如關閉 Blink 的 `text-spacing-trim`
`text-autospace`),使三引擎行為收斂於同一路徑(見 §9.1)。
- **I5 justify 單份間距**:間隙與擠壓的結構設計,須保證每個標點在
`text-align: justify` 下只貢獻**一份**對齊伸縮量(見 §8)。
- **I6 冪等與可還原**:每個 pass 對其產物打標記,重跑跳過;提供 `revert` 乾淨
移除,DOM 回到 pass 前狀態(至少邏輯等價)。
- **I7 邊界感知**:跨樣式行內元素的鄰接(如 `<strong>是</strong>A`)須能識別並
正確處理——由核心 finder 統一提供,各 pass 不自行重複造輪。
- **I8 向下相容 v1**:既有 `createCjkAutospace(options).apply(root)` 消費端
build.sh 單檔串接、Nvim peek、Claude 模板)**不得 break、無功能倒退**。行為
**不要求與 v1 完全一致**,但須等價可用;以相容層達成(見 §3.5)。
- **I9 遍歷聚合**:邏輯上「一功能一 pass」,但執行上**相容之 pass 共享單次遍歷**
(見 §3.4),以減少全樹走訪次數;聚合不得犧牲 I6(冪等可還原)與逐功能開關。
- **I10 作用域與分塊**:作用範圍可由 `scope.include` 選擇器限定(opt-in);功能集
可逐塊覆寫(屬性或 `blocks` 映射),由 pass 在 `collect` 內以 per-node 閘解析
(見 §3.6)。CSS 視覺規則須與 JS 作用域同域收斂,不外溢。
---
## 3. 整體架構
### 3.1 對外 API
```
const jz = createJuzhen(options);
jz.render(root?); // 對 root(預設 document.body)套用啟用之 pass
jz.revert(root?); // 還原
// 全域: window.Juzhen.createJuzhen(...)
```
`options` 形如:
```
{
// 全域。style = 全域擠壓預設(quanjiaokaimingbanjiao);policy = 逐 lang
// 覆寫。優先序:policy[lang] > style > 內建預設(繁→全角、簡→開明)。
lang: { default: "zh-Hant", style: "kaiming", policy: { "zh-Hant": "quanjiao" } },
classPrefix: "jz",
// 作用域限定(§3.6
scope: {
root: document.body, // 預設處理根;render(root) 可逐次覆寫
include: null, // 啟用選擇器;設則僅處理命中子樹(opt-in)
// 如 ".juzhen, [data-juzhen]"
avoid: "code, kbd, pre, samp, textarea, [contenteditable]", // 追加於內建 avoid
},
// 全域逐功能開關(true/false 或細部物件),預設見各 pass
spacing: true, // 中西間隙
longWord: { minLength: 6, lang: "en" },
jiya: true, // 標點擠壓
jinze: true, // 禁則(含 justify 綁定)
hanging: false, // 標點懸掛(預設關,繁體橫排不宜)
biaodian: false, // 標點修正(opt-in,可能改寫文字)
emphasis: false,
ruby: false,
// 分塊功能選擇(§3.6):選擇器 → 該塊內生效之功能集(覆寫全域)
blocks: [
{ selector: ".prose", features: ["spacing", "jiya", "jinze", "emphasis"] },
{ selector: ".code-comment", features: ["spacing"] }, // 此塊只間隙、不擠壓
],
}
```
### 3.2 Pass 管線
每個功能點是一個 `Pass`
```
interface Pass {
name: string;
phase: "P0" | "P1" | "P2"; // 遍歷階段,供聚合(§3.4)
order: number; // phase 內 apply 先後(charify→擠壓→間隙→…)
enabled(options): boolean;
// 聚合模式:collect 純判定(同階段共享單次走訪),apply 批次變更
collect(node: Text, ctx: RenderContext): Target[];
apply(targets: Target[], ctx: RenderContext): void;
revert(ctx: RenderContext): void;
}
```
需獨立走訪/layout 之 pass(如 hanging)可改採 `render(ctx)` 整段式而不參與
collect 聚合;執行器兩種皆容納。
`createJuzhen` 內建一個**有序、可開關**的 pass 註冊表。`render``order` 執行
已啟用者。順序契約(避免互相破壞):
```
10 biaodian 標點修正(若開,最先;可能改寫文字/包裝)
20 charify 標點字元包裝(jz-char/jz-inner)——擠壓、禁則、著重號之共同前置
30 jiya 標點擠壓(依 class + :lang 之 CSS
40 jinze 禁則 + justify 綁定
50 spacing 中西間隙(jz-hws
60 longWord 長英文詞 lang 包裝
70 emphasis 著重號
80 ruby 注音/ruby
90 hanging 標點懸掛(需 layout,最後;掛 ResizeObserver
```
> 順序理由:charify 先把標點變成可定址的 `jz-char`,擠壓/禁則/著重號才有對象;
> 間隙在標點結構穩定後插入;懸掛依賴最終行盒,必須最後且 layout 後再算。
### 3.3 RenderContext
`render``revert` 收到統一上下文:`{ root, options, finder, locale }`。各 pass
共用同一 `finder` 實例與 locale 解析,確保 avoid 名單、邊界規則、語言判定一致。
### 3.4 遍歷聚合(減少 pass 遍歷次數,I9)
樸素做法是每個 pass 各跑一次 `TreeWalker`,9 個 pass 即 9 次全樹走訪。為此引入
**遍歷階段(phase**:相容之 pass 共享單次走訪。Pass 介面拆為
`collect(node, ctx) → targets[]`(純判定、不變更 DOM)與 `apply(targets, ctx)`
(批次變更),執行器:
```
for phase in phases:
walk(root) 一次:對每個文本節點,依序呼叫該 phase 內各 pass 的 collect()
依 order 對收集到的 targets 做 apply()(兩階段:先收集後變更,避免遍歷中改 DOM)
```
階段劃分(把 ~9 次走訪壓到 3 次):
| phase | 內含 pass | 走訪 | 備註 |
|---|---|---|---|
| P0 文字修正 | biaodianopt-in | 1 次 | 唯一可能改寫文字者,須最先 |
| P1 行內結構 | charifyjiya 標記/jinzespacinglongWordemphasis | 1 次 | 共享走訪,`apply` 依 order |
| P2 版面後處理 | hanging | 0 次走訪+layout | 需最終行盒,掛 ResizeObserver |
> 聚合僅是**執行優化**:邏輯上仍「一功能一 pass」、可獨立開關與 `revert`I6);
> 只是 P1 諸 pass 的 `collect` 在同一走訪內完成。若某 pass 不相容(需獨立走訪),
> 退化為自跑一次,不影響正確性。
### 3.5 v1 向下相容層(I8`src/compat/v1.ts`
導出 `createCjkAutospace(options)`,把 v1 API 映射到 `createJuzhen`**保證不
break、無功能倒退**,行為等價但不要求完全一致:
| v1 選項/行為 | 映射 |
|---|---|
| `.apply(root)` | → `.render(root)` |
| `pillSelector` | → pill 鄰接處理 + avoid |
| `styleInlines``blockTags``skipTags` | → finder 設定 |
| `autospaceClass`(預設 `cjk-autospace` | → 套用於 `jz-hws` 之 class,使既有
`.cjk-autospace{…}` CSS 仍命中間隙元素 |
| `skipAttribute``isSkipped` | → avoid 判定 |
| `longWordWrap` | → `longWord` |
| `punctuationSqueeze`v1 halt 句內半角) | → `jiya`(margin 開明/全角;行為近似,
非逐位等同——符合「不要求完全一致」) |
IIFE 構建額外掛 `globalThis.createCjkAutospace` 別名,使
`globalThis.createCjkAutospace` 之消費端(Nvim peek/模板)零改動可跑。相容層之
差異(間隙元素由 `span.cjk-autospace` 變為 `jz-hws[class]`、擠壓由 halt 變 margin
於 README 明列。
### 3.6 作用域與分塊功能選擇(I10)
**作用域限定**——控制「在頁面何處生效」,避免污染無關內容:
- `scope.root`:處理根(節點或選擇器),預設 `document.body``render(root)` 可逐次
覆寫;允許多實例/多根並存。
- `scope.include`**啟用選擇器**(opt-in 模式)。設定後,**僅處理命中該選擇器之
子樹**(如 `".juzhen, [data-juzhen]"`),其餘一概不動——此即「限定作用範圍」。
- `scope.avoid`:排除選擇器,與內建 avoid 合併。
- **CSS 同域**`juzhen.css` 之視覺規則一律收斂於同一啟用選擇器(如 `.juzhen` 下),
與 JS 作用域一致,杜絕樣式外溢(呼應 I2、§9.1 之禁用層亦限於此域)。
**分塊功能選擇**——不同塊啟用不同功能集:
- **宣告式(屬性,就近繼承)**
- `data-juzhen="spacing jiya"`——白名單:此塊內僅啟用列出之功能。
- `data-juzhen-off="jiya hanging"`——黑名單:此塊停用列出者,其餘繼承。
- `lang`——驅動全角/開明(正交於功能開關)。
- **程式式**`options.blocks: [{ selector, features }]`,選擇器 → 功能集。
- **解析(per-node 閘)**:對每個節點,向上找最近帶功能指令之祖先(屬性或命中
`blocks` 選擇器者)決定其**有效功能集**,無則用全域 `options`render 期間以
元素為鍵快取。每個 pass 之 `collect(node)` 先問 `featureEnabledFor(node, name)`
否則跳過——**故分塊閘只是單次走訪內的 per-node 判定,不增遍歷、不破壞聚合
(§3.4**。
- **與 revert 之關係**`revert` 依標記移除某功能產物,**不受當前閘狀態影響**,故
「關閉某功能 + revert」恆能乾淨還原(I6)。
---
## 4. 核心基座(`src/core/`
### 4.1 `finder.ts`——文本遍歷引擎(取代 Fibre.js)
職責(所有 pass 的共同地基):
- **遍歷**`TreeWalker(SHOW_TEXT)``root` 下文本節點。
- **avoid**:跳過 avoid 名單之子樹(內建:`code/kbd/pre/samp/textarea/script/
style/SVG/[contenteditable]/[data-jz-skip]`,可由 `scope.avoid` 追加)。
- **scope.include**:若設啟用選擇器,僅走訪命中之子樹(opt-in,§3.6)。
- **featureEnabledFor(node, name)**:解析該節點之有效功能集(§3.6),供各 pass 之
`collect` per-node 閘用;結果以元素為鍵快取。
- **charify**:把符合條件之字元/字串包進指定 `jz-*` 元素(兩階段:先收集目標、
後變更,避免遍歷中改 DOM 失效)。
- **邊界感知 replace(I7)**:對「跨相鄰文本節點、可跨樣式行內元素」的邏輯字串
做正則 find,回呼決定如何包裝/插入標記。樣式行內元素(`strong/em/a/span/…`
透明穿越;block/pillavoid 中斷邏輯串。此即 v1 Pass A「剝離樣式收集邏輯文本
再反映射」之一般化,提升為可重用引擎。
設計要點:finder 不知道任何具體排版規則;它只提供「在何處、跨何種邊界、對哪些
字元」的遍歷與安全變更原語。規則由各 pass 以 unicode 表 + 回呼注入。
### 4.2 `unicode.ts`——字元分類與規則表
集中所有 Unicode 範圍與標點子類,供各 pass 共用,杜絕分類散落不一致:
- **CJK**:表意文字、假名、注音符號等範圍(沿用並擴充 v1 之 `PANGU_CJK`)。
- **西文**:拉丁字母、數字、常見符號(v1 之 `AN_CHARS` 等)。
- **標點(biaodian)子類**(對應 CSS class):
- `bd-open` 開始括號/引號:`「『(《〈【〖〔[{` 等
- `bd-close` 結束括號/引號:`」』)》〉】〗〕]}` 等
- `bd-cop` 句逗號(comma/period,字面偏移因簡繁而異):`,。.`
- `bd-stop` 句末其餘:`!?;:⁇‼` 等
- `bd-sep` 分隔/間隔號:`·・‧`
- `bd-liga` 連續省略/破折:`…—` 等
- `quote` 彎引號:`“”‘’`
- **規則正則**:中西間隙邊界、擠壓相鄰組合、禁則對象等,皆以此表組裝。
> 借鑑 Han 之 `regex/typeset.js`、`regex/unicode.js` 與 Heti 之分類字集,重寫為
> 現代 TS 常數表。
### 4.3 `locale.ts`——語言與全角/開明政策
- `effectiveLang(node)`:最近祖先 `lang`(小寫),無則回退 `options.lang.default`。
- `classify(lang)`:歸為 `zh-Hans` / `zh-Hant`(含 `zh-TW`/`zh-HK`/ `ja` / 其他;
BCP-47 前綴邊界比對(`zh` 命中 `zh-Hant`,不命中 `zhx`)。
- `style(lang)`:回傳該語言之標點寬度政策——`quanjiao`(全角式)|`kaiming`
(開明式)|`banjiao`(半角式)。預設:`zh-Hant`/`zh-HK` → 全角式;`zh-Hans`
→ 開明式;可由 `options.lang.policy` 覆寫(即你要的「依 lang 自由選擇」)。
CSS 端對應以 `:lang()` 選擇器落實同一政策(I2/I3)。
### 4.4 `dom.ts`——`jz-*` 元素工廠與還原
`createEl('jz-char', {class, attrs})`、`wrap(node, el)`、`splitAt`、`insertMarker`、
冪等標記(`data-jz="<pass>"`)、`revertPass(name, root)`(移除該 pass 之產物、
解包 `jz-*`、合併回文本節點並 `normalize()`)。
---
## 5. 渲染元素詞彙(`jz-*`
自訂元素皆含連字號(合法 custom element 名),預設 `display: inline`(未知元素
天然 inline),語義中性,可被 `revert` 移除:
| 元素 | 用途 |
|---|---|
| `jz-hws` | 中西間隙。`user-select:none`copy-cleanjustify 安全(§8 |
| `jz-char` | 包裝單一標點/漢字,承載 `bd-*` class 與 `data-jz` 屬性 |
| `jz-inner` | `jz-char` 內層字面容器,供 marginletter-spacing 擠壓而不動外盒 |
| `jz-cs` | 字間補償空白(連續/行端擠壓用,於行邊界折疊) |
| `jz-jinze` | 禁則群組:綁定不可斷單元(亦為 justify 綁定載體) |
| `jz-em` | 著重號載體 |
| `jz-ruby``jz-rb``jz-rt` | ruby/注音 |
class 約定:`bd`(標點)、`bd-open/close/cop/stop/sep/liga`、`cjk`、`jiya`、
`jiya-consecutive`、`hangable`、`jinze-{tou,wei,touwei}` 等。
---
## 6. 功能點逐項實作方案(對照 Han.css)
每項列出:Han 做法 → 我們做法 → 跨瀏覽器 → lang → 我們的調整 → 切片歸屬。
### 6.1 中西間隙(HWS,漢字↔西文)— 切片 1
- **Han**:插入 `<h-hws hidden>`,內含**真實空格** + `::before{content:' '}`
**未加 `user-select:none`** → 選取/複製會帶上空格(其已知缺陷)。
- **我們(margin 模型)**finder 邊界感知地把邊界**左側之一個字**(CJK 或西文)
包進 `jz-hws`,間隙由 CSS `margin-right`(0.25em)提供——**不注入任何空白字元**。
WHERE 之規則沿用 v1 pangu 規則表(已驗證);作者原寫之 CJK↔西文空格一律移除
(改由 margin 留隙)。
- **為何不用空白字元/inline-blockjustify 三難)**:純 CSS 下,CJK↔西文間隙在
`justify` 必有一個邊緣瑕疵——①真實空白→被當詞間空白**暴增**;②inline-block 固定
寬→罕見落**行首縮排**;③`margin-right`→**行末右緣內縮 0.25em**。選 ③(margin),
因其行首乾淨、不撐框、不暴增、`textContent` 完全不變(純 copy-clean,複製緊湊),
並以 §6.1a 之 layout pass 消除行末內縮。
- **跨瀏覽器**`margin`inline 普遍支援。
- **copy-cleanI1,較 Han 更純)**:間隙為純視覺 margin,`textContent` 不增不減
語義字元,剪貼板得緊湊原文。
- **切片**:涵蓋 v1 Pass A、Pass Bpill `code/kbd` 邊界亦走同一機制——左字或
pill 元素包入 `jz-hws`pill 由 avoid 與鄰接判定處理)。
- **邊界正規化一致性(下游回饋 1a/1b 修)**:
- **1a pill 穿越 jz-jinze**jinzeorder 40)先於 spacingorder 50),會把尾隨
標點之 pill 納入 `jz-jinze`。`adjacentLogicalChar` 須**透明穿越 `jz-jinze`**
(與 `jz-hws`、樣式行內同列,見 `finder.isTransparentInline`),否則 pill 左鄰字
不可達、左緣間隙與作者空格剝除雙失。
- **1b CJK↔CJK 贅餘空格**:中文不用詞間空格,故 CJK 表意字之間的單一 ASCII 作者
空格(含跨樣式行內標籤,`collectRuns` 已透明合併)一律**剝除、不留隙**(以
`DEL` 哨符 1:1 替換空格保持對齊,`processBlock` 據此刪空格而不插 `jz-hws`)。
與 CJK↔Latin 之剝除一致——凡 inline 邊界之贅餘作者空格皆統一處理。全形空格
U+3000 不動。
### 6.1a 行末去隙(gapTrimjustify 修正)— 切片 1
- **問題**margin-right 落在行末時,justify 下使右緣內縮 0.25em。
- **我們**layout pass `gapTrim` 量測各 `jz-hws`,對「其字落於行末(後一內容在
更低行)」者加 `jz-hws-trim`CSS `margin-right:0`),該行遂齊右、無內縮。掛
`ResizeObserver`+字型載入後重算。純版面、瀏覽器專用(jsdom 跳過)。與 `lineedge`
共用量測工具與 layout 安裝器。
### 6.2 長英文詞斷詞包裝 — 切片 1
- **我們**:把 `[A-Za-z]{N,}` 長串包進 `<span lang="en">`,配合消費端
`hyphens:auto` 由瀏覽器原生斷詞渲染連字符(DOM 不含連字符 → 複製不污染)。
直接沿用 v1 Pass C。
- **跨瀏覽器**`hyphens` 各家支援良好(含 `-webkit-`)。
- **切片**v1 對等。
### 6.3 標點擠壓(Jiya)— 切片 1
- **Han**charify 標點為 `<h-char class="bd-jiya …"><h-inner>`,以負
`letter-spacing``margin` 擠壓;連續、行端、簡繁差異以 CSS + `:lang()` 落實
`bd-cop:lang(zh-Hant)` → 不擠=全角式)。
- **我們**:同構——charify → `jz-char.bd-*` + `jz-inner`;半形與否寫在
`juzhen.css`,由 JS 解析 `locale.style`(讀 lang)後加 `jz-half``jz-half-le`
class 觸發(I3;改用 class 而非 CSS `:lang()`,以避免 `:lang(zh-Hans)` 不匹配
`zh-CN` 等地區標籤之陷阱)。三機制並集落地:①**baseline 政策**(全角不擠/
開明擠句內點號+括號引號/半角全擠);②**行內連續擠壓**(`jiyaAdjacencyPass`
相鄰標點對向側收一字,lang 無關);③**行端擠壓**(`lineedge.ts` layout pass)。
- **跨瀏覽器(I4)— 修訂**:半形改以字型 OpenType **`halt`**`font-feature-
settings:"halt"`)渲染,**非**負 `margin`。原因:負 margin 假設可壓縮空白全在
單側,對**居中字形**`!?`、繁體 `locl` 下之 `。,`)會把字面拉入鄰字而**重疊**
`halt` 收的是字身寬度、由字型按字形正確定位(簡左/繁中/括號偏側皆對),故
不重疊。`font-feature-settings:"halt"` 各引擎一致(當初疑慮之不一致者為**自動**
的 `text-spacing-trim``chws`,非顯式 `halt`);缺 `halt` 之字型優雅降級為全形。
鄰接/行端之「收哪一字」之**決策**仍由 `blankSides` 邏輯定,渲染統一為 `halt`。
- **`margin` 後備模式(.feedback 增補)**:消費端字型未必含 `halt`(多數系統字型、
GBK 老字型,實測**方正書宋 GBK 僅含 `vert`、無 `halt`** → halt 模式不擠)。故提供
後備:`jiya: { halfWidth: "margin" }`。**機制**(演進後):把 `jz-char` 之 inline-block
寬收為 `width:0.5em`(真半形 advance——相鄰兩標點各 0.5em、合 1em,無負 margin 之
鄰接疊加/單側重疊問題),字身 `jz-inner` 以 `overflow` 溢出、再依墨色偏側左移
right 0.5emcenter 0.25emleft 0),使墨色落入 0.5em 盒、空白溢向外側——即以
CSS 盒模型重現 `halt`。偏側由 `core/unicode.ts` 之 `inkBias(ch, langClass, overrides)`
依**字身墨色中心**給出 `leftcenterrightfull`(簡體點號偏左、繁體居中;彎引號
`“”`/角括號 `「」`/尖括號 `〈〉` 墨強偏一側→開 right、閉 left;圓方書名括號
`()【】《》…` 近中→center),charify 時標為 `jz-ink-{left,center,right}`。消費端可
經 `jiya.bias` 逐 lang 逐字覆寫;配字工作流與量測工具 `tools/measure-bias.py` 見 README。
- **激活與粒度(I3/I10)**:模式由 `data-jz-halfwidth` 屬性**所在子樹**決定(CSS
後代選擇器)——`render()` 在全域 margin 時自動掛於 root;亦可手寫於任一祖先作逐區塊
/逐語言控制(含 `margin` 頁中嵌 `halt` 島之 halt-reset,憑來源順序勝出)。bias
class 恆於 charify 標記,halt 模式不讀,故模式切換為純 CSS、無需重 render。實測:
方正書宋 GBK 下同句行寬 `24.0emhalt 無效)→ 19.7emmargin 生效)`、單標點
`1.0em → 0.5em`。
- **切片**:涵蓋並超越 v1 Pass D(開明式),加入全角式、lang 切換、行內連續與
行端擠壓、`halt``margin` 雙渲染。
### 6.4 禁則(Jinze,避頭尾)+ justify 綁定 — 切片 1(基礎)/後續(完整)
- **Han**`h-jinze` 綁定行首行尾不可出現之標點與鄰字,避免標點落行首/行尾。
- **我們**`jz-jinze`CSS `display:inline-block; white-space:nowrap`)綁定不可斷
單元。**避頭尾已實作**`jinze.ts`):因 jiya 把標點原子化為 inline-block,原子
邊界成可斷點、破壞瀏覽器原生 UAX#14 避頭尾,故補回——把「不可置行首」之標點
(閉類/點號/間隔/連接省略)綁到**前一字**、「不可置行末」之開類綁到**後一字**,
且**只隔離單一邊界字**(長引文中段仍正常斷行,不溢出)。實作為兩階段:依原始
相鄰算禁斷邊界→切邊界字;重掃併入 nowrap 群組。亦為 §8 justify 綁定載體。
- **切片**:切片 1 完成行首行末避頭尾(閉類/點號避頭、開類避尾);行首縮排聯動、
更細之禁則對象列後續,沿用同一 `jz-jinze` 結構。
- **已知交互(待瀏覽器精修)**:`lineedge` 之行端偵測需跨 `jz-jinze` 群組邊界向上
攀爬找流內鄰字(已於 `edgeRect` 處理 jz-* 包裹之攀出);屬 layout 層,jsdom 無法
驗,須瀏覽器目視。
### 6.5 標點懸掛(Hanging)— 後續
- **Han**:行尾標點懸掛出版心,需量測。
- **我們**layout pass——量測各行末,對行末可懸掛標點施負位移;掛
`ResizeObserver` 重算。`order=90`(最後)。lang 閘:**繁體橫排預設不懸掛**
clreq`zh-Hant` 預設關),可開。
- **預留**:依賴 `jz-char``jz-cs` 既有結構 + 一個 layout 後鉤子;切片 1 的
charify 已備妥對象,加入時不動既有 pass。
### 6.6 標點修正(Biaodian normalize)— 後續(opt-in
- **Han**`subst``biaodian` 修正標點變體、位置。
- **我們**opt-in pass,預設**只分類+打 class**(不改文字,I1);顯式開啟才做
變體替換(簡↔繁標點、全半形修正),且可 `revert`。
- **預留**`order=10` 最先;產物為 `jz-char` 之 class/屬性,與 charify 銜接。
### 6.7 著重號(Emphasis)— 後續
- **Han**:逐字包裝加圈點。
- **我們**:優先用原生 `text-emphasis`ChromeFFSafari 支援良好);需精細
控制時退回 `jz-em` 逐字包裝 + CSS 圓點。lang 感知(位置簡繁略異)。
- **預留**charify 能力直接複用。
### 6.8 注音/Ruby — 後續(最複雜)
- **我們**:基於原生 `<ruby>` + `jz-ruby/jz-rt` 處理注音符號直排與聲調定位。
- **預留**:獨立 pass`order=80`;不影響其餘。
### 6.9 樣式正常化/字體堆疊 — 貫穿(CSS)
- **我們**`juzhen.css` 內以 `:where()`(零特異度,易被消費端覆寫)提供 CJK
normalize 與依 `:lang` 之 Noto SansSerif CJK SCTCHKJP 字體堆疊。非破壞、
opt-in(掛在 `.juzhen` 容器或 `[data-juzhen]` 下)。
- **跨瀏覽器**`:where()``:lang()` 普遍支援。
### 6.10 計數器/緊排(counterwell-knit)— 低優先
- 後續以純 CSS 模組加入,不涉 JS 引擎。
---
## 7. 第一垂直切片(v1 對等)
**範圍**:核心基座(finderunicodelocaledompass 管線)+ 以下功能 pass
| v1 | 切片 1 對應 | 機制 |
|---|---|---|
| Pass A 中西間隙 | `spacing` | `jz-hws`copy-cleanjustify 安全 |
| Pass B pill 邊界 | `spacing`(同引擎) | avoid + 鄰接判定 |
| Pass C 長英文詞 | `longWord` | `<span lang>` + `hyphens` |
| Pass D 開明式擠壓 | `jiya` + `jinze` | 跨瀏覽器負 margin + `:lang` 全角/開明 + justify 綁定 |
**交付**:可 `build` 出 ESMIIFECSSd.tsjsdom 測試覆蓋 DOM 變換正確性
(包裝、class、邊界、冪等、copy-clean、lang 政策);justify/簡繁 demo 頁供瀏覽器
目視驗證版面(layout 行為無法於 jsdom 驗證,見 §12)。
**擴充保證**:切片 1 所用之 findercharifypass 管線/`jz-*` 詞彙/CSS-JS 契約,
即後續所有 Han 功能所用者;新增功能 = 新增一個遵守 §2 不變量的 pass,不改切片 1。
---
## 8. justify 句末標點單份間距解法(要求 #3)
**問題**`text-align: justify` 下,UA 在「對齊機會點」插入伸縮量。全角句末標點
(如 ``)字面只占字框一部分、自帶半形留白;若 UA 在其**兩側**都開伸縮,標點
看似分到「兩份」間距(伸縮 gap + 自身留白)。
**解法**CSS 主導,I2I5):
1. charify 句末/句逗標點為 `<jz-char class="bd …"><jz-inner>。</jz-inner>`。
2. `jz-char` 設 `display: inline-block`:使其成為 justify 的**單一原子項**,內部
不開伸縮 gap,標點與其字面留白同屬一項,對齊伸縮只落在**項與項之間** → 標點
只貢獻一份。
3. **開明式**下,`jz-inner` 以負 letter-spacing 擠去半形留白,該項本身即半形,
justify 一份 gap 即足。
4. **全角式**下,留白保留於原子項內,仍只算一份;以 `jz-jinze` 綁定確保不在留白
側額外開機會點。
> inline-block 影響基線對齊與行內換行,須以 CSS 校正(`vertical-align`、行高),
> 並**跨 ChromeSafariFF 目視驗證**(§12);此為切片 1 的重點驗證項。
**分頁器相容(下游回饋問題二)**:常規流式版面 inline-block 原子正常,但**分頁器**
(Paged.js)之對齊引擎會把每個 inline-block 邊界當伸縮點、與 CJK inter-ideograph
疊加,於窄欄混排散架。故提供 `options.justifyAtoms`(預設 true);設 false 時 render
於 root 掛 `data-jz-atoms="inline"`CSS 令 `jz-char``jz-jinze` 改 `display:inline`
halt 半形與 jinze nowrap 仍保留)。此即 R1 預案之一(解耦對齊原子化),切換僅 CSS、
不動 JS。注意:`margin` 後備半形依賴 inline-block 之 `width:0.5em`,與
`justifyAtoms:false` 不可兼得——分頁器場景須用含 `halt` 字型。
---
## 9. 跨瀏覽器策略
- **一致性關鍵層**(間隙、擠壓、justify):只用 `margin`、`inline-block`、
`user-select`、`white-space`、`:where()`、`hyphens`,及字型 OpenType
`font-feature-settings:"halt"`(標點半形;各引擎一致,效果僅依字型是否含 halt,
Noto CJK 具備,缺則優雅降級為全形)——三大引擎(Blink/WebKitGecko)皆穩定
支援。**注意**:標點半形預設不用負 `margin`(對居中字形會重疊),改用 `halt`(見
§6.3 修訂)。**缺 `halt` 字型之後備**`jiya.halfWidth:"margin"` 以 `width:0.5em`
字身依偏側(`jz-ink-*`)溢出定位,於 CSS 重現 halt、真半形 advance(見 §6.3)。
- **漸進增強(非必要)**`text-spacing-trim`Blink)、`hanging-punctuation`
WebKit)、`text-emphasis`、`text-autospace`——僅在支援時錦上添花,缺席不影響
基本排版(I4)。
- **驗證矩陣**ChromeBlink,含 VSCodeElectron)、SafariWebKit、Firefox
(Gecko)。demo 頁含三引擎可開的測試案例。
### 9.1 顯式禁用不一致原語(I4)
當原生原語在各引擎支援不一,或會與我們託管之版面**重複作用**(雙重擠壓/間隙)
時,在 `.juzhen` 範圍內以 `@supports` 偵測並**主動關閉**,使所有引擎收斂於我們的
一致路徑:
```css
@supports (text-spacing-trim: space-all) {
.juzhen { text-spacing-trim: space-all; } /* 關閉 Blink 原生 CJK 標點 kerning */
}
@supports (text-autospace: no-autospace) {
.juzhen { text-autospace: no-autospace; } /* 關閉原生中西自動間距 */
}
```
政策:凡屬「一致性關鍵層」者(間隙、擠壓),一律由我們統一處理並禁用對應原生
原語;凡屬「漸進增強」且不致衝突者(如 Safari 之 `hanging-punctuation`,在我們未
託管懸掛時),方任其生效。此清單隨功能演進維護於 `css/_normalize.css`。
---
## 10. 模組/檔案佈局
```
src/
index.ts createJuzhen / Juzhen / pass 註冊 / render / revert
core/
pass.ts Pass 介面 + 管線執行器
finder.ts 遍歷/avoidcharify/邊界感知 replace
unicode.ts 字元類 + 標點子類 + 規則正則表
locale.ts 有效 lang / 分類 / 全角開明政策
dom.ts jz-* 工廠 / wrap / split / marker / revert
typeset/
spacing.ts 中西間隙 [切片 1]
longword.ts 長英文詞包裝 [切片 1]
jiya.ts 標點擠壓 + 全角開明 [切片 1]
jinze.ts 禁則 + justify 綁定 [切片 1 基礎]
hanging.ts 標點懸掛 [後續]
biaodian.ts 標點修正 [後續]
emphasis.ts 著重號 [後續]
ruby.ts 注音/ruby [後續]
css/
juzhen.css 入口(@import 各 partial
_normalize.css _lang.css _spacing.css _jiya.css _jinze.css
_hanging.css _emphasis.css _ruby.css
test/ jsdom DOM 正確性測試
demo/ 瀏覽器 layoutjustify/簡繁 目視驗證頁
build.mjs esbuildESM + IIFE + CSStscd.ts
```
---
## 11. 構建與發佈
- **源碼**TypeScriptstrict),模組化。
- **構建**`build.mjs` 以 esbuild 產出
- `dist/juzhen.mjs`ESM,供打包器/TS
- `dist/juzhen.iife.js`IIFE,全域 `Juzhen`,原生 `<script>` 直跑——保留
build.shNvim peek 之單檔串接消費)
- `dist/juzhen.css`(合併之樣式)
- `tsc --emitDeclarationOnly` → `dist/juzhen.d.ts`
- **發佈物**`dist/` 提交入庫(消費端可直接取單檔/CSS)。
---
## 12. 測試與驗證策略
- **jsdom(自動)**:驗證 DOM 變換——包裝結構、`bd-*` class、邊界處理、冪等、
`revert` 還原、copy-clean`textContent` 不被注入污染)、lang 政策分支。
- **瀏覽器(目視,demo 頁)**layout 相關**無法**於 jsdom 驗證——justify 單份
間距、擠壓視覺量、行端折疊、懸掛、inline-block 基線。demo 頁載 Noto CJK,簡繁
並列、可切 justify、附選取/複製測試區,於 Chrome/SafariFF 目視核對。
- 文件明確標注哪些結論來自自動測試、哪些待瀏覽器目視。
---
## 13. 擴充性保證(如何加新功能而不返工)
新增任一 Han 功能點,標準流程:
1. 在 `unicode.ts` 補該功能所需字元類/正則(集中、不散落)。
2. 新增 `typeset/<feature>.ts`,實作 `Pass``render``revert``order`)。
3. 在 `index.ts` 註冊,加 `options.<feature>` 開關。
4. 在 `css/_<feature>.css` 寫視覺規則(I2:尺寸只在 CSS)。
5. 補 jsdom 測試 + demo 案例。
因所有功能共用 findercharifylocale`jz-*`CSS-JS 契約,且順序由 `order`
集中編排,新增不觸動既有 pass——此即「預留完整復刻 Han.css 能力」之具體保證。
---
## 14. 風險與未決項
原則:**風險上線後持續觀察,但下列預案先行設計就緒**,一旦觀察指標踩線即可切換,
不致臨時返工。
- **R1 justifyinline-block 基線**(§8)。觀察:句末標點在三引擎 justify 下是否
單份間距、inline-block 是否擾動基線/行高/行內換行。**預案**:改採 Han 路線之
`jz-cs` 補償空白法(標點維持 inline,以行邊界可折疊之 `jz-cs` 控制伸縮側),
結構已預留,切換僅換 CSS 機制不動 JS。
- **R2 行端擠壓免量測**。觀察:純 CSS(`jz-cs` 折疊)能否在三引擎一致達成行首行尾
擠壓。**預案**:退化為「連續+行內擠壓用 CSS(一致)+ 行端擠壓交由 layout pass
量測或漸進增強原語」,行端與連續解耦,互不影響。
- **R3 標點修正改寫文字 vs I1**。觀察:opt-in 開啟後複製/序列化是否仍可接受。
**預案**:預設只分類不改寫;改寫模式強制可 `revert`,並提供「僅視覺替換(CSS
字符替身)不動文字」之次級模式作折衷。
- **R4 注音/ruby 複雜度**。觀察:原生 `<ruby>` 對注音直排/聲調定位之不足。
**預案**:獨立 pass、最後做;先以原生 `<ruby>` 覆蓋多數,注音特例再以 `jz-ruby`
子結構補強,不影響其餘 pass。
- **R5 DOM 重量**。觀察:長文 charify 之節點數與 render 耗時。**預案**:兩階段批次
變更(§3.4)+ 僅包裝必要標點 + 可選分塊/`requestIdleCallback` 漸進渲染;demo
量測超標才啟用分塊。
---
*本文件為實作契約。任何偏離(尤其 §2 不變量)須先更新本文件再改碼。*