Files
cjk-autospace/src/typeset/lineedge.ts
T

366 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 行端標點處理(架構 §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<object, Map<string, ResizeObserver>>
>();
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<unknown> } }).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<string>
{
const s = new Set<string>(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(<mark>)——isolate 斷的是
// 邏輯 run/相鄰,其內部字元之物理行首行尾判定仍須跨 mark 找真實相鄰內容(如
// 「文字<mark>「引」</mark>」之 「 物理上接在「文字」之後、非行首,須攀出 mark 取「文字」
// 之 rect,否則誤判行首而錯收左半)。pill 之內部無 jz-char(eachTextNode 已濾),且 pill
// 標籤(code/kbd)不在 styleInlines、自然不在 wrapSet,攀爬遇之即止,無需另判。
function edgeRect(
start: Node | null,
side: "prev" | "next",
from: Node,
wrapSet: Set<string>,
): 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<Element, boolean>): 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<string>,
): void
{
const alignCache = new Map<Element, boolean>();
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** 亦會令夾在中間之內容(如 `或<gap>$1299<gap>`)放不下該行、致後字
// 假性換行。
//
// 解法:**起始全部 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); }
});
},
};