Files
cjk-autospace/ARCHITECTURE.md
T
admin 2890549f44 fix: 行內排版透明性——pill 前標點誤隙+行內元素整體掉行+審計(Bug1/Bug2/T4-T6)
統一「行內元素對排版透明」原則在各遍歷原語之口徑,修復兩個下游 Bug 及三項同根隱患。

Bug1(pill 前標點誤加間隙):
  isTransparentInline 補 JZ-CHAR/JZ-INNER。`漢字。<code>` 經禁則綁定後,
  adjacentLogicalChar 之 descend 遇不透明 jz-char 既不進入亦不終止、而跳過它越過
  標點取到「字」誤補 jz-hws;透明後回傳標點「。」(非 pill 鄰字)→ 不補隙,與
  collectRuns 既有透明遞迴對齊。

Bug2(行內樣式元素整體掉行):
  新增 core/split.ts 之 isolateBoundaryToken/splitElementAt——沿祖先鏈逐層把邊界
  詞元剝入淺克隆(保留標籤/class/style/lang、去 id;標 data-jz-kind="splitoff"),
  jinze/orphan 改最小綁定:`<strong>長串</strong>,` 只綁末字+逗號(非整串入 nowrap
  inline-block 致行末整體掉行);段末長 strong 不再整綁溢出容器。revertPass 增延後
  階段把 splitoff 併回原元素(先解包 wrap、後併 splitoff),render→revert 還原一致。
  純樣式標籤白名單可切,a/abbr/ruby/帶 id 退化整綁。

審計(同根隱患):
  T4 lineedge edgeRect 攀爬集改由 options.finder.styleInlines 派生(棄硬編碼子集,
     免遺漏 ins/del/sub 等致行端誤判)。
  T5 jiyaAdjacency 遇 pill(pillMatches)斷相鄰、不穿越(內容對排版不可見)。
  T6 spacing processPills 補 per-node 功能閘(data-juzhen-off=spacing 區域內不補隙)。

文檔:README 切片表(spacing/jinze/orphan 三行)+ARCHITECTURE §4.1 透明性原則、
  §4.4 splitoff 還原、§6.4 行內元素最小綁定、§5 jz-orphan 詞彙。

測試 90→102(Bug1×4、Bug2×6、T5/T6×2)。headless Chrome 實證 Bug2:段末 strong
最小切分、orphan 寬 60px 不溢出版心。
2026-06-11 16:06:17 +08:00

63 KiB
Raw Blame History

聚珍(Juzhen)架構文檔

現代簡/繁中文網頁排版套件。Han.css 之現代化重寫,沿其功能版圖,並針對 我們關注的數種情況(複製乾淨、依 lang 全角/開明切換、justify 句末標點 單份間距、跨瀏覽器一致)作調整。

倉庫名 cjk-autospace 不變;公開 API 為 createJuzhen / 全域 Juzhen 自訂元素前綴 jz-。本文件先於任何程式碼,作為實作契約——後續每個功能點 皆按此落地,以避免不一致與重大返工。


1. 目標與範圍

  1. 第一垂直切片 = v1cjk-autospace)全部功能,在新引擎上重寫:中西間隙 (Pass A/B)、長英文詞斷詞包裝(Pass C)、開明式標點擠壓(Pass D)。
  2. 架構須預留完整復刻 Han.css 的能力:標點修正、擠壓、懸掛、禁則、著重號、 注音/ruby、樣式正常化、字體偵測、計數器等,皆能在不改動切片 1 既有程式碼 的前提下,以「新增一個 pass」的方式加入。
  3. 跨瀏覽器一致ChromeEdgeSafariWebKitFirefox):一致性關鍵的版面 行為,只用普遍支援的 CSS 原語;原生新特性僅作非必要增強。

非目標:不依賴 Han.css/Heti 等既有庫之執行期程式碼(MIT 條款下可借鑑其思路與 分類表,但成品自含)。


2. 設計不變量(所有 pass 必須遵守)

這些不變量是「避免返工」的核心。任何新功能點都不得違反。

  • I1 複製乾淨(copy-clean:注入之間隙與標記一律 user-select: none,且不 改變邏輯 textContent 的語義;若某 pass 必須改寫文字(如標點替換),則須為 顯式 opt-in 且可 revert 還原。預設「只包裝、不改寫文字」。
  • I2 JSCSS 職責分離JS 只注入結構jz-* 元素、class、data-jz-* 屬性);所有視覺尺寸(marginletter-spacing/寬度/懸掛位移)一律寫在 juzhen.css。同一段 DOM 在 justify 與否、主題與否下都成立。此分離是擴充性 與跨瀏覽器調校的關鍵。
  • I3 依 lang 行為:每個 pass 解析節點的有效 lang(最近祖先),行為與 CSS 皆以 :lang() 與 locale 政策驅動。全角式/開明式之別即由此決定。
  • I4 跨瀏覽器一致:間隙、擠壓等一致性關鍵層只用 marginletter-spacing inline-blockuser-select 等普遍原語。text-spacing-trim hanging-punctuationtext-emphasis 等僅作漸進增強,絕不作為唯一機制。且當 某原生原語在各引擎支援不一致、會與我們託管之版面衝突或重複作用時,須在 .juzhen 範圍內以 @supports 顯式禁用之(如關閉 Blink 的 text-spacing-trim text-autospace),使三引擎行為收斂於同一路徑(見 §9.1)。
  • I5 justify 單份間距:間隙與擠壓的結構設計,須保證每個標點在 text-align: justify 下只貢獻一份對齊伸縮量(見 §8)。
  • I6 冪等與可還原:每個 pass 對其產物打標記,重跑跳過;提供 revert 乾淨 移除,DOM 回到 pass 前狀態(至少邏輯等價)。
  • I7 邊界感知:跨樣式行內元素的鄰接(如 <strong>是</strong>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 形如:

{
  // 全域。style = 全域擠壓預設(quanjiaokaimingbanjiao);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: "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 註冊表。renderorder 執行 已啟用者。順序契約(避免互相破壞):

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

renderrevert 收到統一上下文:{ 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 文字修正 biaodianopt-in 1 次 唯一可能改寫文字者,須最先
P1 行內結構 charifyjiya 標記/jinzespacinglongWordemphasis 1 次 共享走訪,apply 依 order
P2 版面後處理 hanging 0 次走訪+layout 需最終行盒,掛 ResizeObserver

聚合僅是執行優化:邏輯上仍「一功能一 pass」、可獨立開關與 revertI6); 只是 P1 諸 pass 的 collect 在同一走訪內完成。若某 pass 不相容(需獨立走訪), 退化為自跑一次,不影響正確性。

3.5 v1 向下相容層(I8src/compat/v1.ts

導出 createCjkAutospace(options),把 v1 API 映射到 createJuzhen保證不 break、無功能倒退,行為等價但不要求完全一致:

v1 選項/行為 映射
.apply(root) .render(root)
pillSelector → pill 鄰接處理 + avoid
styleInlinesblockTagsskipTags → finder 設定
autospaceClass(預設 cjk-autospace → 套用於 jz-hws 之 class,使既有
.cjk-autospace{…} CSS 仍命中間隙元素
skipAttributeisSkipped → avoid 判定
longWordWrap longWord
punctuationSqueezev1 halt 句內半角) jiyamargin 開明/全角;行為近似,
非逐位等同——符合「不要求完全一致」)

IIFE 構建額外掛 globalThis.createCjkAutospace 別名,使 globalThis.createCjkAutospace 之消費端(Nvim peek/模板)零改動可跑。相容層之 差異(間隙元素由 span.cjk-autospace 變為 jz-hws[class]、擠壓由 halt 變 margin 於 README 明列。

3.6 作用域與分塊功能選擇(I10

作用域限定——控制「在頁面何處生效」,避免污染無關內容:

  • scope.root:處理根(節點或選擇器),預設 document.bodyrender(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 失效)。
  • 邊界感知 replaceI7:對「跨相鄰文本節點、可跨樣式行內元素」的邏輯字串 做正則 find,回呼決定如何包裝/插入標記。樣式行內元素(strong/em/a/span/… 透明穿越;block/pillavoid 中斷邏輯串。此即 v1 Pass A「剝離樣式收集邏輯文本 再反映射」之一般化,提升為可重用引擎。

設計要點:finder 不知道任何具體排版規則;它只提供「在何處、跨何種邊界、對哪些 字元」的遍歷與安全變更原語。規則由各 pass 以 unicode 表 + 回呼注入。

「行內透明」原則之一致性(下游 Bug 修復):「邏輯文本應透明穿越之行內元素」一語 須在所有遍歷原語中口徑一致,否則同一段文本在不同原語下被切成不同邏輯串、生成相 互矛盾之結果。isTransparentInline 為單一真相源,列入:樣式行內標籤、jz-hws jz-jinzewrap 群組)、jz-charjz-inner(標點原子)。後者必須透明,否則 adjacentLogicalChardescend 遇標點原子既不進入亦不終止、而是跳過它續找更前 之兄弟——漢字。<code> 經禁則綁定後,pill 之左鄰字應為「。」(標點,非 pill 鄰字、不 補間隙),舊邏輯卻越過 jz-char 取到「字」誤補 jz-hws。修復後與 collectRuns 之既 有透明遞迴對齊。同理 lineedgeedgeRect 攀爬集由 options.finder.styleInlines 派生(非硬編碼子集),免遺漏 insdelsub 等致行端誤判(T4)。

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 彎引號:“”‘’
  • 規則正則:中西間隙邊界、擠壓相鄰組合、禁則對象等,皆以此表組裝。

規則集抽象(RulesetcharClass,可配置):字符分類不再各 pass 硬編碼,而統一為 一個規則集——{ cjk, ansAfter, ansBefore, biaodian{6 子類} },各為 regex 字元類片段。 DEFAULT_RULESET 即內置默認(上述 CJK/西文/BD_* 值)。消費端經 options.charClass { cjk?, western?, biaodian? }擴充resolveRuleset(override)追加語義合併 為最終規則集,置於 ResolvedOptions.ruleset。各 pass 由此取分類:

  • spacingmakeRules(ruleset)cjkansAfteransBefore per-instance 編譯 全部間隙正則(createJuzhen 各一次,非每 block);ALNUMALPHA/運算符/括號等結構性 ASCII 集為固定常數、不隨規則集變。
  • jiyaclassifyBiaodian(ch, ruleset.biaodian) 決定 charify 與 bd-* 子類。
  • jinzeanyCjk = RegExp(ruleset.cjk) 決定詞元邊界之 CJK 判定。

設計取捨:① 向下兼容——不傳 charClassruleset === DEFAULT_RULESET,行為與 重構前完全一致(現有測試全綠)。② 追加語義(v1——擴充內置類(把未分類字符如英文全角 句號 U+FF0E 歸入、或納新符號);不支援重分類/移除內置字符(範圍式集合難以挖洞、且需 求罕見,列為後續)。③ 結構性 ASCII 不開放(非「字符類型」)。

借鑑 Han 之 regex/typeset.jsregex/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() 選擇器落實同一政策(I2I3)。

4.4 dom.ts——jz-* 元素工廠與還原

createEl('jz-char', {class, attrs})wrap(node, el)splitAtinsertMarker、 冪等標記(data-jz="<pass>")、revertPass(name, root)(移除該 pass 之產物、 解包 jz-*、合併回文本節點並 normalize())。

data-jz-kind 區分產物還原方式:marker(純注入,移除)、wrap(包裝原內容,解包)、 splitoff(行內元素克隆切分之邊界詞元,見 core/split.ts/§6.4)。revertPass兩階段處理:主迴圈逆序解包 marker/wrap,第二階段再把 splitoff 併回前兄弟(同標籤 原元素)——順序必要,否則 splitoff 之前兄弟可能仍是 wrap 殼(如 jz-jinze)而非原元素。


5. 渲染元素詞彙(jz-*

自訂元素皆含連字號(合法 custom element 名),預設 display: inline(未知元素 天然 inline),語義中性,可被 revert 移除:

元素 用途
jz-hws 中西間隙。user-select:nonecopy-cleanjustify 安全(§8
jz-char 包裝單一標點/漢字,承載 bd-* class 與 data-jz 屬性
jz-inner jz-char 內層字面容器,供 marginletter-spacing 擠壓而不動外盒
jz-cs 字間補償空白(連續/行端擠壓用,於行邊界折疊)
jz-jinze 禁則群組:綁定不可斷單元(亦為 justify 綁定載體)
jz-orphan 垂懸字避免群組:段末末 N 實義字 nowrap(§6.5.1
jz-em 著重號載體
jz-rubyjz-rbjz-rt ruby/注音

class 約定:bd(標點)、bd-open/close/cop/stop/sep/ligacjkjiyajiya-consecutivehangablejinze-{tou,wei,touwei} 等。


6. 功能點逐項實作方案(對照 Han.css)

每項列出:Han 做法 → 我們做法 → 跨瀏覽器 → lang → 我們的調整 → 切片歸屬。

6.1 中西間隙(HWS,漢字↔西文)— 切片 1

  • Han:插入 <h-hws hidden>,內含真實空格 + ::before{content:' '} 未加 user-select:none → 選取/複製會帶上空格(其已知缺陷)。
  • 我們(margin 模型)finder 邊界感知地把邊界左側之一個字CJK 或西文) 包進 jz-hws,間隙由 CSS margin-right0.25em)提供——不注入任何空白字元。 WHERE 之規則沿用 v1 pangu 規則表(已驗證);作者原寫之 CJK↔西文空格一律移除 (改由 margin 留隙)。
  • 為何不用空白字元/inline-blockjustify 三難):純 CSS 下,CJK↔西文間隙在 justify 必有一個邊緣瑕疵——①真實空白→被當詞間空白暴增;②inline-block 固定 寬→罕見落行首縮排;③margin-right行末右緣內縮 0.25em。選 ③(margin), 因其行首乾淨、不撐框、不暴增、textContent 完全不變(純 copy-clean,複製緊湊), 並以 §6.1a 之 layout pass 消除行末內縮。
  • 跨瀏覽器margininline 普遍支援。
  • copy-cleanI1,較 Han 更純):間隙為純視覺 margintextContent 不增不減 語義字元,剪貼板得緊湊原文。
  • 切片:涵蓋 v1 Pass A、Pass Bpill code/kbd 邊界亦走同一機制——左字或 pill 元素包入 jz-hwspill 由 avoid 與鄰接判定處理)。
  • 邊界正規化一致性(下游回饋 1a/1b 修)
    • 1a pill 穿越 jz-jinzejinzeorder 40)先於 spacingorder 50),會把尾隨 標點之 pill 納入 jz-jinzeadjacentLogicalChar透明穿越 jz-jinze (與 jz-hws、樣式行內同列,見 finder.isTransparentInline),否則 pill 左鄰字 不可達、左緣間隙與作者空格剝除雙失。
    • 1b CJK↔CJK 贅餘空格:中文不用詞間空格,故 CJK 表意字之間的單一 ASCII 作者 空格(含跨樣式行內標籤,collectRuns 已透明合併)一律剝除、不留隙(以 DEL 哨符 1:1 替換空格保持對齊,processBlock 據此刪空格而不插 jz-hws)。 與 CJK↔Latin 之剝除一致——凡 inline 邊界之贅餘作者空格皆統一處理。全形空格 U+3000 不動。
  • 西文字集之完整化(下游回饋 D;列舉法)CJK↔西文之邊界由 unicode.tsANS_AFTER_CJKANS_BEFORE_CJK(共用片段 WEST_SHARED)界定。原僅涵蓋拉丁基本 +拉丁-1+部分希臘,致貨幣符號 U+20AC 等整塊 U+20A020BF)、拼音聲調 ā ǎ ē 等拉丁擴充 A/B U+0100024F)、西里爾數學運算符U+2200–22FF) 不被視為西文 → 鄰接 CJK 不補隙、手工空格亦不剝除。改採逐區塊列舉補全(見 WEST_SHARED 註記),並把希臘由原 U+0370–037D 補全至整塊 U+037003FF(原漏 Αα 本身)。取捨:選列舉(whitelist)而非 \p{…} 類別法,因類別法之 集合減法須 v flagES2024),會破壞 2016Windows 10 時代相容性下限;列舉法所有 範圍皆 BMP、以 \uXXXX 表示於字元類內無需任何 flag。代價:新區塊需手補。
  • 全角省略號不誤判(下游回饋 BDOTS_CJK 原以 alternation ([\.]{2,}|…) 匹配「省略號後接 CJK 補隙」,但 |… 誤把全角省略號 U+2026 納入;全角 …/…… 屬 CJK 標點(已由 jiya charify 為 bd-liga),後接 CJK 不應有中西間隙。移除 |… 僅留 ASCII ...

6.1a 行末去隙(gapTrimjustify 修正)— 切片 1

  • 問題margin-right 落在行末時,justify 下使右緣內縮 0.25em。
  • 我們layout pass gapTrim 量測各 jz-hws,對「其字落於行末(後一內容在 更低行)」者加 jz-hws-trimCSS margin-right:0),該行遂齊右、無內縮。掛 ResizeObserver+字型載入後重算。純版面、瀏覽器專用(jsdom 跳過)。與 lineedge 共用量測工具與 layout 安裝器。
  • 自我應驗誤判(下游回饋 C/容器寬相關之「最後一個間隙消失」):間隙之 margin-right 會參與斷行。若依「後字是否在更低行」判行末,而後字落點又被 margin 推擠,便自相矛盾 ——間隙 margin 把後字擠到次行 → 判行末 → trim 去 margin → 後字回同行,振盪停在「已 trim、後字同行、間隙消失」之錯誤終態(ResizeObserver 只觀 root 尺寸、不因內部 margin 變化重觸發,故卡死;resize 容器則該處重判、間隙「恢復」或轉移)。污染雙向:不僅當前 間隙自身 margin下游間隙之 margin 亦會令夾在中間之內容(如 或<gap>$1299<gap> 放不下其行、致後字假性換行。
  • 修法trimGaps 改為起始全部 trimmargin 全 0),再由左至右逐一定案。量測 某間隙時其前皆已定案、其自身與其後全部仍 trim,故後字落點不受任何 margin 污染,純粹反映 「無本間隙 margin 時後字是否仍被擠到次行」:仍在更低行(或無後字)→ 真行末、維持 trim; 在同行 → 行內容得下、非真行末 → 還原間隙。逐一必要(斷行左→右因果);以末行 rect (lastRect,margin 實際落點)為準,兼顧跨行 pill(行尾 code 折行亦曾致右側間隙誤消)。 實 Chromium 多寬度(822em、raggedjustify)掃描確認:偽 trim 由「某些寬度存在」歸零; 真行末仍 trim(justify 齊右不變)。

6.1b 標點稽核:CJK 標點不生誤隙(下游回饋)

  • 不變量:任何現代漢語標點介於 CJK 之間,皆補中西間隙。實 Chromium 稽核全部 常用標點(。,、;:?!…… —— ·~ 簡繁引號 “”‘’「」『』、各式括號 ()【】《》〈〉 []{}〔〕〖〗 等)× 簡繁兩政策共 104 例,jz-hws 皆 0、textContent 不變。
  • 修正之偏例:①全角省略號 ……(§6.1 之 BDOTS_CJK|… 誤匹配,已移除); ②收尾彎引號 (原 pangu QUOTE_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

  • Hancharify 標點為 <h-char class="bd-jiya …"><h-inner>,以負 letter-spacingmargin 擠壓;連續、行端、簡繁差異以 CSS + :lang() 落實 bd-cop:lang(zh-Hant) → 不擠=全角式)。
  • 我們:同構——charify → jz-char.bd-* + jz-inner;半形與否寫在 juzhen.css,由 JS 解析 locale.style(讀 lang)後加 jz-halfjz-half-le class 觸發(I3;改用 class 而非 CSS :lang(),以避免 :lang(zh-Hans) 不匹配 zh-CN 等地區標籤之陷阱)。三機制並集落地:①baseline 政策(全角不擠/ 開明擠句內點號+括號引號/半角全擠);②行內連續擠壓jiyaAdjacencyPass, lang 無關,見下「連續標點挤壓規則」);③行端擠壓lineedge.ts layout pass)。
  • 跨瀏覽器(I4)— 修訂:半形改以字型 OpenType haltfont-feature- settings:"halt")渲染,margin。原因:負 margin 假設可壓縮空白全在 單側,對居中字形!?、繁體 locl 下之 。,)會把字面拉入鄰字而重疊 halt 收的是字身寬度、由字型按字形正確定位(簡左/繁中/括號偏側皆對),故 不重疊。font-feature-settings:"halt" 各引擎一致(當初疑慮之不一致者為自動text-spacing-trimchws,非顯式 halt);缺 halt 之字型優雅降級為全形。 鄰接/行端之「收哪一字」之決策仍由 blankSides 邏輯定,渲染統一為 halt
  • 句末標點集含全角句點 (下游回饋)BD_STOP。.!?——除表意句號 U+3002)外,納入全角句點 U+FF0E)。實測 Noto CJK SCTC 之 haltGPOS SinglePos 調 advance)皆覆蓋 、挤壓收 0.5em,與 一致;inkBias 同步補 (簡 left/繁 center),margin 後備亦可挤。政策行為與 。!? 同:quanjiaokaiming 句末 保持全角、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(不宜挤之例外)。多標點鏈逐對處理。jiyaAdjacencyPass從 root 之透明遍歷(穿越 strong em… 等樣式行內;block 邊界與 avoid 子樹斷相鄰;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-inneroverflow 溢出、再依墨色偏側左移 right 0.5emcenter 0.25emleft 0),使墨色落入 0.5em 盒、空白溢向外側——即以 CSS 盒模型重現 halt。偏側由 core/unicode.tsinkBias(ch, langClass, overrides)字身墨色中心給出 leftcenterrightfull(簡體點號偏左、繁體居中;彎引號 “”/角括號 「」/尖括號 〈〉 墨強偏一側→開 right、閉 left;圓方書名括號 ()【】《》… 近中→center),charify 時標為 jz-ink-{left,center,right}。消費端可 經 jiya.bias 逐 lang 逐字覆寫;配字工作流與量測工具 tools/measure-bias.py 見 README。
  • 激活與粒度(I3I10:模式由 data-jz-halfwidth 屬性所在子樹決定(CSS 後代選擇器)——render() 在全域 margin 時自動掛於 root;亦可手寫於任一祖先作逐區塊 /逐語言控制(含 margin 頁中嵌 halt 島之 halt-reset,憑來源順序勝出)。bias class 恆於 charify 標記,halt 模式不讀,故模式切換為純 CSS、無需重 render。實測: 方正書宋 GBK(開明式 zh-Hans)下同句行寬 24.0emhalt 無效)→ 19.7emmargin 生效)、 單一句內點號 1.0em → 0.5em
  • 切片:涵蓋並超越 v1 Pass D(開明式),加入全角式、lang 切換、行內連續與 行端擠壓、haltmargin 雙渲染。

6.4 禁則(Jinze,避頭尾)+ justify 綁定 — 切片 1(基礎)/後續(完整)

  • Hanh-jinze 綁定行首行尾不可出現之標點與鄰字,避免標點落行首/行尾。
  • 我們jz-jinzeCSS display:inline-block; white-space:nowrap)綁定不可斷 單元。避頭尾已實作jinze.ts):因 jiya 把標點原子化為 inline-block,原子 邊界成可斷點、破壞瀏覽器原生 UAX#14 避頭尾,故補回——把「不可置行首」之標點 (閉類/點號/間隔/連接省略)綁到前一字、「不可置行末」之開類綁到後一字只隔離單一邊界字(長引文中段仍正常斷行,不溢出)。實作為兩階段:依原始 相鄰算禁斷邊界→切邊界字;重掃併入 nowrap 群組。亦為 §8 justify 綁定載體。
  • 行內元素最小綁定(core/split.ts,下游 Bug 修復):邊界字落於樣式行內元素 strongemspan 等)內時,舊版整個元素入 jz-jinze——<strong>很長一串</strong> 會把整串粗體塞進 nowrap inline-block,行末放不下即整體掉行、上行留巨大空隙。 改以 isolateBoundaryToken(el, side) 克隆切分:沿「文本節點→邊界元素」祖先鏈逐層 把邊界詞元剝入一個淺克隆(保留標籤/class/style/lang,去 id),只綁該克隆(最小綁定), 原元素其餘部分留原位、渲染視覺無縫。克隆標 data-jz-kind="splitoff"revertPass延後 階段(先解包 wrap、後併 splitoff,確保前兄弟為原元素而非 wrap 殼)併回原元素、還原一致 (I6)。僅純樣式標籤白名單可切;aabbrruby/帶 id 者退化為整元素綁定(安全)。
  • 切片:切片 1 完成行首行末避頭尾(閉類/點號避頭、開類避尾);行首縮排聯動、 更細之禁則對象列後續,沿用同一 jz-jinze 結構。
  • 已知交互(待瀏覽器精修)lineedge 之行端偵測需跨 jz-jinze 群組邊界向上 攀爬找流內鄰字(已於 edgeRect 處理 jz-* 包裹之攀出);屬 layout 層,jsdom 無法 驗,須瀏覽器目視。

6.5 標點懸掛 + 垂懸字避免 + 行端禁則 — 整體實作(已落地)

三個相關機制之整體實作(同步引入),皆處理「行端/段末之標點與字」;其分層職責須先 釐清,以免重複處理或衝突。前置為「功能分級」(見 6.5.0a),因懸掛/垂懸字屬段落級、 不應作用於標題/按鈕/單行文本。

實作狀態(2026-066.5.0a 功能分級、6.5.1 垂懸字(方案 C,預設開)已落地且工作良好。 6.5.2 行端模式重構為 squeezehangnonesqueeze 為現有行端半形、保留並改善);惟標點懸掛 hang 經實測確認不可行、已全面停用(仍保留休眠程式碼)。

行歸屬判定 bug 修正(連帶改善 squeeze,保留):原以「行盒 top 差 > EPS=4px」判同/異行, 但 jz-char 為 inline-block,其行盒 top 與相鄰純文本字形 top 因基線差約 5px、遠小於行高卻超 舊容差,致同行誤判為下一行。改判「垂直區間」:下一行=b.top ≥ a.bottom、上一行= b.bottom ≤ a.top(容 2px 亞像素)。

標點懸掛停用之原因(純負 margin 機制不可行,headless + 真實瀏覽器實測):

  1. 句號等標點墨色偏字框左下角;安全凸出量(< 1 個 CJK 字寬 1em)只把空白半框推出版心、 墨色仍在版心內(視覺不懸掛)。
  2. 凸出滿 1em 又釋放一整字寬、把下一字拉上本行、與懸掛標點重疊
  3. 懸掛與 inline-block 對齊原子互斥justifyAtoms:true 下負 margin 被 justify 吸收)。
  4. ResizeObserver 重排時,「自然佈局標記→施加 margin→佈局再變」致標記錯亂

真正的解是原生 CSS hanging-punctuation,惟 Chrome 不支援、且與本庫 inline-block 原子衝突。 故 hang 之 lineedge 程式碼與 CSS 保留休眠,並設三重防逃逸lineEnd 鎖死 squeeze、 modeForChar 降級、applyEdgeHANG_ENABLED=false 總開關)——縱使下游以任何方式要求亦 不啟用。詳見 6.5.6。

6.5.0a 功能分級:文本級 vs 段落級 + 元素標記(前置基礎設施)

動機:懸掛/垂懸字/禁則等為段落級功能(依賴行/段布局),不應作用於標題、按鈕、單行 或單元素文本;而中西間隙、標點挤压為文本級,任意文本片段皆適用且想要。下游須能方便標記 元素屬哪級。此為貫穿性基礎設施(已實作:Finder.levelFor/levelAllows、各 pass level 元數據、選項 level.text/屬性 data-jz-level)。

分類(含現有與待加功能)

級別 功能 判據/依賴
文本級 spacing(中西間隙)、jiya baseline 政策半形、jiya 連續挤压(adjacency 僅字符鄰接,對單行/單元素文本即有意義
段落級 jinze 避頭尾、行端(半形/懸掛)、gapTrimorphan 垂懸字、longWord 斷詞 需行/段布局(換行、行端、段末、justify)才有意義
貫穿 charClass、原子透明、normalize 基礎設施,不分級

直覺:文本級=「字符級排版」(間隙、標點形態);段落級=「行/段級排版」(換行、行端、段末)。

標記 API(下游)

  • 預設推斷:塊級元素(blockTagspdivlitd…)→ 段落級(全功能)
  • 顯式降級: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 選擇器 預設(塊→段落)。
  • gate:段落級 pass 對 text-level 元素跳過;文本級 pass 不受限。與現有 blocks featureEnabledFor 疊加(level=粗粒度語義組先判,blocks=細粒度逐功能後調)。
  • 文本級功能須支持非塊/單元素spacing 現用 eachBlock,須確認 scope root 為 inline/ 單元素時亦能處理(或放寬走訪),使單行標題等之中西間隙、標點半形生效。

與本批關係:先落地分級(pass 標 level、levelFordata-jz-level/選項)→ orphan/hanging 註冊為段落級,自動只作用段落級元素、跳過 text-level 者。實作順序插於 6.5.5 之首。

6.5.0 四機制之分工與邊界(核心約定,實作前必讀)

機制 作用層 唯一職責 手段 狀態
禁則(§6.4 jinze 斷行層 把標點趕到正確的邊(點號/閉類→行末、開類→行首;連續標點不拆) nowrap jz-jinze 已實作
行端半形(lineedge 現有) 渲染層·紧排 行首開類/行末閉類收半形 jz-half-lehalt 已實作(將併入行端模式)
標點懸掛 渲染層·凸出 行末點號/閉類右凸、行首開類左凸出版心 margin 實作後停用(見 6.5.6
垂懸字避免(本計劃) 斷行層 段末行 CJK 實義字數 ≥ N nowrap jz-orphan 待實作
  • 正交性:斷行層(禁則/垂懸字,管「哪些字符在哪行、不可斷」)與渲染層(半形/懸掛,管 「邊緣標點凸出/收縮」)作用於不同 CSS 維度(white-space vs margin),可疊加共存。
  • 協同:禁則決定標點落行首或行末 → 半形/懸掛對落在邊緣者施收縮/凸出。
  • 行端「半形 vs 懸掛」互斥(同屬渲染層):將 lineedge 行端處理升級為可配模式 lineEnd: "squeeze"(現有半形)|"hang"(懸掛)|"none",二者共用行端標點偵測碼。
  • 垂懸字 vs 懸掛邊界(已研究):垂懸字限「末行實義字 ≥ N」、懸掛限「邊緣標點凸出」。唯一 重疊=純標點末行(實義字=0):懸掛可「吸回」(不動正文)、垂懸字綁定可「拉字下來」。 約定:方案 C(無測量)附帶消除純標點末行(副作用,現有益無害);未來若改由懸掛吸回 (更優),把 C 綁定條件化(僅末行實義字 ∈ [1,N−1] 才綁),借懸掛 layout pass 之測量協同 判定,C 不獨立測。末行實義字 ∈ [1,N−1] 僅垂懸字(換行)可解(懸掛不增實義字)。

6.5.1 垂懸字避免(方案 C,斷行層,無測量)— 已實作

  • 目標:段末行 CJK 實義字數 ≥ N(預設 2;標點/符號不計)。單行段無效。
  • 機制standalone pass,遍歷作用域段落;自段末向前數 N 個 CJK 實義字(ruleset.cjk 判定、 跳過標點/空白/西文計數),把「第 N 個實義字起至段末」(含其間/尾隨標點)包入 <jz-orphan> CSS white-space: nowrap)。
  • 保證(數學、免測 layout:該整體不可斷 → 末行恒含之 ⇒ 末行實義字 ≥ N,任意寬度/字型/ 瀏覽器成立。平時無害(末行本多字時不改變),僅末行將塌為 <N 字時「拉字下來」。
  • 選項orphan?: boolean | { chars?: number }feature gate預設開 chars=2per-block 經 blocksdata-jz;段落級,data-jz-level="text" 子樹跳過)。lang 無關。
  • 邊界:段落實義字 < N → 不綁。極窄容器(<N 字寬)→ 整體可能溢出(罕見,文檔提示)。
  • order=70charify 20 後、layout pass 前;longWord 60 後)。
  • 與禁則協同:段末範圍可能含 jinze 已綁之 jz-jinze;置於 jinze(40) 後,將 jz-jinze 整體 納入 jz-orphan,並處理邊界字定位跨 jz-jinze 攀爬(實作細節)。免重複包裹。
  • CSSjz-orphan { white-space: nowrap; }revertwrap-class,隨 revertPass 還原。
  • 測試:jsdom 結構(末 N 實義字+尾標點入 jz-orphan;符號不計;單行段/實義字<N 不綁); headless 窄容器拖動驗末行恒 ≥N。

6.5.2 標點懸掛(Hanging,渲染層,layout)— ⚠ 已實作後停用

停用(2026-06:本節原設計之懸掛經實測不可行、已全面停用(原因見 6.5 開頭與 6.5.6 三重防逃逸見 6.5.6)。lineedge 之 squeeze 模式(行端半形)保留並改善。下文為原設計記錄, 供日後改用原生 hanging-punctuation 重啟時參考。

  • 目標:行末 bd-stoppauseclose 右凸出右緣;行首 bd-open 左凸出左緣。文字邊緣齊。
  • 不用 CSS hanging-punctuation:瀏覽器支援差(僅 Safari 較全)+聚珍「原子透明」已對 jz-* hanging-punctuation: none。故自管負 margin
  • 機制layout pass(複用 lineedge installLayout:立即跑+ResizeObserver(root) fonts.ready 重算)。量測各標點 jz-char 是否在行首/行末(同 lineedge edgeRect/firstRect, 跨 jz-* 包裹攀爬)。行末點號/閉類加 jz-hang-rmargin-right: -<凸出量>;行首開類加 jz-hang-lmargin-left: -<凸出量>
  • 與現有行端半形整合(已實作):抽出「行端標點偵測」共用,按 lineEnd 模式(由 options.hanging 解析:開→hang、關→squeeze)於 relayoutjz-half-lesqueezejz-hang-*hang)。squeeze 行為不變(回歸保綠)。
  • 凸出量按類headless 實測校準,Noto Sans CJK SC):句末/句內點號(。,等墨色小、留白 多)近全形 --jz-hang-stop(預設 1em,整體落版心外、正文齊邊);括號引號半形 --jz-hang-bracket(預設 0.5em,墨色偏側、留白半框)。實測句號右凸 +1.005em、開括號左凸 +0.5em,正確。CSS 變數可逐字型調校。
  • 已 baseline 半形者不懸掛jz-char 帶 jz-half(開明/半角式之括號等)已收為半形、無留白 可凸,JS 端 relayout 跳過(否則負 margin 把墨色整體拉出)。故 SC 開明式之括號維持半形不掛、 句末點號(不收半形)才掛。
  • 懸掛與 inline-block 對齊原子互斥(實測,重要約束):預設 justifyAtoms:truejz-char 為 inline-block)下,懸掛之負 margin 被 justify 重分配吸收、標點不凸出headless 量測 凸出量 0);改 justifyAtoms:falsejz-char 為 inline)則正確凸出(+1em)。故欲懸掛須設 justifyAtoms:false。二者皆渲染層、不可兼得(如同 squeeze/hang 互斥)。容器宜留右 padding ≥1em 容納凸出標點。
  • lang 閘(已實作):繁體橫排預設不懸掛(relayoutmodeForCharzh-Hant 降回 squeezeclreq);簡體可掛。options.hanging 預設(顯式開)。
  • 自我應驗(已解,§6.5 開頭發現 4):採兩階段 relayout——先 clearEdge 清全部行端 margin/半形,於自然(無行端效果)佈局量測全部標點之行端狀態(不變更 DOM),再一次性 套用。每次量測皆見無污染佈局(仿 gapTrim「起始全清」教訓),終態穩定。
  • 行歸屬判定(已修,§6.5 開頭發現 1):以「垂直區間」lowerLine/higherLine 取代舊 「top 差 > EPS」,吸收 inline-block 行盒與純文本字形之基線差(~5px),免同行誤判。
  • order90 lineEdgesqueeze/hang 共此 pass)。revertjz-half-le/jz-hang-*
  • 跨瀏覽器:負 margin 一致;須 ChromeSafariFF 目視(R1/R2)。headlessChrome)已驗 行末標點凸出右緣、行首凸出左緣、繁體不掛、無中段誤觸。

6.5.3 行端禁則(§6.4review

  • 禁則避頭尾與 squeeze 行端半形協同(禁則趕標點到邊、行端收半形);jz-orphan 與 jz-jinze 嵌套 已處理(邊界字跨 jz-jinze 攀爬)。lineedge 偵測已跨 jz-jinze 攀爬。jinze 本身未大改。 (懸掛已停用,故無「懸掛凸出最外側」之協同需求。)

6.5.4 統一 pass order

20 charify → 25 jiyaAdj → 40 jinze → 50 spacing → 60 longWord → 70 orphan(垂懸字)→ 90 lineEdge(行端 squeezehang 已停用)→ 92 gapTrim

6.5.5 實作順序與結果

  1. 功能分級(前置,6.5.0apass 標 levelfinder.levelFor/levelAllowsdata-jz-level/選項 level.text;段落級 pass gate 跳過 text-level 元素。
  2. 垂懸字(方案 Cstandalone pass、order 70、純結構無 layout、預設開 chars=2。工作良好。
  3. lineedge 重構為行端模式:抽出偵測,squeeze 行為不變、回歸保綠;連帶修「行歸屬判定」 bug(垂直區間取代 top 差)。保留。
  4. 懸掛 hang 模式:實作後經 headless + 真實瀏覽器實測確認不可行、全面停用(原因見 6.5 開頭 + 6.5.6)。程式碼休眠 + 三重防逃逸。
  5. 邊界落地:純標點末行由 orphan 之尾段綁定(含尾隨標點)全包消除。
  6. 文檔/docsdemoARCHITECTUREREADMEdocs 分支標記懸掛停用;demoshowcase resize-check)移除懸掛、保留功能分級與垂懸字。

6.5.6 標點懸掛停用記錄與未來路徑

  • 停用結論:純負 margin 機制無法視覺懸掛——(1) 句號墨色偏框左下,安全凸出量(< 1em) 只推出空白半框、墨色仍在版心內;(2) 凸出滿 1em 釋放一整字寬、拉升後字致重疊;(3) 與 inline-block 對齊原子互斥(justifyAtoms:true 下 margin 被 justify 吸收);(4) ResizeObserver 重排致兩階段標記錯亂。四者皆實測確認。
  • 三重防逃逸(確保下游無法以任何方式啟用):① index.tslineEnd: "squeeze"(忽略 hanging 選項);② lineedge.ts modeForChar 無條件降 squeeze;③ applyEdgeHANG_ENABLED = false 總開關。三層任一即足,疊加為冗餘保險。hanging 選項仍被接受(API 相容)但無效果。單元測試覆蓋 optiondata-juzhenblocks 各路徑皆不產生 jz-hang-*。
  • 未來路徑:原生 CSS hanging-punctuationallow-end/first/last)為正解,待 Chrome 支援(現僅 Safari),且須解決與 inline-block 原子之衝突(屆時或令行端標點局部退原子化)。 hang 之 lineedge 偵測碼、CSSjz-hang-*)、LineEndMode 型別皆保留休眠,重啟成本低。

6.6 標點修正(Biaodian normalize)— 後續(opt-in

  • Hansubstbiaodian 修正標點變體、位置。
  • 我們opt-in pass,預設只分類+打 class(不改文字,I1);顯式開啟才做 變體替換(簡↔繁標點、全半形修正),且可 revert
  • 預留order=10 最先;產物為 jz-char 之 class/屬性,與 charify 銜接。

6.7 著重號(Emphasis)— 後續

  • Han:逐字包裝加圈點。
  • 我們:優先用原生 text-emphasisChromeFFSafari 支援良好);需精細 控制時退回 jz-em 逐字包裝 + CSS 圓點。lang 感知(位置簡繁略異)。
  • 預留charify 能力直接複用。

6.8 注音/Ruby — 後續(最複雜)

  • 我們:基於原生 <ruby> + jz-ruby/jz-rt 處理注音符號直排與聲調定位。
  • 預留:獨立 passorder=80;不影響其餘。

6.9 樣式正常化/字型歸屬 — 貫穿(CSS)

  • normalizejuzhen.css 內以 @supports:where() 提供 CJK normalize(關閉 與託管擠壓重複作用之原生 text-spacing-trimtext-autospace、間隙 user-select 等),限 .juzhen 域、非破壞。
  • 字型不歸聚珍管(設計決策,下游回饋):曾內建依 :lang 之 Noto 字型堆疊作「後備」, 但字型屬消費端領域、內建即越權(且曾蓋掉消費端字型釀 bug)。故移除 _lang.css 逐語言 font-family 完全交消費端 CSS。聚珍僅守三項非管理性本分:① _normalize.cssjz-* { 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.halfWidthjiya.bias 參數+文檔承擔,無需新參數。
  • 跨瀏覽器:where():lang()font-feature-settings 普遍支援。

6.10 計數器/緊排(counterwell-knit)— 低優先

  • 後續以純 CSS 模組加入,不涉 JS 引擎。

7. 第一垂直切片(v1 對等)

範圍:核心基座(finderunicodelocaledompass 管線)+ 以下功能 pass:

v1 切片 1 對應 機制
Pass A 中西間隙 spacing jz-hwscopy-cleanjustify 安全
Pass B pill 邊界 spacing(同引擎) avoid + 鄰接判定
Pass C 長英文詞 longWord <span lang> + hyphens
Pass D 開明式擠壓 jiya + jinze 跨瀏覽器負 margin + :lang 全角/開明 + justify 綁定

交付:可 build 出 ESMIIFECSSd.tsjsdom 測試覆蓋 DOM 變換正確性 (包裝、class、邊界、冪等、copy-clean、lang 政策);justify/簡繁 demo 頁供瀏覽器 目視驗證版面(layout 行為無法於 jsdom 驗證,見 §12)。

擴充保證:切片 1 所用之 findercharifypass 管線/jz-* 詞彙/CSS-JS 契約, 即後續所有 Han 功能所用者;新增功能 = 新增一個遵守 §2 不變量的 pass,不改切片 1。


8. justify 句末標點單份間距解法(要求 #3)

問題text-align: justify 下,UA 在「對齊機會點」插入伸縮量。全角句末標點 (如 )字面只占字框一部分、自帶半形留白;若 UA 在其兩側都開伸縮,標點 看似分到「兩份」間距(伸縮 gap + 自身留白)。

解法CSS 主導,I2I5):

  1. charify 句末/句逗標點為 <jz-char class="bd …"><jz-inner>。</jz-inner>
  2. jz-chardisplay: inline-block:使其成為 justify 的單一原子項,內部 不開伸縮 gap,標點與其字面留白同屬一項,對齊伸縮只落在項與項之間 → 標點 只貢獻一份。
  3. 開明式下,jz-inner 以負 letter-spacing 擠去半形留白,該項本身即半形, justify 一份 gap 即足。
  4. 全角式下,留白保留於原子項內,仍只算一份;以 jz-jinze 綁定確保不在留白 側額外開機會點。

inline-block 影響基線對齊與行內換行,須以 CSS 校正(vertical-align、行高), 並跨 ChromeSafariFF 目視驗證(§12);此為切片 1 的重點驗證項。

分頁器相容(下游回饋問題二):常規流式版面 inline-block 原子正常,但分頁器 (Paged.js)之對齊引擎會把每個 inline-block 邊界當伸縮點、與 CJK inter-ideograph 疊加,於窄欄混排散架。故提供 options.justifyAtoms(預設 true);設 false 時 render 於 root 掛 data-jz-atoms="inline"CSS 令 jz-charjz-jinzedisplay:inline halt 半形與 jinze nowrap 仍保留)。此即 R1 預案之一(解耦對齊原子化),切換僅 CSS、 不動 JS。注意:margin 後備半形依賴 inline-block 之 width:0.5em,與 justifyAtoms:false 不可兼得——分頁器場景須用含 halt 字型。

原子透明原則(繼承屬性漏溢,下游回饋)jz-charjz-jinze(及 margin 模式之 jz-inner)為 inline-block,而 inline-block 自成區塊容器。凡「作用於區塊容器首/末行」之 繼承屬性,會被每個單字標點原子各自重新套用一次,使「用聚珍」與「未用聚珍」表現不一致。 作用範圍判斷:此類間距/位移源於我們生成之原子,屬本庫責任,由庫內統一免疫(消費端無需 配置);且須以「同類機制」整體界定範圍——要麼全修、要麼全交下游,不做逐案補丁。修法 (_normalize.css)於原子上歸零此類屬性,只切斷其向原子內部之再套用,段落本身之同名屬性不 受影響(仍作用於段落自身行盒)。

經 headless Chrome 逐屬性實測,屬此類者僅兩項,皆已歸零:

  • text-indent: 0——段落首行縮排傳入後,每原子內部首行各縮排一次,shrink-to-fit 寬膨脹為 縮排量+字形,標點附近冒出巨大間距。實測:歸零後段落首行縮排仍保持 2em,原子寬由 ~3em 回到 0.5em1em。
  • hanging-punctuation: none——同屬區塊容器首/末行機制,每原子各自懸掛自身標點。Chrome 未實作(測不出)、Safari 會觸發,且與規劃中之「標點懸掛」(§任務 6)相衝,故按機制一併歸零。

不歸零者(實測不洩漏或本應逐字穿透):letter-spacingword-spacing(原子邊界處理 一致、無翻倍,實測 delta 與基線同);text-aligntext-align-lasttext-justify halt 為 shrink-to-fit、margin 為內容溢出,皆無對齊餘量,兩模式實測 jz-inner 偏移不變); text-transformline-heightcolorfont(逐字內容或縱向屬性,須穿透原子); word-breakoverflow-wraphyphenswhite-spacetab-sizetext-emphasis text-shadowfont-variantfont-stretch(逐屬性實測 delta 皆與基線同,不洩漏)。

透明之另一面——欠套用(text-decoration:與上述「過套用」相反,text-decoration 非繼承屬性,由段落之「裝飾盒」統一繪製並跨後代延伸;但原子級 inline-block 會被裝飾盒 跳過,致段落下劃線在每個標點原子下斷缺(像素掃描實測:純文字 coverage 1.0、聚珍僅 ~0.25)。故反向處理——令原子 text-decoration: inherit 承段落裝飾自繪,因共用基線與字型 度量而無縫接續(_normalize.cssjz-charjz-jinzemargin 模式 jz-inner 亦為 inline-block,於 _jiya.css 補一條)。實測:haltmargin 兩模式 coverage 皆回 1.0,且 margin 下劃線隨半形收為 0.5em advance。此為「同類問題」整體界定之一部分:凡因原子化 而生之版面瑕疵,要麼庫內統一修復、要麼統一交下游;本庫選前者,使原子對消費端樣式透明。


9. 跨瀏覽器策略

  • 一致性關鍵層(間隙、擠壓、justify):只用 margininline-blockuser-selectwhite-space:where()hyphens,及字型 OpenType font-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-trimBlink)、hanging-punctuation WebKit)、text-emphasistext-autospace——僅在支援時錦上添花,缺席不影響 基本排版(I4)。
  • 驗證矩陣ChromeBlink,含 VSCodeElectron)、SafariWebKit、Firefox Gecko)。demo 頁含三引擎可開的測試案例。

9.1 顯式禁用不一致原語(I4

當原生原語在各引擎支援不一,或會與我們託管之版面重複作用(雙重擠壓/間隙) 時,在 .juzhen 範圍內以 @supports 偵測並主動關閉,使所有引擎收斂於我們的 一致路徑:

@supports (text-spacing-trim: space-all) {
  .juzhen { text-spacing-trim: space-all; }   /* 關閉 Blink 原生 CJK 標點 kerning */
}
@supports (text-autospace: no-autospace) {
  .juzhen { text-autospace: no-autospace; }   /* 關閉原生中西自動間距 */
}

政策:凡屬「一致性關鍵層」者(間隙、擠壓),一律由我們統一處理並禁用對應原生 原語;凡屬「漸進增強」且不致衝突者(如 Safari 之 hanging-punctuation,在我們未 託管懸掛時),方任其生效。此清單隨功能演進維護於 css/_normalize.css


10. 模組/檔案佈局

src/
  index.ts            createJuzhen / Juzhen / pass 註冊 / render / revert
  core/
    pass.ts           Pass 介面 + 管線執行器
    finder.ts         遍歷/avoidcharify/邊界感知 replace
    unicode.ts        字元類 + 標點子類 + 規則正則表
    locale.ts         有效 lang / 分類 / 全角開明政策
    dom.ts            jz-* 工廠 / wrap / split / marker / revert
  typeset/
    spacing.ts        中西間隙          [切片 1]
    longword.ts       長英文詞包裝      [切片 1]
    jiya.ts           標點擠壓 + 全角開明 [切片 1]
    jinze.ts          禁則 + justify 綁定 [切片 1 基礎]
    hanging.ts        標點懸掛          [後續]
    biaodian.ts       標點修正          [後續]
    emphasis.ts       著重號            [後續]
    ruby.ts           注音/ruby        [後續]
  css/
    juzhen.css        入口(@import 各 partial
    _normalize.css _spacing.css _jiya.css _jinze.css
    _hanging.css _emphasis.css _ruby.css   (字型不內建,交消費端)
test/                 jsdom DOM 正確性測試
demo/                 瀏覽器 layoutjustify/簡繁 目視驗證頁
build.mjs             esbuildESM + IIFE + CSStscd.ts

11. 構建與發佈

  • 源碼TypeScriptstrict),模組化。
  • 構建build.mjs 以 esbuild 產出
    • dist/juzhen.mjsESM,供打包器/TS
    • dist/juzhen.iife.jsIIFE,全域 Juzhen,原生 <script> 直跑——保留 build.shNvim peek 之單檔串接消費)
    • dist/juzhen.css(合併之樣式)
    • tsc --emitDeclarationOnlydist/juzhen.d.ts
  • 發佈物dist/ 提交入庫(消費端可直接取單檔/CSS)。

12. 測試與驗證策略

  • jsdom(自動):驗證 DOM 變換——包裝結構、bd-* class、邊界處理、冪等、 revert 還原、copy-cleantextContent 不被注入污染)、lang 政策分支。
  • 瀏覽器(目視,demo 頁)layout 相關無法於 jsdom 驗證——justify 單份 間距、擠壓視覺量、行端折疊、懸掛、inline-block 基線。demo 頁載 Noto CJK,簡繁 並列、可切 justify、附選取/複製測試區,於 Chrome/SafariFF 目視核對。
  • 文件明確標注哪些結論來自自動測試、哪些待瀏覽器目視。

13. 擴充性保證(如何加新功能而不返工)

新增任一 Han 功能點,標準流程:

  1. unicode.ts 補該功能所需字元類/正則(集中、不散落)。
  2. 新增 typeset/<feature>.ts,實作 Passrenderrevertorder)。
  3. index.ts 註冊,加 options.<feature> 開關。
  4. css/_<feature>.css 寫視覺規則(I2:尺寸只在 CSS)。
  5. 補 jsdom 測試 + demo 案例。

因所有功能共用 findercharifylocalejz-*CSS-JS 契約,且順序由 order 集中編排,新增不觸動既有 pass——此即「預留完整復刻 Han.css 能力」之具體保證。


14. 風險與未決項

原則:風險上線後持續觀察,但下列預案先行設計就緒,一旦觀察指標踩線即可切換, 不致臨時返工。

  • R1 justifyinline-block 基線(§8)。觀察:句末標點在三引擎 justify 下是否 單份間距、inline-block 是否擾動基線/行高/行內換行。預案:改採 Han 路線之 jz-cs 補償空白法(標點維持 inline,以行邊界可折疊之 jz-cs 控制伸縮側), 結構已預留,切換僅換 CSS 機制不動 JS。
  • R2 行端擠壓免量測。觀察:純 CSSjz-cs 折疊)能否在三引擎一致達成行首行尾 擠壓。預案:退化為「連續+行內擠壓用 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 不變量)須先更新本文件再改碼。