feat(rules): 建立共享排版契约并收束 WebKit 与 Unicode 基线

This commit is contained in:
2026-09-04 21:44:11 +08:00
parent 8ee9895247
commit 993cf576c6
42 changed files with 8511 additions and 1422 deletions
+62 -31
View File
@@ -13,6 +13,9 @@
// 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";
@@ -33,7 +36,10 @@ const TAIL_CLASSES = new Set([ "bd-close" ]);
// 建,於每次 resize 皆先派發——確保 gapTrim 之行末去隙判定恆基於 lineEdge **已套用**之半形
// 版面(非陳舊態)。**勿調換兩 pass 之 order 或創建序**,否則 gapTrim 可能以 resize 前之半形
// 狀態誤判行末(且 ResizeObserver 只觀 root 尺寸、不因內部 class 變化重觸發,錯誤會持續)。
const observers = new WeakMap<Element, Map<string, ResizeObserver>>();
const observers = new WeakMap<
Element,
Map<object, Map<string, ResizeObserver>>
>();
function hasLayout(): boolean
{
@@ -41,11 +47,18 @@ function hasLayout(): boolean
}
/** 安裝一個 layout 重算:立即跑一次,掛 ResizeObserver,字型載入後再跑。 */
function installLayout(root: Element, key: string, run: () => void): void
function installLayout(
root: Element,
owner: object,
key: string,
run: () => void,
): void
{
run();
let m = observers.get(root);
if (!m) { m = new Map(); observers.set(root, m); }
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(); }
const ro = new ResizeObserver(() => run());
@@ -58,12 +71,14 @@ function installLayout(root: Element, key: string, run: () => void): void
}
}
function uninstallLayout(root: Element, key: string): void
function uninstallLayout(root: Element, owner: object, key: string): void
{
const m = observers.get(root);
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 可能被
@@ -89,9 +104,10 @@ function rectOf(n: Node, side: "prev" | "next"): DOMRect | null
const doc = t.ownerDocument;
if (!doc) { return null; }
const range = doc.createRange();
const at = side === "prev" ? t.data.length - 1 : 0;
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 + 1);
range.setEnd(t, at + length);
const rects = range.getClientRects();
if (rects.length) { return rects[side === "prev" ? rects.length - 1 : 0]!; }
}
@@ -166,11 +182,14 @@ function lastRect(el: Element): DOMRect | null
return rects.length ? rects[rects.length - 1]! : null;
}
function clearEdge(root: Element): void
function clearEdge(ctx: RenderContext): void
{
for (const cls of ALL_EDGE)
{
root.querySelectorAll("jz-char." + cls).forEach((e) => e.classList.remove(cls));
ctx.root.querySelectorAll("jz-char." + cls).forEach((e) =>
{
if (ctx.ownedElements.has(e)) { ctx.setOwnedClass(e, cls, false); }
});
}
}
@@ -184,21 +203,21 @@ interface EdgeDecision { el: Element; }
* 如此每次量測皆見無污染之自然佈局——不受自身或他者之 halt 收縮影響(避免迴圈內逐一 add
* class 致下游量測偏移,同 gapTrim 之雙向污染教訓)。ResizeObserver 僅觀 root 尺寸、不因
* 套用後內部重排再觸發,故終態穩定。 */
function relayout(root: Element, finder: Finder, options: ResolvedOptions): void
function relayout(ctx: RenderContext): void
{
clearEdge(root);
const wrapSet = buildWrapSet(options);
clearEdge(ctx);
const wrapSet = buildWrapSet(ctx.options);
const chars = Array.from(root.querySelectorAll("jz-char")).filter(
(c) => finder.inScope(c) && !finder.isAvoided(c),
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 (!finder.levelAllows(el, "paragraph")) { continue; }
if (!finder.featureEnabledFor(el, "jiya")) { continue; }
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);
@@ -219,7 +238,7 @@ function relayout(root: Element, finder: Finder, options: ResolvedOptions): void
}
}
for (const d of decisions) { d.el.classList.add(LE); }
for (const d of decisions) { ctx.setOwnedClass(d.el, LE, true); }
}
export const lineEdgePass: StandalonePass = {
@@ -231,12 +250,15 @@ export const lineEdgePass: StandalonePass = {
render(ctx: RenderContext): void
{
if (!hasLayout()) { return; } // jsdom 等無版面環境:跳過。
installLayout(ctx.root, "lineEdge", () => relayout(ctx.root, ctx.finder, ctx.options));
installLayout(ctx.root, ctx.ownerToken, "lineEdge", () =>
{
relayout(ctx);
});
},
revert(ctx: RenderContext): void
{
uninstallLayout(ctx.root, "lineEdge");
clearEdge(ctx.root);
uninstallLayout(ctx.root, ctx.ownerToken, "lineEdge");
clearEdge(ctx);
},
};
@@ -275,13 +297,16 @@ function skippableAlign(g: Element, cache: Map<Element, boolean>): boolean
return result;
}
function trimGaps(root: Element, finder: Finder, wrapSet: Set<string>): void
function trimGaps(
ctx: RenderContext,
wrapSet: Set<string>,
): void
{
const alignCache = new Map<Element, boolean>();
const gaps = Array.from(root.querySelectorAll("jz-hws")).filter(
(g) => finder.inScope(g) && !finder.isAvoided(g)
&& finder.levelAllows(g, "paragraph")
&& finder.featureEnabledFor(g, "spacing")
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; }
@@ -301,13 +326,13 @@ function trimGaps(root: Element, finder: Finder, wrapSet: Set<string>): void
// 行末 → 還原間隙。逐一(非一次量全部)必要:斷行為左→右因果,當前間隙之行起點取決
// 於其前已定案內容;讀取 getClientRects 觸發同步重排,故每次量測皆為最新佈局。以末行
// rect(lastRect,margin 實際落點)為準,兼顧跨行 pill(行尾 code 折行亦曾致隙誤消)。
gaps.forEach((g) => g.classList.add(TRIM));
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;
const atLineEnd = !r || !next || next.top > r.top + EPS;
if (!atLineEnd) { g.classList.remove(TRIM); }
if (!atLineEnd) { ctx.setOwnedClass(g, TRIM, false); }
}
}
@@ -321,11 +346,17 @@ export const gapTrimPass: StandalonePass = {
{
if (!hasLayout()) { return; }
const wrapSet = buildWrapSet(ctx.options);
installLayout(ctx.root, "gapTrim", () => trimGaps(ctx.root, ctx.finder, wrapSet));
installLayout(ctx.root, ctx.ownerToken, "gapTrim", () =>
{
trimGaps(ctx, wrapSet);
});
},
revert(ctx: RenderContext): void
{
uninstallLayout(ctx.root, "gapTrim");
ctx.root.querySelectorAll("jz-hws." + TRIM).forEach((e) => e.classList.remove(TRIM));
uninstallLayout(ctx.root, ctx.ownerToken, "gapTrim");
ctx.root.querySelectorAll("jz-hws." + TRIM).forEach((e) =>
{
if (ctx.ownedElements.has(e)) { ctx.setOwnedClass(e, TRIM, false); }
});
},
};