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:
+97
-33
@@ -1,8 +1,15 @@
|
||||
// 文本遍歷引擎(架構 §4.1,取代 v1 之內聯 DFS / Fibre.js 角色)。
|
||||
//
|
||||
// 所有 pass 之共同地基:avoid 名單、scope.include、邏輯文本 run 收集(跨樣式
|
||||
// 行內元素透明、pill/block/avoid 中斷)、pill 鄰字查找、per-node 功能閘
|
||||
// (featureEnabledFor)。finder 不知具體排版規則,只提供遍歷與安全變更原語。
|
||||
// 所有 pass 之共同地基:avoid 名單、scope.include、邏輯文本 run 收集、pill 鄰字
|
||||
// 查找、per-node 功能閘(featureEnabledFor)。finder 不知具體排版規則,只提供
|
||||
// 遍歷與安全變更原語。
|
||||
//
|
||||
// 行內元素對排版有且僅有**三種口徑**(§4.1,所有遍歷原語共用同一判定):
|
||||
// 透明(styleInlines + jz-* wrap)— 穿越、run 延續、可克隆切分;
|
||||
// 隔離(isolateSelector,預設 mark)— 邊界斷 run/斷相鄰/不可切分、兩端補隙,
|
||||
// 內部照常處理(自成脈絡);
|
||||
// 整體(pillSelector)— 原子單位:內部對一切 pass 不可見,邊界同隔離。
|
||||
// avoid(skip 名單/屬性/選擇器)疊加其上:自身即邊界、內外皆不處理。
|
||||
|
||||
import type { FeatureLevel, ResolvedOptions } from "../types.js";
|
||||
|
||||
@@ -37,6 +44,7 @@ export class Finder
|
||||
private readonly blockSet: Set<string>;
|
||||
private readonly skipSet: Set<string>;
|
||||
private readonly pillSelector: string;
|
||||
private readonly isolateSelector: string;
|
||||
private readonly avoidSelector: string | null;
|
||||
private readonly includeSelector: string | null;
|
||||
private readonly skipAttr: string | null;
|
||||
@@ -52,6 +60,7 @@ export class Finder
|
||||
this.blockSet = opts.finder.blockTags;
|
||||
this.skipSet = opts.finder.skipTags;
|
||||
this.pillSelector = opts.finder.pillSelector;
|
||||
this.isolateSelector = opts.finder.isolateSelector;
|
||||
this.skipAttr = opts.finder.skipAttribute;
|
||||
this.userIsSkipped = opts.finder.isSkipped;
|
||||
this.levelTextSelector = opts.levelText;
|
||||
@@ -61,16 +70,40 @@ export class Finder
|
||||
this.levelCache = new WeakMap();
|
||||
}
|
||||
|
||||
/** 該元素是否匹配 pill 選擇器(行內塊,如 code/kbd)。 */
|
||||
/** 該元素是否匹配 pill 選擇器(三分類之「整體」:code/kbd/行內公式等)。 */
|
||||
pillMatches(el: Element): boolean
|
||||
{
|
||||
return !!(el.matches && this.pillSelector && el.matches(this.pillSelector));
|
||||
}
|
||||
|
||||
/** 該元素是否匹配 isolate 選擇器(三分類之「隔離」:帶盒樣式之行內,如背景色
|
||||
* <mark>)。邊界口徑同 pill(斷 run/斷相鄰/不可切分/兩端補隙),內部照常處理。 */
|
||||
isolateMatches(el: Element): boolean
|
||||
{
|
||||
return !!(el.matches && this.isolateSelector && el.matches(this.isolateSelector));
|
||||
}
|
||||
|
||||
/** 節點是否位於 pill **內部**(僅查祖先,不含自身)。pill 為整體元素:內部對
|
||||
* 一切 pass 不可見——不 charify、不收 run、不切分、不於其內插間隙(下游問題六:
|
||||
* .katex 內部曾被 jiya/spacing 直接變更)。 */
|
||||
insidePill(node: Node): boolean
|
||||
{
|
||||
let p: Node | null = node.parentNode;
|
||||
while (p && p.nodeType === 1)
|
||||
{
|
||||
if (this.pillMatches(p as Element)) { return true; }
|
||||
p = p.parentNode;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 邏輯文本應**透明穿越**之行內元素:樣式行內標籤,以及聚珍自身之 wrap 類
|
||||
* jz-*(jz-hws 間隙、jz-jinze 禁則群組、jz-char/jz-inner 標點原子)。穿越這些
|
||||
* 才能正確找到跨包裝之邏輯鄰字(如 jinze 已把 pill 納入 jz-jinze 後,pill 之左
|
||||
* 鄰字仍須可達;I7)。
|
||||
* ⚠ 以**元素**而非標籤名判定:pill/isolate/avoid 縱使標籤名在樣式集亦為邊界、
|
||||
* 不可穿越(下游問題六:KaTeX 全樹皆 SPAN,名字判定令整棵公式被當透明下潛而
|
||||
* 肢解;scope.avoid/skipAttribute 命中自身者同理)。
|
||||
* ⚠ jz-char/jz-inner 必須透明:否則 adjacentLogicalChar 之 descend 遇標點原子既
|
||||
* 不進入亦不終止、而是**跳過**它續找更前之兄弟(下游 Bug:`汉字。<code>` 經 jinze
|
||||
* 綁定為 `汉<jz-jinze>字<jz-char>。</jz-char></jz-jinze><code>`,pill 之左鄰字應
|
||||
@@ -78,31 +111,47 @@ export class Finder
|
||||
* jz-char 僅含標點(jiya 只 charify biaodian),標點非 pillNeighbor,故透明後 descend
|
||||
* 回傳標點 → 不補隙,與 collectRuns 之既有透明遞迴一致。jz-inner 亦須列入,否則
|
||||
* descend 進入 jz-char 後因 jz-inner 不透明而跳過其內標點。 */
|
||||
private isTransparentInline(name: string): boolean
|
||||
private transparentInline(el: Element): boolean
|
||||
{
|
||||
if (this.pillMatches(el) || this.isolateMatches(el) || this.avoidsSelf(el))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
const name = el.nodeName;
|
||||
return this.styleSet.has(name)
|
||||
|| name === "JZ-HWS" || name === "JZ-JINZE"
|
||||
|| name === "JZ-CHAR" || name === "JZ-INNER";
|
||||
}
|
||||
|
||||
/** 節點是否處於 avoid 子樹(內建 skip 名單 / SVG / contentEditable /
|
||||
* data-jz-skip / 使用者 avoid 選擇器 / skipAttribute / isSkipped)。 */
|
||||
/** 該元素**自身**是否為 avoid 邊界(內建 skip 名單 / SVG / contentEditable /
|
||||
* data-jz-skip / skipAttribute / avoid 選擇器 / isSkipped)。isAvoided 沿祖先
|
||||
* 鏈逐層呼叫本判定;遍歷原語另須對「正要進入」之元素直接判自身——舊版僅查
|
||||
* 祖先,元素自身命中 avoid 時被當透明穿越(下游問題六:scope.avoid 對 .katex
|
||||
* 形同虛設)。 */
|
||||
avoidsSelf(el: Element): boolean
|
||||
{
|
||||
if (this.userIsSkipped && this.userIsSkipped(el)) { return true; }
|
||||
if (this.skipSet.has(el.nodeName)) { return true; }
|
||||
if ((el as { namespaceURI?: string }).namespaceURI === SVG_NS) { return true; }
|
||||
if ((el as HTMLElement).isContentEditable) { return true; }
|
||||
if (el.hasAttribute("data-jz-skip")) { return true; }
|
||||
if (this.skipAttr && el.hasAttribute(this.skipAttr)) { return true; }
|
||||
if (this.avoidSelector && el.matches && el.matches(this.avoidSelector))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 節點是否處於 avoid 子樹(祖先鏈上任一元素 avoidsSelf;自身不計——元素
|
||||
* 自身之邊界判定由遍歷原語以 avoidsSelf 執行)。 */
|
||||
isAvoided(node: Node): boolean
|
||||
{
|
||||
if (this.userIsSkipped && this.userIsSkipped(node)) { return true; }
|
||||
let p: Node | null = node.parentNode;
|
||||
while (p && p.nodeType === 1)
|
||||
{
|
||||
const el = p as Element;
|
||||
if (this.skipSet.has(el.nodeName)) { return true; }
|
||||
if ((el as { namespaceURI?: string }).namespaceURI === SVG_NS) { return true; }
|
||||
if ((el as HTMLElement).isContentEditable) { return true; }
|
||||
if (el.hasAttribute("data-jz-skip")) { return true; }
|
||||
if (this.skipAttr && el.hasAttribute(this.skipAttr)) { return true; }
|
||||
if (this.avoidSelector && el.matches && el.matches(this.avoidSelector))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (this.avoidsSelf(p as Element)) { return true; }
|
||||
p = p.parentNode;
|
||||
}
|
||||
return false;
|
||||
@@ -119,7 +168,7 @@ export class Finder
|
||||
return !!(el.closest && el.closest(this.includeSelector));
|
||||
}
|
||||
|
||||
/** 走訪 root 下所有「可處理」文本節點(已過濾 avoid / 非作用域)。 */
|
||||
/** 走訪 root 下所有「可處理」文本節點(已過濾 avoid / pill 內部 / 非作用域)。 */
|
||||
eachTextNode(root: Element, cb: (node: Text) => void): void
|
||||
{
|
||||
const ownerDoc = root.ownerDocument || (typeof document !== "undefined" ? document : null);
|
||||
@@ -130,7 +179,10 @@ export class Finder
|
||||
while (n)
|
||||
{
|
||||
const t = n as Text;
|
||||
if (!this.isAvoided(t) && this.inScope(t)) { nodes.push(t); }
|
||||
if (!this.isAvoided(t) && !this.insidePill(t) && this.inScope(t))
|
||||
{
|
||||
nodes.push(t);
|
||||
}
|
||||
n = walker.nextNode();
|
||||
}
|
||||
// 先收集後回呼:容許 cb 變更 DOM 而不擾動走訪。
|
||||
@@ -199,13 +251,21 @@ export class Finder
|
||||
const el = node as Element;
|
||||
// jz-hws 現包裹左側邊界字(以 margin-right 留隙)→ 透明遞迴,使其內
|
||||
// 之字仍計入邏輯 run;冪等改由「該字已在 jz-hws 內則不重複包裝」保證。
|
||||
if (this.isAvoided(el)) { flush(); return; }
|
||||
if (this.skipSet.has(el.nodeName) || this.blockSet.has(el.nodeName))
|
||||
if (this.avoidsSelf(el) || this.blockSet.has(el.nodeName))
|
||||
{
|
||||
flush();
|
||||
return;
|
||||
}
|
||||
if (this.pillMatches(el)) { flush(); return; }
|
||||
if (this.pillMatches(el)) { flush(); return; } // 整體:邊界斷 run、內部不入
|
||||
if (this.isolateMatches(el))
|
||||
{
|
||||
// 隔離:邊界斷 run,內部自成獨立 run(照常處理、不與外部相鄰)。
|
||||
flush();
|
||||
let ic = el.firstChild;
|
||||
while (ic) { visit(ic); ic = ic.nextSibling; }
|
||||
flush();
|
||||
return;
|
||||
}
|
||||
// 樣式行內 / 未知行內 / wrap 類 jz-* / span[lang]:透明遞迴。
|
||||
let c = el.firstChild;
|
||||
while (c) { visit(c); c = c.nextSibling; }
|
||||
@@ -236,7 +296,10 @@ export class Finder
|
||||
return { textNode: tn, offset: 0, ch: tn.data.charAt(0) };
|
||||
};
|
||||
|
||||
const descend = (into: Element): AdjacentChar | null =>
|
||||
// descend 三態:邊界字/null(子樹無內容,續找兄弟)/"blocked"(遇 pill/
|
||||
// isolate/avoid/block 邊界——邏輯鄰居非「字」,終止整個查找)。不可把邊界
|
||||
// 當無內容跳過續找更遠之字:那是「越過 jz-char 取更前字 → 誤隙」之同型錯誤。
|
||||
const descend = (into: Element): AdjacentChar | "blocked" | null =>
|
||||
{
|
||||
let n: Node | null = direction === -1 ? into.lastChild : into.firstChild;
|
||||
while (n)
|
||||
@@ -245,10 +308,11 @@ export class Finder
|
||||
{
|
||||
return pickEnd(n as Text);
|
||||
}
|
||||
if (n.nodeType === 1 && this.isTransparentInline((n as Element).nodeName))
|
||||
if (n.nodeType === 1)
|
||||
{
|
||||
if (!this.transparentInline(n as Element)) { return "blocked"; }
|
||||
const r = descend(n as Element);
|
||||
if (r) { return r; }
|
||||
if (r) { return r; } // 字或 blocked 皆上拋
|
||||
}
|
||||
n = direction === -1 ? n.previousSibling : n.nextSibling;
|
||||
}
|
||||
@@ -270,19 +334,19 @@ export class Finder
|
||||
if (n.nodeType === 1)
|
||||
{
|
||||
const e = n as Element;
|
||||
if (this.isAvoided(e)) { return null; }
|
||||
if (this.isTransparentInline(e.nodeName))
|
||||
if (!this.transparentInline(e))
|
||||
{
|
||||
const r = descend(e);
|
||||
if (r) { return r; }
|
||||
n = direction === -1 ? e.previousSibling : e.nextSibling;
|
||||
continue;
|
||||
return null; // pill/isolate/avoid/block — 邊界,止。
|
||||
}
|
||||
return null; // pill 或 block — 止。
|
||||
const r = descend(e);
|
||||
if (r === "blocked") { return null; }
|
||||
if (r) { return r; }
|
||||
n = direction === -1 ? e.previousSibling : e.nextSibling;
|
||||
continue;
|
||||
}
|
||||
n = direction === -1 ? n.previousSibling : n.nextSibling;
|
||||
}
|
||||
if (!p || p.nodeType !== 1 || !this.isTransparentInline((p as Element).nodeName))
|
||||
if (!p || p.nodeType !== 1 || !this.transparentInline(p as Element))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
+37
-19
@@ -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、非 pill/isolate/avoid 邊界)。 */
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user