// 中西間隙(架構 §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(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>(); 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): 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, ): 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): 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, ownerToken: object, block: Element, scopeRoot: Element, ): void { const containingGaps = new Set(); 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, touched: Set, ): 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 { const touched = new Set(); const records = MOVED_PARTS.get(ctx.ownerToken); if (!records) { return touched; } const gaps = new Map(); 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, 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, 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, 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(); ctx.finder.eachBlock(ctx.root, (block) => { processBlock(block, ctx.root, finder, R, ctx.ownedElements, ctx.ownerToken); processedRoots.add(block); }); // 文本級功能須支持單元素(§6.5.0a):root 為行內/單一非塊元素(如消費端對 // 標題呼叫 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?.(); } }, };