# 聚珍(Juzhen)架構文檔 > 現代簡/繁中文網頁排版套件。Han.css 之現代化重寫,沿其功能版圖,並針對 > 我們關注的數種情況(複製乾淨、依 `lang` 全角/開明切換、justify 句末標點 > 單份間距、跨瀏覽器一致)作調整。 > > 倉庫名 `cjk-autospace` 不變;公開 API 為 `createJuzhen` / 全域 `Juzhen`; > 自訂元素前綴 `jz-`。本文件先於任何程式碼,作為實作契約——後續每個功能點 > 皆按此落地,以避免不一致與重大返工。 --- ## 1. 目標與範圍 1. **第一垂直切片 = v1(cjk-autospace)全部功能**,在新引擎上重寫:中西間隙 (Pass A/B)、長英文詞斷詞包裝(Pass C)、開明式標點擠壓(Pass D)。 2. **架構須預留完整復刻 Han.css 的能力**:標點修正、擠壓、懸掛、禁則、著重號、 注音/ruby、樣式正常化、字體偵測、計數器等,皆能在不改動切片 1 既有程式碼 的前提下,以「新增一個 pass」的方式加入。 3. **跨瀏覽器一致**(Chrome/Edge/Safari/WebKit/Firefox):一致性關鍵的版面 行為,只用普遍支援的 CSS 原語;原生新特性僅作非必要增強。 非目標:不依賴 Han.css/Heti 等既有庫之執行期程式碼(MIT 條款下可借鑑其思路與 分類表,但成品自含)。 --- ## 2. 設計不變量(所有 pass 必須遵守) 這些不變量是「避免返工」的核心。任何新功能點都不得違反。 - **I1 複製乾淨(copy-clean)**:注入之間隙與標記一律 `user-select: none`,不改變邏輯 `textContent` 的**語義**內容。**一項刻意之常態正規化例外**:`spacing` pass 剝除**贅餘之作者 空格**——CJK↔西文之作者空格吸收為受管 `margin` 間隙、CJK↔CJK(含跨 inline 標籤)之作者 空格剝除不留隙。此為 CJK 排版正解(該空格屬裝飾性、間隙改由 margin 承載,語義內容不變), 但**確實刪除空格字元、且此刪除不經 `revert` 還原**(見 I6)。除此常態正規化外,預設「只包裝、 不改寫文字」;其餘真正會改寫文字之 pass(如標點替換 `biaodian`)須為顯式 opt-in 且可 `revert` 還原。(實測 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 末「快取生命週期與失效」。 `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: null, // 追加排除子樹之選擇器(預設 null,與內建 skipTags 名單並存;見 §4.1) }, // 全域逐功能開關(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` 導出、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 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 之**七源**(`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` 默認邊界——同一未登記行內標籤(如 `