# 聚珍(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` 行為**:JS 解析節點的有效 `lang` 與逐元素樣式政策,決定標點是否半形, 再以 `jz-half`/`jz-half-le` class 交由 CSS 渲染;CSS 不以 `:lang()` 另判寬度政策。 字型地域字形由呼叫端的 `lang` 與字型決定,`options.lang.default` 不會改寫 DOM 的 `lang`。 - **I4 跨瀏覽器一致**:間隙、擠壓等一致性關鍵層只用 `margin`/`letter-spacing`/ `inline-block`/`user-select` 等普遍原語。`text-spacing-trim`/ `hanging-punctuation`/`text-emphasis` 等僅作漸進增強,絕不作為唯一機制。**且當 某原生原語在各引擎支援不一致、會與我們託管之版面衝突或重複作用時,須在 `.juzhen` 範圍內以 `@supports` 顯式禁用之**(現行關閉 `text-autospace`; `text-spacing-trim` 不設定),使三引擎行為收斂於同一路徑(見 §9.1)。 - **I5 justify 單份間距**:間隙與擠壓的結構設計,須保證每個標點在 `text-align: justify` 下只貢獻**一份**對齊伸縮量(見 §8)。 - **I6 冪等與可還原**:每個 pass 對產物打標記供定址,私有所有權憑證才決定還原權限。 `revert` 撤回本實例仍有效的產物(wrap→解包、splitoff→併回)及受控屬性,保留作者與 其他實例內容。沒有外部修改時,DOM 回到處理前的**邏輯等價**態;`spacing` 正規化的 贅餘作者空格不還原(見 I1),此差異不隨反覆 render/revert 累積。 外部克隆、替換、刪除後,按產物及來源完整性檢查安全回退,不復活過時文字; 不保證恢復歷史快照或清空所有 `[data-jz]`。DOM 標記本身不授予所有權。 - **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)。`scope.include` 只限定 JS 處理;結構樣式直接作用於 `jz-*`,原生間距 禁用層固定於 `.juzhen`。呼叫端負責讓容器標記與 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 末「快取生命週期與失效」。 變更目標與待新增塊皆以交付時的觀察根包含關係檢查;整根未掛入 document 仍可更新, 同批移出根的節點與已離根的舊記錄則跳過,不以 `isConnected` 代替作用域判斷。 搬入既有塊時,以同批移除來源、私有產物或已知 render 根辨識;先 revert 再 render 撤回舊祖先的語言/級別/功能效果。純新增塊直接 render,但插入子樹的快取先失效。 手動 `render`/`revert`/`rerender` 在最外層暫停 observer 前,先同步結算 `takeRecords()` 中的待交付變化,可能更新其他區塊;隨後才執行手動操作,局部回退仍最後生效。 結算舊記錄的錯誤由宿主微任務非同步報告,不成為手動 API 的同步例外;手動操作自己的 錯誤仍照常同步拋出。 **增量粒度契約(下游說明八)**:增量更新須以**塊**為粒度——換塊內容(`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"; prepare?: (ctx: RenderContext) => void; collect(node: Text, ctx: RenderContext): WrapRequest[]; } interface StandalonePass extends BasePass { kind: "standalone"; render(ctx: RenderContext): void; } type Pass = CharifyPass | StandalonePass; ``` `CharifyPass` 可先以 `prepare` 處理跨節點完整原子,再收集普通 Text 的包裝請求; `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。 走訪前可執行 `prepare(ctx)`;jiya 用此步驟先建立跨透明 Text 的完整標點原子, 一般同節點標點與長詞仍共用上述 collect/apply 流程。 之後 `StandalonePass` 依 `order` 各自呼叫 `render(ctx)`: ``` charify = enabled.filter(kind == "charify").sort(order) standalone = enabled.filter(kind == "standalone").sort(order) for pass in charify: pass.prepare?.(ctx) 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)。 需要完整獨立掃描或 layout 的功能採 standalone;跨節點標點的 prepare 是明列的 前置掃描,並不算入共享文本走訪的成本。 ### 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 作用域**:`scope.include` 不改寫 CSS。`jz-*` 結構樣式直接按元素/class 生效; §9.1 的原生間距禁用層固定於 `.juzhen`。若 JS 用 `.prose` 啟用,呼叫端可用 `class="prose juzhen"` 對齊兩者;作者自行建立的 `jz-*` 也會匹配結構樣式。 **分塊功能選擇**——不同塊啟用不同功能集: - **宣告式(屬性,就近繼承)**: - `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 閘用;結果以元素為鍵快取。 - **文字走訪**:`eachTextNode` 提供受過濾的 Text,charify pass 收集 `WrapRequest`, 由 `core/pass.ts` 批次包裝;Finder 本身不提供 charify mutation API。 - **邊界與來源映射(I7)**:`collectRuns` 收集跨透明 Text 的邏輯字串及完整來源跨度, `adjacentLogicalChar` 查詢完整鄰字。block/pill/avoid/isolate 等按分類中斷脈絡; 正則匹配與間隙回放由功能模組實作,不是 Finder 的泛用 replace/回呼插入 API。 - **完整 Unicode 原子**:`collectRuns` 將跨透明 Text 的 surrogate pair 先合成完整碼點, 再將 base 與至多一個 variation selector 合為原子;`map.parts` 保留所有來源跨度。 間隙、禁則與避孤字共用此映射,不在原子中間建間隙/斷點、不把 selector 另算實義字。 排除文本(含空 Text)、作用域及元素分類邊界中斷 run,不跨邊界拼接。 `collectRuns` 與鄰字查詢可傳入功能名;禁用 Text/空透明元素中斷該功能脈絡, 最近子白名單仍可重新啟用。spacing 與 jiya 的跨節點預處理共用此口徑。 設計要點: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 一律不可切);整綁仍須通過禁則/避孤字的 參與範圍檢查,avoid 不參與整綁。 **「行內透明」判定之分層(階段二確立)**:「透明穿越」曾有兩套口徑散落——`collectRuns` 默認透明、`isTransparent`/`adjacentLogicalChar` 默認邊界——同一未登記行內標籤(如 `