保留逐字 margin/jiya.bias 字体后备方案;补齐 demo 重排、量测边界说明及共享规则 1.0.1 反例向量。 验证:完整构建通过,Node 376 项、WKWebView 64 项及原生 demo 9 项通过;Firefox 仍暂缓。
84 KiB
聚珍(Juzhen)架構文檔
現代簡/繁中文網頁排版套件。Han.css 之現代化重寫,沿其功能版圖,並針對 我們關注的數種情況(複製乾淨、依
lang全角/開明切換、justify 句末標點 單份間距、跨瀏覽器一致)作調整。倉庫名
cjk-autospace不變;公開 API 為createJuzhen/ 全域Juzhen; 自訂元素前綴jz-。平台中立之排版語義以rules/juzhen-rules.json、其 Schema 與 黃金向量為唯一權威源;本文件描述 Web adapter 架構及設計理由,不得覆蓋或分叉共享規則。
1. 目標與範圍
- 第一垂直切片 = v1(cjk-autospace)全部功能,在新引擎上重寫:中西間隙 (Pass A/B)、長英文詞斷詞包裝(Pass C)、開明式標點擠壓(Pass D)。
- 架構保留擴充 Han.css 功能版圖的能力:標點修正、著重號與注音/ruby 等目前 尚未實作,若未來加入,須沿既有 pass、finder 與 CSS 契約擴充。標點懸掛之舊 負 margin 實作則已移除;四個同名公開選項現階段僅為相容 no-op(見 §3.1)。
- 跨瀏覽器一致(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。一項刻意之常態正規化例外:spacingpass 剝除贅餘之作者 空格——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-leclass 交由 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 累積。 外部克隆、替換、刪除後,按產物及來源完整性檢查安全回退,不復活過時文字; splitoff 無法安全併回時保留作者當前樣式及文字位置,僅撤銷本庫標記及憑證; 不保證恢復歷史快照或清空所有[data-jz]。DOM 標記本身不授予所有權。 - I7 邊界感知:跨樣式行內元素的鄰接(如
<strong>是</strong>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 之
collectper-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),內部照常處理(自成脈絡)。典型:帶背景色之<mark>(切分 背景盒有可見接縫,故亦自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 全樹皆 <span>,舊版按標籤名判透明曾令 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 默認邊界——同一未登記行內標籤(如 <button>)
在不同原語被判為兩種身份、切出相互矛盾之邏輯串。階段二歸一:邏輯文本流共用單一判定
isTransparent(列入樣式行內標籤、jz-hws/jz-jinze wrap 群組、jz-char/jz-inner
標點原子——後者必須透明,否則 adjacentLogicalChar 之 descend 遇標點原子既不進入亦不
終止而跳過它續找更前之字:漢字。<code> 綁定後 pill 左鄰字應為「。」卻誤取「字」補
jz-hws);未登記之未知行內標籤採嚴格白名單、一律當邊界。
另兩層各有正當之不同邊界、刻意不併入 isTransparent(承 lineedge#4 之教訓:正當差異不硬併):
- 結構克隆切分安全(
core/split之canSplit)——更窄白名單(STRONG/EM/…,排除<a>/ABBR/RUBY/帶id等——切分會重複語義/結構),故「可透明穿越」⊋「可克隆切分」。 - 物理版面(
lineedge之edgeRect)——依getClientRects之物理相鄰判行首/行尾,以 computeddisplay判斷物理行內流(inline 系列/ruby/contents 可攀出;block 停止), 可見br是強制行緣,隱藏br跳過。無計算樣式時才用styleInlines+jz 包裹標籤後備, 包含jz-orphan。須跨物理行內的 isolate/opaque(如<mark>/自訂標籤) 找真實相鄰內容:isolate 斷的是邏輯 run/相鄰,非物理位置(文字<mark>「引」</mark>之「物理上接在「文字」後、非行首,若停在 mark 邊界會誤判行首而錯收左半)。
4.2 unicode.ts——字元分類與規則表
集中所有 Unicode 範圍與標點子類,供各 pass 共用,杜絕分類散落不一致:
- CJK:表意文字、假名、注音符號等範圍(沿用並擴充 v1 之
PANGU_CJK)。 - 西文:拉丁字母、數字、常見符號(v1 之
AN_CHARS等)。 - 標點(biaodian)六子類(對應
jz-char之bd-*class):bd-stop句末點號:。.!?(開明式與全角式保持全形,banjiao 擠半)bd-pause句內點號:,、;:(開明式擠半形)bd-open開始括號/引號:「『(《〈【〖〔[{“‘bd-close結束括號/引號:」』)》〉】〗〕]}”’bd-middle間隔號:·・‧bd-liga省略/破折:…—
- 規則正則:中西間隙邊界、擠壓相鄰組合、禁則對象等,皆以此表組裝。
規則集抽象(Ruleset/charClass,可配置):字符分類不再各 pass 硬編碼,而統一為
一個規則集——{ cjk, ansAfter, ansBefore, biaodian{6 子類} },中文/西文為 regex 字元類片段,標點各子類為逐碼點字串;
後者不解讀範圍(追加 ①②③,不能以 ①-③ 表示三個圈碼)。regex 特殊字元須轉義。
DEFAULT_RULESET 即內置默認(上述 CJK/西文/BD_* 值)。消費端經 options.charClass
({ cjk?, western?, biaodian? })擴充,resolveRuleset(override) 以追加語義合併
為最終規則集,置於 ResolvedOptions.ruleset。各 pass 由此取分類:
spacing:每次 spacing render 開始時,makeRules(ruleset)按該實例的cjk/ansAfter/ansBefore編譯全部間隙正則,復用於本次各處理塊;ALNUM/ALPHA/運算符/括號等結構性 ASCII 集為固定常數、不隨規則集變。jiya:classifyBiaodian(ch, ruleset.biaodian)決定 charify 與 bd-* 子類。jinze:anyCjk = RegExp(ruleset.cjk)決定詞元邊界之 CJK 判定。
設計取捨:① 向下兼容——不傳 charClass 則 ruleset === DEFAULT_RULESET,行為與
重構前完全一致(現有測試全綠)。② 追加語義(v1)——擴充尚未內置的字符;全角句點
.(U+FF0E)已是內置 BD_STOP,無須追加。規則不支援重分類/移除內置字符(範圍式集合
難以挖洞、且需求罕見,列為後續)。③ 結構性 ASCII 不開放(非「字符類型」)。
借鑑 Han 之
regex/typeset.js、regex/unicode.js與 Heti 之分類字集,重寫為 現代 TS 常數表+可配置規則集。
4.3 locale.ts——語言與全角/開明政策
effectiveLang(node):自身及祖先最近的lang(小寫),僅缺失時回退options.lang.default; 顯式空值為未知語言,不繼承祖先。空 default 亦表示未知。classify(lang):歸為zh-Hans/zh-Hant(含zh-TW/zh-HK)/ja/ 其他; BCP-47 前綴邊界比對(zh命中zh-Hant,不命中zhx)。只讀 language/ extlang 後的 script/region 位置,不把 extension/private-use 當腳本或地區。 本函數不提供完整 BCP-47 有效性驗證。依據 RFC 5646 §2.1; HTML 空 lang 依 HTML 語言契約。resolveStyle(node):回傳節點之有效標點寬度政策——quanjiao(全角式)|kaiming(開明式)|banjiao(半角式)。優先序:逐元素覆寫(resolveStyleOverride:自身及 祖先鏈掃data-jz-style屬性/stylePolicy選擇器命中,近祖先勝、同層屬性優先於選擇器) > 逐 langpolicy> 全域style> 內建逐語言預設(zh-Hant/zh-HK→ 全角、zh-Hans→ 開明)。逐元素覆寫供「非段落/UI 元素用半角、段落用全角/開明」等需求(data-jz-style屬性 值非法時只忽略該信號,仍查同層選擇器與祖先;逐元素信號皆缺失才回退語言政策)。 此純為「按元素選樣式」之路由,banjiao半形渲染沿用halt/margin(§6.3),無新增壓縮邏輯。
語言與逐元素政策均由 JS 判定並轉為半形 class;CSS 按 class 使用 halt/margin 渲染。
lang.policy 的鍵是 zh-Hans/zh-Hant/ja/other 四個分類,非任意完整語言標籤。
4.4 dom.ts——jz-* 元素工廠與還原
createJz(ownedElements, tag, pass, kind, opts) 建立元素、登記私有所有權,並寫入
data-jz/data-jz-kind 供定址。setOwnedAttribute 與 revertOwnedAttribute/
revertOwnedAttributes 以共享寫者堆疊管理受控屬性;外部修改或其他實例寫入不被無條件覆蓋。
revertPass(pass, root, ownedElements) 先驗證本實例所有權與合法 pass/kind/tag 組合。
目前接受 wrap(包裝原內容)與 splitoff(行內元素克隆切分之邊界詞元);不提供
泛用 marker 還原。第一階段逆序解包 wrap,第二階段依私有來源弱映射,僅在前兄弟仍為切分來源或經私有切分/合併鏈證明同源的片段、
同標籤且作者屬性一致時併回,
最後 normalize 受影響父節點。外部插入、替換來源或改樣式導致不安全合併時,保留片段
元素、樣式及文字位置,還原其原有 marker 屬性並撤銷本庫憑證,不跨新內容搬字。
順序必要,否則 splitoff 前兄弟可能仍是 wrap 殼。
recordUnwrappedParent/resolveUnwrappedParent 記錄及解析合法產物的受控解包鏈,
供跨節點來源回退使用;重用空殼會使舊位置失效,重定向環不作為文字恢復依據。
包裝請求由 core/pass.ts 執行,安全行內切分位於 core/split.ts;本模組沒有
createEl/wrap/splitAt/insertMarker 等泛用變更函數。
5. 渲染元素詞彙(jz-*)
自訂元素皆含連字號(合法 custom element 名),預設 display: inline(未知元素
天然 inline),語義中性,可被 revert 移除:
| 元素 | 用途 |
|---|---|
jz-hws |
中西間隙,以 margin 留隙、不插入空白字元;跨節點 IVS 例外見 §6.1 |
jz-char |
包裝單一標點/漢字,承載 bd-* class 與 data-jz 屬性 |
jz-inner |
jz-char 內層字面容器,供 margin/letter-spacing 擠壓而不動外盒 |
jz-jinze |
禁則群組:綁定不可斷單元(亦為 justify 綁定載體) |
jz-orphan |
垂懸字避免群組:段末末 N 實義字 nowrap(§6.5.1) |
jz-cs、jz-em、jz-ruby/jz-rb/jz-rt 只屬未實作之未來設計名稱,當前程式
不會產生。
class 約定以現有 CSS/pass 為準:標點採 bd-open/bd-close/bd-pause/
bd-stop/bd-middle/bd-liga;擠壓另用 jz-half/jz-half-le 與 jz-ink-*。
6. 功能點逐項實作方案(對照 Han.css)
每項列出:Han 做法 → 我們做法 → 跨瀏覽器 → lang → 我們的調整 → 切片歸屬。
6.1 中西間隙(HWS,漢字↔西文)— 切片 1
- 逐來源功能與行內作用域:間隙及作者空格刪除前,run/pill 查詢先以 spacing
門控建立有效邊界。不能只查 block,也不能只禁止生成 gap 卻仍刪空格。
scope.include所選的行內子樹另作處理根;已由入選祖先處理者略過,避免巢狀重掃。 - 搬移回退與多實例:只對仍有效的真實載體恢復來源,克隆/刪除後不復活文字。 真正移動的載體及完整 render 根脫離 document 仍可還原。來源父若由本庫已驗證所有權 的解包/splitoff 合併撤回,dom 私有弱映射登記舊父→新父;spacing/cross-atom 沿傳遞鏈解析。作者自行解包、屬性偽造或 anchor 移動不會建立可信重定向。 已撤回空殼重新作作者容器時使舊位置記錄失效;解析有環檢測,循環來源不搬文字。
- Han:插入
<h-hws hidden>,內含真實空格 +::before{content:' '}, 未加user-select:none→ 選取/複製會帶上空格(其已知缺陷)。 - 我們(margin 模型):finder 邊界感知地把邊界左側之一個字(CJK 或西文)
包進
jz-hws,間隙由 CSSmargin-right(0.25em)提供——不注入任何空白字元。 WHERE 之規則沿用 v1 pangu 規則表(已驗證);作者原寫之 CJK↔西文空格一律移除 (改由 margin 留隙)。 - 跨節點 IVS:當 base 與 variation selector 分居文本節點時,
jz-hws承載完整 base+VS 序列;僅此類 gap 以::after生成U+200B斷行機會,並只對該偽元素設user-select:none。它不是 DOM 文本,不改變textContent或複製結果。 - 源文本與內部標記分離:每個 run 從未出現於源文本、且不屬規則 CJK/西文字集的 候選字符中選四個標記,僅用於間隙/刪除/複合詞遮罩回放;私用區原文與貌似 placeholder 的字串保持作者內容。搜索有固定上界,候選耗盡時在間隙回放前報錯。
- pill 鄰接:鄰字按完整碼點/base+VS 查找,含跨透明 Text 的 surrogate pair;可越過 一個 ASCII 作者空格並吸收為受管間隙,但不越過第二個空格、換行或分類/作用域邊界。
- 為何不用空白字元/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 與鄰接判定處理)。Pass B 之邊界選擇器為 pill ∪ isolate(§4.1 三分類:隔離元素兩端補隙口徑與 pill 一致,差異僅在 內部處理;巢於 pill 內者跳過)。 - 邊界正規化一致性(下游回饋 1a/1b 修):
- 1a pill 穿越 jz-jinze:jinze(order 40)先於 spacing(order 50),會把尾隨
標點之 pill 納入
jz-jinze。adjacentLogicalChar須透明穿越jz-jinze(與jz-hws、樣式行內同列,見finder.isTransparent),否則 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 齊右不變)。 - 效能(S1 對齊門):逐一定案為「左→右斷行因果 + 讀
getClientRects觸發同步重排」之固有 代價——每間隙一次重排,gap 密集之頁面達 O(gaps) 次重排(實測 Chromium 上 ~19k gap 之頁面 render 達數秒、且gapTrim佔約 99%)。此代價唯 justify/right/center 等「右緣 flush 或含 margin 置中」之對齊才需付:尾隨margin-right之可見性依對齊而定——left/start(ltr) 之右緣 ragged、行末 margin 落於不可見側,去隙與否視覺無別。故trimGaps按最近容器之 computedtext-align/direction過濾:left/start(ltr) 之間隙整批跳過(省其全部重排),其餘對齊 (justify/right/center/end/RTL)保留完整逐一定案、行為不變。實測非兩端對齊之常見內容gapTrim由數千毫秒降至數十毫秒(getClientRects 歸零);justify 內容之成本不變(固有)。門以 per-parentElement memo(text-align/direction皆繼承)攤薄。
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
- 完整標點原子:base、surrogate pair 或 VS 被拆到透明 Text 時,prepare 依
collectRuns的完整原子映射建立一個jz-char/jz-inner,鄰接擠壓不在原子內斷開。 只搬移文本,作者 Element 身份與監聽器保留;來源/載體註解錨點避免其他 pass 的 normalize 合併來源節點。回退由私有搬移記錄還原分布;局部回退任一端也撤回完整原子。 克隆或刪除載體、替換來源父節點時不依舊記錄復活文字;真正移動的原載體與整根脫離 document 的回退仍可處理。自訂標點集合以完整碼點匹配,不把 surrogate 半字當成成員。 - 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後備。 - 句末標點集含全角句點
.(下游回饋):BD_STOP為。.!?——除表意句號。(U+3002)外,納入全角句點.(U+FF0E)。實測 Noto CJK SC/TC 之halt(GPOS SinglePos 調 advance)皆覆蓋.、挤壓收 0.5em,與。一致;inkBias同步補.(簡 left/繁 center),margin 後備亦可挤。政策行為與。!?同:quanjiao/kaiming 句末 保持全角、banjiao 挤半。 - halt 即字型之「挤壓意願」(設計原則):字型對某標點提供
halt=該字形適合挤半; 不提供=該字形不宜挤(占滿字框,如 Noto TC 之全角?與 SC 不同、〈〉無 halt)。 故聚珍信任字型 halt 覆蓋:有 halt 字型中個別無 halt 之字符,保持全角、不強挤(優雅 降級),不為其特設規則。margin後備僅用於整體無 halt 之字型(GBK 老字型等),以inkBias逐字型調教近似 halt——非用來強挤「有 halt 字型刻意不挤」之字符。 - 連續標點挤壓規則(雙側獨立 + 透明穿越;下游回饋):相鄰兩標點,對每一標點獨立
判斷——若其「有空白之側」緊鄰另一標點,則挤掉該側空白(收半形
jz-half)。故,「(pause 右空白 + open 左空白)兩側皆收 → 全角式與開明式皆合占 1em;。」(stop 右空白; 」左為墨)只收。→ 全角式 1.5em/開明式 1em(」baseline 已半);·—(皆無指向對方之 空白)皆不收 → 兩式皆 2em(不宜挤之例外)。句界例外:句末點號(。.!?)後接開類 (。「/?「)為句界,保留句末點號右側空白、不收 L,僅收開類左側 → 全角式與開明式皆 1.5em(對稱於。」,半角式句末點號本已半形故仍 1em)。例外僅限句末點號+開類;句內點號,「與雙括號」「/》《(同句相連/貼合,非句界)仍兩側皆收 1em。多標點鏈逐對處理。jiyaAdjacencyPass以從 root 之透明遍歷(穿越 strong/ em… 等樣式行內;block/pill/avoid(自身判定)斷相鄰且不下探,isolate 斷相鄰但內部 自成脈絡照常配對——§4.1 三分類;jz-char 為原子不下探)取代原「同 parent 直接兄弟」掃描,使跨 inline 邊界之連續標點亦正確配對(原漏致跨 strong/em 之標點對未挤、 達 2em)。 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逐語言分類、逐字覆寫;full不套 margin 的 0.5em 盒寬,保留全形。 此逐字字型相容方案仍是當前支援路徑;配字工作流、JSON 範例與量測工具tools/measure-bias.py見 README,不屬已移除的負 letter-spacing/標點懸掛機制。- 激活與粒度(I3/I10):最近有效
data-jz-halfwidth="halt|margin"屬性透過 可繼承 CSS 變數決定模式;render()在全域 margin 時自動掛於 root。可在任意祖先 逐區塊/逐語言指定並交替巢狀,切回 halt 會撤銷 margin 盒寬、內盒及偏移。bias class 恆於 charify 標記,但偏側僅作用於 margin 中已收半的標點,全形標點不偏移。 模式切換為純 CSS、無需重 render。歷史實測: 方正書宋 GBK(開明式 zh-Hans)下同句行寬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 綁定載體。 - 行內元素最小綁定(
core/split.ts,下游 Bug 修復):邊界字落於樣式行內元素 (strong/em/span等)內時,舊版整個元素入jz-jinze——<strong>很長一串</strong>,會把整串粗體塞進nowrapinline-block,行末放不下即整體掉行、上行留巨大空隙。 改以isolateBoundaryToken(el, side)克隆切分:沿「文本節點→邊界元素」祖先鏈逐層 把邊界詞元剝入一個淺克隆(保留標籤/class/style/lang,去 id),只綁該克隆(最小綁定), 原元素其餘部分留原位、渲染視覺無縫。克隆標data-jz-kind="splitoff",revertPass之延後 階段(先解包 wrap、後併 splitoff)驗私有來源身份、鄰接與屬性一致後併回; 未受外部干預時還原一致(I6),無法安全合併時保留片段,詳見 §4.4。僅純樣式標籤白名單可切,且canSplit諮詢 finder 三分類(§4.1):a/abbr/ruby/帶 id 者,以及 pill(整體)/isolate(隔離),可退化為 整元素綁定(安全)——pill 即「作為原子鄰字單位整體綁入jz-jinze」之期望行為(下游問題六: KaTeX 全樹皆 SPAN,舊版僅憑標籤白名單曾整棵下潛肢解公式);isolate(如背景色mark)切分 有可見接縫故整綁;MARK並自白名單移除。 兩側邊界及切分後的整個候選單元都須符合作用域、paragraph 級別與jinze功能閘; avoid/text/off 後代不得因不可切而被整綁帶入。攀升不超過本次 render 根。 跨 Text 的完整碼點/base+VS 邊界與標點禁斷一併綁定;群組中的註解錨點保持身份及次序。canSplit須對「切點textNode至top之整條祖先鏈」成立、非僅top:下潛由firstText/lastText守(遇非可切回null),攀升由splitTreeAfter之對稱守衛守 (切分前先驗祖先鏈全可切,任一層 opaque/pill/isolate/帶 id 即放棄、退化整綁)。二者 缺一則splitElementAt(orphan:切點取自collectSubst,可穿透 opaquebutton)會令 中間 opaque 祖先被逐層克隆肢解——下游.selection-bar-message病例。 - 切片:切片 1 完成行首行末避頭尾(閉類/點號避頭、開類避尾);行首縮排聯動、
更細之禁則對象列後續,沿用同一
jz-jinze結構。 - 已知交互(待瀏覽器精修):
lineedge之行端偵測需跨jz-jinze群組邊界向上 攀爬找流內鄰字(已於edgeRect處理 jz-* 包裹之攀出);屬 layout 層,jsdom 無法 驗,須瀏覽器目視。
6.5 標點懸掛 + 垂懸字避免 + 行端禁則 — 整體實作(已落地)
三個相關機制之整體實作(同步引入),皆處理「行端/段末之標點與字」;其分層職責須先 釐清,以免重複處理或衝突。前置為「功能分級」(見 6.5.0a),因懸掛/垂懸字屬段落級、 不應作用於標題/按鈕/單行文本。
實作狀態(2026-06):6.5.0a 功能分級、6.5.1 垂懸字(方案 C,預設開)已落地且工作良好。 6.5.2 行端僅做半形擠壓(squeeze,行首開類收左/行尾閉類收右);標點懸掛 hang 經實測為機制級 死路、已移除(負 margin 之 lineedge 程式碼、CSS、
lineEnd/LineEndMode/HANG_ENABLED等一併刪除;hanging選項保留為 no-op)。行歸屬判定 bug 修正(squeeze 之改善,保留):原以「行盒 top 差 > EPS=4px」判同/異行, 但 jz-char 為 inline-block,其行盒 top 與相鄰純文本字形 top 因基線差約 5px、遠小於行高卻超 舊容差,致同行誤判為下一行。改判「垂直區間」:下一行=
b.top ≥ a.bottom、上一行=b.bottom ≤ a.top(容 2px 亞像素)。標點懸掛移除之根因(純 CSS 之寬度守恆矛盾,headless + 真實瀏覽器實測):行端標點盒若 保持非零寬,墨色出去多少、版心內就空出多少(行末實義字無法齊版心右緣);若收為零寬,釋放 一整字寬、斷行器把下一字拉上本行與標點重疊(且 justifyAtoms 預設之 inline-block 下負 margin 被 justify 吸收)。CSS hack 接觸不到斷行器,此恒等式無解。未來可評估原生
hanging-punctuation(須驗證引擎能力,且 jz-char 為 inline-block 原子非文本), 屬遠期「原生管線」新功能、與已移除程式碼無共享。詳見 6.5.6。
6.5.0a 功能分級:文本級 vs 段落級 + 元素標記(前置基礎設施)
動機:垂懸字/禁則等為段落級功能(依賴行/段布局),不應作用於標題、按鈕、單行
或單元素文本;而中西間隙、標點挤压為文本級,任意文本片段皆適用且想要。下游須能方便標記
元素屬哪級。此為貫穿性基礎設施(已實作:Finder.levelFor/levelAllows、各 pass level
元數據、選項 level.text/屬性 data-jz-level)。
分類(含現有與待加功能):
| 級別 | 功能 | 判據/依賴 |
|---|---|---|
| 文本級 | spacing(中西間隙)、jiya baseline 政策半形、jiya 連續挤压(adjacency) |
僅字符鄰接,對單行/單元素文本即有意義 |
| 段落級 | jinze 避頭尾、行端半形、gapTrim、orphan 垂懸字、longWord 斷詞 |
需行/段布局(換行、行端、段末、justify)才有意義 |
| 貫穿 | charClass、原子透明、normalize |
基礎設施,不分級 |
直覺:文本級=「字符級排版」(間隙、標點形態);段落級=「行/段級排版」(換行、行端、段末)。
標記 API(下游):
- 級別由自身向上查最近有效信號,不依標籤名推斷;沒有任何信號時預設 paragraph。
- 顯式降級:
data-jz-level="text"於元素/祖先 → 該子樹僅文本級(段落級功能自動關); 子元素可用data-jz-level="paragraph"重新允許段落級功能,但仍受全域功能開關、 逐元素功能閘與作用域限制。不標記的子元素繼承祖先信號,不會自動重新啟用全功能。 - 選項
level?: { text?: string }:選擇器一鍵標記(如level:{ text: "h1,h2,h3,button,.ui" })。 - 典型:標題/按鈕/UI 文字標 text-level(要間隙與標點半形、不要避孤字/避頭尾);
正文
<p>預設全功能。
實現:
- 每個 pass 加元數據
level: "text" | "paragraph"。 finder.levelFor(el):從自身向上查;每層先取合法data-jz-level,再查level.text選擇器,第一個有效信號勝出;皆無才回退 paragraph。非法屬性值繼續查找。- gate:段落級 pass 對 text-level 元素跳過;文本級 pass 不受限。與現有
blocks/featureEnabledFor疊加(level=粗粒度語義組先判,blocks=細粒度逐功能後調)。 - 文本級功能支持非塊/單元素:
spacing在逐塊處理後,補做未被塊覆蓋的行內 root 及scope.include命中的行內子樹,避免重複處理;標點 charify 也可走訪行內 root。
與本批關係:分級已落地(pass 標 level、levelFor、data-jz-level/選項);orphan
等段落級 pass 只作用於段落級元素、跳過 text-level 者。hanging 未註冊為 pass。
Resolved 分類之 CSS 鉤子(下游建議七,typeset/levelmark.ts):消費端之段落級 CSS(首要
text-align:justify)語義上與段落級 pass 應作用於同一集合,但 level 判定原僅存內部快取,
消費端只能另立一份選擇器白名單、兩份分類必然漂移(實例:.callout 綁了避頭尾卻不在 justify
名單)。levelMark pass(standalone,order 10,恆開)於 render 時把 resolved 級別寫回塊級元素:
data-jz-block-level="paragraph|text",revert 全數移除(I6)。僅標「直接承載可處理文本」
之塊(存在非空白文本節點、其最近塊祖先即該塊、非 avoid/pill 內/域外)——純容器(body/ul
/table…)不標,否則 body 恆被判 paragraph、justify 鉤子經繼承污染全頁;此集合恰為段落級 pass
實際作用之塊集(collectRuns 對純容器收不到 run)。輸入/輸出分離:作者輸入 data-jz-level
只讀不寫;輸出屬性本庫絕不讀取、亦不在 C2 OBSERVE_ATTRS 白名單(外部改動不觸發重處理);
增量路徑(rerender/observe)revert 即清、render 即重標,天然刷新。本庫只供分類、不介入樣式
(justify 非段落之純函數——窄欄邊注可 opt-out,由消費端 CSS 決定)。
6.5.0 四機制之分工與邊界(核心約定,實作前必讀)
| 機制 | 作用層 | 唯一職責 | 手段 | 狀態 |
|---|---|---|---|---|
| 禁則(§6.4 jinze) | 斷行層 | 把標點趕到正確的邊(點號/閉類→行末、開類→行首;連續標點不拆) | nowrap jz-jinze |
已實作 |
| 行端半形(lineedge 現有) | 渲染層·紧排 | 行首開類/行末閉類收半形 | jz-half-le(halt/margin) |
已實作 |
| 標點懸掛 | 渲染層·凸出 | 行末點號/閉類右凸、行首開類左凸出版心 | margin |
❌ 已移除(機制級死路,見 6.5.6) |
| 垂懸字避免 | 斷行層 | 段末行 CJK 實義字數 ≥ N | nowrap jz-orphan |
已實作 |
- 正交性:斷行層(禁則/垂懸字,管「哪些字符在哪行、不可斷」)與渲染層(半形/懸掛,管
「邊緣標點凸出/收縮」)作用於不同 CSS 維度(
white-spacevsmargin),可疊加共存。 - 協同:禁則決定標點落行首或行末 → 半形/懸掛對落在邊緣者施收縮/凸出。
- 行端僅半形擠壓:lineedge 行端處理只做 squeeze(行首開類收左、行尾閉類收右半形)。 原計劃之「懸掛」模式已移除(機制級死路,§6.5.6),故無「半形 vs 懸掛」之模式切換。
- 垂懸字 vs 懸掛邊界(歷史記錄):曾研究兩者於「純標點末行」之重疊(懸掛吸回 vs 垂懸字 拉字)。懸掛移除後此協同不再適用;方案 C(無測量)附帶消除純標點末行即可(副作用, 現有益無害)。
6.5.1 垂懸字避免(方案 C,斷行層,無測量)— 已實作
- 目標:段末可處理的連續區域 CJK 實義字數 ≥ N(預設 2;標點/符號不計)。單行段無害。
- 機制:standalone pass,遍歷作用域段落;自段末向前數 N 個 CJK 實義字(
ruleset.cjk判定、 跳過標點/空白/西文計數),把「第 N 個實義字起至段末」(含其間/尾隨標點)包入<jz-orphan>(CSSwhite-space: nowrap)。 - 保證(成功建立尾部綁定時,免測 layout):該整體不可斷 → 末行恒含之 ⇒ 末行實義字 ≥ N,任意寬度/字型/ 瀏覽器成立。平時無害(末行本多字時不改變),僅末行將塌為 <N 字時「拉字下來」。
- 選項:
orphan?: boolean | { chars?: number }(feature gate;預設開 chars=2;per-block 經blocks/data-juzhen/data-juzhen-off;段落級,data-jz-level="text"子樹跳過)。lang 無關。 - 邊界:avoid/text/
orphan關閉/isSkipped區域清空先前計數,只綁其後的合法 段末區域;不足 N 字則不綁。完整碼點及 base+VS 只算一個字。pill 不計內部字,仍可 作完整外部單元參與綁定;不可切且含排除內容的容器不退化整綁。極窄容器(<N 字寬) 仍可能溢出。 - 僅葉塊:含塊級後代之容器(其實義字屬內層段落)跳過、另由內層處理。效能(S2):舊版
逐塊
block.querySelector(sel)掃整棵子樹判葉塊,累加 O(Σ塊子樹)、深塊嵌套下 O(n²);改以一趟 上攀預計算「非葉塊」Set(某塊為另一塊之最近塊祖先 ⟺ 含塊級後代),scope 無涉、與原 querySelector 同口徑,行為等價。 - order=70(standalone 群中位於 jinze 40/spacing 50 後、layout pass 前;整個 standalone 群又在含 longWord 60 的 charify 群之後)。
- 與禁則協同:段末範圍可能含 jinze 已綁之
jz-jinze;置於 jinze(40) 後,將 jz-jinze 整體 納入 jz-orphan,並處理邊界字定位跨 jz-jinze 攀爬(實作細節)。免重複包裹。 - CSS:
jz-orphan { white-space: nowrap; }。revert:wrap-class,隨revertPass還原。 - 測試:jsdom 結構(末 N 實義字+尾標點入 jz-orphan;符號不計;單行段/實義字<N 不綁); headless 窄容器拖動驗末行恒 ≥N。
6.5.2 標點懸掛(Hanging)— ❌ 已移除(機制級死路)
已移除(2026-06):原計劃之懸掛(行端標點負 margin 凸出版心)經 headless + 真實瀏覽器 實測確認為機制級死路(寬度守恆矛盾,純 CSS 接觸不到斷行器),程式碼與 CSS 已刪。完整 論證、已移除清單、未來原生路徑見 §6.5.6。
hanging選項保留為 no-op(API 相容)。
lineedge 行端處理只餘 squeeze(行端半形):量測各 jz-char 是否在行首(開類)/行末(閉類),
加 jz-half-le(CSS 同 halt)。兩階段免自我應驗(先 clearEdge、自然佈局量測全部、再一次套用);
行歸屬以「垂直區間」lowerLine/higherLine 判定(吸收 inline-block 行盒與純文本基線差 ~5px)。
order 90 jiyaLineEdge,revert 清 jz-half-le。純版面、jsdom 跳過、須瀏覽器目視。
中西間隙的行末去隙亦以 lowerLine 比較垂直區間;同行小字/下標的 top 可能更低,
不得僅以 top 差誤判換行。layout 安裝按根/實例/pass 保留目前 observer 身份;重裝或
回退後,舊字型 Promise 及已排隊回調不再進入量測,目前安裝仍照常重算。
6.5.3 行端禁則(§6.4)review
- 禁則避頭尾與 squeeze 行端半形協同(禁則趕標點到邊、行端收半形);jz-orphan 與 jz-jinze 嵌套 已處理(邊界字跨 jz-jinze 攀爬)。lineedge 偵測已跨 jz-jinze 攀爬。jinze 本身未大改。 (懸掛已停用,故無「懸掛凸出最外側」之協同需求。)
6.5.4 統一 pass order
實際分兩群且不跨群比較 order:charify 群為 20 jiya → 60 longWord,共用一次文本
遍歷;其後 standalone 群為 10 levelMark → 25 jiyaAdjacency → 40 jinze → 50 spacing → 70 orphan → 90 jiyaLineEdge → 92 spacingGapTrim。
6.5.5 實作順序與結果
- ✅ 功能分級(前置,6.5.0a):pass 標
level、finder.levelFor/levelAllows、data-jz-level/選項level.text;段落級 pass gate 跳過 text-level 元素。 - ✅ 垂懸字(方案 C):standalone pass、order 70、純結構無 layout、預設開 chars=2。工作良好。
- ✅ lineedge 行端半形(squeeze):量測行首開類/行末閉類加
jz-half-le;連帶修「行歸屬 判定」bug(垂直區間取代 top 差)。保留。 - ❌ 懸掛 hang:實作後經 headless + 真實瀏覽器實測確認為機制級死路、已移除(負 margin
程式碼/CSS/
LineEndMode/lineEnd/features.hanging一併刪;hanging選項保留 no-op)。 論證見 6.5.6。 - ✅ 邊界落地:純標點末行由 orphan 之尾段綁定(含尾隨標點)全包消除。
- ✅ 行內透明性修復(下游 Bug):pill 前標點誤隙(finder 透明)、行內元素整體掉行 (core/split 克隆切分)、行端攀爬集派生(T4)等,見 §4.1/§6.4。
- ✅ 文檔/docs/demo:ARCHITECTURE/README/docs 分支記懸掛移除;demo 同步。
6.5.6 標點懸掛移除記錄與未來路徑
-
移除結論(寬度守恆論證):純 CSS 手段(負 margin/相對位移/零寬盒)存在守恆矛盾, 機制級不可行。設行末標點墨色須落版心外、且行末實義字須齊版心右緣:
- 行末盒保持非零寬(負 margin 收一部分、或
position:relative純視覺外移)→ 墨色可移出, 但盒仍佔行末寬 → 版心內留等寬空缺,「行末齊邊」不成立(0.5em 實測之「看不出懸掛」即此相)。 - 行末盒收為零寬(margin-right:-1em 等)→ 釋放一整字寬 → 斷行器把下一字拉上本行與標點
重疊(headless 實測每標點疊一字);且
justifyAtoms:true之 inline-block 下負 margin 被 justify 吸收。
墨色出去多少、版心內就空出多少;想不空就得讓斷行器重排這塊寬度,而 CSS hack 接觸不到 斷行器。 補償墊片、兩階段試錯都改變不了此恒等式,只會重蹈 ResizeObserver 標記錯亂之覆轍。
- 行末盒保持非零寬(負 margin 收一部分、或
-
已移除之物:
lineedge.ts之jz-hang-*class/HANG_*常量/applyEdgehang 分支/modeForChar/HANG_ENABLED;_jiya.css之jz-hang-*規則;types.ts之LineEndMode/ResolvedOptions.lineEnd/features.hanging;index.ts之lineEnd鎖。行端僅餘半形擠壓 (jz-half-le)。hanging公開選項保留為 @deprecated no-op(API 相容;測試覆蓋 option/data-juzhen/blocks 各路徑皆不產生 jz-hang-* 且輸出與不傳一致)。 -
未來路徑(與已移除碼無共享):原生 CSS
hanging-punctuation為可評估路徑,須另行核對 各引擎實際能力及字型行為;且本庫jz-char為 inline-block 原子、非文本,原生懸掛 不作用之。未來若做,須是繞開 jz-char 的「原生管線」新功能——text-spacing-trim接管 擠壓 +hanging-punctuation接管懸掛,按CSS.supports能力門控,整體退出 jz-char 機制。 與已移除之負 margin 程式碼沒有一行可複用,故不保留休眠碼。
6.6 標點修正(Biaodian normalize)— 未實作之未來設計
- Han:
subst/biaodian修正標點變體、位置。 - 擬議方向:未來可設計 opt-in pass;顯式開啟才做變體替換(簡↔繁標點、全半形
修正),且須可
revert。目前沒有biaodian模組、pass 或文字改寫。 - 相容狀態:頂層
biaodian選項僅為 no-op;charClass.biaodian是另一套已實作的 字符分類擴充 API。
6.7 著重號(Emphasis)— 未實作之未來設計
- Han:逐字包裝加圈點。
- 擬議方向:可優先使用原生
text-emphasis;若未來需要精細控制,再評估逐字 包裝與 lang 感知。jz-em只是設計草案名稱,當前沒有對應模組、pass、DOM 或 CSS。 - 相容狀態:頂層
emphasis選項僅為 no-op。
6.8 注音/Ruby — 未實作之未來設計
- 擬議方向:未來可基於原生
<ruby>評估注音符號直排與聲調定位;jz-ruby/jz-rb/jz-rt只是設計草案名稱,當前沒有對應模組、pass、DOM 或 CSS。 - 相容狀態:頂層
ruby選項僅為 no-op。
6.9 樣式正常化/字型歸屬 — 貫穿(CSS)
- normalize:
juzhen.css內以@supports/:where()提供 CJK normalize(關閉 與託管間隙重複作用之原生text-autospace),限.juzhen域、非破壞。間隙本身不插入空白;只有跨節點 IVS 的U+200B偽元素設user-select:none。 - 字型不歸聚珍管(設計決策,下游回饋):曾內建依
:lang之 Noto 字型堆疊作「後備」, 但字型屬消費端領域、內建即越權(且曾蓋掉消費端字型釀 bug)。故移除_lang.css, 逐語言font-family完全交消費端 CSS。聚珍僅守三項非管理性本分:①_normalize.css之jz-* { font: inherit }使原子繼承消費端字型、不強加;②_jiya.css半形之font-feature-settings顯式併入"locl" 1(連同halt),確保泛 CJK 字型之地域標點 字形不因設 halt 而失;③ render 只讀不改lang。body 文本之locl本就不受聚珍影響 (未設任何 feature),由消費端字型+正確lang決定。消費端範式見 README「字型與 locl」。 - B 案(字型參數)之否決:以 JS 參數管字型須注入 CSS、違反「JS 注入結構/CSS 給視覺」
分工;而「缺 halt 字型須逐字型微調 margin」之指引由既有
jiya.halfWidth/jiya.bias參數+文檔承擔,無需新參數。 - 跨瀏覽器:
:where()/:lang()/font-feature-settings普遍支援。
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 |
OpenType halt;缺 halt 時用 0.5em 盒寬+ink bias 後備;lang/style 政策+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,標點與其字面留白同屬一項,對齊伸縮只落在項與項之間 → 標點 只貢獻一份。- 開明式下,JS 為可壓縮標點加半形 class;預設由
jz-inner的 OpenTypehalt收 advance。缺半形特性的字型可用margin後備:0.5em 外盒搭配逐字jiya.bias偏側定位(§6.3)。原子內部不另開 justify 伸縮量;舊負 letter-spacing 方案不再使用。 - 全角式下,留白保留於原子項內,仍只算一份;以
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——避免區塊首/末行機制在每個標點原子內重複套用。 當前管線沒有標點懸掛 pass;段落保留此屬性不代表 inline-block 內的受管標點可以懸掛。
不歸零者(實測不洩漏或本應逐字穿透):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/text-autospace在現行託管功能中須避免 重複作用;hanging-punctuation/text-emphasis只屬尚未實作之未來設計評估, 不是當前 pass 或驗收項目。 - 驗證矩陣:Chrome(Blink,含 VSCode/Electron)、Safari/WebKit、Firefox (Gecko)。demo 頁含三引擎可開的測試案例。
9.1 顯式禁用不一致原語(I4)
當原生原語在各引擎支援不一,或會與我們託管之版面重複作用(雙重擠壓/間隙)
時,在 .juzhen 範圍內以 @supports 偵測並主動關閉,使所有引擎收斂於我們的
一致路徑:
@supports (text-autospace: no-autospace) {
.juzhen { text-autospace: no-autospace; } /* 關閉原生中西自動間距 */
}
政策:凡屬「一致性關鍵層」者(間隙、擠壓),一律由我們統一處理並禁用對應原生
原語。現行不設定 text-spacing-trim:標點由 inline-block 與 halt/margin 處理;
設 space-all 反可能抵銷 halt。hanging-punctuation 等未來設計若要加入,須另立能力門控與三引擎驗收,不能
從目前保留的 no-op 選項推定已實作。此清單隨功能演進維護於 css/_normalize.css。
10. 模組/檔案佈局
src/
index.ts createJuzhen / Juzhen / pass 註冊 / observer / render / revert
types.ts 公開選項、RenderContext、CharifyPass / StandalonePass 型別
core/
pass.ts 兩類 pass 之分組、排序與管線執行器
cross-atom.ts 跨 Text 標點原子搬移/私有來源記錄/回退
finder.ts 遍歷/avoid/功能閘/鄰字與跨節點原子映射
unicode.ts 字元類 + 標點子類 + 規則正則表
locale.ts 有效 lang / 分類 / 全角開明政策
dom.ts jz-* 工廠 / 所有權與屬性寫者 / 還原及受控解包鏈
split.ts 禁則/垂懸字之行內元素邊界切分
typeset/
spacing.ts 中西間隙
longword.ts 長英文詞包裝
jiya.ts 標點擠壓 + 全角開明
jinze.ts 禁則 + justify 綁定
orphan.ts 段末垂懸字避免
levelmark.ts resolved block level 寫回
lineedge.ts 行端半形擠壓
css/
juzhen.css 入口(@import 各 partial)
_normalize.css _spacing.css _jiya.css _jinze.css _orphan.css
test/ jsdom DOM 測試 + 原生 WKWebView Swift 宿主/runner
demo/ layout/justify/簡繁 目視頁 + e2e.html 自動斷言頁
build.mjs esbuild:ESM + IIFE + CSS
package.json npm build 串聯 build.mjs 與 tsc 聲明生成
biaodian.ts/emphasis.ts/ruby.ts/hanging.ts 及同名 CSS partial 均不存在;
前述章節是明確標記的未來設計或已移除機制,不是當前模組清單。
11. 構建與發佈
- 源碼:TypeScript(strict),模組化。
- JS/CSS 構建:
node build.mjs以 esbuild 產出dist/juzhen.mjs(ESM,供打包器/TS)dist/juzhen.iife.js(IIFE,全域Juzhen,原生<script>直跑——保留 build.sh/Nvim peek 之單檔串接消費)dist/juzhen.css(合併之樣式)
- 完整構建:
npm run build先執行node build.mjs,再執行tsc --emitDeclarationOnly→dist/index.d.ts及各模組型別宣告。 - 發佈物:
dist/提交入庫(消費端可直接取單檔/CSS)。
12. 測試與驗證策略
- jsdom(自動):驗證 DOM 變換——包裝結構、
bd-*class、邊界處理、冪等、revert還原、copy-clean(textContent不被注入污染)、lang 政策分支。 - 原生 WKWebView(自動):
npm run test:wkwebview需 macOS、Xcode 與 GUI 會話; runner 編譯 Swift 宿主並載入demo/e2e.html,驗證真實行盒、幾何、計算樣式、 Selection 及 observer/layout 生命週期。此命令使用現有dist,不自動構建; 修改源碼後應先npm run build。jsdom 本身不提供真實版面量測。 - 跨引擎目視(demo 頁):檢查擠壓墨色、基線、字型適配、justify 和寬度變化等。 demo 頁簡繁並列、可切 justify、附選取/複製測試區;Chrome/Safari/Firefox 是 待逐環境執行的驗證路線,WKWebView 通過不代表三引擎目視或真實下游頁面已通過。
- 驗證記錄須綁定實際快照與環境,分別標示 DOM、原生幾何與目視證據;已知交付記錄
見
UPDATES.md,不能由測試入口存在推定當前驗收完成。
13. 擴充性保證(如何加新功能而不返工)
新增任一 Han 功能點,標準流程:
- 在
unicode.ts補該功能所需字元類/正則(集中、不散落)。 - 新增
typeset/<feature>.ts,依需求實作CharifyPass(collect)或StandalonePass(render),並提供revert/order/level。 - 在
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控制伸縮側), 但此為未實作設計;採用時須新增 JS 生產/revert契約、CSS 與三引擎驗收,不能只換 CSS。 - R2 行端擠壓免量測(未實作設計)。觀察:純 CSS(未來若引入
jz-cs折疊)能否 在三引擎一致達成行首行尾擠壓。採用時同樣須新增 JS 生產/revert契約、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 不變量)須先更新本文件再改碼。