fix: 行內元素三分類(透明/隔離/整體)——jinze 下潛肢解 KaTeX 修復(下游問題六);v1 相容層移除

下游問題六:行內公式緊鄰禁則標點時,jinze 為綁鄰字透明下潛 .katex(全樹皆 SPAN,
僅憑標籤名判透明),把公式內側末字連同完整殼層逐層克隆切出(splitoff),上標漂移
錯位;pillSelector/skipAttribute/scope.avoid 三層防護全被刺穿。復現另揭兩向量:
相鄰 pill 時 descend 刺穿致 jz-hws 插入公式內部;pill 內部標點被 jiya charify。

確立三分類(§4.1,所有遍歷原語共用同一判定):
  透明(styleInlines+jz-*)— 穿越/run 延續/可克隆切分;
  隔離(isolateSelector,預設 "mark",新增選項)— 邊界斷 run/斷相鄰/不可切分、
    兩端補隙(同 pill),內部照常處理(自成脈絡);
  整體(pillSelector)— 內部對一切 pass 不可見(insidePill 過濾),禁則時作為
    原子鄰字單位整體綁入 jz-jinze。

機制修復:
  finder — transparentInline 改以元素判定(先排除 pill/isolate/avoid 再查標籤集);
    avoidsSelf 自身判定(isAvoided 僅查祖先,自身命中 avoid 曾被穿越);descend 三態
    (邊界字/無內容續找兄弟/blocked 終止——不可越界跳找更遠之字);eachTextNode
    過濾 pill 內部;collectRuns 隔離元素內部自成 run。
  core/split — canSplit 諮詢 finder(pill/isolate/avoid 不可切 → 退化整綁);
    MARK 移出 SPLITTABLE(背景盒切分有可見接縫)。
  jiyaAdjacency — pill/avoid(自身判定)斷相鄰不下探;isolate 斷而內部自成脈絡。
  spacing Pass B — 邊界選擇器擴為 pill ∪ isolate;巢於 pill 內者跳過。
  orphan — avoidsSelf 邊界;avoid 塊不綁(舊漏判:avoid 子樹內裸文本曾被計數包綁)。

v1 相容層移除(消費端已全數遷移 v2 API):src/compat/v1.ts、IIFE 全域別名
createCjkAutospace、死選項 autospaceClass(經查從未被任何 pass 讀取)一併刪;
I8 退役。dist/compat 殘留聲明清除、dist/core/split.d.ts 補入庫(前次遺漏)。

測試 112 全綠(問題六 ×7、isolate ×6、compat 移除 ×1);headless Chrome 實證
公式完整(splitoff=0、內部無 jz-hws/jz-char)、mark 與公式整體入 jz-jinze。
文檔:README 三分類節+選項示例+功能表(hanging 行補「已移除」);ARCHITECTURE
§4.1 三分類/§3.5 相容層移除/§6.1/§6.3/§6.4/I8 退役。
This commit is contained in:
2026-06-12 17:15:00 +08:00
parent a3c94ddee5
commit c4e8c6cfd5
21 changed files with 732 additions and 546 deletions
+5 -3
View File
@@ -122,10 +122,12 @@ function processParent(parent: Node, finder: Finder, anyCjk: RegExp): void
{
// 元素單元(樣式行內如 <strong>)最小綁定(下游 Bug:整元素入 nowrap inline-block
// 致行末整體掉行):只隔離與 jz-char 相鄰側之邊界詞元,餘下留原位。雙側禁斷時先
// tail 後 head——head 作用於切剩之左半(仍為原元素 u)。不可切之元素退化整綁。
// tail 後 head——head 作用於切剩之左半(仍為原元素 u)。不可切之元素(含 pill
// isolateavoid,由 canSplit 諮詢 finder 判定)退化整綁——pill 即「作為原子
// 鄰字單位整體綁入 jz-jinze」之期望行為(下游問題六)。
const el = u as Element;
if (needLast) { isolateBoundaryToken(el, "tail", anyCjk, PASS); }
if (needFirst) { isolateBoundaryToken(el, "head", anyCjk, PASS); }
if (needLast) { isolateBoundaryToken(el, "tail", anyCjk, PASS, finder); }
if (needFirst) { isolateBoundaryToken(el, "head", anyCjk, PASS, finder); }
}
}
+6 -2
View File
@@ -158,8 +158,12 @@ export const jiyaAdjacencyPass: StandalonePass = {
if (prev) { squeeze(prev, el); }
prev = el; // jz-char 為原子,不下探(內含 jz-inner)
}
else if (finder.pillMatches(el)) { prev = null; } // pillcode/kbd):斷相鄰、不下探(內容對排版不可見,不應被當透明行內穿越
else if (finder.isAvoided(el)) { prev = null; } // avoid/skip 子樹:斷、不下探
else if (finder.pillMatches(el)) { prev = null; } // pill整體):斷相鄰、不下探(內容對排版不可見)
else if (finder.avoidsSelf(el)) { prev = null; } // avoid 邊界(自身判定):斷、不下探
else if (finder.isolateMatches(el))
{
prev = null; visit(el); prev = null; // 隔離:前後皆斷相鄰,內部自成脈絡(同 block)
}
else if (blockSet.has(nm))
{
prev = null; visit(el); prev = null; // block 邊界:前後皆斷相鄰,內部另計
+7 -3
View File
@@ -70,7 +70,9 @@ function collectSubst(
}
if (node.nodeType !== 1) { return; }
const el = node as Element;
if (finder.isAvoided(el)) { return; }
// avoid 以自身判定(舊版僅查祖先,自身命中 avoid 之元素被下潛);pill 整體
// 不計不下探;isolate 內部為正常文本,照常下探計數(綁定時不可切、整綁)。
if (finder.avoidsSelf(el)) { return; }
if (blockSet.has(el.nodeName) || finder.pillMatches(el)) { return; } // 邊界:不計、不下探
for (let c = el.firstChild; c; c = c.nextSibling) { visit(c, directChild); }
};
@@ -111,7 +113,7 @@ function processBlock(
else if (boundary.textNode && startChild.nodeType === 1)
{
const clone = splitElementAt(
startChild as Element, boundary.textNode, boundary.offset, PASS,
startChild as Element, boundary.textNode, boundary.offset, PASS, finder,
);
if (clone) { startChild = clone; }
}
@@ -144,9 +146,11 @@ export const orphanPass: StandalonePass = {
finder.eachBlock(ctx.root, (block) =>
{
// 段落級閘 per-node 功能閘
// 段落級閘 per-node 功能閘 + avoid(自身或祖先命中皆不綁——舊版
// 漏判,avoid 子樹內塊之裸文本直接子曾被計數並包入 jz-orphan)。
if (!finder.levelAllows(block, "paragraph")) { return; }
if (!finder.featureEnabledFor(block, PASS)) { return; }
if (finder.avoidsSelf(block) || finder.isAvoided(block)) { return; }
// 僅處理葉段落(無塊級後代):含巢狀塊之容器,其實義字屬內層段落,另行處理。
const sel = Array.from(blockSet).map((t) => t.toLowerCase()).join(",");
if (sel && block.querySelector(sel)) { return; }
+16 -5
View File
@@ -246,11 +246,16 @@ function processBlock(block: Element, finder: Finder, R: Rules): void
}
}
function processPills(root: Element, finder: Finder, pillSelector: string, pillNeighbor: RegExp): void
// boundarySelector pill(整體)∪ isolate(隔離):兩端補隙之口徑一致(§4.1
// 三分類);差異僅在內部(pill 不可見、isolate 照常處理),不在此 pass。
function processPills(root: Element, finder: Finder, boundarySelector: string, pillNeighbor: RegExp): void
{
root.querySelectorAll(pillSelector).forEach((pill) =>
root.querySelectorAll(boundarySelector).forEach((pill) =>
{
if (finder.isAvoided(pill) || !finder.inScope(pill)) { return; }
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; }
@@ -331,8 +336,14 @@ export const spacingPass: StandalonePass = {
{
processBlock(ctx.root, finder, R);
}
// Pass Bpill 邊界。
processPills(ctx.root, finder, options.finder.pillSelector, R.pillNeighbor);
// Pass Bpillisolate 邊界。
const boundarySelector = [ options.finder.pillSelector, options.finder.isolateSelector ]
.filter(Boolean)
.join(", ");
if (boundarySelector)
{
processPills(ctx.root, finder, boundarySelector, R.pillNeighbor);
}
},
revert(ctx: RenderContext): void
{