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
+127 -123
View File
@@ -7,6 +7,7 @@ var Finder = class {
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;
@@ -15,14 +16,35 @@ var Finder = class {
this.featureCache = /* @__PURE__ */ new WeakMap();
this.levelCache = /* @__PURE__ */ new WeakMap();
}
/** 該元素是否匹配 pill 選擇器(行內塊,如 code/kbd)。 */
/** 該元素是否匹配 pill 選擇器(三分類之「整體」:codekbd/行內公式等)。 */
pillMatches(el) {
return !!(el.matches && this.pillSelector && el.matches(this.pillSelector));
}
/** 該元素是否匹配 isolate 選擇器(三分類之「隔離」:帶盒樣式之行內,如背景色
* <mark>)。邊界口徑同 pill(斷 run/斷相鄰/不可切分/兩端補隙),內部照常處理。 */
isolateMatches(el) {
return !!(el.matches && this.isolateSelector && el.matches(this.isolateSelector));
}
/** 節點是否位於 pill **內部**(僅查祖先,不含自身)。pill 為整體元素:內部對
* 一切 pass 不可見——不 charify、不收 run、不切分、不於其內插間隙(下游問題六:
* .katex 內部曾被 jiyaspacing 直接變更)。 */
insidePill(node) {
let p = node.parentNode;
while (p && p.nodeType === 1) {
if (this.pillMatches(p)) {
return true;
}
p = p.parentNode;
}
return false;
}
/** 邏輯文本應**透明穿越**之行內元素:樣式行內標籤,以及聚珍自身之 wrap 類
* jz-*jz-hws 間隙、jz-jinze 禁則群組、jz-charjz-inner 標點原子)。穿越這些
* 才能正確找到跨包裝之邏輯鄰字(如 jinze 已把 pill 納入 jz-jinze 後,pill 之左
* 鄰字仍須可達;I7)。
* ⚠ 以**元素**而非標籤名判定:pillisolate/avoid 縱使標籤名在樣式集亦為邊界、
* 不可穿越(下游問題六:KaTeX 全樹皆 SPAN,名字判定令整棵公式被當透明下潛而
* 肢解;scope.avoidskipAttribute 命中自身者同理)。
* ⚠ jz-charjz-inner 必須透明:否則 adjacentLogicalChar 之 descend 遇標點原子既
* 不進入亦不終止、而是**跳過**它續找更前之兄弟(下游 Bug`汉字。<code>` 經 jinze
* 綁定為 `汉<jz-jinze>字<jz-char>。</jz-char></jz-jinze><code>`pill 之左鄰字應
@@ -30,34 +52,51 @@ var Finder = class {
* jz-char 僅含標點(jiya 只 charify biaodian),標點非 pillNeighbor,故透明後 descend
* 回傳標點 → 不補隙,與 collectRuns 之既有透明遞迴一致。jz-inner 亦須列入,否則
* descend 進入 jz-char 後因 jz-inner 不透明而跳過其內標點。 */
isTransparentInline(name) {
transparentInline(el) {
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) {
if (this.userIsSkipped && this.userIsSkipped(el)) {
return true;
}
if (this.skipSet.has(el.nodeName)) {
return true;
}
if (el.namespaceURI === SVG_NS) {
return true;
}
if (el.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) {
if (this.userIsSkipped && this.userIsSkipped(node)) {
return true;
}
let p = node.parentNode;
while (p && p.nodeType === 1) {
const el = p;
if (this.skipSet.has(el.nodeName)) {
return true;
}
if (el.namespaceURI === SVG_NS) {
return true;
}
if (el.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)) {
if (this.avoidsSelf(p)) {
return true;
}
p = p.parentNode;
@@ -75,7 +114,7 @@ var Finder = class {
}
return !!(el.closest && el.closest(this.includeSelector));
}
/** 走訪 root 下所有「可處理」文本節點(已過濾 avoid / 非作用域)。 */
/** 走訪 root 下所有「可處理」文本節點(已過濾 avoid / pill 內部 / 非作用域)。 */
eachTextNode(root, cb) {
const ownerDoc = root.ownerDocument || (typeof document !== "undefined" ? document : null);
if (!ownerDoc) {
@@ -86,7 +125,7 @@ var Finder = class {
let n = walker.nextNode();
while (n) {
const t = n;
if (!this.isAvoided(t) && this.inScope(t)) {
if (!this.isAvoided(t) && !this.insidePill(t) && this.inScope(t)) {
nodes.push(t);
}
n = walker.nextNode();
@@ -148,11 +187,7 @@ var Finder = class {
return;
}
const el = node;
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;
}
@@ -160,6 +195,16 @@ var Finder = class {
flush();
return;
}
if (this.isolateMatches(el)) {
flush();
let ic = el.firstChild;
while (ic) {
visit(ic);
ic = ic.nextSibling;
}
flush();
return;
}
let c2 = el.firstChild;
while (c2) {
visit(c2);
@@ -195,7 +240,10 @@ var Finder = class {
if (n2.nodeType === 3 && n2.data.length > 0) {
return pickEnd(n2);
}
if (n2.nodeType === 1 && this.isTransparentInline(n2.nodeName)) {
if (n2.nodeType === 1) {
if (!this.transparentInline(n2)) {
return "blocked";
}
const r = descend(n2);
if (r) {
return r;
@@ -218,22 +266,22 @@ var Finder = class {
}
if (n.nodeType === 1) {
const e = n;
if (this.isAvoided(e)) {
if (!this.transparentInline(e)) {
return null;
}
if (this.isTransparentInline(e.nodeName)) {
const r = descend(e);
if (r) {
return r;
}
n = direction === -1 ? e.previousSibling : e.nextSibling;
continue;
const r = descend(e);
if (r === "blocked") {
return null;
}
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.nodeName)) {
if (!p || p.nodeType !== 1 || !this.transparentInline(p)) {
return null;
}
const pe = p;
@@ -812,7 +860,11 @@ var jiyaAdjacencyPass = {
prev = el;
} else if (finder.pillMatches(el)) {
prev = null;
} else if (finder.isAvoided(el)) {
} else if (finder.avoidsSelf(el)) {
prev = null;
} else if (finder.isolateMatches(el)) {
prev = null;
visit(el);
prev = null;
} else if (blockSet.has(nm)) {
prev = null;
@@ -840,26 +892,25 @@ var SPLITTABLE = /* @__PURE__ */ new Set([
"U",
"S",
"SPAN",
"MARK",
"SMALL",
"INS",
"DEL",
"SUB",
"SUP"
]);
function canSplit(el) {
return SPLITTABLE.has(el.nodeName) && !el.hasAttribute("id");
function canSplit(el, finder) {
return SPLITTABLE.has(el.nodeName) && !el.hasAttribute("id") && !finder.pillMatches(el) && !finder.isolateMatches(el) && !finder.avoidsSelf(el);
}
function firstText(el) {
function firstText(el, finder) {
for (let c = el.firstChild; c; c = c.nextSibling) {
if (c.nodeType === 3 && c.data.length > 0) {
return c;
}
if (c.nodeType === 1) {
if (!canSplit(c)) {
if (!canSplit(c, finder)) {
return null;
}
const r = firstText(c);
const r = firstText(c, finder);
if (r) {
return r;
}
@@ -867,16 +918,16 @@ function firstText(el) {
}
return null;
}
function lastText(el) {
function lastText(el, finder) {
for (let c = el.lastChild; c; c = c.previousSibling) {
if (c.nodeType === 3 && c.data.length > 0) {
return c;
}
if (c.nodeType === 1) {
if (!canSplit(c)) {
if (!canSplit(c, finder)) {
return null;
}
const r = lastText(c);
const r = lastText(c, finder);
if (r) {
return r;
}
@@ -908,8 +959,8 @@ function trailingTokenStart(d, anyCjk) {
}
return s;
}
function splitTreeAfter(top, textNode, offset, pass) {
if (offset <= 0 && firstText(top) === textNode) {
function splitTreeAfter(top, textNode, offset, pass, finder) {
if (offset <= 0 && firstText(top, finder) === textNode) {
return null;
}
let rightAtLevel;
@@ -959,26 +1010,26 @@ function splitTreeAfter(top, textNode, offset, pass) {
}
return topClone;
}
function isolateBoundaryToken(el, side, anyCjk, pass) {
if (!canSplit(el)) {
function isolateBoundaryToken(el, side, anyCjk, pass, finder) {
if (!canSplit(el, finder)) {
return el;
}
const text = side === "tail" ? lastText(el) : firstText(el);
const text = side === "tail" ? lastText(el, finder) : firstText(el, finder);
if (!text) {
return el;
}
const 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;
}
return side === "tail" ? clone : el;
}
function splitElementAt(el, textNode, offset, pass) {
if (!canSplit(el)) {
function splitElementAt(el, textNode, offset, pass, finder) {
if (!canSplit(el, finder)) {
return null;
}
return splitTreeAfter(el, textNode, offset, pass);
return splitTreeAfter(el, textNode, offset, pass, finder);
}
// src/typeset/jinze.ts
@@ -1076,10 +1127,10 @@ function processParent(parent, finder, anyCjk) {
} else if (u.nodeType === 1) {
const el = u;
if (needLast) {
isolateBoundaryToken(el, "tail", anyCjk, PASS2);
isolateBoundaryToken(el, "tail", anyCjk, PASS2, finder);
}
if (needFirst) {
isolateBoundaryToken(el, "head", anyCjk, PASS2);
isolateBoundaryToken(el, "head", anyCjk, PASS2, finder);
}
}
}
@@ -1421,7 +1472,7 @@ function collectSubst(block, finder, blockSet, anyCjk, biaodian) {
return;
}
const el = node;
if (finder.isAvoided(el)) {
if (finder.avoidsSelf(el)) {
return;
}
if (blockSet.has(el.nodeName) || finder.pillMatches(el)) {
@@ -1455,7 +1506,8 @@ function processBlock(block, finder, blockSet, anyCjk, biaodian, chars) {
startChild,
boundary.textNode,
boundary.offset,
PASS4
PASS4,
finder
);
if (clone) {
startChild = clone;
@@ -1489,6 +1541,9 @@ var orphanPass = {
if (!finder.featureEnabledFor(block, PASS4)) {
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;
@@ -1665,9 +1720,9 @@ function processBlock2(block, finder, R) {
}
}
}
function processPills(root, finder, pillSelector, pillNeighbor) {
root.querySelectorAll(pillSelector).forEach((pill) => {
if (finder.isAvoided(pill) || !finder.inScope(pill)) {
function processPills(root, finder, boundarySelector, pillNeighbor) {
root.querySelectorAll(boundarySelector).forEach((pill) => {
if (finder.isAvoided(pill) || finder.insidePill(pill) || !finder.inScope(pill)) {
return;
}
if (!finder.featureEnabledFor(pill, PASS5)) {
@@ -1732,68 +1787,16 @@ var spacingPass = {
if (!options.finder.blockTags.has(ctx.root.nodeName) && finder.featureEnabledFor(ctx.root, PASS5) && finder.inScope(ctx.root) && !finder.isAvoided(ctx.root)) {
processBlock2(ctx.root, finder, R);
}
processPills(ctx.root, finder, options.finder.pillSelector, R.pillNeighbor);
const boundarySelector = [options.finder.pillSelector, options.finder.isolateSelector].filter(Boolean).join(", ");
if (boundarySelector) {
processPills(ctx.root, finder, boundarySelector, R.pillNeighbor);
}
},
revert(ctx) {
revertPass(PASS5, ctx.root);
}
};
// src/compat/v1.ts
function mapLongWord(v) {
if (v === false) {
return false;
}
if (v === void 0 || v === true || v === null) {
return true;
}
const out = {};
if (typeof v.minLength === "number") {
out.minLength = v.minLength;
}
if (typeof v.lang === "string") {
out.lang = v.lang;
}
return out;
}
function createCjkAutospace(options = {}) {
const o = options || {};
const mapped = {
// finder 設定直通。
...o.styleInlines ? { styleInlines: o.styleInlines } : {},
...o.blockTags ? { blockTags: o.blockTags } : {},
...o.skipTags ? { skipTags: o.skipTags } : {},
pillSelector: o.pillSelector || "code, kbd",
autospaceClass: o.autospaceClass || "cjk-autospace",
skipAttribute: o.skipAttribute || "data-md-key",
...o.isSkipped ? { isSkipped: o.isSkipped } : {},
spacing: true,
longWord: mapLongWord(o.longWordWrap),
// v1 Pass D 不分 lang 地擠句內點號(開明式);compat 強制 kaiming
// 政策以避免功能倒退(預設 zh-Hant→quanjiao 不擠壓)。
jiya: o.punctuationSqueeze === false ? false : true,
// v1 無連續標點綁定,保持 DOM 與 v1 一致:關閉 jinze。
jinze: false,
lang: {
policy: {
"zh-Hant": "kaiming",
"zh-Hans": "kaiming",
"ja": "kaiming",
"other": "kaiming"
}
}
};
const jz = createJuzhen(mapped);
return {
apply(root) {
jz.render(root);
},
revert(root) {
jz.revert(root);
}
};
}
// src/index.ts
var DEFAULT_STYLE_INLINES = [
"STRONG",
@@ -1932,7 +1935,6 @@ function normalizeOptions(opts = {}) {
policy: langOpt.policy || {}
},
ruleset: resolveRuleset(opts.charClass),
autospaceClass: opts.autospaceClass || "",
justifyAtoms: opts.justifyAtoms === false ? false : true,
levelText: opts.level && opts.level.text || null,
scope: {
@@ -1962,6 +1964,9 @@ function normalizeOptions(opts = {}) {
blockTags: new Set(opts.blockTags || DEFAULT_BLOCK_TAGS),
skipTags: new Set(opts.skipTags || DEFAULT_SKIP_TAGS),
pillSelector: opts.pillSelector || "code, kbd",
// 內外隔離(§4.1 三分類):預設 <mark>(瀏覽器預設帶背景,切分有可見
// 接縫、邊界需留隙)。顯式判 undefined,使 "" 可停用。
isolateSelector: opts.isolateSelector !== void 0 ? opts.isolateSelector : "mark",
skipAttribute: opts.skipAttribute || null,
isSkipped: typeof opts.isSkipped === "function" ? opts.isSkipped : null
}
@@ -2009,7 +2014,6 @@ function createJuzhen(opts = {}) {
};
}
export {
createCjkAutospace,
createJuzhen,
normalizeOptions
};