// 行端標點處理(架構 §6.4/§6.5;clreq 行首行尾擠壓)。機制 B。 // standalone layout pass:量測各 jz-char 之行盒位置,偵測「行首開類」「行尾閉類」, // 收為行端半形(jz-half-le,CSS 同 halt):行首開類收左、行尾閉類收右。掛 ResizeObserver // 於 root,容器寬度變動時重算;字型載入後亦重算。 // // 註:原計劃之**標點懸掛**(行端標點負 margin 凸出版心)經實測為機制級死路(寬度守恆 // 矛盾:行端盒非零寬則墨色出去多少版心內就空多少、零寬則釋放一字寬致下一字拉上重疊; // 純 CSS 接觸不到斷行器),已於 §6.5.6 移除。唯一正解為原生 hanging-punctuation(僅 // Safari 支援、且與本庫 inline-block 原子互斥),屬遠期「原生管線」新功能、與此處無共享。 // // ⚠ 純版面、無法於 jsdom 驗證(getClientRects 回傳 0);故無 ResizeObserver // 之環境(jsdom)直接跳過,僅於真實瀏覽器生效(架構 §12/R1/R2)。須跨 // Chrome/Safari/Firefox 目視核對。 import { bdClassOf } from "../core/dom.js"; import { previousVariationSequenceStart, variationSequenceAtUtf16, } from "../core/unicode.js"; import type { Finder } from "../core/finder.js"; import type { RenderContext, ResolvedOptions, StandalonePass } from "../types.js"; const LE = "jz-half-le"; // 行端半形(CSS 同 halt) const ALL_EDGE = [ LE ]; const EPS = 2; // px:行盒比較之亞像素容差。 // 行端合格之 bd 子類:head=行首左緣偵測(開類),tail=行尾右緣偵測(閉類)。 // 僅括號引號;點號於行端保持全形(避免孤立句末誤收,實測困惑來源)。 const HEAD_CLASSES = new Set([ "bd-open" ]); const TAIL_CLASSES = new Set([ "bd-close" ]); // 每 root 各 layout pass(以 key 區分)一個 ResizeObserver。 // // **跨 observer 派發序不變量(已於 Chrome headless 實測確認)**:resize 時 ResizeObserver // 之回調按 **observer 創建順序** 派發(ResizeObserver 規範遍歷 observers 列表之插入序,非 // observe 序)。lineEdgePass(order 90)先於 gapTrimPass(order 92)執行、故其 observer 先 // 建,於每次 resize 皆先派發——確保 gapTrim 之行末去隙判定恆基於 lineEdge **已套用**之半形 // 版面(非陳舊態)。**勿調換兩 pass 之 order 或創建序**,否則 gapTrim 可能以 resize 前之半形 // 狀態誤判行末(且 ResizeObserver 只觀 root 尺寸、不因內部 class 變化重觸發,錯誤會持續)。 const observers = new WeakMap< Element, Map> >(); function hasLayout(): boolean { return typeof ResizeObserver !== "undefined"; } /** 安裝一個 layout 重算:立即跑一次,掛 ResizeObserver,字型載入後再跑。 */ function installLayout( root: Element, owner: object, key: string, run: () => void, ): void { run(); let owners = observers.get(root); if (!owners) { owners = new Map(); observers.set(root, owners); } let m = owners.get(owner); if (!m) { m = new Map(); owners.set(owner, m); } const existing = m.get(key); if (existing) { existing.disconnect(); } // 字型 Promise/已排隊回調可能晚於 revert 或重裝;只允許目前安裝執行。 const runCurrent = (): void => { if (m!.get(key) === ro) { run(); } }; const ro = new ResizeObserver(runCurrent); m.set(key, ro); ro.observe(root); const fonts = (root.ownerDocument as Document & { fonts?: { ready?: Promise } }).fonts; if (fonts && fonts.ready && typeof fonts.ready.then === "function") { fonts.ready.then(runCurrent, () => undefined); } } function uninstallLayout(root: Element, owner: object, key: string): void { const owners = observers.get(root); const m = owners?.get(owner); if (!m) { return; } const ro = m.get(key); if (ro) { ro.disconnect(); m.delete(key); } if (m.size === 0) { owners!.delete(owner); } } // 行內包裹集:edgeRect 攀爬時須跨越「不中斷行內流」之元素找相鄰內容(jz-char 可能被 // jz-jinze 包住,其流鄰居在群組外)。遇 block 邊界即止(回 null,視為行端)。 // **由 options.finder.styleInlines 派生**+聚珍自身之 jz 包裹標籤——舊版硬編碼僅含 // SPAN/STRONG/EM/A/B/I/U/MARK 子集,遇未列之樣式行內(S/INS/DEL/SUB/SUP/SMALL/ // ABBR/CITE/DFN/Q/VAR/SAMP/TIME/BDO/BDI/RUBY 等)會提前止步、誤判行端而錯加行端 // 半形(透明性不一致之同根隱患 T4)。 const JZ_WRAP_TAGS = [ "JZ-JINZE", "JZ-CHAR", "JZ-INNER", "JZ-HWS" ]; function buildWrapSet(options: ResolvedOptions): Set { const s = new Set(JZ_WRAP_TAGS); for (const name of options.finder.styleInlines) { s.add(name); } return s; } function rectOf(n: Node, side: "prev" | "next"): DOMRect | null { if (n.nodeType === 3 && (n as Text).data.length > 0) { const t = n as Text; const doc = t.ownerDocument; if (!doc) { return null; } const range = doc.createRange(); const at = side === "prev" ? previousVariationSequenceStart(t.data, t.data.length) : 0; const length = variationSequenceAtUtf16(t.data, at).length; range.setStart(t, at); range.setEnd(t, at + length); const rects = range.getClientRects(); if (rects.length) { return rects[side === "prev" ? rects.length - 1 : 0]!; } } else if (n.nodeType === 1) { const rects = (n as Element).getClientRects(); if (rects.length) { return rects[side === "prev" ? rects.length - 1 : 0]!; } } return null; } /** 取某節點側邊界字元之矩形(橫排:判定相鄰內容是否同行);跨我們的行內包裹 * 向上攀爬,遇 block 邊界回 null(視為行端)。 */ // 註:edgeRect 為**版面**測量(getClientRects 之物理相鄰),非邏輯文本流。故攀爬邊界 // 僅依 wrapSet(行內包裹之標籤集),**刻意不**排除 isolate()——isolate 斷的是 // 邏輯 run/相鄰,其內部字元之物理行首行尾判定仍須跨 mark 找真實相鄰內容(如 // 「文字「引」」之 「 物理上接在「文字」之後、非行首,須攀出 mark 取「文字」 // 之 rect,否則誤判行首而錯收左半)。pill 之內部無 jz-char(eachTextNode 已濾),且 pill // 標籤(code/kbd)不在 styleInlines、自然不在 wrapSet,攀爬遇之即止,無需另判。 function edgeRect( start: Node | null, side: "prev" | "next", from: Node, wrapSet: Set, ): DOMRect | null { let n = start; let parentRef: Node | null = from.parentNode; for (;;) { while (n) { const r = rectOf(n, side); if (r) { return r; } n = side === "prev" ? n.previousSibling : n.nextSibling; } // 本層無內容:若父為行內包裹,攀出續找其鄰居。 if (!parentRef || parentRef.nodeType !== 1) { return null; } if (!wrapSet.has((parentRef as Element).nodeName)) { return null; } n = side === "prev" ? parentRef.previousSibling : parentRef.nextSibling; parentRef = parentRef.parentNode; } } function firstRect(el: Element): DOMRect | null { const rects = el.getClientRects(); return rects.length ? rects[0]! : null; } // 行歸屬比較須以**垂直區間**而非單純 top 差判定:jz-char 為 inline-block,其行盒矩形 // 之 top 與相鄰純文本字形矩形之 top 因基線/盒高差異常差數像素(實測 ~5px),遠小於 // 行高(如 2em=40px)卻足以超過小容差 → 誤判同行為「下一行」(hang 模式句末 。 過度 // 觸發之根因)。改判「b 是否整體落於 a 之下一行」= b.top ≥ a.bottom(容 EPS 亞像素), // 同行(垂直重疊)則否。對稱地,higherLine 判 b 在 a 之上一行。 function lowerLine(a: DOMRect, b: DOMRect): boolean { return b.top >= a.bottom - EPS; } function higherLine(a: DOMRect, b: DOMRect): boolean { return b.bottom <= a.top + EPS; } // 元素之**最後**一個行盒矩形。jz-hws 之 margin-right 落在其內容末尾之 fragment, // 故行末判定須以此 rect(而非 firstRect)為準——否則包裹會跨行之 pill 時,firstRect // 取到首行矩形、與末行之後續內容比較會誤判(見 trimGaps)。 function lastRect(el: Element): DOMRect | null { const rects = el.getClientRects(); return rects.length ? rects[rects.length - 1]! : null; } function clearEdge(ctx: RenderContext): void { for (const cls of ALL_EDGE) { ctx.root.querySelectorAll("jz-char." + cls).forEach((e) => { if (ctx.ownedElements.has(e)) { ctx.setOwnedClass(e, cls, false); } }); } } interface EdgeDecision { el: Element; } /** * 行端偵測 + 半形套用(行首開類收左、行尾閉類收右)。 * * **兩階段免自我應驗**:先 clearEdge 清除全部行端半形,再於「自然(無行端效果)佈局」下 * 量測**全部**標點之行端狀態(階段 1,不變更 DOM),最後一次性套用 jz-half-le(階段 2)。 * 如此每次量測皆見無污染之自然佈局——不受自身或他者之 halt 收縮影響(避免迴圈內逐一 add * class 致下游量測偏移,同 gapTrim 之雙向污染教訓)。ResizeObserver 僅觀 root 尺寸、不因 * 套用後內部重排再觸發,故終態穩定。 */ function relayout(ctx: RenderContext): void { clearEdge(ctx); const wrapSet = buildWrapSet(ctx.options); const chars = Array.from(ctx.root.querySelectorAll("jz-char")).filter( (c) => ctx.ownedElements.has(c) && ctx.finder.inScope(c) && !ctx.finder.isAvoided(c), ); const decisions: EdgeDecision[] = []; for (const el of chars) { // 行端為段落級(§6.5.0a):text-level 子樹(單行標題等)不處理行端。 if (!ctx.finder.levelAllows(el, "paragraph")) { continue; } if (!ctx.finder.featureEnabledFor(el, "jiya")) { continue; } const bd = bdClassOf(el); if (!bd) { continue; } const isHead = HEAD_CLASSES.has(bd); const isTail = TAIL_CLASSES.has(bd); if (!isHead && !isTail) { continue; } const r = firstRect(el); if (!r) { continue; } if (isHead) { const prev = edgeRect(el.previousSibling, "prev", el, wrapSet); if (!prev || higherLine(r, prev)) { decisions.push({ el }); } } else { const next = edgeRect(el.nextSibling, "next", el, wrapSet); if (!next || lowerLine(r, next)) { decisions.push({ el }); } } } for (const d of decisions) { ctx.setOwnedClass(d.el, LE, true); } } export const lineEdgePass: StandalonePass = { name: "jiyaLineEdge", kind: "standalone", order: 90, level: "paragraph", enabled: (o) => o.features.jiya, render(ctx: RenderContext): void { if (!hasLayout()) { return; } // jsdom 等無版面環境:跳過。 installLayout(ctx.root, ctx.ownerToken, "lineEdge", () => { relayout(ctx); }); }, revert(ctx: RenderContext): void { uninstallLayout(ctx.root, ctx.ownerToken, "lineEdge"); clearEdge(ctx); }, }; // ----- 中西間隙之行末去隙(機制:margin-right 模型之 justify 修正)----- // // jz-hws 以 margin-right 留隙;若其包裹之左字落在**行末**(下一內容在更低行), // 該尾隨 margin 在 justify 下會使右緣內縮 0.25em。行末本不需間隙,故量測後對 // 行末之 jz-hws 加 jz-hws-trim(CSS margin-right:0),該行遂齊右、無內縮。 // 純版面、瀏覽器專用(jsdom 跳過)。 const TRIM = "jz-hws-trim"; // 尾隨 margin-right 是否**可見**、需去行末隙,取決於容器對齊: // · justify/justify-all → 右緣 flush,行末 margin 內縮右緣 0.25em(需去); // · right/end(ltr) → 內容離右緣 0.25em(需去);· center → 行內容含 margin 一併置中、 // 偏移 0.125em(需去); // · **left/start(ltr) → 右緣 ragged,行末 margin 落於不可見側**(可跳過)。 // 唯 left/start(ltr) 可安全整批跳過,省其每個 O(gaps) 次同步重排(S1:常見之非兩端對齊左 // 起內容零成本;其餘對齊之逐一定案為 margin 機制固有代價、保留)。RTL 一律保留(保守)。 // text-align/direction 皆繼承,故同一 parentElement 下之間隙共用同值,per-parent memo。 function skippableAlign(g: Element, cache: Map): boolean { const key = g.parentElement; if (!key) { return false; } const cached = cache.get(key); if (cached !== undefined) { return cached; } let result = false; const view = g.ownerDocument ? g.ownerDocument.defaultView : null; if (view) { const cs = view.getComputedStyle(g); const ltr = cs.direction !== "rtl"; result = cs.textAlign === "left" || (cs.textAlign === "start" && ltr); } cache.set(key, result); return result; } function trimGaps( ctx: RenderContext, wrapSet: Set, ): void { const alignCache = new Map(); const gaps = Array.from(ctx.root.querySelectorAll("jz-hws")).filter( (g) => ctx.ownedElements.has(g) && ctx.finder.inScope(g) && !ctx.finder.isAvoided(g) && ctx.finder.levelAllows(g, "paragraph") && ctx.finder.featureEnabledFor(g, "spacing") && !skippableAlign(g, alignCache), ); if (!gaps.length) { return; } // 自我應驗陷阱(下游回饋 C/容器寬相關之「最後一個間隙消失」):間隙以 margin-right // 承載、會參與斷行。若依「後字是否在更低行」判行末,而後字落點又被 margin 推擠,便 // 自相矛盾——間隙 margin 把後字擠到次行 → 判行末 → trim 去 margin → 後字回同行,振盪 // 停在「已 trim、後字同行、間隙消失」之錯誤終態(ResizeObserver 只觀 root 尺寸、不因 // 內部 margin 變化重觸發,故卡死)。**污染是雙向的**:不僅當前間隙自身 margin,**下游 // 間隙之 margin** 亦會令夾在中間之內容(如 `或$1299`)放不下該行、致後字 // 假性換行。 // // 解法:**起始全部 trim**(margin 全 0),再由左至右逐一定案。量測某間隙時,其前皆已 // 定案(最終 margin 狀態)、其自身與**其後全部**仍 trim(margin 0);故後字落點既不受 // 自身、亦不受任何下游 margin 污染,純粹反映「無本間隙 margin 時後字是否仍被擠到次 // 行」。後字仍在更低行(或無後字)→ 真行末、維持 trim;後字在同行 → 行內容得下、非真 // 行末 → 還原間隙。逐一(非一次量全部)必要:斷行為左→右因果,當前間隙之行起點取決 // 於其前已定案內容;讀取 getClientRects 觸發同步重排,故每次量測皆為最新佈局。以末行 // rect(lastRect,margin 實際落點)為準,兼顧跨行 pill(行尾 code 折行亦曾致隙誤消)。 gaps.forEach((g) => ctx.setOwnedClass(g, TRIM, true)); for (const g of gaps) { const r = lastRect(g); const next = r ? edgeRect(g.nextSibling, "next", g, wrapSet) : null; // 同行小字/下標的 top 亦會較低;須整體落到下一行才算行末。 const atLineEnd = !r || !next || lowerLine(r, next); if (!atLineEnd) { ctx.setOwnedClass(g, TRIM, false); } } } export const gapTrimPass: StandalonePass = { name: "spacingGapTrim", kind: "standalone", order: 92, level: "paragraph", enabled: (o) => o.features.spacing, render(ctx: RenderContext): void { if (!hasLayout()) { return; } const wrapSet = buildWrapSet(ctx.options); installLayout(ctx.root, ctx.ownerToken, "gapTrim", () => { trimGaps(ctx, wrapSet); }); }, revert(ctx: RenderContext): void { uninstallLayout(ctx.root, ctx.ownerToken, "gapTrim"); ctx.root.querySelectorAll("jz-hws." + TRIM).forEach((e) => { if (ctx.ownedElements.has(e)) { ctx.setOwnedClass(e, TRIM, false); } }); }, };