829 lines
33 KiB
TypeScript
829 lines
33 KiB
TypeScript
// 中西間隙(架構 §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?.(); }
|
||
},
|
||
};
|