fix(web): 修復排版邊界與增量生命週期
This commit is contained in:
+130
-72
@@ -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 ]
|
||||
|
||||
Reference in New Issue
Block a user