feat(rules): 建立共享排版契约并收束 WebKit 与 Unicode 基线
This commit is contained in:
+62
-31
@@ -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); }
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user