fix(web): 修復排版邊界與增量生命週期

This commit is contained in:
2026-10-02 19:08:02 +08:00
parent 1c2fb0ed59
commit b1a16c4969
39 changed files with 4763 additions and 673 deletions
+130 -72
View File
@@ -9,19 +9,14 @@
import {
ALNUM, ALPHA, VARIATION_SELECTORS,
previousVariationSequenceStart, splitVariationSequences, variationSequenceAtUtf16,
splitVariationSequences,
} from "../core/unicode.js";
import { createJz, revertPass } from "../core/dom.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";
// 內部標記字元(PUA,不撞正常文本字形)。
const MARK = "";
const PH_OPEN = "";
const PH_CLOSE = "";
// ----- Pangu 規則正則(沿用 v1)-----
const OPS_HYPHEN = "\\+\\*=&\\-";
const OPS_GRADE = "\\+\\-\\*";
@@ -31,14 +26,18 @@ const RBR_BASIC = "\\)\\]\\}";
const LBR_EXT = "\\(\\[\\{<>";
const RBR_EXT = "\\)\\]\\}<>";
// 純刪除哨符(U+E003):標記「此處原作者空格應刪除、不留隙」(CJK↔CJK,問題 1b)。
// 與空格 1:1 替換以保持 spaced/text 對齊(processBlock 據此刪空格而不插 jz-hws)。
const DEL = String.fromCharCode(0xE003);
interface SpacingMarkers
{
gap: string;
compoundOpen: string;
compoundClose: string;
deletion: string;
}
/** 由規則集編譯之 per-instance 間隙正則集。 */
interface Rules
{
anyCjk: RegExp; pillNeighbor: RegExp;
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;
@@ -73,6 +72,7 @@ function makeRules(rs: Ruleset): Rules
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"),
@@ -114,9 +114,44 @@ function makeRules(rs: Ruleset): Rules
// 複合詞(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 PH_PATTERN = new RegExp(PH_OPEN + "(\\d+)" + PH_CLOSE, "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
@@ -136,9 +171,14 @@ function shadowAstralCjkAtoms(atoms: readonly string[], R: Rules): string
return shadowed.join("");
}
function panguSpace(text: string, R: Rules): string
function panguSpace(text: string, R: Rules, markers: SpacingMarkers): string
{
if (splitVariationSequences(text).length <= 1 || !R.anyCjk.test(text)) { return text; }
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");
@@ -514,6 +554,7 @@ function unwrapCrossGap(
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);
}
@@ -527,7 +568,29 @@ function restoreMovedParts(ctx: RenderContext): Set<Node>
for (const record of Array.from(records))
{
if (!recordWithinRoot(record, ctx.root)) { continue; }
const restored = restoreMovedText(record);
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
@@ -567,27 +630,29 @@ function processBlock(
ownerToken: object,
): void
{
const runs = finder.collectRuns(block);
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);
const spaced = panguSpace(ruleText, 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 === DEL)
if (sc === markers.deletion)
{
// CJK↔CJK 贅餘空格:純刪除、不留隙(before 留空)。
actions.push({ before: undefined, space: map[origIdx]! });
origIdx += 1;
continue;
}
if (sc === MARK)
if (sc === markers.gap)
{
const oc = origIdx < originalChars.length ? originalChars[origIdx]! : null;
if (oc === " ")
@@ -645,16 +710,16 @@ function processPills(
if (!finder.featureEnabledFor(pill, PASS)) { return; }
if (!pill.parentNode) { return; }
const prev = finder.adjacentLogicalChar(pill, -1);
const prev = finder.adjacentLogicalChar(pill, -1, root, undefined, PASS);
if (prev)
{
handlePillSide(prev, pill, -1, pillNeighbor, ownedElements, ownerToken, root);
handlePillSide(prev, pill, -1, finder, pillNeighbor, ownedElements, ownerToken, root);
}
const next = finder.adjacentLogicalChar(pill, 1);
const next = finder.adjacentLogicalChar(pill, 1, root, undefined, PASS);
if (next)
{
handlePillSide(next, pill, 1, pillNeighbor, ownedElements, ownerToken, root);
handlePillSide(next, pill, 1, finder, pillNeighbor, ownedElements, ownerToken, root);
}
});
}
@@ -665,58 +730,41 @@ 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)
{
if (pillNeighbor.test(adj.ch))
{
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 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({
text: bc,
parts: [ { textNode: tn, start: beforeOffset, end: beforeOffset + bc.length } ],
}, ownedElements, ownerToken, pill.parentElement ?? scopeRoot, scopeRoot);
}
}
}
else
{
if (pillNeighbor.test(adj.ch))
{
wrapNodeInGap(pill, ownedElements);
return;
}
if (adj.ch === " " && adj.textNode.data.length > 1)
{
const ac = variationSequenceAtUtf16(adj.textNode.data, 1);
if (ac !== " " && pillNeighbor.test(ac))
{
adj.textNode.data = adj.textNode.data.slice(1);
wrapNodeInGap(pill, ownedElements);
}
}
wrapGapLeft({ text: neighbor.ch, parts: neighbor.parts },
ownedElements, ownerToken, pill.parentElement ?? scopeRoot, scopeRoot);
}
else { wrapNodeInGap(pill, ownedElements); }
}
export const spacingPass: StandalonePass = {
@@ -730,20 +778,30 @@ export const spacingPass: StandalonePass = {
const { finder, options } = ctx;
// 由規則集編譯間隙正則(per-instance;含 charClass 覆寫)。
const R = makeRules(options.ruleset);
// Pass A:逐 block 處理(功能閘以 block 為粒度)。
// Pass A:逐 block 收集,但功能閘逐 Text/完整原子;子白名單可重新啟用。
const processedRoots = new Set<Element>();
ctx.finder.eachBlock(ctx.root, (block) =>
{
if (!finder.featureEnabledFor(block, PASS)) { return; }
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 子)。
if (!options.finder.blockTags.has(ctx.root.nodeName)
&& finder.featureEnabledFor(ctx.root, PASS)
&& finder.inScope(ctx.root) && !finder.isAvoided(ctx.root))
const inlineRoots = [ ctx.root ];
if (options.scope.include) { inlineRoots.push(...Array.from(ctx.root.querySelectorAll(options.scope.include))); }
for (const root of inlineRoots)
{
processBlock(ctx.root, ctx.root, finder, R, ctx.ownedElements, ctx.ownerToken);
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 ]