# 聚珍(Juzhen)架構文檔 > 現代簡/繁中文網頁排版套件。Han.css 之現代化重寫,沿其功能版圖,並針對 > 我們關注的數種情況(複製乾淨、依 `lang` 全角/開明切換、justify 句末標點 > 單份間距、跨瀏覽器一致)作調整。 > > 倉庫名 `cjk-autospace` 不變;公開 API 為 `createJuzhen` / 全域 `Juzhen`; > 自訂元素前綴 `jz-`。平台中立之排版語義以 `rules/juzhen-rules.json`、其 Schema 與 > 黃金向量為唯一權威源;本文件描述 Web adapter 架構及設計理由,不得覆蓋或分叉共享規則。 --- ## 1. 目標與範圍 1. **第一垂直切片 = v1(cjk-autospace)全部功能**,在新引擎上重寫:中西間隙 (Pass A/B)、長英文詞斷詞包裝(Pass C)、開明式標點擠壓(Pass D)。 2. **架構保留擴充 Han.css 功能版圖的能力**:標點修正、著重號與注音/ruby 等目前 **尚未實作**,若未來加入,須沿既有 pass、finder 與 CSS 契約擴充。標點懸掛之舊 負 margin 實作則已移除;四個同名公開選項現階段僅為相容 no-op(見 §3.1)。 3. **跨瀏覽器一致**(Chrome/Edge/Safari/WebKit/Firefox):一致性關鍵的版面 行為,只用普遍支援的 CSS 原語;原生新特性僅作非必要增強。 非目標:不依賴 Han.css/Heti 等既有庫之執行期程式碼(MIT 條款下可借鑑其思路與 分類表,但成品自含)。 ### 1.1 共享規則與 adapter 所有權 `rules/` 固定字符分類、語言政策、標點目標寬度、四分之一字間隙、禁斷、段末約束與條件 行端結果;Web/CSS 與並行 XeLaTeX 實作均消費同一 `rulesetVersion` 及黃金向量。DOM wrapper、 CSS box、observer、瀏覽器量測,以及 TeX node/glue/penalty/macro 都只是各 adapter 可選之 非規範機制。共享規則由本項目維護;任何變更須先更新 Schema/向量,再由兩個 adapter 分別 證明相容,不能在某一實作內靜默改義。 --- ## 2. 設計不變量(所有 pass 必須遵守) 這些不變量是「避免返工」的核心。任何新功能點都不得違反。 - **I1 複製乾淨(copy-clean)**:`spacing` 以 `jz-hws` 的 margin 留隙,**不插入空白字元**; 唯跨節點 IVS 間隙以 `::after` 生成 `U+200B` 斷行機會,且只對此生成內容設 `user-select:none`。該偽元素不進入 DOM/`textContent`。**一項刻意之常態正規化例外**: `spacing` pass 剝除**贅餘之作者 空格**——CJK↔西文之作者空格吸收為受管 `margin` 間隙、CJK↔CJK(含跨 inline 標籤)之作者 空格剝除不留隙。此為 CJK 排版正解(該空格屬裝飾性、間隙改由 margin 承載,語義內容不變), 但**確實刪除空格字元、且此刪除不經 `revert` 還原**(見 I6)。除此常態正規化外,預設「只包裝、 不改寫文字」。未來若新增會改寫文字之 pass,須為顯式 opt-in 且可 `revert` 還原;目前不存在 `biaodian` 改寫 pass。(實測 4 份真人撰寫之真實文檔:非空白字元多重集守恆、無任何字元改/增/漏/亂序,唯 贅餘作者空格依此契約規範化。) - **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` **乾淨移除聚珍注入之 全部結構**(`jz-*` 元素、class、`data-jz-*` 屬性;wrap→解包還原原內容、marker→移除、 splitoff→併回原元素),DOM 回到 pass 前狀態之**邏輯等價**態。「邏輯等價」而非逐字節相同之**唯一 差異**:`spacing` 規範化之贅餘作者空格不還原(見 I1)——故 `render→revert` 對含 CJK↔西文作者 空格之源,還原後語義一致、結構全清,唯該空格已正規化(此差異穩定、不隨 `render`/`revert` 反覆 累積)。`revert` 後 DOM 內無任何 `[data-jz]` 殘留(實測驗收成立)。 - **I7 邊界感知**:跨樣式行內元素的鄰接(如 `是A`)須能識別並 正確處理——由核心 finder 統一提供,各 pass 不自行重複造輪。 - **I8 向下相容 v1(已退役)**:相容層曾保障 v1 消費端(build.sh 單檔串接、Nvim peek、Claude 模板)過渡期不 break;消費端遷移 v2 API 完成後相容層已移除(見 §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?); // 還原 // 增量恢復(動態頁面;root 皆可為局部 Element,成本 O(該塊) 而非 O(全文)) jz.rerender(root?); // = revert(root) + render(root)(便利) jz.invalidate(root?); // 清該子樹之內部快取(改屬性但不 revert 時用;revert 已自動失效) jz.observe(root?); // 以 MutationObserver 自動增量恢復(opt-in;自身注入不自觸發) jz.disconnect(); // 停止觀察 // 全域: window.Juzhen.createJuzhen(...) ``` **增量恢復(第三層)**:`render`/`revert` 皆收局部 Element,配合**持久化 Finder 快取**(§4.1 末)與快取失效,頁面局部變化只重處理受影響子樹。`observe` 之 `MutationObserver`:內容/分類屬性 變化 → 自動 `revert`+`render` 最近塊、新增之塊直接 `render`(免全容器重掃);render/revert 期間 **暫停觀察**,故 juzhen 自身之 jz-* 注入絕不自觸發。實測編輯 400 段中之一段,局部恢復較全樹快數 百倍。**元素變化正確性**見 §4.1 末「快取生命週期與失效」。 **增量粒度契約(下游說明八)**:增量更新須以**塊**為粒度——換塊內容(`textContent`/ `innerHTML`)、增刪塊、改分類屬性,皆與全量重繪一致(差分複驗逐位元相同)。**不支持**對聚珍 已切分之文本節點做外科式 `characterData` 直改(如持有 render **前**之 `p.firstChild` 引用、 事後直改其 `.data`):charify 以 fragment **替換**原文本節點(§3.4),舊引用已脫離文檔或僅指 碎片,直改令 `revert` 合併出之內容偏離作者意圖。此為後處理排版器之固有限制、非缺陷——聚珍對 當前 DOM 之處理始終自洽;外科式文本 patch 之框架(vdom 等)須以塊為單位替換,或對受管區域 `revert` 後再 patch。 `options` 形如: ``` { // 全域。style = 全域擠壓預設(quanjiao|kaiming|banjiao);policy = 逐 lang // 覆寫。優先序:policy[lang] > style > 內建預設(繁→全角、簡→開明)。 lang: { default: "zh-Hant", style: "kaiming", policy: { "zh-Hant": "quanjiao" } }, // 作用域限定(§3.6) scope: { root: document.body, // 預設處理根;render(root) 可逐次覆寫 include: null, // 啟用選擇器;設則僅處理命中子樹(opt-in) // 如 ".juzhen, [data-juzhen]" avoid: null, // 追加排除子樹之選擇器(預設 null,與內建 skipTags 名單並存;見 §4.1) }, // 全域逐功能開關(true/false 或細部物件),預設見各 pass spacing: true, // 中西間隙 longWord: { minLength: 6, lang: "en" }, jiya: true, // 標點擠壓 jinze: true, // 禁則(含 justify 綁定) orphan: { chars: 2 }, // 段末垂懸字避免 level: { text: "h1,h2,h3,button,.ui" }, // 分塊功能選擇(§3.6):選擇器 → 該塊內生效之功能集(覆寫全域) blocks: [ { selector: ".prose", features: ["spacing", "jiya", "jinze"] }, { selector: ".code-comment", features: ["spacing"] }, // 此塊只間隙、不擠壓 ], } ``` `hanging`/`biaodian`/`emphasis`/`ruby` 仍可作為頂層選項傳入,但目前全部為 **相容 no-op**:沒有註冊 pass,也不產生 DOM 或 CSS 效果。`hanging` 的舊負 margin 實作已移除;其餘三項僅保留為明確標記的未來設計。`charClass.biaodian` 則是已實作的 標點字符分類擴充,與同名頂層 no-op 選項無關。 ### 3.2 Pass 管線 每個功能點是一個 `Pass`;目前型別是 `CharifyPass | StandalonePass`: ``` interface BasePass { name: string; order: number; kind: "charify" | "standalone"; level: "text" | "paragraph"; enabled(options: ResolvedOptions): boolean; revert(ctx: RenderContext): void; } interface CharifyPass extends BasePass { kind: "charify"; collect(node: Text, ctx: RenderContext): WrapRequest[]; } interface StandalonePass extends BasePass { kind: "standalone"; render(ctx: RenderContext): void; } type Pass = CharifyPass | StandalonePass; ``` `CharifyPass` 只收集文本包裝請求;`StandalonePass` 自行執行功能所需的掃描或 layout。 兩類皆由同一註冊表管理,且均按 `level` 與功能閘判定是否生效。 `createJuzhen` 內建一個**有序、可開關**的 pass 註冊表。`render` 依 `order` 執行 已啟用者。順序契約(避免互相破壞): ``` Charify group(共享一次文本遍歷): 20 jiya 標點字元包裝(jz-char/jz-inner) 60 longWord 長英文詞 lang 包裝 Standalone group(各自 render): 10 levelMark 寫回 resolved block level 25 jiyaAdjacency 連續標點擠壓標記 40 jinze 禁則 + justify 綁定 50 spacing 中西間隙(jz-hws) 70 orphan 垂懸字避免 90 jiyaLineEdge 行端半形擠壓 92 spacingGapTrim 行末中西間隙裁切 ``` 執行器先跑完整個 charify group,再跑 standalone group;因此跨組不以數字互相比較。 例如 `levelMark` 的 `order=10` 仍在 charify 完成後執行。`revert` 則把全部 pass 合併後 按 `order` 逆序呼叫,產物的 ownership/kind 標記負責精確還原。 ### 3.3 RenderContext `render`/`revert` 收到統一上下文:`{ root, options, finder, ownedElements, ownerToken, setOwnedClass }`。各 pass 共用同一 `finder` 與 resolved options;產物透過 本實例的 ownership 集合/token 辨識,避免把外來或偽造的 `jz-*` 當成本實例產物。 ### 3.4 遍歷聚合(減少 pass 遍歷次數,I9) 執行器先篩出已啟用的 pass,再按 `kind` 分組。所有 `CharifyPass` 共用一次 `finder.eachTextNode(root)`:對每個文本節點依 `order` 呼叫各 pass 的 `collect`, 先把節點與 `WrapRequest` 累積為 work,遍歷結束後才由管線執行器統一套用,避免走訪途中改 DOM。 之後 `StandalonePass` 依 `order` 各自呼叫 `render(ctx)`: ``` charify = enabled.filter(kind == "charify").sort(order) standalone = enabled.filter(kind == "standalone").sort(order) walk(root) 一次:對每個 Text,依序把 charify 的 WrapRequest 收入 work for item in work: applyRequests(item.node, item.requests, ownedElements) for pass in standalone: pass.render(ctx) ``` 聚合僅是**執行優化**:邏輯上仍「一功能一 pass」、可獨立開關與 `revert`(I6)。 需要自訂掃描、跨節點 run 或 layout 的功能採 standalone,不假裝參與 charify 聚合。 ### 3.5 v1 向下相容層 — 已移除(I8 退役) 相容層(`src/compat/v1.ts`:`createCjkAutospace` 導出、IIFE 之全域別名、 `autospaceClass` 選項)曾把 v1 API 映射到 `createJuzhen`,服務 build.sh 單檔串接/ Nvim peek/Claude 模板等 v1 消費端之過渡期。**最後一批 v1 消費端遷移 v2 API 完成後 已整體移除**(`autospaceClass` 經查從未被任何 pass 讀取,屬死選項,一併刪)。v1 原始碼保存於 `v1-autospace` 分支。 ### 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 orphan"`——黑名單:此塊停用列出者,其餘繼承。 - `lang`——驅動全角/開明(正交於功能開關)。 - **程式式**:`options.blocks: [{ selector, features }]`,選擇器 → 功能集。 - **解析(per-node 閘)**:對每個節點,向上找最近帶功能指令之祖先(屬性或命中 `blocks` 選擇器者)決定其**有效功能集**,無則用全域 `options`;render 期間以 元素為鍵快取。charify pass 在 `collect(node)`、standalone pass 在自身掃描時查詢 `featureEnabledFor(node, name)`,未啟用即跳過。分塊閘不改變 §3.4 的兩類 pass 執行模型。 - **與 revert 之關係**:`revert` 依標記移除某功能產物,**不受當前閘狀態影響**,故 「關閉某功能 + revert」恆能乾淨還原(I6)。 --- ## 4. 核心基座(`src/core/`) ### 4.1 `finder.ts`——文本遍歷引擎(取代 Fibre.js) 職責(所有 pass 的共同地基): - **遍歷**:`TreeWalker(SHOW_TEXT)` 走 `root` 下文本節點。 - **avoid**:avoid 之**七源**(`isSkipped`/`skipTags` 名單/SVG/`contentEditable`/ `data-jz-skip`/`skipAttribute`/`scope.avoid`)任一命中即跳過其子樹。內建 `skipTags`: `pre/samp/tt/var/script/style/textarea/input`(**不含 code/kbd**——二者由 `pillSelector` 以 pill 涵蓋,三分類互斥)。自身判定用 `avoidsSelf`、自身或祖先之組合用 `isAvoided`。 - **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/pill/avoid 中斷邏輯串。此即 v1 Pass A「剝離樣式收集邏輯文本 再反映射」之一般化,提升為可重用引擎。 設計要點:finder 不知道任何具體排版規則;它只提供「在何處、跨何種邊界、對哪些 字元」的遍歷與安全變更原語。規則由各 pass 以 unicode 表 + 回呼注入。 **行內元素三分類(下游問題六後確立)**:行內元素對排版有且僅有三種口徑。**邏輯文本流**之 遍歷原語(`collectRuns`/`adjacentLogicalChar`、`jiyaAdjacency` 之相鄰掃描、orphan 之實義字 收集)共用單一判定 `isTransparent`。**未在 `styleInlines` 之未知行內標籤採嚴格白名單、一律 當隔離式邊界**(斷 run/相鄰、內部自成脈絡),透明須顯式登記 `styleInlines`(另兩層見下)。 三口徑: - **透明**(`styleInlines` + wrap 類 `jz-*`)——穿越、run 延續、禁則可克隆切分。 - **隔離**(`isolateSelector`,預設 `mark`)——邊界斷 run/斷相鄰/不可切分、兩端 補隙(同 pill),**內部照常處理**(自成脈絡)。典型:帶背景色之 ``(切分 背景盒有可見接縫,故亦自 `SPLITTABLE` 白名單移除)。 - **整體**(`pillSelector`)——原子單位:內部對一切 pass **不可見**(`eachTextNode` 過濾 `insidePill`,不 charify/不插間隙/不切分/不下潛),禁則時作為原子鄰字 單位**整體綁入** `jz-jinze`。典型:`code`/`kbd`、行內公式 `.katex`。 **單一分類 `finder.category(el)`(優先序)**:一元素恰屬六類之一,多選擇器同命中時依序短路 定案——`avoid > pill > isolate > block > transparent > opaque`(`opaque`=未在 styleInlines 之未知行內標籤,嚴格白名單下當隔離式邊界)。故帶 `data-jz-skip` 之 pill/isolate 判為 avoid; 同命中 pill 與 isolate 判為 pill(pill 覆蓋 isolate)。邏輯文本流之遍歷原語(`collectRuns`/ `jiyaAdjacency`/`isTransparent`)皆 `switch(category)` 分派,`canSplit` = `SPLITTABLE ∩ transparent` ——分類集中一處、優先序不再散落各原語各自短路(階段二後之清晰化)。 **效能(記憶化)**:三處逐元素/逐節點之重複計算以 `WeakMap` 記憶化—— - **S3 category**:`category` 含至多三次 `el.matches()`(pill/isolate/avoid),且被上列各遍歷原語跨 pass 重算 → `categoryCache` 砍除跨 pass 重複。 - **祖先游走(`insidePill`/`isAvoided`)**:舊版逐文本節點沿祖先鏈調 `pillMatches`/`avoidsSelf` (`eachTextNode` 每文本節點一趟、`collectRuns` 再一趟),O(文本節點 × depth)、深 DOM 下二次 → `ancestorOrSelfPill`/`…Avoided` 以「沿鏈迭代回填」記憶化(每元素恰算一次、兄弟共用祖先鏈快取), 收斂為 O(元素數)。`insidePill` 為 `el.matches()` 之最大來源;記憶化後掃描層 `el.matches()` 累計約降 五成、深 DOM render 時間顯著下降。 **快取生命週期與失效(元素變化正確性)**:Finder 隨實例**持久化**(跨 render/revert 復用上列快取, 非每次 render 重建),使頁面局部變化時未變子樹之分類/級別/功能免重算。元素鍵 `WeakMap`:移除之元素 自動回收、新增者重新計算。**屬性變化**(class/data-jz-*/lang 等改變既有元素之分類/級別/功能)會令其 快取變脏——且因級別/功能/祖先游走依賴祖先鏈,某祖先變則其**整棵子樹**之快取變脏,故 `invalidate(el)` 清 el 子樹全部元素鍵快取。`revert(el)` **自動** `invalidate` 其子樹(安全預設),故 `revert`/`rerender` /`observe` 路徑天然正確;`invalidate(el)` 另供「改屬性但不 revert」之顯式入口(見 §3.1 增量 API)。 判定**以元素為準、非標籤名**(`isTransparent(el) = category(el)==="transparent"`,前置排除 pill/ isolate/avoid 再查標籤集):KaTeX 全樹皆 ``,舊版按標籤名判透明曾令 `canSplit`/`descend` 整棵下潛——禁則把公式內側末字連同完整殼層逐層克隆切出(`splitoff`),上標漂移錯位 (下游問題六);`pillSelector`/`skipAttribute`/`scope.avoid` 三層防護全被刺穿。 同根修復:**avoid 自身判定** `avoidsSelf(el)` 供遍歷原語逐元素下探自判;一次性「是否 avoid」用組合入口 `isAvoided`(=自身或祖先,階段二歸一,免「avoidsSelf ‖ isAvoided」 漏一半致自身命中 avoid 時仍被穿越)。`adjacentLogicalChar` 之 `descend` 改三態 (邊界字/無內容續找兄弟/**blocked** 終止——遇 pill/isolate/avoid 不可跳過續找 更遠之字,否則重蹈「越過 `jz-char` 取更前字 → 誤隙」之同型錯誤);`core/split` 之 `canSplit` 諮詢 finder(pill/isolate/avoid 一律不可切、退化整綁)。 **「行內透明」判定之分層(階段二確立)**:「透明穿越」曾有兩套口徑散落——`collectRuns` 默認透明、`isTransparent`/`adjacentLogicalChar` 默認邊界——同一未登記行內標籤(如 `