Files
cjk-autospace/src/typeset/spacing.ts
T

829 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 中西間隙(架構 §6.1,切片 1)。standalone pass。
//
// 沿用 v1(cjk-autospace)已驗證之 Pangu 規則管線與 per-block 邏輯文本處理,
// 改用 finder 提供之 collectRuns/adjacentLogicalChar,並把間隙 marker 換為
// copy-clean 之 jz-hws(user-select 由 CSS 關閉,修正 Han.css 之選中缺陷,I1)。
//
// 涵蓋 v1 Pass A(跨樣式邊界間隙)與 Pass B(pill code/kbd 邊界間隙)。
// 規則正則為間隙專用,故與本 pass 同處;共用字集自 unicode.ts。
import {
ALNUM, ALPHA, VARIATION_SELECTORS,
splitVariationSequences,
} from "../core/unicode.js";
import { createJz, recordUnwrappedParent, resolveUnwrappedParent, revertPass } from "../core/dom.js";
import type { AdjacentChar, CharSpan, Finder, TextSpan } from "../core/finder.js";
import type { RenderContext, Ruleset, StandalonePass } from "../types.js";
const PASS = "spacing";
// ----- Pangu 規則正則(沿用 v1)-----
const OPS_HYPHEN = "\\+\\*=&\\-";
const OPS_GRADE = "\\+\\-\\*";
const QUOTES_CLS = "`\"״";
const LBR_BASIC = "\\(\\[\\{";
const RBR_BASIC = "\\)\\]\\}";
const LBR_EXT = "\\(\\[\\{<>";
const RBR_EXT = "\\)\\]\\}<>";
interface SpacingMarkers
{
gap: string;
compoundOpen: string;
compoundClose: string;
deletion: string;
}
/** 由規則集編譯之 per-instance 間隙正則集。 */
interface Rules
{
anyCjk: RegExp; pillNeighbor: RegExp; markerForbidden: RegExp;
STRIP_CJK_SPACE_ANS: RegExp; STRIP_ANS_SPACE_CJK: RegExp; STRIP_CJK_SPACE_CJK: RegExp;
DOTS_CJK: RegExp; CJK_PUNCTUATION: RegExp; AN_PUNCTUATION_CJK: RegExp;
CJK_TILDE: RegExp; CJK_TILDE_EQUALS: RegExp; CJK_PERIOD: RegExp;
AN_PERIOD_CJK: RegExp; AN_COLON_CJK: RegExp; CJK_QUOTE: RegExp; QUOTE_CJK: RegExp;
CJK_QUOTE_AN: RegExp; CJK_HASH: RegExp; HASH_CJK: RegExp; SINGLE_LETTER_GRADE: RegExp;
CJK_OPERATOR_ANS: RegExp; ANS_OPERATOR_CJK: RegExp;
CJK_LESS_THAN: RegExp; LESS_THAN_CJK: RegExp; CJK_GREATER_THAN: RegExp; GREATER_THAN_CJK: RegExp;
CJK_LEFT_BRACKET: RegExp; RIGHT_BRACKET_CJK: RegExp; AN_LEFT_BRACKET: RegExp; RIGHT_BRACKET_AN: RegExp;
CJK_ANS: RegExp; ANS_CJK: RegExp; S_A: RegExp;
}
/**
* 由規則集編譯間隙正則。CJK/ANS 字集隨 options.charClass 而變,故 per-instance 編譯
* (createJuzhen 各一次,非每 block);ALNUM/ALPHA/運算符/括號等結構性 ASCII 集為
* 固定模組常數,不隨規則集變。
* · STRIP_CJK_SPACE_CJK:CJK↔CJK 之單一作者空格(含跨 inline 標籤,collectRuns 已透明
* 合併)→ 以 DEL 標記刪除、不留隙(1b;只匹配 ASCII 空格,全形 U+3000 不動)。
* · DOTS_CJK:僅 ASCII 省略號 `...中文` 補隙,**不含**全角 U+2026(全角 …/…… 屬 CJK
* 標點、後接 CJK 不應補隙;問題 B)。
* · 原 pangu QUOTE_AN(收尾彎引號後接字母數字補隙)已移除——標點稽核發現它令 ” 成為唯一
* 「後接拉丁/數字會補隙」之收尾標點,與 」』)》】〕 不一致(clreq 下全角收尾標點字身
* 已含右側留白,不需額外隙)。
*/
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_ATOM, "u"),
pillNeighbor: new RegExp("^(?:[" + ALNUM + "]|" + CJK_ATOM + ")$", "u"),
markerForbidden: new RegExp("[" + CJK + ANS_AFTER + ANS_BEFORE + "]", "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_ATOM + ")([" + ANS_AFTER + "])", "gu"),
ANS_CJK: new RegExp("([" + ANS_BEFORE + "])(" + CJK_ATOM + ")", "gu"),
S_A: new RegExp("(%)([" + ALPHA + "])", "g"),
};
}
// 複合詞(GPT-4 / state-of-the-art)不可被運算符間隙楔入:先遮罩,運算符
// 過後、括號 / 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 ASTRAL_CJK_SHADOW = "漢";
/** 每個 run 使用源文本未佔用、且規則不識別為 CJK/西文的標記,絕不寫回 DOM。 */
function markersFor(text: string, R: Rules): SpacingMarkers
{
const occupied = new Set(text);
const markers: string[] = [];
const tryRange = (start: number, end: number): boolean =>
{
for (let cp = start; cp <= end; cp += 1)
{
const ch = String.fromCodePoint(cp);
if (occupied.has(ch) || R.markerForbidden.test(ch)) { continue; }
markers.push(ch);
if (markers.length === 4) { return true; }
}
return false;
};
// 私用區及非字符區皆可能出現在作者源中,須逐一檢查;搜索有固定上界。
let found = tryRange(0xE000, 0xF8FF)
|| tryRange(0xF0000, 0xFFFFD)
|| tryRange(0x100000, 0x10FFFD)
|| tryRange(0xFDD0, 0xFDEF);
for (let plane = 0; !found && plane <= 16; plane += 1)
{
found = tryRange(plane * 0x10000 + 0xFFFE, plane * 0x10000 + 0xFFFF);
}
if (!found)
{
// 無無歧義標記時停在回放之前;不得把作者字符誤當刪除指令。
throw new Error("juzhen: 源文本與字符規則已佔用全部間隙標記候選。");
}
return {
gap: markers[0]!, compoundOpen: markers[1]!,
compoundClose: markers[2]!, deletion: markers[3]!,
};
}
/**
* 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, markers: SpacingMarkers): string
{
const MARK = markers.gap;
const PH_OPEN = markers.compoundOpen;
const PH_CLOSE = markers.compoundClose;
// 刪除標記與原作者空格 1:1 替換,保持 DOM map 對齊。
const DEL = markers.deletion;
const PH_PATTERN = new RegExp(PH_OPEN + "(\\d+)" + PH_CLOSE, "g");
let s = text;
s = s.replace(R.STRIP_CJK_SPACE_ANS, "$1$2");
s = s.replace(R.STRIP_ANS_SPACE_CJK, "$1$2");
s = s.replace(R.STRIP_CJK_SPACE_CJK, "$1" + DEL);
s = s.replace(R.DOTS_CJK, "$1" + MARK + "$2");
s = s.replace(R.CJK_PUNCTUATION, "$1$2" + MARK);
s = s.replace(R.AN_PUNCTUATION_CJK, "$1$2" + MARK + "$3");
s = s.replace(R.CJK_TILDE, "$1$2" + MARK);
s = s.replace(R.CJK_TILDE_EQUALS, "$1" + MARK + "$2" + MARK);
s = s.replace(R.CJK_PERIOD, "$1$2" + MARK);
s = s.replace(R.AN_PERIOD_CJK, "$1$2" + MARK + "$3");
s = s.replace(R.AN_COLON_CJK, "$1$2" + MARK + "$3");
s = s.replace(R.CJK_QUOTE, "$1" + MARK + "$2");
s = s.replace(R.QUOTE_CJK, "$1" + MARK + "$2");
s = s.replace(R.CJK_QUOTE_AN, "$1$2" + MARK + "$3");
s = s.replace(R.CJK_HASH, "$1" + MARK + "$2");
s = s.replace(R.HASH_CJK, "$1" + MARK + "$3");
const compounds: string[] = [];
s = s.replace(COMPOUND_WORD, (m) =>
{
compounds.push(m);
return PH_OPEN + (compounds.length - 1) + PH_CLOSE;
});
s = s.replace(R.SINGLE_LETTER_GRADE, "$1$2" + MARK + "$3");
s = s.replace(R.CJK_OPERATOR_ANS, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(R.ANS_OPERATOR_CJK, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(R.CJK_LESS_THAN, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(R.LESS_THAN_CJK, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(R.CJK_GREATER_THAN, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(R.GREATER_THAN_CJK, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(PH_PATTERN, (_m, idx: string) => compounds[Number(idx)] || "");
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$2" + MARK);
s = s.replace(R.RIGHT_BRACKET_AN, "$1" + MARK + "$2");
s = s.replace(R.CJK_ANS, "$1" + MARK + "$2");
s = s.replace(R.ANS_CJK, "$1" + MARK + "$2");
s = s.replace(R.S_A, "$1" + MARK + "$2");
return s;
}
interface SpaceAction
{
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, ownedElements: WeakSet<Element>): void
{
const parent = node.parentNode;
if (!parent || parent.nodeName === "JZ-HWS") { return; }
const gap = createJz(ownedElements, "jz-hws", PASS, "wrap");
parent.insertBefore(gap, node);
gap.appendChild(node);
}
// 隔離 pos 處之 base+optional-VS 並包入 jz-hws(左側字 + margin-right 留隙)。
function isolateTextSpan(part: TextSpan): Text | null
{
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 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);
recordUnwrappedParent(gap, parent, ownedElements);
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 sourceParent = resolveUnwrappedParent(record.originalParent);
const gapParent = resolveUnwrappedParent(record.gapOriginalParent);
if (!sourceParent || !gapParent)
{
// 循環/失效来源不能指引文本搬移;保留當前文字,由正常還原清理可驗證載體。
record.anchor.remove();
record.gapAnchor.remove();
records.delete(record);
continue;
}
record.originalParent = sourceParent;
record.gapOriginalParent = gapParent;
const live = (node: Node): boolean => record.scopeRoot.contains(node) || node.isConnected;
if (!isOwnedCrossGap(record.gap, ctx.ownedElements) || !live(record.gap))
{
// 刪除/克隆載體後不復活原文字;真正移動與整根脫離 document 仍保留。
record.anchor.remove();
record.gapAnchor.remove();
if (!live(record.gap)) { ctx.ownedElements.delete(record.gap); }
records.delete(record);
continue;
}
const restored = live(record.originalParent) && 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, false, PASS);
for (const run of runs)
{
const { map } = run;
const originalChars = map.map((span) => span.text);
const ruleText = shadowAstralCjkAtoms(originalChars, R);
if (originalChars.length < 2 || !R.anyCjk.test(ruleText)) { continue; }
const markers = markersFor(ruleText, R);
const spaced = panguSpace(ruleText, R, markers);
if (spaced === ruleText) { continue; }
const actions: SpaceAction[] = [];
let origIdx = 0;
for (const sc of splitVariationSequences(spaced))
{
if (sc === markers.deletion)
{
// CJK↔CJK 贅餘空格:純刪除、不留隙(before 留空)。
actions.push({ before: undefined, space: map[origIdx]! });
origIdx += 1;
continue;
}
if (sc === markers.gap)
{
const oc = origIdx < originalChars.length ? originalChars[origIdx]! : null;
if (oc === " ")
{
actions.push({ before: map[origIdx - 1], space: map[origIdx]! });
origIdx += 1;
}
else
{
actions.push({ before: map[origIdx - 1], space: null });
}
continue;
}
origIdx += 1;
}
// 倒序執行,避免前面動作之 offset 被後面變更影響。先刪空格(若有)、再包左字
// 留隙(若有 before)。刪空格在較高 offset、不擾動更前之 before。
for (let i = actions.length - 1; i >= 0; i -= 1)
{
const act = actions[i]!;
if (act.space)
{
deleteSpan(act.space);
}
if (act.before)
{
wrapGapLeft(act.before, ownedElements, ownerToken, block, scopeRoot);
}
}
}
}
// boundarySelector = pill(整體)∪ isolate(隔離):兩端補隙之口徑一致(§4.1
// 三分類);差異僅在內部(pill 不可見、isolate 照常處理),不在此 pass。
function processPills(
root: Element,
finder: Finder,
boundarySelector: string,
pillNeighbor: RegExp,
ownedElements: WeakSet<Element>,
ownerToken: object,
): void
{
root.querySelectorAll(boundarySelector).forEach((pill) =>
{
// isAvoided 為組合判定(自身或祖先),涵蓋 pill 自身命中 avoid(如 data-jz-skip)之
// 情形——下游問題六同型:僅查祖先則 avoid 對自身形同虛設。
if (finder.isAvoided(pill) || finder.insidePill(pill) || !finder.inScope(pill))
{
return;
}
// per-node 功能閘(T6):data-juzhen-off="spacing" 等區域內之 pill 不補間隙
// (與 Pass A 逐 block 之 featureEnabledFor 一致;舊版漏判)。
if (!finder.featureEnabledFor(pill, PASS)) { return; }
if (!pill.parentNode) { return; }
const prev = finder.adjacentLogicalChar(pill, -1, root, undefined, PASS);
if (prev)
{
handlePillSide(prev, pill, -1, finder, pillNeighbor, ownedElements, ownerToken, root);
}
const next = finder.adjacentLogicalChar(pill, 1, root, undefined, PASS);
if (next)
{
handlePillSide(next, pill, 1, finder, 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,
finder: Finder,
pillNeighbor: RegExp,
ownedElements: WeakSet<Element>,
ownerToken: object,
scopeRoot: Element,
): void
{
if (!adj.parts.every((part) => finder.featureEnabledFor(part.textNode, PASS))) { return; }
let neighbor = adj;
if (adj.ch === " ")
{
// 只剝一個 ASCII 作者空格;再次查詢仍受透明/作用域/排除邊界限制。
const beyond = finder.adjacentLogicalChar(pill, dir, scopeRoot, adj, PASS);
if (!beyond || !pillNeighbor.test(beyond.ch)
|| !beyond.parts.every((part) => finder.featureEnabledFor(part.textNode, PASS))) { return; }
const space = adj.parts[0]!;
deleteSpan({ text: " ", parts: adj.parts });
for (const part of beyond.parts)
{
if (part.textNode === space.textNode && part.start >= space.end)
{
part.start -= 1;
part.end -= 1;
}
}
beyond.offset = beyond.parts[0]!.start;
neighbor = beyond;
}
if (!pillNeighbor.test(neighbor.ch)) { return; }
if (dir === -1)
{
wrapGapLeft({ text: neighbor.ch, parts: neighbor.parts },
ownedElements, ownerToken, pill.parentElement ?? scopeRoot, scopeRoot);
}
else { wrapNodeInGap(pill, ownedElements); }
}
export const spacingPass: StandalonePass = {
name: PASS,
kind: "standalone",
order: 50,
level: "text",
enabled: (o) => o.features.spacing,
render(ctx: RenderContext): void
{
const { finder, options } = ctx;
// 由規則集編譯間隙正則(per-instance;含 charClass 覆寫)。
const R = makeRules(options.ruleset);
// Pass A:逐 block 收集,但功能閘逐 Text/完整原子;子白名單可重新啟用。
const processedRoots = new Set<Element>();
ctx.finder.eachBlock(ctx.root, (block) =>
{
processBlock(block, ctx.root, finder, R, ctx.ownedElements, ctx.ownerToken);
processedRoots.add(block);
});
// 文本級功能須支持單元素(§6.5.0a):root 為行內/單一非塊元素(如消費端對
// <span> 標題呼叫 render)時 eachBlock 不含之,補處理 root 自身(collectRuns
// 遇巢狀 block 仍會 flush,故與上面逐 block 不重複;行內 root 通常無 block 子)。
const inlineRoots = [ ctx.root ];
if (options.scope.include) { inlineRoots.push(...Array.from(ctx.root.querySelectorAll(options.scope.include))); }
for (const root of inlineRoots)
{
let covered = processedRoots.has(root);
for (let parent = root.parentElement; !covered && parent; parent = parent.parentElement)
{
covered = processedRoots.has(parent);
if (parent === ctx.root) { break; }
}
if (options.finder.blockTags.has(root.nodeName) || covered || !finder.inScope(root) || finder.isAvoided(root)
|| finder.category(root) === "pill" || finder.insidePill(root)) { continue; }
processBlock(root, ctx.root, finder, R, ctx.ownedElements, ctx.ownerToken);
processedRoots.add(root);
}
// Pass B:pill/isolate 邊界。
const boundarySelector = [ options.finder.pillSelector, options.finder.isolateSelector ]
.filter(Boolean)
.join(", ");
if (boundarySelector)
{
processPills(
ctx.root,
finder,
boundarySelector,
R.pillNeighbor,
ctx.ownedElements,
ctx.ownerToken,
);
}
},
revert(ctx: RenderContext): void
{
const touched = restoreMovedParts(ctx);
revertPass(PASS, ctx.root, ctx.ownedElements);
for (const parent of touched) { (parent as Element).normalize?.(); }
},
};