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
+37 -19
View File
@@ -13,16 +13,22 @@
// 包 wrap、後併 splitoff,確保前兄弟為原元素而非 wrap 殼;I6 還原一致)。
//
// 僅純樣式行內標籤可切(白名單);帶語義/引用者(A 連結會克隆出兩個錨點、ABBR 之
// title、RUBY 結構、帶 id 者)一律回退整元素綁定——退化但安全。
// title、RUBY 結構、帶 id 者)一律回退整元素綁定——退化但安全。可切性另須諮詢
// finder 三分類(§4.1):pill(整體,如 .katex——KaTeX 全樹皆 SPAN,僅憑標籤名會
// 整棵下潛肢解;下游問題六)、isolate(隔離,帶盒樣式切分有可見接縫)、avoid 自身
// 命中者,皆不可切——回退整元素綁定。
import type { Finder } from "./finder.js";
const PASS_ATTR = "data-jz";
const KIND_ATTR = "data-jz-kind";
// 可安全克隆切分之純樣式行內標籤(切分不改變語義、相鄰兩份視覺無縫)。
// 刻意排除:A(克隆出兩個連結)、ABBR/Q(title/引號語義重複)、RUBY 系(結構性)、
// 帶 id 者(id 重複)——皆回退整元素綁定。
// MARK(盒樣式:背景色切分有可見接縫;預設亦列為 isolate)、帶 id 者(id 重複)——
// 皆回退整元素綁定。
const SPLITTABLE = new Set([
"STRONG", "EM", "B", "I", "U", "S", "SPAN", "MARK",
"STRONG", "EM", "B", "I", "U", "S", "SPAN",
"SMALL", "INS", "DEL", "SUB", "SUP",
]);
@@ -32,22 +38,26 @@ interface Point
offset: number;
}
/** 元素是否可克隆切分(純樣式行內標籤、無 id)。 */
function canSplit(el: Element): boolean
/** 元素是否可克隆切分(純樣式行內標籤、無 id、非 pillisolateavoid 邊界)。 */
function canSplit(el: Element, finder: Finder): boolean
{
return SPLITTABLE.has(el.nodeName) && !el.hasAttribute("id");
return SPLITTABLE.has(el.nodeName)
&& !el.hasAttribute("id")
&& !finder.pillMatches(el)
&& !finder.isolateMatches(el)
&& !finder.avoidsSelf(el);
}
/** el 內**首**個非空文本節點(僅下探可切標籤;遇非可切元素回 null=放棄切分)。 */
function firstText(el: Element): Text | null
function firstText(el: Element, finder: Finder): Text | null
{
for (let c = el.firstChild; c; c = c.nextSibling)
{
if (c.nodeType === 3 && (c as Text).data.length > 0) { return c as Text; }
if (c.nodeType === 1)
{
if (!canSplit(c as Element)) { return null; }
const r = firstText(c as Element);
if (!canSplit(c as Element, finder)) { return null; }
const r = firstText(c as Element, finder);
if (r) { return r; }
}
}
@@ -55,15 +65,15 @@ function firstText(el: Element): Text | null
}
/** el 內**末**個非空文本節點(僅下探可切標籤;遇非可切元素回 null=放棄切分)。 */
function lastText(el: Element): Text | null
function lastText(el: Element, finder: Finder): Text | null
{
for (let c = el.lastChild; c; c = c.previousSibling)
{
if (c.nodeType === 3 && (c as Text).data.length > 0) { return c as Text; }
if (c.nodeType === 1)
{
if (!canSplit(c as Element)) { return null; }
const r = lastText(c as Element);
if (!canSplit(c as Element, finder)) { return null; }
const r = lastText(c as Element, finder);
if (r) { return r; }
}
}
@@ -102,10 +112,16 @@ function trailingTokenStart(d: string, anyCjk: RegExp): number
* 沿祖先鏈逐層剝離:每層把「切點側之首節點及其後兄弟」搬入該層元素之淺克隆,空克隆
* 跳過(避免殘留空元素破壞 revert 還原)。
*/
function splitTreeAfter(top: Element, textNode: Text, offset: number, pass: string): Element | null
function splitTreeAfter(
top: Element,
textNode: Text,
offset: number,
pass: string,
finder: Finder,
): Element | null
{
// 切點位於 top 最起點(首文本節點之 offset 0)→ 左側無內容,不切。
if (offset <= 0 && firstText(top) === textNode) { return null; }
if (offset <= 0 && firstText(top, finder) === textNode) { return null; }
// 該文本層之右側首節點。
let rightAtLevel: Node | null;
@@ -175,17 +191,18 @@ export function isolateBoundaryToken(
side: "head" | "tail",
anyCjk: RegExp,
pass: string,
finder: Finder,
): Element
{
if (!canSplit(el)) { return el; }
const text = side === "tail" ? lastText(el) : firstText(el);
if (!canSplit(el, finder)) { return el; }
const text = side === "tail" ? lastText(el, finder) : firstText(el, finder);
if (!text) { return el; }
const point: Point = side === "tail"
? { textNode: text, offset: trailingTokenStart(text.data, anyCjk) }
: { textNode: text, offset: leadingTokenEnd(text.data, anyCjk) };
const clone = splitTreeAfter(el, point.textNode, point.offset, pass);
const clone = splitTreeAfter(el, point.textNode, point.offset, pass, finder);
if (!clone) { return el; } // 整元素即邊界詞元 → 綁整個 el。
return side === "tail" ? clone : el;
}
@@ -199,8 +216,9 @@ export function splitElementAt(
textNode: Text,
offset: number,
pass: string,
finder: Finder,
): Element | null
{
if (!canSplit(el)) { return null; }
return splitTreeAfter(el, textNode, offset, pass);
if (!canSplit(el, finder)) { return null; }
return splitTreeAfter(el, textNode, offset, pass, finder);
}