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
+490 -74
View File
@@ -7,9 +7,12 @@
// 涵蓋 v1 Pass A(跨樣式邊界間隙)與 Pass B(pill code/kbd 邊界間隙)。
// 規則正則為間隙專用,故與本 pass 同處;共用字集自 unicode.ts。
import { ALNUM, ALPHA } from "../core/unicode.js";
import {
ALNUM, ALPHA, VARIATION_SELECTORS,
previousVariationSequenceStart, splitVariationSequences, variationSequenceAtUtf16,
} from "../core/unicode.js";
import { createJz, revertPass } from "../core/dom.js";
import type { AdjacentChar, Finder } from "../core/finder.js";
import type { AdjacentChar, CharSpan, Finder, TextSpan } from "../core/finder.js";
import type { RenderContext, Ruleset, StandalonePass } from "../types.js";
const PASS = "spacing";
@@ -64,38 +67,46 @@ function makeRules(rs: Ruleset): Rules
const CJK = rs.cjk;
const ANS_AFTER = rs.ansAfter;
const ANS_BEFORE = rs.ansBefore;
// VS 只能隨 CJK base 被吞併:正則捕獲、marker 回放與 DOM 包裝皆以此 atom 為單位。
const CJK_ATOM = "(?:[" + CJK + "](?:[" + VARIATION_SELECTORS + "])?)";
const CJK_OR_ALNUM = "(?:" + CJK_ATOM + "|[" + ALNUM + "])";
return {
anyCjk: new RegExp("[" + CJK + "]"),
pillNeighbor: new RegExp("[" + ALNUM + CJK + "]"),
STRIP_CJK_SPACE_ANS: new RegExp("([" + CJK + "]) ([" + ANS_AFTER + "])", "g"),
STRIP_ANS_SPACE_CJK: new RegExp("([" + ANS_BEFORE + "]) ([" + CJK + "])", "g"),
STRIP_CJK_SPACE_CJK: new RegExp("([" + CJK + "]) (?=[" + CJK + "])", "g"),
DOTS_CJK: new RegExp("([\\.]{2,})([" + CJK + "])", "g"),
CJK_PUNCTUATION: new RegExp("([" + CJK + "])([!;,\\?:]+)(?=[" + CJK + ALNUM + "])", "g"),
AN_PUNCTUATION_CJK: new RegExp("([" + ALNUM + "])([!;,\\?]+)([" + CJK + "])", "g"),
CJK_TILDE: new RegExp("([" + CJK + "])(~+)(?!=)(?=[" + CJK + ALNUM + "])", "g"),
CJK_TILDE_EQUALS: new RegExp("([" + CJK + "])(~=)", "g"),
CJK_PERIOD: new RegExp("([" + CJK + "])(\\.)(?![" + ALNUM + "\\./])(?=[" + CJK + ALNUM + "])", "g"),
AN_PERIOD_CJK: new RegExp("([" + ALNUM + "])(\\.)([" + CJK + "])", "g"),
AN_COLON_CJK: new RegExp("([" + ALNUM + "])(:)([" + CJK + "])", "g"),
CJK_QUOTE: new RegExp("([" + CJK + "])([" + QUOTES_CLS + "])", "g"),
QUOTE_CJK: new RegExp("([" + QUOTES_CLS + "])([" + CJK + "])", "g"),
CJK_QUOTE_AN: new RegExp("([" + CJK + "])(\")([" + ALNUM + "])", "g"),
CJK_HASH: new RegExp("([" + CJK + "])(#([^ ]))", "g"),
HASH_CJK: new RegExp("(([^ ])#)([" + CJK + "])", "g"),
SINGLE_LETTER_GRADE: new RegExp("\\b([" + ALPHA + "])([" + OPS_GRADE + "])([" + CJK + "])", "g"),
CJK_OPERATOR_ANS: new RegExp("([" + CJK + "])([" + OPS_HYPHEN + "])([" + ALNUM + "])", "g"),
ANS_OPERATOR_CJK: new RegExp("([" + ALNUM + "])([" + OPS_HYPHEN + "])([" + CJK + "])", "g"),
CJK_LESS_THAN: new RegExp("([" + CJK + "])(<)([" + ALNUM + "])", "g"),
LESS_THAN_CJK: new RegExp("([" + ALNUM + "])(<)([" + CJK + "])", "g"),
CJK_GREATER_THAN: new RegExp("([" + CJK + "])(>)([" + ALNUM + "])", "g"),
GREATER_THAN_CJK: new RegExp("([" + ALNUM + "])(>)([" + CJK + "])", "g"),
CJK_LEFT_BRACKET: new RegExp("([" + CJK + "])([" + LBR_EXT + "])", "g"),
RIGHT_BRACKET_CJK: new RegExp("([" + RBR_EXT + "])([" + CJK + "])", "g"),
AN_LEFT_BRACKET: new RegExp("([" + ALNUM + "])(?<!\\.[" + ALNUM + "]*)([" + LBR_BASIC + "])", "g"),
anyCjk: new RegExp(CJK_ATOM, "u"),
pillNeighbor: new RegExp("^(?:[" + ALNUM + "]|" + CJK_ATOM + ")$", "u"),
STRIP_CJK_SPACE_ANS: new RegExp("(" + CJK_ATOM + ") ([" + ANS_AFTER + "])", "gu"),
STRIP_ANS_SPACE_CJK: new RegExp("([" + ANS_BEFORE + "]) (" + CJK_ATOM + ")", "gu"),
STRIP_CJK_SPACE_CJK: new RegExp("(" + CJK_ATOM + ") (?=" + CJK_ATOM + ")", "gu"),
DOTS_CJK: new RegExp("([\\.]{2,})(" + CJK_ATOM + ")", "gu"),
CJK_PUNCTUATION: new RegExp("(" + CJK_ATOM + ")([!;,\\?:]+)(?=" + CJK_OR_ALNUM + ")", "gu"),
AN_PUNCTUATION_CJK: new RegExp("([" + ALNUM + "])([!;,\\?]+)(" + CJK_ATOM + ")", "gu"),
CJK_TILDE: new RegExp("(" + CJK_ATOM + ")(~+)(?!=)(?=" + CJK_OR_ALNUM + ")", "gu"),
CJK_TILDE_EQUALS: new RegExp("(" + CJK_ATOM + ")(~=)", "gu"),
CJK_PERIOD: new RegExp("(" + CJK_ATOM + ")(\\.)(?![" + ALNUM + "\\./])(?=" + CJK_OR_ALNUM + ")", "gu"),
AN_PERIOD_CJK: new RegExp("([" + ALNUM + "])(\\.)(" + CJK_ATOM + ")", "gu"),
AN_COLON_CJK: new RegExp("([" + ALNUM + "])(:)(" + CJK_ATOM + ")", "gu"),
CJK_QUOTE: new RegExp("(" + CJK_ATOM + ")([" + QUOTES_CLS + "])", "gu"),
QUOTE_CJK: new RegExp("([" + QUOTES_CLS + "])(" + CJK_ATOM + ")", "gu"),
CJK_QUOTE_AN: new RegExp("(" + CJK_ATOM + ")(\")([" + ALNUM + "])", "gu"),
CJK_HASH: new RegExp("(" + CJK_ATOM + ")(#([^ ]))", "gu"),
HASH_CJK: new RegExp("(([^ ])#)(" + CJK_ATOM + ")", "gu"),
SINGLE_LETTER_GRADE: new RegExp("\\b([" + ALPHA + "])([" + OPS_GRADE + "])(" + CJK_ATOM + ")", "gu"),
CJK_OPERATOR_ANS: new RegExp("(" + CJK_ATOM + ")([" + OPS_HYPHEN + "])([" + ALNUM + "])", "gu"),
ANS_OPERATOR_CJK: new RegExp("([" + ALNUM + "])([" + OPS_HYPHEN + "])(" + CJK_ATOM + ")", "gu"),
CJK_LESS_THAN: new RegExp("(" + CJK_ATOM + ")(<)([" + ALNUM + "])", "gu"),
LESS_THAN_CJK: new RegExp("([" + ALNUM + "])(<)(" + CJK_ATOM + ")", "gu"),
CJK_GREATER_THAN: new RegExp("(" + CJK_ATOM + ")(>)([" + ALNUM + "])", "gu"),
GREATER_THAN_CJK: new RegExp("([" + ALNUM + "])(>)(" + CJK_ATOM + ")", "gu"),
CJK_LEFT_BRACKET: new RegExp("(" + CJK_ATOM + ")([" + LBR_EXT + "])", "gu"),
RIGHT_BRACKET_CJK: new RegExp("([" + RBR_EXT + "])(" + CJK_ATOM + ")", "gu"),
// 英數字串段前之字元一併匹配,才能在不用 lookbehind 下排除 `.foo(`。
// 括號只作 lookahead,避免 `a(b(` 之前一次匹配吞掉 `(` 而漏掉第二處。
AN_LEFT_BRACKET: new RegExp(
"(^|[^." + ALNUM + "])([" + ALNUM + "]+)(?=[" + LBR_BASIC + "])",
"g",
),
RIGHT_BRACKET_AN: new RegExp("([" + RBR_BASIC + "])([" + ALNUM + "])", "g"),
CJK_ANS: new RegExp("([" + CJK + "])([" + ANS_AFTER + "])", "g"),
ANS_CJK: new RegExp("([" + ANS_BEFORE + "])([" + CJK + "])", "g"),
CJK_ANS: new RegExp("(" + CJK_ATOM + ")([" + ANS_AFTER + "])", "gu"),
ANS_CJK: new RegExp("([" + ANS_BEFORE + "])(" + CJK_ATOM + ")", "gu"),
S_A: new RegExp("(%)([" + ALPHA + "])", "g"),
};
}
@@ -104,10 +115,30 @@ function makeRules(rs: Ruleset): Rules
// 過後、括號 / CJK_ANS / ANS_CJK 過前還原。
const COMPOUND_WORD = /\b(?:[A-Za-z0-9]*[a-z][A-Za-z0-9]*-[A-Za-z0-9]+|[A-Za-z0-9]+-[A-Za-z0-9]*[a-z][A-Za-z0-9]*|[A-Za-z]+-[0-9]+|[A-Za-z]+[0-9]+-[A-Za-z0-9]+)(?:-[A-Za-z0-9]+)*\b/g;
const PH_PATTERN = new RegExp(PH_OPEN + "(\\d+)" + PH_CLOSE, "g");
const ASTRAL_CJK_SHADOW = "漢";
/**
* Safari 26.5.2 於實際 CJK 大字元類中,會漏掉非零 UTF-16 起點之補充平面
* CJK(global 與 sticky 皆同)。規則層不需原字,只需與 DOM map 同構之邏輯 atom
* 序列,故線性將「base > U+FFFF 且自身 index 0 命中 CJK」之整個 base+VS atom
* 替為固定 BMP CJK shadow。Pangu 輸出僅供 MARK/DEL 動作回放,不會寫回 shadow。
*/
function shadowAstralCjkAtoms(atoms: readonly string[], R: Rules): string
{
const shadowed = new Array<string>(atoms.length);
for (let i = 0; i < atoms.length; i += 1)
{
const atom = atoms[i]!;
const base = atom.codePointAt(0);
const match = base !== undefined && base > 0xFFFF ? R.anyCjk.exec(atom) : null;
shadowed[i] = match?.index === 0 ? ASTRAL_CJK_SHADOW : atom;
}
return shadowed.join("");
}
function panguSpace(text: string, R: Rules): string
{
if (text.length <= 1 || !R.anyCjk.test(text)) { return text; }
if (splitVariationSequences(text).length <= 1 || !R.anyCjk.test(text)) { return text; }
let s = text;
s = s.replace(R.STRIP_CJK_SPACE_ANS, "$1$2");
@@ -150,7 +181,7 @@ function panguSpace(text: string, R: Rules): string
s = s.replace(R.CJK_LEFT_BRACKET, "$1" + MARK + "$2");
s = s.replace(R.RIGHT_BRACKET_CJK, "$1" + MARK + "$2");
s = s.replace(R.AN_LEFT_BRACKET, "$1" + MARK + "$2");
s = s.replace(R.AN_LEFT_BRACKET, "$1$2" + MARK);
s = s.replace(R.RIGHT_BRACKET_AN, "$1" + MARK + "$2");
s = s.replace(R.CJK_ANS, "$1" + MARK + "$2");
@@ -162,59 +193,403 @@ function panguSpace(text: string, R: Rules): string
interface SpaceAction
{
before: { textNode: Text; offset: number } | undefined;
space: { textNode: Text; offset: number } | null;
before: CharSpan | undefined;
space: CharSpan | null;
}
interface MovedPart
{
anchor: Comment;
gapAnchor: Comment;
gap: HTMLElement;
textNode: Text;
originalParent: Node;
originalPreviousSibling: Node | null;
originalNextSibling: Node | null;
gapOriginalParent: Node;
gapOriginalPreviousSibling: Node | null;
gapOriginalNextSibling: Node | null;
block: Element;
scopeRoot: Element;
scopeAncestors: Element[];
}
// 跨節點 variation sequence 需在排版期間聚合成單一行內載體;以 ownerToken
// 隔離各實例的原位錨點,revert 時恢復每個原始 TextSpan 的父子分佈。
const MOVED_PARTS = new WeakMap<object, Set<MovedPart>>();
const MOVED_ANCHOR = "jz-spacing-anchor";
const MOVED_GAP_ANCHOR = "jz-spacing-gap-anchor";
const CROSS_NODE_ATTR = "data-jz-cross-node-atom";
// 把一個節點(已隔離之單字文本,或元素如 pill)包進 jz-hws——間隙由 CSS 之
// margin-right 提供。不注入空白字元:故 justify 不把間隙當詞間空白放大(邊界
// 只吃均勻字間量),margin 尾隨左側內容(換行時落行尾、永不落行首),且
// textContent 完全不變(內容仍在)。冪等:已在 jz-hws 內則跳過。
function wrapNodeInGap(node: Node): void
function wrapNodeInGap(node: Node, ownedElements: WeakSet<Element>): void
{
const parent = node.parentNode;
if (!parent || parent.nodeName === "JZ-HWS") { return; }
const gap = createJz("jz-hws", PASS, "wrap");
const gap = createJz(ownedElements, "jz-hws", PASS, "wrap");
parent.insertBefore(gap, node);
gap.appendChild(node);
}
// 隔離 pos 處之單一字元並包入 jz-hws(左側字 + margin-right 留隙)。
function wrapGapLeft(pos: { textNode: Text; offset: number }): void
// 隔離 pos 處之 base+optional-VS 並包入 jz-hws(左側字 + margin-right 留隙)。
function isolateTextSpan(part: TextSpan): Text | null
{
let tn = pos.textNode;
if (!tn.parentNode || tn.parentNode.nodeName === "JZ-HWS") { return; }
if (pos.offset > 0) { tn = tn.splitText(pos.offset); }
if (tn.data.length > 1) { tn.splitText(1); }
wrapNodeInGap(tn);
let tn = part.textNode;
if (!tn.parentNode) { return null; }
if (part.start > 0) { tn = tn.splitText(part.start); }
const length = part.end - part.start;
if (tn.data.length > length) { tn.splitText(length); }
return tn;
}
function processBlock(block: Element, finder: Finder, R: Rules): void
function wrapSingleSpanInGap(
part: TextSpan,
ownedElements: WeakSet<Element>,
): HTMLElement | null
{
const containingGap = part.textNode.parentElement?.closest("jz-hws") as HTMLElement | null;
if (containingGap)
{
return ownedElements.has(containingGap)
&& containingGap.getAttribute("data-jz") === PASS
&& containingGap.getAttribute("data-jz-kind") === "wrap"
? containingGap
: null;
}
const tn = isolateTextSpan(part);
if (!tn) { return null; }
const parent = tn.parentNode;
if (!parent) { return null; }
const gap = createJz(ownedElements, "jz-hws", PASS, "wrap");
try
{
parent.insertBefore(gap, tn);
gap.appendChild(tn);
return gap;
}
catch (error)
{
if (tn.parentNode === gap) { parent.insertBefore(tn, gap); }
if (gap.parentNode) { gap.parentNode.removeChild(gap); }
ownedElements.delete(gap);
(parent as Element).normalize?.();
throw error;
}
}
function isOwnedCrossGap(gap: HTMLElement, ownedElements: WeakSet<Element>): boolean
{
return ownedElements.has(gap)
&& gap.getAttribute("data-jz") === PASS
&& gap.getAttribute("data-jz-kind") === "wrap"
&& gap.hasAttribute(CROSS_NODE_ATTR);
}
function trustedInsertionPoint(record: MovedPart): Node | null | undefined
{
if (record.anchor.parentNode === record.originalParent) { return record.anchor; }
if (record.originalNextSibling?.parentNode === record.originalParent)
{
return record.originalNextSibling;
}
if (record.originalNextSibling !== null) { return undefined; }
if (record.originalPreviousSibling?.parentNode === record.originalParent)
{
return record.originalPreviousSibling === record.originalParent.lastChild ? null : undefined;
}
if (record.originalPreviousSibling === null && record.originalNextSibling === null
&& record.anchor.parentNode !== null && record.anchor.parentNode !== record.originalParent
&& !record.originalParent.hasChildNodes())
{
// selector 原本是原父唯一子節點:anchor 仍存在但被外部搬走,與
// anchor 已被作者刪除(parentNode === null)可明確區分。不跟隨外部父,
// 只回復到仍為空的 originalParent 末尾。
return null;
}
// anchor 與原前/後兄弟皆不存在時,空原父可能是作者明確刪除內容之結果;
// 不得以「目前為空」當作恢復授權,否則會復活舊 selector。
return undefined;
}
function restoreMovedText(record: MovedPart): boolean
{
if (record.textNode.parentNode === record.originalParent) { return true; }
if (record.textNode.parentNode !== record.gap) { return false; }
const before = trustedInsertionPoint(record);
if (before === undefined) { return false; }
record.originalParent.insertBefore(record.textNode, before);
return true;
}
function movePartIntoGap(
part: TextSpan,
gap: HTMLElement,
block: Element,
scopeRoot: Element,
): MovedPart | null
{
const textNode = isolateTextSpan(part);
const parent = textNode?.parentNode;
const gapParent = gap.parentNode;
if (!textNode || !parent || !gapParent) { return null; }
const anchor = textNode.ownerDocument.createComment(MOVED_ANCHOR);
const gapAnchor = textNode.ownerDocument.createComment(MOVED_GAP_ANCHOR);
const record: MovedPart = {
anchor,
gapAnchor,
gap,
textNode,
originalParent: parent,
originalPreviousSibling: textNode.previousSibling,
originalNextSibling: textNode.nextSibling,
gapOriginalParent: gapParent,
gapOriginalPreviousSibling: gap.previousSibling,
gapOriginalNextSibling: gap.nextSibling,
block,
scopeRoot,
scopeAncestors: (() =>
{
const ancestors: Element[] = [];
for (let parent = scopeRoot.parentElement; parent; parent = parent.parentElement)
{
ancestors.push(parent);
}
return ancestors;
})(),
};
try
{
parent.insertBefore(anchor, textNode);
gap.appendChild(gapAnchor);
gap.appendChild(textNode);
return record;
}
catch (error)
{
restoreMovedText(record);
if (anchor.parentNode) { anchor.parentNode.removeChild(anchor); }
if (gapAnchor.parentNode) { gapAnchor.parentNode.removeChild(gapAnchor); }
(parent as Element).normalize?.();
throw error;
}
}
// 單 Text 節點直接包裝完整 base+optional-VS。跨節點 atom 先包裝 base,
// 再只搬入後續 selector TextSpan(不搬動或複製作者 Element),使 WebKit 斷行器
// 將完整序列視為單一左側原子。每個搬移處的 Comment 錨點僅供精確還原。
function wrapGapLeft(
span: CharSpan,
ownedElements: WeakSet<Element>,
ownerToken: object,
block: Element,
scopeRoot: Element,
): void
{
const containingGaps = new Set<HTMLElement>();
for (const part of span.parts)
{
const containing = part.textNode.parentElement?.closest("jz-hws") as HTMLElement | null;
if (containing) { containingGaps.add(containing); }
}
if (containingGaps.size > 0)
{
if (containingGaps.size === 1)
{
const existing = Array.from(containingGaps)[0]!;
if (isOwnedCrossGap(existing, ownedElements)
&& span.parts.every((part) => existing.contains(part.textNode)))
{
return;
}
}
// 外來/偽造 gap 或不完整之已包裝狀態一律 fail closed,不搬文本、不登記。
return;
}
const gap = wrapSingleSpanInGap(span.parts[0]!, ownedElements);
if (!gap) { return; }
if (span.parts.length === 1) { return; }
const moved: MovedPart[] = [];
try
{
for (let i = 1; i < span.parts.length; i += 1)
{
const record = movePartIntoGap(span.parts[i]!, gap, block, scopeRoot);
if (!record) { throw new Error("juzhen: 跨節點字元搬移失敗。"); }
moved.push(record);
}
gap.setAttribute(CROSS_NODE_ATTR, "");
let records = MOVED_PARTS.get(ownerToken);
if (!records)
{
records = new Set();
MOVED_PARTS.set(ownerToken, records);
}
for (const record of moved) { records.add(record); }
}
catch (error)
{
for (let i = moved.length - 1; i >= 0; i -= 1)
{
const record = moved[i]!;
restoreMovedText(record);
if (record.anchor.parentNode) { record.anchor.parentNode.removeChild(record.anchor); }
if (record.gapAnchor.parentNode)
{
record.gapAnchor.parentNode.removeChild(record.gapAnchor);
}
(record.originalParent as Element).normalize?.();
}
gap.removeAttribute(CROSS_NODE_ATTR);
const gapParent = gap.parentNode;
if (gapParent)
{
while (gap.firstChild) { gapParent.insertBefore(gap.firstChild, gap); }
gapParent.removeChild(gap);
(gapParent as Element).normalize?.();
}
ownedElements.delete(gap);
throw error;
}
}
function recordWithinRoot(record: MovedPart, root: Element): boolean
{
const within = (node: Node): boolean => node === root || root.contains(node);
const detached = !record.anchor.isConnected && !record.gap.isConnected
&& !record.gapAnchor.isConnected && !record.originalParent.isConnected;
return root === record.scopeRoot || root.contains(record.scopeRoot)
|| record.scopeAncestors.some((ancestor) => root === ancestor || root.contains(ancestor))
|| within(record.gap) || within(record.anchor) || within(record.originalParent)
|| within(record.block)
|| (detached && root === root.ownerDocument.body);
}
function placeGapAtOriginalParent(record: MovedPart): boolean
{
if (record.gap.parentNode === record.gapOriginalParent) { return true; }
let before: Node | null = null;
if (record.gapOriginalNextSibling?.parentNode === record.gapOriginalParent)
{
before = record.gapOriginalNextSibling;
}
else if (record.gapOriginalNextSibling !== null)
{
return false;
}
else if (record.gapOriginalPreviousSibling?.parentNode === record.gapOriginalParent)
{
if (record.gapOriginalPreviousSibling !== record.gapOriginalParent.lastChild) { return false; }
before = null;
}
else if (record.gapOriginalParent.lastChild !== record.gapOriginalPreviousSibling
|| (record.gapOriginalPreviousSibling === null
&& record.gapOriginalNextSibling === null
&& record.gapOriginalParent.hasChildNodes()))
{
return false;
}
record.gapOriginalParent.insertBefore(record.gap, before);
return true;
}
function unwrapCrossGap(
record: MovedPart,
ownedElements: WeakSet<Element>,
touched: Set<Node>,
): void
{
const gap = record.gap;
if (!isOwnedCrossGap(gap, ownedElements)) { return; }
if (!placeGapAtOriginalParent(record))
{
if (!gap.isConnected) { ownedElements.delete(gap); }
return;
}
const parent = gap.parentNode;
if (!parent) { ownedElements.delete(gap); return; }
while (gap.firstChild) { parent.insertBefore(gap.firstChild, gap); }
parent.removeChild(gap);
ownedElements.delete(gap);
touched.add(parent);
}
function restoreMovedParts(ctx: RenderContext): Set<Node>
{
const touched = new Set<Node>();
const records = MOVED_PARTS.get(ctx.ownerToken);
if (!records) { return touched; }
const gaps = new Map<HTMLElement, MovedPart>();
for (const record of Array.from(records))
{
if (!recordWithinRoot(record, ctx.root)) { continue; }
const restored = restoreMovedText(record);
if (!restored && record.textNode.parentNode === record.gap)
{
// 原父已以 textContent/replaceChildren/innerHTML 取代:舊 selector
// 屬過時的本實例搬移產物,不得因 anchor 被搬往任意父節點而復活。
record.gap.removeChild(record.textNode);
}
if (restored) { touched.add(record.originalParent); }
if (record.anchor.parentNode) { record.anchor.parentNode.removeChild(record.anchor); }
if (record.gapAnchor.parentNode)
{
record.gapAnchor.parentNode.removeChild(record.gapAnchor);
}
gaps.set(record.gap, record);
records.delete(record);
}
for (const record of gaps.values()) { unwrapCrossGap(record, ctx.ownedElements, touched); }
if (records.size === 0) { MOVED_PARTS.delete(ctx.ownerToken); }
return touched;
}
function deleteSpan(span: CharSpan): void
{
for (let i = span.parts.length - 1; i >= 0; i -= 1)
{
const part = span.parts[i]!;
part.textNode.data = part.textNode.data.slice(0, part.start)
+ part.textNode.data.slice(part.end);
}
}
function processBlock(
block: Element,
scopeRoot: Element,
finder: Finder,
R: Rules,
ownedElements: WeakSet<Element>,
ownerToken: object,
): void
{
const runs = finder.collectRuns(block);
for (const run of runs)
{
const { text, map } = run;
const spaced = panguSpace(text, R);
if (spaced === text) { continue; }
const { map } = run;
const originalChars = map.map((span) => span.text);
const ruleText = shadowAstralCjkAtoms(originalChars, R);
const spaced = panguSpace(ruleText, R);
if (spaced === ruleText) { continue; }
const actions: SpaceAction[] = [];
let origIdx = 0;
let spacedIdx = 0;
while (spacedIdx < spaced.length)
for (const sc of splitVariationSequences(spaced))
{
const sc = spaced.charAt(spacedIdx);
if (sc === DEL)
{
// CJK↔CJK 贅餘空格:純刪除、不留隙(before 留空)。
actions.push({ before: undefined, space: map[origIdx]! });
origIdx += 1;
spacedIdx += 1;
continue;
}
if (sc === MARK)
{
const oc = origIdx < text.length ? text.charAt(origIdx) : null;
const oc = origIdx < originalChars.length ? originalChars[origIdx]! : null;
if (oc === " ")
{
actions.push({ before: map[origIdx - 1], space: map[origIdx]! });
@@ -224,11 +599,9 @@ function processBlock(block: Element, finder: Finder, R: Rules): void
{
actions.push({ before: map[origIdx - 1], space: null });
}
spacedIdx += 1;
continue;
}
origIdx += 1;
spacedIdx += 1;
}
// 倒序執行,避免前面動作之 offset 被後面變更影響。先刪空格(若有)、再包左字
@@ -238,17 +611,26 @@ function processBlock(block: Element, finder: Finder, R: Rules): void
const act = actions[i]!;
if (act.space)
{
const tn = act.space.textNode;
tn.data = tn.data.slice(0, act.space.offset) + tn.data.slice(act.space.offset + 1);
deleteSpan(act.space);
}
if (act.before)
{
wrapGapLeft(act.before, ownedElements, ownerToken, block, scopeRoot);
}
if (act.before) { wrapGapLeft(act.before); }
}
}
}
// boundarySelector = pill(整體)∪ isolate(隔離):兩端補隙之口徑一致(§4.1
// 三分類);差異僅在內部(pill 不可見、isolate 照常處理),不在此 pass。
function processPills(root: Element, finder: Finder, boundarySelector: string, pillNeighbor: RegExp): void
function processPills(
root: Element,
finder: Finder,
boundarySelector: string,
pillNeighbor: RegExp,
ownedElements: WeakSet<Element>,
ownerToken: object,
): void
{
root.querySelectorAll(boundarySelector).forEach((pill) =>
{
@@ -264,32 +646,57 @@ function processPills(root: Element, finder: Finder, boundarySelector: string, p
if (!pill.parentNode) { return; }
const prev = finder.adjacentLogicalChar(pill, -1);
if (prev) { handlePillSide(prev, pill, -1, pillNeighbor); }
if (prev)
{
handlePillSide(prev, pill, -1, pillNeighbor, ownedElements, ownerToken, root);
}
const next = finder.adjacentLogicalChar(pill, 1);
if (next) { handlePillSide(next, pill, 1, pillNeighbor); }
if (next)
{
handlePillSide(next, pill, 1, pillNeighbor, ownedElements, ownerToken, root);
}
});
}
// dir -1:間隙在 adj 字(左)與 pill(右)之間 → margin 掛 adj 字(包裝該字)。
// dir +1:間隙在 pill(左)與 adj 字(右)之間 → margin 掛 pill(包裝 pill)。
function handlePillSide(adj: AdjacentChar, pill: Element, dir: -1 | 1, pillNeighbor: RegExp): void
function handlePillSide(
adj: AdjacentChar,
pill: Element,
dir: -1 | 1,
pillNeighbor: RegExp,
ownedElements: WeakSet<Element>,
ownerToken: object,
scopeRoot: Element,
): void
{
if (dir === -1)
{
if (pillNeighbor.test(adj.ch))
{
wrapGapLeft({ textNode: adj.textNode, offset: adj.offset });
wrapGapLeft({
text: adj.ch,
parts: [ {
textNode: adj.textNode,
start: adj.offset,
end: adj.offset + adj.ch.length,
} ],
}, ownedElements, ownerToken, pill.parentElement ?? scopeRoot, scopeRoot);
return;
}
if (adj.ch === " " && adj.offset >= 1)
{
const tn = adj.textNode;
const bc = tn.data.charAt(adj.offset - 1);
const beforeOffset = previousVariationSequenceStart(tn.data, adj.offset);
const bc = variationSequenceAtUtf16(tn.data, beforeOffset);
if (bc !== " " && pillNeighbor.test(bc))
{
tn.data = tn.data.slice(0, adj.offset) + tn.data.slice(adj.offset + 1);
wrapGapLeft({ textNode: tn, offset: adj.offset - 1 });
wrapGapLeft({
text: bc,
parts: [ { textNode: tn, start: beforeOffset, end: beforeOffset + bc.length } ],
}, ownedElements, ownerToken, pill.parentElement ?? scopeRoot, scopeRoot);
}
}
}
@@ -297,16 +704,16 @@ function handlePillSide(adj: AdjacentChar, pill: Element, dir: -1 | 1, pillNeigh
{
if (pillNeighbor.test(adj.ch))
{
wrapNodeInGap(pill);
wrapNodeInGap(pill, ownedElements);
return;
}
if (adj.ch === " " && adj.textNode.data.length >= 2)
if (adj.ch === " " && adj.textNode.data.length > 1)
{
const ac = adj.textNode.data.charAt(1);
const ac = variationSequenceAtUtf16(adj.textNode.data, 1);
if (ac !== " " && pillNeighbor.test(ac))
{
adj.textNode.data = adj.textNode.data.slice(1);
wrapNodeInGap(pill);
wrapNodeInGap(pill, ownedElements);
}
}
}
@@ -327,7 +734,7 @@ export const spacingPass: StandalonePass = {
ctx.finder.eachBlock(ctx.root, (block) =>
{
if (!finder.featureEnabledFor(block, PASS)) { return; }
processBlock(block, finder, R);
processBlock(block, ctx.root, finder, R, ctx.ownedElements, ctx.ownerToken);
});
// 文本級功能須支持單元素(§6.5.0a):root 為行內/單一非塊元素(如消費端對
// <span> 標題呼叫 render)時 eachBlock 不含之,補處理 root 自身(collectRuns
@@ -336,7 +743,7 @@ export const spacingPass: StandalonePass = {
&& finder.featureEnabledFor(ctx.root, PASS)
&& finder.inScope(ctx.root) && !finder.isAvoided(ctx.root))
{
processBlock(ctx.root, finder, R);
processBlock(ctx.root, ctx.root, finder, R, ctx.ownedElements, ctx.ownerToken);
}
// Pass B:pill/isolate 邊界。
const boundarySelector = [ options.finder.pillSelector, options.finder.isolateSelector ]
@@ -344,11 +751,20 @@ export const spacingPass: StandalonePass = {
.join(", ");
if (boundarySelector)
{
processPills(ctx.root, finder, boundarySelector, R.pillNeighbor);
processPills(
ctx.root,
finder,
boundarySelector,
R.pillNeighbor,
ctx.ownedElements,
ctx.ownerToken,
);
}
},
revert(ctx: RenderContext): void
{
revertPass(PASS, ctx.root);
const touched = restoreMovedParts(ctx);
revertPass(PASS, ctx.root, ctx.ownedElements);
for (const parent of touched) { (parent as Element).normalize?.(); }
},
};