實 Chromium 定位並修復下游回饋(Peek.nvim,commit 96f5ac3 基線)之全部間隙問題,
另完成現代漢語標點之誤隙稽核。jsdom 不可信(TreeWalker/offset 失真),結論均以
真實 Chromium 量測。
B 全角省略號誤隙:DOTS_CJK 之 alternation `|…` 誤把全角省略號 U+2026 納入,致
`……` 後接 CJK 冒出中西隙。移除 `|…`,僅留 ASCII `...`。
C+E gapTrim 自我應驗誤判(容器寬相關「最後一個間隙消失」、pill 跨行右隙消失):
間隙之 margin-right 參與斷行;依「後字是否在更低行」判行末,而後字落點又被
margin 推擠 → 間隙自身 margin 把後字擠到次行 → 判行末 → trim 去 margin → 後字
回同行,自相矛盾、卡死於「已 trim、後字同行、間隙消失」。污染雙向:下游間隙之
margin 亦令夾在中間之內容放不下其行。修法:trimGaps 改「起始全部 trim、由左至右
逐一定案」——量測時前方已定案、自身與其後皆 trim,後字落點不受任何 margin 污染。
以 lastRect 為準兼顧跨行 pill。實測 8–22em(ragged/justify)偽 trim 歸零、真行末
仍 trim(justify 齊右不變)。
D 西文字集完整化(列舉法,2016/Windows 10 相容、\uXXXX 無需 flag):補入貨幣
U+20A0–20BF(€ 等)、拉丁擴充 A/B(拼音聲調 ā ǎ ē)、西里爾、數學運算符
U+2200–22FF;並把希臘由原 U+0370–037D 補全至整塊(原漏 α-ω 本身)。€ 鄰接 CJK
之手工空格遂正規化、邊界補隙。
標點稽核:實 Chromium 全部常用中文標點 × 簡繁共 104 例,CJK 間皆 0 誤隙。修正
唯一偏例——收尾彎引號 `”`(pangu QUOTE_AN 令其後接拉丁/數字補隙,為唯一會補隙
之收尾標點,與 」』)》】〕 不一致;clreq 下全角收尾標點字身已含右側留白)。移除
QUOTE_AN。
- src/core/unicode.ts:WEST_SHARED 列舉補全(\uXXXX、區塊註記)。
- src/typeset/spacing.ts:DOTS_CJK 去 `|…`;移除 QUOTE_AN。
- src/typeset/lineedge.ts:trimGaps 起始全 trim、左→右逐一定案;新增 lastRect。
- test/juzhen.test.mjs:B/D/標點稽核/彎引號 回歸(56 測試通過)。
- demo/resize-check.html:可拖曳容器檢查頁(各寬度間隙穩定性、標點無誤隙)。
- README.md 目視驗證、ARCHITECTURE.md §6.1/§6.1a 自我應驗/§6.1b 標點稽核。
- dist/ 重建(juzhen.css 不變,僅 JS 邏輯)。
42 KiB
聚珍(Juzhen)架構文檔
現代簡/繁中文網頁排版套件。Han.css 之現代化重寫,沿其功能版圖,並針對 我們關注的數種情況(複製乾淨、依
lang全角/開明切換、justify 句末標點 單份間距、跨瀏覽器一致)作調整。倉庫名
cjk-autospace不變;公開 API 為createJuzhen/ 全域Juzhen; 自訂元素前綴jz-。本文件先於任何程式碼,作為實作契約——後續每個功能點 皆按此落地,以避免不一致與重大返工。
1. 目標與範圍
- 第一垂直切片 = v1(cjk-autospace)全部功能,在新引擎上重寫:中西間隙 (Pass A/B)、長英文詞斷詞包裝(Pass C)、開明式標點擠壓(Pass D)。
- 架構須預留完整復刻 Han.css 的能力:標點修正、擠壓、懸掛、禁則、著重號、 注音/ruby、樣式正常化、字體偵測、計數器等,皆能在不改動切片 1 既有程式碼 的前提下,以「新增一個 pass」的方式加入。
- 跨瀏覽器一致(Chrome/Edge/Safari/WebKit/Firefox):一致性關鍵的版面 行為,只用普遍支援的 CSS 原語;原生新特性僅作非必要增強。
非目標:不依賴 Han.css/Heti 等既有庫之執行期程式碼(MIT 條款下可借鑑其思路與 分類表,但成品自含)。
2. 設計不變量(所有 pass 必須遵守)
這些不變量是「避免返工」的核心。任何新功能點都不得違反。
- I1 複製乾淨(copy-clean):注入之間隙與標記一律
user-select: none,且不 改變邏輯textContent的語義;若某 pass 必須改寫文字(如標點替換),則須為 顯式 opt-in 且可revert還原。預設「只包裝、不改寫文字」。 - I2 JS/CSS 職責分離:JS 只注入結構(
jz-*元素、class、data-jz-*屬性);所有視覺尺寸(margin/letter-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 = 全域擠壓預設(quanjiao|kaiming|banjiao);policy = 逐 lang
// 覆寫。優先序:policy[lang] > style > 內建預設(繁→全角、簡→開明)。
lang: { default: "zh-Hant", style: "kaiming", policy: { "zh-Hant": "quanjiao" } },
classPrefix: "jz",
// 作用域限定(§3.6)
scope: {
root: document.body, // 預設處理根;render(root) 可逐次覆寫
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 文字修正 | biaodian(opt-in) | 1 次 | 唯一可能改寫文字者,須最先 |
| P1 行內結構 | charify/jiya 標記/jinze/spacing/longWord/emphasis | 1 次 | 共享走訪,apply 依 order |
| P2 版面後處理 | hanging | 0 次走訪+layout | 需最終行盒,掛 ResizeObserver |
聚合僅是執行優化:邏輯上仍「一功能一 pass」、可獨立開關與
revert(I6); 只是 P1 諸 pass 的collect在同一走訪內完成。若某 pass 不相容(需獨立走訪), 退化為自跑一次,不影響正確性。
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 之
collectper-node 閘用;結果以元素為鍵快取。 - charify:把符合條件之字元/字串包進指定
jz-*元素(兩階段:先收集目標、 後變更,避免遍歷中改 DOM 失效)。 - 邊界感知 replace(I7):對「跨相鄰文本節點、可跨樣式行內元素」的邏輯字串
做正則 find,回呼決定如何包裝/插入標記。樣式行內元素(
strong/em/a/span/…) 透明穿越;block/pill/avoid 中斷邏輯串。此即 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-clean,justify 安全(§8) |
jz-char |
包裝單一標點/漢字,承載 bd-* class 與 data-jz 屬性 |
jz-inner |
jz-char 內層字面容器,供 margin/letter-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,間隙由 CSSmargin-right(0.25em)提供——不注入任何空白字元。 WHERE 之規則沿用 v1 pangu 規則表(已驗證);作者原寫之 CJK↔西文空格一律移除 (改由 margin 留隙)。 - 為何不用空白字元/inline-block(justify 三難):純 CSS 下,CJK↔西文間隙在
justify必有一個邊緣瑕疵——①真實空白→被當詞間空白暴增;②inline-block 固定 寬→罕見落行首縮排;③margin-right→行末右緣內縮 0.25em。選 ③(margin), 因其行首乾淨、不撐框、不暴增、textContent完全不變(純 copy-clean,複製緊湊), 並以 §6.1a 之 layout pass 消除行末內縮。 - 跨瀏覽器:
margin/inline 普遍支援。 - copy-clean(I1,較 Han 更純):間隙為純視覺 margin,
textContent不增不減 語義字元,剪貼板得緊湊原文。 - 切片:涵蓋 v1 Pass A、Pass B(pill
code/kbd邊界亦走同一機制——左字或 pill 元素包入jz-hws;pill 由 avoid 與鄰接判定處理)。 - 邊界正規化一致性(下游回饋 1a/1b 修):
- 1a pill 穿越 jz-jinze:jinze(order 40)先於 spacing(order 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 不動。
- 1a pill 穿越 jz-jinze:jinze(order 40)先於 spacing(order 50),會把尾隨
標點之 pill 納入
- 西文字集之完整化(下游回饋 D;列舉法):CJK↔西文之邊界由
unicode.ts之ANS_AFTER_CJK/ANS_BEFORE_CJK(共用片段WEST_SHARED)界定。原僅涵蓋拉丁基本 +拉丁-1+部分希臘,致貨幣符號(€U+20AC 等整塊 U+20A0–20BF)、拼音聲調 (ā ǎ ē等拉丁擴充 A/B U+0100–024F)、西里爾、數學運算符(U+2200–22FF) 不被視為西文 → 鄰接 CJK 不補隙、手工空格亦不剝除。改採逐區塊列舉補全(見WEST_SHARED註記),並把希臘由原 U+0370–037D 補全至整塊 U+0370–03FF(原漏Α-Ω α-ω本身)。取捨:選列舉(whitelist)而非\p{…}類別法,因類別法之 集合減法須vflag(ES2024),會破壞 2016/Windows 10 時代相容性下限;列舉法所有 範圍皆 BMP、以\uXXXX表示於字元類內無需任何 flag。代價:新區塊需手補。 - 全角省略號不誤判(下游回饋 B):
DOTS_CJK原以 alternation([\.]{2,}|…)匹配「省略號後接 CJK 補隙」,但|…誤把全角省略號 U+2026 納入;全角…/……屬 CJK 標點(已由 jiya charify 為bd-liga),後接 CJK 不應有中西間隙。移除|…, 僅留 ASCII...。
6.1a 行末去隙(gapTrim,justify 修正)— 切片 1
- 問題:margin-right 落在行末時,justify 下使右緣內縮 0.25em。
- 我們:layout pass
gapTrim量測各jz-hws,對「其字落於行末(後一內容在 更低行)」者加jz-hws-trim(CSSmargin-right:0),該行遂齊右、無內縮。掛ResizeObserver+字型載入後重算。純版面、瀏覽器專用(jsdom 跳過)。與lineedge共用量測工具與 layout 安裝器。 - 自我應驗誤判(下游回饋 C/容器寬相關之「最後一個間隙消失」):間隙之 margin-right
會參與斷行。若依「後字是否在更低行」判行末,而後字落點又被 margin 推擠,便自相矛盾
——間隙 margin 把後字擠到次行 → 判行末 → trim 去 margin → 後字回同行,振盪停在「已
trim、後字同行、間隙消失」之錯誤終態(
ResizeObserver只觀 root 尺寸、不因內部 margin 變化重觸發,故卡死;resize 容器則該處重判、間隙「恢復」或轉移)。污染雙向:不僅當前 間隙自身 margin,下游間隙之 margin 亦會令夾在中間之內容(如或<gap>$1299<gap>) 放不下其行、致後字假性換行。 - 修法:
trimGaps改為起始全部 trim(margin 全 0),再由左至右逐一定案。量測 某間隙時其前皆已定案、其自身與其後全部仍 trim,故後字落點不受任何 margin 污染,純粹反映 「無本間隙 margin 時後字是否仍被擠到次行」:仍在更低行(或無後字)→ 真行末、維持 trim; 在同行 → 行內容得下、非真行末 → 還原間隙。逐一必要(斷行左→右因果);以末行 rect (lastRect,margin 實際落點)為準,兼顧跨行 pill(行尾code折行亦曾致右側間隙誤消)。 實 Chromium 多寬度(8–22em、ragged/justify)掃描確認:偽 trim 由「某些寬度存在」歸零; 真行末仍 trim(justify 齊右不變)。
6.1b 標點稽核:CJK 標點不生誤隙(下游回饋)
- 不變量:任何現代漢語標點介於 CJK 之間,皆不補中西間隙。實 Chromium 稽核全部 常用標點(。,、;:?!…… —— ·~ 簡繁引號 “”‘’「」『』、各式括號 ()【】《》〈〉 []{}〔〕〖〗 等)× 簡繁兩政策共 104 例,jz-hws 皆 0、textContent 不變。
- 修正之偏例:①全角省略號
……(§6.1 之 B:DOTS_CJK之|…誤匹配,已移除); ②收尾彎引號”(原 panguQUOTE_AN規則令”後接拉丁/數字補隙,使其成為唯一會補隙 之收尾標點,與」』)》】〕皆不補不一致;clreq 下全角收尾標點字身已內含右側留白,後接 拉丁不需額外隙——已移除QUOTE_AN以求一致、消除誤隙)。
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-leclass 觸發(I3;改用 class 而非 CSS:lang(),以避免:lang(zh-Hans)不匹配zh-CN等地區標籤之陷阱)。三機制並集落地:①baseline 政策(全角不擠/ 開明擠句內點號+括號引號/半角全擠);②行內連續擠壓(jiyaAdjacencyPass, 相鄰標點對向側收一字,lang 無關);③行端擠壓(lineedge.tslayout 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.5em/center −0.25em/left 0),使墨色落入 0.5em 盒、空白溢向外側——即以 CSS 盒模型重現halt。偏側由core/unicode.ts之inkBias(ch, langClass, overrides)依字身墨色中心給出left|center|right|full(簡體點號偏左、繁體居中;彎引號“”/角括號「」/尖括號〈〉墨強偏一側→開 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.0em(halt 無效)→ 19.7em(margin 生效)、單標點1.0em → 0.5em。 - 切片:涵蓋並超越 v1 Pass D(開明式),加入全角式、lang 切換、行內連續與
行端擠壓、
halt/margin雙渲染。
6.4 禁則(Jinze,避頭尾)+ justify 綁定 — 切片 1(基礎)/後續(完整)
- Han:
h-jinze綁定行首行尾不可出現之標點與鄰字,避免標點落行首/行尾。 - 我們:
jz-jinze(CSSdisplay: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(Chrome/FF/Safari 支援良好);需精細 控制時退回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 Sans/Serif CJK SC/TC/HK/JP 字體堆疊。非破壞、 opt-in(掛在.juzhen容器或[data-juzhen]下)。 - 跨瀏覽器:
:where()/:lang()普遍支援。
6.10 計數器/緊排(counter/well-knit)— 低優先
- 後續以純 CSS 模組加入,不涉 JS 引擎。
7. 第一垂直切片(v1 對等)
範圍:核心基座(finder/unicode/locale/dom/pass 管線)+ 以下功能 pass:
| v1 | 切片 1 對應 | 機制 |
|---|---|---|
| Pass A 中西間隙 | spacing |
jz-hws,copy-clean,justify 安全 |
| Pass B pill 邊界 | spacing(同引擎) |
avoid + 鄰接判定 |
| Pass C 長英文詞 | longWord |
<span lang> + hyphens |
| Pass D 開明式擠壓 | jiya + jinze |
跨瀏覽器負 margin + :lang 全角/開明 + justify 綁定 |
交付:可 build 出 ESM/IIFE/CSS/d.ts;jsdom 測試覆蓋 DOM 變換正確性
(包裝、class、邊界、冪等、copy-clean、lang 政策);justify/簡繁 demo 頁供瀏覽器
目視驗證版面(layout 行為無法於 jsdom 驗證,見 §12)。
擴充保證:切片 1 所用之 finder/charify/pass 管線/jz-* 詞彙/CSS-JS 契約,
即後續所有 Han 功能所用者;新增功能 = 新增一個遵守 §2 不變量的 pass,不改切片 1。
8. justify 句末標點單份間距解法(要求 #3)
問題:text-align: justify 下,UA 在「對齊機會點」插入伸縮量。全角句末標點
(如 。)字面只占字框一部分、自帶半形留白;若 UA 在其兩側都開伸縮,標點
看似分到「兩份」間距(伸縮 gap + 自身留白)。
解法(CSS 主導,I2/I5):
- charify 句末/句逗標點為
<jz-char class="bd …"><jz-inner>。</jz-inner>。 jz-char設display: inline-block:使其成為 justify 的單一原子項,內部 不開伸縮 gap,標點與其字面留白同屬一項,對齊伸縮只落在項與項之間 → 標點 只貢獻一份。- 開明式下,
jz-inner以負 letter-spacing 擠去半形留白,該項本身即半形, justify 一份 gap 即足。 - 全角式下,留白保留於原子項內,仍只算一份;以
jz-jinze綁定確保不在留白 側額外開機會點。
inline-block 影響基線對齊與行內換行,須以 CSS 校正(
vertical-align、行高), 並跨 Chrome/Safari/FF 目視驗證(§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 字型。
原子透明原則(繼承屬性漏溢,下游回饋):jz-char/jz-jinze(及 margin 模式之
jz-inner)為 inline-block,而 inline-block 自成區塊容器。凡「作用於區塊容器首/末行」之
繼承屬性,會被每個單字標點原子各自重新套用一次,使「用聚珍」與「未用聚珍」表現不一致。
作用範圍判斷:此類間距/位移源於我們生成之原子,屬本庫責任,由庫內統一免疫(消費端無需
配置);且須以「同類機制」整體界定範圍——要麼全修、要麼全交下游,不做逐案補丁。修法
(_normalize.css)於原子上歸零此類屬性,只切斷其向原子內部之再套用,段落本身之同名屬性不
受影響(仍作用於段落自身行盒)。
經 headless Chrome 逐屬性實測,屬此類者僅兩項,皆已歸零:
text-indent: 0——段落首行縮排傳入後,每原子內部首行各縮排一次,shrink-to-fit 寬膨脹為縮排量+字形,標點附近冒出巨大間距。實測:歸零後段落首行縮排仍保持 2em,原子寬由 ~3em 回到 0.5em/1em。hanging-punctuation: none——同屬區塊容器首/末行機制,每原子各自懸掛自身標點。Chrome 未實作(測不出)、Safari 會觸發,且與規劃中之「標點懸掛」(§任務 6)相衝,故按機制一併歸零。
不歸零者(實測不洩漏或本應逐字穿透):letter-spacing/word-spacing(原子邊界處理
一致、無翻倍,實測 delta 與基線同);text-align/text-align-last/text-justify
(halt 為 shrink-to-fit、margin 為內容溢出,皆無對齊餘量,兩模式實測 jz-inner 偏移不變);
text-transform/line-height/color/font(逐字內容或縱向屬性,須穿透原子);
word-break/overflow-wrap/hyphens/white-space/tab-size/text-emphasis/
text-shadow/font-variant/font-stretch(逐屬性實測 delta 皆與基線同,不洩漏)。
透明之另一面——欠套用(text-decoration):與上述「過套用」相反,text-decoration
非繼承屬性,由段落之「裝飾盒」統一繪製並跨後代延伸;但原子級 inline-block 會被裝飾盒
跳過,致段落下劃線在每個標點原子下斷缺(像素掃描實測:純文字 coverage 1.0、聚珍僅
~0.25)。故反向處理——令原子 text-decoration: inherit 承段落裝飾自繪,因共用基線與字型
度量而無縫接續(_normalize.css 設 jz-char/jz-jinze;margin 模式 jz-inner 亦為
inline-block,於 _jiya.css 補一條)。實測:halt/margin 兩模式 coverage 皆回 1.0,且
margin 下劃線隨半形收為 0.5em advance。此為「同類問題」整體界定之一部分:凡因原子化
而生之版面瑕疵,要麼庫內統一修復、要麼統一交下游;本庫選前者,使原子對消費端樣式透明。
9. 跨瀏覽器策略
- 一致性關鍵層(間隙、擠壓、justify):只用
margin、inline-block、user-select、white-space、:where()、hyphens,及字型 OpenTypefont-feature-settings:"halt"(標點半形;各引擎一致,效果僅依字型是否含 halt, Noto CJK 具備,缺則優雅降級為全形)——三大引擎(Blink/WebKit/Gecko)皆穩定 支援。注意:標點半形預設不用負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)。 - 驗證矩陣:Chrome(Blink,含 VSCode/Electron)、Safari/WebKit、Firefox (Gecko)。demo 頁含三引擎可開的測試案例。
9.1 顯式禁用不一致原語(I4)
當原生原語在各引擎支援不一,或會與我們託管之版面重複作用(雙重擠壓/間隙)
時,在 .juzhen 範圍內以 @supports 偵測並主動關閉,使所有引擎收斂於我們的
一致路徑:
@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 遍歷/avoid/charify/邊界感知 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/ 瀏覽器 layout/justify/簡繁 目視驗證頁
build.mjs esbuild:ESM + IIFE + CSS;tsc:d.ts
11. 構建與發佈
- 源碼:TypeScript(strict),模組化。
- 構建:
build.mjs以 esbuild 產出dist/juzhen.mjs(ESM,供打包器/TS)dist/juzhen.iife.js(IIFE,全域Juzhen,原生<script>直跑——保留 build.sh/Nvim 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/Safari/FF 目視核對。
- 文件明確標注哪些結論來自自動測試、哪些待瀏覽器目視。
13. 擴充性保證(如何加新功能而不返工)
新增任一 Han 功能點,標準流程:
- 在
unicode.ts補該功能所需字元類/正則(集中、不散落)。 - 新增
typeset/<feature>.ts,實作Pass(render/revert/order)。 - 在
index.ts註冊,加options.<feature>開關。 - 在
css/_<feature>.css寫視覺規則(I2:尺寸只在 CSS)。 - 補 jsdom 測試 + demo 案例。
因所有功能共用 finder/charify/locale/jz-*/CSS-JS 契約,且順序由 order
集中編排,新增不觸動既有 pass——此即「預留完整復刻 Han.css 能力」之具體保證。
14. 風險與未決項
原則:風險上線後持續觀察,但下列預案先行設計就緒,一旦觀察指標踩線即可切換, 不致臨時返工。
- R1 justify/inline-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 不變量)須先更新本文件再改碼。