366 lines
16 KiB
TypeScript
366 lines
16 KiB
TypeScript
// 行端標點處理(架構 §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); }
|
||
});
|
||
},
|
||
};
|