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