diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..9c6095d --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,566 @@ +# 聚珍(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` 的語義;若某 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 邊界感知**:跨樣式行內元素的鄰接(如 `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` 形如: + +``` +{ + // 全域 + lang: { default: "zh-Hant", policy: { "zh-Hant": "quanjiao", "zh-Hans": "kaiming" } }, + 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 之 + `collect` per-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=""`)、`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**:插入 `