diff --git a/dist/core/finder.d.ts b/dist/core/finder.d.ts index 3d391f3..b8fc2ec 100644 --- a/dist/core/finder.d.ts +++ b/dist/core/finder.d.ts @@ -53,15 +53,21 @@ export declare class Finder { * jz-char 僅含標點(jiya 只 charify biaodian),標點非 pillNeighbor,故透明後 descend * 回傳標點 → 不補隙,與 collectRuns 之既有透明遞迴一致。jz-inner 亦須列入,否則 * descend 進入 jz-char 後因 jz-inner 不透明而跳過其內標點。 */ - private transparentInline; - /** 該元素**自身**是否為 avoid 邊界(內建 skip 名單 / SVG / contentEditable / - * data-jz-skip / skipAttribute / avoid 選擇器 / isSkipped)。isAvoided 沿祖先 - * 鏈逐層呼叫本判定;遍歷原語另須對「正要進入」之元素直接判自身——舊版僅查 - * 祖先,元素自身命中 avoid 時被當透明穿越(下游問題六:scope.avoid 對 .katex - * 形同虛設)。 */ + isTransparent(el: Element): boolean; + /** 該元素**自身**是否為 avoid 邊界。avoid 之七種來源,依序短路判定、任一命中即是: + * ① isSkipped 謂詞(消費端函數) ② skipTags 標籤名單(PRE/SAMP/SCRIPT…,可覆寫) + * ③ SVG 命名空間 ④ contentEditable + * ⑤ data-jz-skip 屬性(內建) ⑥ skipAttribute(消費端指定屬性名) + * ⑦ scope.avoid 選擇器 + * **此為自身判定**,供遍歷原語(collectRuns/jiyaAdjacency/canSplit/isTransparent)於 + * 下探時逐元素自判(祖先由遞迴涵蓋)。**一次性**問「某節點是否被 avoid(自身或祖先)」 + * 請改用 isAvoided(單一組合入口),勿只用本方法而漏判祖先、或只用 isAvoided 而漏判自身 + * (下游問題六/階段一 #2:漏一半致 avoid 對自身形同虛設)。 */ avoidsSelf(el: Element): boolean; - /** 節點是否處於 avoid 子樹(祖先鏈上任一元素 avoidsSelf;自身不計——元素 - * 自身之邊界判定由遍歷原語以 avoidsSelf 執行)。 */ + /** 某節點是否被 avoid——**自身即邊界**(元素經 avoidsSelf 之七源;非元素節點僅判 + * isSkipped)**或**祖先鏈上任一為 avoid 邊界。此為「是否 avoid」之**單一組合入口(安全 + * 預設)**:一次性檢查用此,免除「avoidsSelf ‖ isAvoided」易漏一半之誤用。遍歷原語逐 + * 元素下探時則用 avoidsSelf(僅自身,祖先由遞迴涵蓋)。 */ isAvoided(node: Node): boolean; /** scope.include 啟用判定:未設則恆 true;設則須有命中祖先(opt-in)。 */ inScope(node: Node): boolean; @@ -70,9 +76,9 @@ export declare class Finder { /** 走訪 root 下所有 block 元素(含 root 本身若為 block)。 */ eachBlock(root: Element, cb: (block: Element) => void): void; /** - * 收集 block 之邏輯文本 run(架構 §4.1,I7):樣式行內元素與 wrap 類 - * jz-* 透明穿越;block/pill/avoid/間隙 marker(jz-hws)中斷 run。 - * 回傳 text.length >= 2 之 run(短於 2 無間隙可言)。 + * 收集 block 之邏輯文本 run(架構 §4.1,I7):透明行內(isTransparent:styleInlines + * + wrap 類 jz-*)穿越、run 延續;block/pill/avoid 中斷 run;isolate 與**未知行內 + * 標籤**(嚴格白名單)中斷 run 且內部自成獨立 run。回傳 text.length >= 2 之 run。 */ collectRuns(block: Element): LogicalRun[]; /** diff --git a/dist/juzhen.iife.js b/dist/juzhen.iife.js index f29a69e..aff8808 100644 --- a/dist/juzhen.iife.js +++ b/dist/juzhen.iife.js @@ -79,18 +79,22 @@ var Juzhen = (() => { * jz-char 僅含標點(jiya 只 charify biaodian),標點非 pillNeighbor,故透明後 descend * 回傳標點 → 不補隙,與 collectRuns 之既有透明遞迴一致。jz-inner 亦須列入,否則 * descend 進入 jz-char 後因 jz-inner 不透明而跳過其內標點。 */ - transparentInline(el) { + isTransparent(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 / skipAttribute / avoid 選擇器 / isSkipped)。isAvoided 沿祖先 - * 鏈逐層呼叫本判定;遍歷原語另須對「正要進入」之元素直接判自身——舊版僅查 - * 祖先,元素自身命中 avoid 時被當透明穿越(下游問題六:scope.avoid 對 .katex - * 形同虛設)。 */ + /** 該元素**自身**是否為 avoid 邊界。avoid 之七種來源,依序短路判定、任一命中即是: + * ① isSkipped 謂詞(消費端函數) ② skipTags 標籤名單(PRE/SAMP/SCRIPT…,可覆寫) + * ③ SVG 命名空間 ④ contentEditable + * ⑤ data-jz-skip 屬性(內建) ⑥ skipAttribute(消費端指定屬性名) + * ⑦ scope.avoid 選擇器 + * **此為自身判定**,供遍歷原語(collectRuns/jiyaAdjacency/canSplit/isTransparent)於 + * 下探時逐元素自判(祖先由遞迴涵蓋)。**一次性**問「某節點是否被 avoid(自身或祖先)」 + * 請改用 isAvoided(單一組合入口),勿只用本方法而漏判祖先、或只用 isAvoided 而漏判自身 + * (下游問題六/階段一 #2:漏一半致 avoid 對自身形同虛設)。 */ avoidsSelf(el) { if (this.userIsSkipped && this.userIsSkipped(el)) { return true; @@ -115,10 +119,16 @@ var Juzhen = (() => { } return false; } - /** 節點是否處於 avoid 子樹(祖先鏈上任一元素 avoidsSelf;自身不計——元素 - * 自身之邊界判定由遍歷原語以 avoidsSelf 執行)。 */ + /** 某節點是否被 avoid——**自身即邊界**(元素經 avoidsSelf 之七源;非元素節點僅判 + * isSkipped)**或**祖先鏈上任一為 avoid 邊界。此為「是否 avoid」之**單一組合入口(安全 + * 預設)**:一次性檢查用此,免除「avoidsSelf ‖ isAvoided」易漏一半之誤用。遍歷原語逐 + * 元素下探時則用 avoidsSelf(僅自身,祖先由遞迴涵蓋)。 */ isAvoided(node) { - if (this.userIsSkipped && this.userIsSkipped(node)) { + if (node.nodeType === 1) { + if (this.avoidsSelf(node)) { + return true; + } + } else if (this.userIsSkipped && this.userIsSkipped(node)) { return true; } let p = node.parentNode; @@ -179,9 +189,9 @@ var Juzhen = (() => { } } /** - * 收集 block 之邏輯文本 run(架構 §4.1,I7):樣式行內元素與 wrap 類 - * jz-* 透明穿越;block/pill/avoid/間隙 marker(jz-hws)中斷 run。 - * 回傳 text.length >= 2 之 run(短於 2 無間隙可言)。 + * 收集 block 之邏輯文本 run(架構 §4.1,I7):透明行內(isTransparent:styleInlines + * + wrap 類 jz-*)穿越、run 延續;block/pill/avoid 中斷 run;isolate 與**未知行內 + * 標籤**(嚴格白名單)中斷 run 且內部自成獨立 run。回傳 text.length >= 2 之 run。 */ collectRuns(block) { const runs = []; @@ -222,21 +232,21 @@ var Juzhen = (() => { flush(); return; } - if (this.isolateMatches(el)) { - flush(); - let ic = el.firstChild; - while (ic) { - visit(ic); - ic = ic.nextSibling; + if (this.isTransparent(el)) { + let c2 = el.firstChild; + while (c2) { + visit(c2); + c2 = c2.nextSibling; } - flush(); return; } - let c2 = el.firstChild; - while (c2) { - visit(c2); - c2 = c2.nextSibling; + flush(); + let ic = el.firstChild; + while (ic) { + visit(ic); + ic = ic.nextSibling; } + flush(); }; let c = block.firstChild; while (c) { @@ -268,7 +278,7 @@ var Juzhen = (() => { return pickEnd(n2); } if (n2.nodeType === 1) { - if (!this.transparentInline(n2)) { + if (!this.isTransparent(n2)) { return "blocked"; } const r = descend(n2); @@ -293,7 +303,7 @@ var Juzhen = (() => { } if (n.nodeType === 1) { const e = n; - if (!this.transparentInline(e)) { + if (!this.isTransparent(e)) { return null; } const r = descend(e); @@ -308,7 +318,7 @@ var Juzhen = (() => { } n = direction === -1 ? n.previousSibling : n.nextSibling; } - if (!p || p.nodeType !== 1 || !this.transparentInline(p)) { + if (!p || p.nodeType !== 1 || !this.isTransparent(p)) { return null; } const pe = p; @@ -851,8 +861,7 @@ var Juzhen = (() => { level: "text", enabled: (o) => o.features.jiya, render(ctx) { - const { finder, options } = ctx; - const blockSet = options.finder.blockTags; + const { finder } = ctx; let prev = null; const squeeze = (L, R) => { if (!finder.featureEnabledFor(L, PASS)) { @@ -888,20 +897,14 @@ var Juzhen = (() => { squeeze(prev, el); } prev = el; - } else if (finder.pillMatches(el)) { - prev = null; - } else if (finder.avoidsSelf(el)) { - prev = null; - } else if (finder.isolateMatches(el)) { + } else if (finder.pillMatches(el) || finder.avoidsSelf(el)) { prev = null; + } else if (finder.isTransparent(el)) { visit(el); - prev = null; - } else if (blockSet.has(nm)) { - prev = null; - visit(el); - prev = null; } else { + prev = null; visit(el); + prev = null; } } }; @@ -1069,21 +1072,66 @@ var Juzhen = (() => { function isCharEl(n) { return !!n && n.nodeType === 1 && n.nodeName === "JZ-CHAR"; } + function firstMeaningfulChild(el) { + for (let c = el.firstChild; c; c = c.nextSibling) { + if (c.nodeType === 3 && c.data.length === 0) { + continue; + } + return c; + } + return null; + } + function lastMeaningfulChild(el) { + for (let c = el.lastChild; c; c = c.previousSibling) { + if (c.nodeType === 3 && c.data.length === 0) { + continue; + } + return c; + } + return null; + } + function atEdge(node, el, side) { + return node === (side === "head" ? firstMeaningfulChild(el) : lastMeaningfulChild(el)); + } + function edgeCharEl(node, side, finder) { + let n = node; + while (n && n.nodeType === 1) { + if (isCharEl(n)) { + return n; + } + if (!finder.isTransparent(n)) { + return null; + } + n = side === "head" ? firstMeaningfulChild(n) : lastMeaningfulChild(n); + } + return null; + } function forbiddenBreak(a, b, finder) { - if (isCharEl(b)) { - const c = bdClassOf(b); - if (c && AVOID_HEAD.has(c) && finder.featureEnabledFor(b, PASS2)) { + const head = edgeCharEl(b, "head", finder); + if (head) { + const c = bdClassOf(head); + if (c && AVOID_HEAD.has(c) && finder.featureEnabledFor(head, PASS2)) { return true; } } - if (isCharEl(a)) { - const c = bdClassOf(a); - if (c && AVOID_TAIL.has(c) && finder.featureEnabledFor(a, PASS2)) { + const tail = edgeCharEl(a, "tail", finder); + if (tail) { + const c = bdClassOf(tail); + if (c && AVOID_TAIL.has(c) && finder.featureEnabledFor(tail, PASS2)) { return true; } } return false; } + function depthOf(node) { + let d = 0; + let p = node.parentNode; + while (p) { + d += 1; + p = p.parentNode; + } + return d; + } function isTokenChar2(ch, anyCjk) { return !anyCjk.test(ch) && !/\s/.test(ch); } @@ -1195,18 +1243,31 @@ var Juzhen = (() => { level: "paragraph", enabled: (o) => o.features.jinze, render(ctx) { + const { finder } = ctx; const anyCjk = new RegExp("[" + ctx.options.ruleset.cjk + "]"); const parents = /* @__PURE__ */ new Set(); ctx.root.querySelectorAll("jz-char").forEach((c) => { - if (!ctx.finder.levelAllows(c, "paragraph")) { + if (!finder.levelAllows(c, "paragraph")) { return; } - if (c.parentNode) { - parents.add(c.parentNode); + let child = c; + let parent = c.parentNode; + while (parent && parent.nodeType === 1) { + parents.add(parent); + const pe = parent; + if (!finder.isTransparent(pe)) { + break; + } + if (!atEdge(child, pe, "head") && !atEdge(child, pe, "tail")) { + break; + } + child = pe; + parent = pe.parentNode; } }); - for (const parent of parents) { - processParent(parent, ctx.finder, anyCjk); + const ordered = Array.from(parents).sort((a, b) => depthOf(b) - depthOf(a)); + for (const parent of ordered) { + processParent(parent, finder, anyCjk); } }, revert(ctx) { @@ -1388,7 +1449,7 @@ var Juzhen = (() => { var TRIM = "jz-hws-trim"; function trimGaps(root, finder, wrapSet) { const gaps = Array.from(root.querySelectorAll("jz-hws")).filter( - (g) => finder.inScope(g) && !finder.isAvoided(g) && finder.levelAllows(g, "paragraph") + (g) => finder.inScope(g) && !finder.isAvoided(g) && finder.levelAllows(g, "paragraph") && finder.featureEnabledFor(g, "spacing") ); if (!gaps.length) { return; @@ -1574,7 +1635,7 @@ var Juzhen = (() => { if (!finder.featureEnabledFor(block, PASS4)) { return; } - if (finder.avoidsSelf(block) || finder.isAvoided(block)) { + if (finder.isAvoided(block)) { return; } const sel = Array.from(blockSet).map((t) => t.toLowerCase()).join(","); @@ -1755,7 +1816,7 @@ var Juzhen = (() => { } function processPills(root, finder, boundarySelector, pillNeighbor) { root.querySelectorAll(boundarySelector).forEach((pill) => { - if (finder.avoidsSelf(pill) || finder.isAvoided(pill) || finder.insidePill(pill) || !finder.inScope(pill)) { + if (finder.isAvoided(pill) || finder.insidePill(pill) || !finder.inScope(pill)) { return; } if (!finder.featureEnabledFor(pill, PASS5)) { @@ -1817,7 +1878,7 @@ var Juzhen = (() => { } processBlock2(block, finder, R); }); - if (!options.finder.blockTags.has(ctx.root.nodeName) && finder.featureEnabledFor(ctx.root, PASS5) && finder.inScope(ctx.root) && !finder.avoidsSelf(ctx.root) && !finder.isAvoided(ctx.root)) { + 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); } const boundarySelector = [options.finder.pillSelector, options.finder.isolateSelector].filter(Boolean).join(", "); diff --git a/dist/juzhen.mjs b/dist/juzhen.mjs index f17778f..6d6dc77 100644 --- a/dist/juzhen.mjs +++ b/dist/juzhen.mjs @@ -52,18 +52,22 @@ var Finder = class { * jz-char 僅含標點(jiya 只 charify biaodian),標點非 pillNeighbor,故透明後 descend * 回傳標點 → 不補隙,與 collectRuns 之既有透明遞迴一致。jz-inner 亦須列入,否則 * descend 進入 jz-char 後因 jz-inner 不透明而跳過其內標點。 */ - transparentInline(el) { + isTransparent(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 / skipAttribute / avoid 選擇器 / isSkipped)。isAvoided 沿祖先 - * 鏈逐層呼叫本判定;遍歷原語另須對「正要進入」之元素直接判自身——舊版僅查 - * 祖先,元素自身命中 avoid 時被當透明穿越(下游問題六:scope.avoid 對 .katex - * 形同虛設)。 */ + /** 該元素**自身**是否為 avoid 邊界。avoid 之七種來源,依序短路判定、任一命中即是: + * ① isSkipped 謂詞(消費端函數) ② skipTags 標籤名單(PRE/SAMP/SCRIPT…,可覆寫) + * ③ SVG 命名空間 ④ contentEditable + * ⑤ data-jz-skip 屬性(內建) ⑥ skipAttribute(消費端指定屬性名) + * ⑦ scope.avoid 選擇器 + * **此為自身判定**,供遍歷原語(collectRuns/jiyaAdjacency/canSplit/isTransparent)於 + * 下探時逐元素自判(祖先由遞迴涵蓋)。**一次性**問「某節點是否被 avoid(自身或祖先)」 + * 請改用 isAvoided(單一組合入口),勿只用本方法而漏判祖先、或只用 isAvoided 而漏判自身 + * (下游問題六/階段一 #2:漏一半致 avoid 對自身形同虛設)。 */ avoidsSelf(el) { if (this.userIsSkipped && this.userIsSkipped(el)) { return true; @@ -88,10 +92,16 @@ var Finder = class { } return false; } - /** 節點是否處於 avoid 子樹(祖先鏈上任一元素 avoidsSelf;自身不計——元素 - * 自身之邊界判定由遍歷原語以 avoidsSelf 執行)。 */ + /** 某節點是否被 avoid——**自身即邊界**(元素經 avoidsSelf 之七源;非元素節點僅判 + * isSkipped)**或**祖先鏈上任一為 avoid 邊界。此為「是否 avoid」之**單一組合入口(安全 + * 預設)**:一次性檢查用此,免除「avoidsSelf ‖ isAvoided」易漏一半之誤用。遍歷原語逐 + * 元素下探時則用 avoidsSelf(僅自身,祖先由遞迴涵蓋)。 */ isAvoided(node) { - if (this.userIsSkipped && this.userIsSkipped(node)) { + if (node.nodeType === 1) { + if (this.avoidsSelf(node)) { + return true; + } + } else if (this.userIsSkipped && this.userIsSkipped(node)) { return true; } let p = node.parentNode; @@ -152,9 +162,9 @@ var Finder = class { } } /** - * 收集 block 之邏輯文本 run(架構 §4.1,I7):樣式行內元素與 wrap 類 - * jz-* 透明穿越;block/pill/avoid/間隙 marker(jz-hws)中斷 run。 - * 回傳 text.length >= 2 之 run(短於 2 無間隙可言)。 + * 收集 block 之邏輯文本 run(架構 §4.1,I7):透明行內(isTransparent:styleInlines + * + wrap 類 jz-*)穿越、run 延續;block/pill/avoid 中斷 run;isolate 與**未知行內 + * 標籤**(嚴格白名單)中斷 run 且內部自成獨立 run。回傳 text.length >= 2 之 run。 */ collectRuns(block) { const runs = []; @@ -195,21 +205,21 @@ var Finder = class { flush(); return; } - if (this.isolateMatches(el)) { - flush(); - let ic = el.firstChild; - while (ic) { - visit(ic); - ic = ic.nextSibling; + if (this.isTransparent(el)) { + let c2 = el.firstChild; + while (c2) { + visit(c2); + c2 = c2.nextSibling; } - flush(); return; } - let c2 = el.firstChild; - while (c2) { - visit(c2); - c2 = c2.nextSibling; + flush(); + let ic = el.firstChild; + while (ic) { + visit(ic); + ic = ic.nextSibling; } + flush(); }; let c = block.firstChild; while (c) { @@ -241,7 +251,7 @@ var Finder = class { return pickEnd(n2); } if (n2.nodeType === 1) { - if (!this.transparentInline(n2)) { + if (!this.isTransparent(n2)) { return "blocked"; } const r = descend(n2); @@ -266,7 +276,7 @@ var Finder = class { } if (n.nodeType === 1) { const e = n; - if (!this.transparentInline(e)) { + if (!this.isTransparent(e)) { return null; } const r = descend(e); @@ -281,7 +291,7 @@ var Finder = class { } n = direction === -1 ? n.previousSibling : n.nextSibling; } - if (!p || p.nodeType !== 1 || !this.transparentInline(p)) { + if (!p || p.nodeType !== 1 || !this.isTransparent(p)) { return null; } const pe = p; @@ -824,8 +834,7 @@ var jiyaAdjacencyPass = { level: "text", enabled: (o) => o.features.jiya, render(ctx) { - const { finder, options } = ctx; - const blockSet = options.finder.blockTags; + const { finder } = ctx; let prev = null; const squeeze = (L, R) => { if (!finder.featureEnabledFor(L, PASS)) { @@ -861,20 +870,14 @@ var jiyaAdjacencyPass = { squeeze(prev, el); } prev = el; - } else if (finder.pillMatches(el)) { - prev = null; - } else if (finder.avoidsSelf(el)) { - prev = null; - } else if (finder.isolateMatches(el)) { + } else if (finder.pillMatches(el) || finder.avoidsSelf(el)) { prev = null; + } else if (finder.isTransparent(el)) { visit(el); - prev = null; - } else if (blockSet.has(nm)) { - prev = null; - visit(el); - prev = null; } else { + prev = null; visit(el); + prev = null; } } }; @@ -1042,21 +1045,66 @@ var AVOID_TAIL = /* @__PURE__ */ new Set(["bd-open"]); function isCharEl(n) { return !!n && n.nodeType === 1 && n.nodeName === "JZ-CHAR"; } +function firstMeaningfulChild(el) { + for (let c = el.firstChild; c; c = c.nextSibling) { + if (c.nodeType === 3 && c.data.length === 0) { + continue; + } + return c; + } + return null; +} +function lastMeaningfulChild(el) { + for (let c = el.lastChild; c; c = c.previousSibling) { + if (c.nodeType === 3 && c.data.length === 0) { + continue; + } + return c; + } + return null; +} +function atEdge(node, el, side) { + return node === (side === "head" ? firstMeaningfulChild(el) : lastMeaningfulChild(el)); +} +function edgeCharEl(node, side, finder) { + let n = node; + while (n && n.nodeType === 1) { + if (isCharEl(n)) { + return n; + } + if (!finder.isTransparent(n)) { + return null; + } + n = side === "head" ? firstMeaningfulChild(n) : lastMeaningfulChild(n); + } + return null; +} function forbiddenBreak(a, b, finder) { - if (isCharEl(b)) { - const c = bdClassOf(b); - if (c && AVOID_HEAD.has(c) && finder.featureEnabledFor(b, PASS2)) { + const head = edgeCharEl(b, "head", finder); + if (head) { + const c = bdClassOf(head); + if (c && AVOID_HEAD.has(c) && finder.featureEnabledFor(head, PASS2)) { return true; } } - if (isCharEl(a)) { - const c = bdClassOf(a); - if (c && AVOID_TAIL.has(c) && finder.featureEnabledFor(a, PASS2)) { + const tail = edgeCharEl(a, "tail", finder); + if (tail) { + const c = bdClassOf(tail); + if (c && AVOID_TAIL.has(c) && finder.featureEnabledFor(tail, PASS2)) { return true; } } return false; } +function depthOf(node) { + let d = 0; + let p = node.parentNode; + while (p) { + d += 1; + p = p.parentNode; + } + return d; +} function isTokenChar2(ch, anyCjk) { return !anyCjk.test(ch) && !/\s/.test(ch); } @@ -1168,18 +1216,31 @@ var jinzePass = { level: "paragraph", enabled: (o) => o.features.jinze, render(ctx) { + const { finder } = ctx; const anyCjk = new RegExp("[" + ctx.options.ruleset.cjk + "]"); const parents = /* @__PURE__ */ new Set(); ctx.root.querySelectorAll("jz-char").forEach((c) => { - if (!ctx.finder.levelAllows(c, "paragraph")) { + if (!finder.levelAllows(c, "paragraph")) { return; } - if (c.parentNode) { - parents.add(c.parentNode); + let child = c; + let parent = c.parentNode; + while (parent && parent.nodeType === 1) { + parents.add(parent); + const pe = parent; + if (!finder.isTransparent(pe)) { + break; + } + if (!atEdge(child, pe, "head") && !atEdge(child, pe, "tail")) { + break; + } + child = pe; + parent = pe.parentNode; } }); - for (const parent of parents) { - processParent(parent, ctx.finder, anyCjk); + const ordered = Array.from(parents).sort((a, b) => depthOf(b) - depthOf(a)); + for (const parent of ordered) { + processParent(parent, finder, anyCjk); } }, revert(ctx) { @@ -1361,7 +1422,7 @@ var lineEdgePass = { var TRIM = "jz-hws-trim"; function trimGaps(root, finder, wrapSet) { const gaps = Array.from(root.querySelectorAll("jz-hws")).filter( - (g) => finder.inScope(g) && !finder.isAvoided(g) && finder.levelAllows(g, "paragraph") + (g) => finder.inScope(g) && !finder.isAvoided(g) && finder.levelAllows(g, "paragraph") && finder.featureEnabledFor(g, "spacing") ); if (!gaps.length) { return; @@ -1547,7 +1608,7 @@ var orphanPass = { if (!finder.featureEnabledFor(block, PASS4)) { return; } - if (finder.avoidsSelf(block) || finder.isAvoided(block)) { + if (finder.isAvoided(block)) { return; } const sel = Array.from(blockSet).map((t) => t.toLowerCase()).join(","); @@ -1728,7 +1789,7 @@ function processBlock2(block, finder, R) { } function processPills(root, finder, boundarySelector, pillNeighbor) { root.querySelectorAll(boundarySelector).forEach((pill) => { - if (finder.avoidsSelf(pill) || finder.isAvoided(pill) || finder.insidePill(pill) || !finder.inScope(pill)) { + if (finder.isAvoided(pill) || finder.insidePill(pill) || !finder.inScope(pill)) { return; } if (!finder.featureEnabledFor(pill, PASS5)) { @@ -1790,7 +1851,7 @@ var spacingPass = { } processBlock2(block, finder, R); }); - if (!options.finder.blockTags.has(ctx.root.nodeName) && finder.featureEnabledFor(ctx.root, PASS5) && finder.inScope(ctx.root) && !finder.avoidsSelf(ctx.root) && !finder.isAvoided(ctx.root)) { + 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); } const boundarySelector = [options.finder.pillSelector, options.finder.isolateSelector].filter(Boolean).join(", "); diff --git a/dist/types.d.ts b/dist/types.d.ts index 9948abb..e80f221 100644 --- a/dist/types.d.ts +++ b/dist/types.d.ts @@ -58,7 +58,7 @@ export interface JuzhenOptions { /** 作用域限定(§3.6)。 */ scope?: { root?: Element | string; - include?: string | null; + include?: string; avoid?: string; }; spacing?: boolean; diff --git a/src/core/finder.ts b/src/core/finder.ts index 804aa5f..7c5fd8c 100644 --- a/src/core/finder.ts +++ b/src/core/finder.ts @@ -4,12 +4,19 @@ // 查找、per-node 功能閘(featureEnabledFor)。finder 不知具體排版規則,只提供 // 遍歷與安全變更原語。 // -// 行內元素對排版有且僅有**三種口徑**(§4.1,所有遍歷原語共用同一判定): +// 行內元素對排版有且僅有**三種口徑**(§4.1): // 透明(styleInlines + jz-* wrap)— 穿越、run 延續、可克隆切分; // 隔離(isolateSelector,預設 mark)— 邊界斷 run/斷相鄰/不可切分、兩端補隙, // 內部照常處理(自成脈絡); // 整體(pillSelector)— 原子單位:內部對一切 pass 不可見,邊界同隔離。 // avoid(skip 名單/屬性/選擇器)疊加其上:自身即邊界、內外皆不處理。 +// +// **未知行內標籤**(不在 styleInlines、非 pill/isolate/avoid/block,如自訂元素/ +// web component)採**嚴格白名單**:一律當隔離式邊界(斷 run/斷相鄰、內部自成脈絡), +// 透明須顯式登記 styleInlines。**邏輯文本流**之遍歷原語(collectRuns/adjacentLogicalChar +// /jiya 之 jiyaAdjacency)共用 `isTransparent` 此**單一判定**。另兩層各有正當之不同邊界、 +// 刻意不併入:**結構切分安全**(core/split 之 canSplit——更窄白名單,/ABBR 等不可切) +// 與**物理版面**(typeset/lineedge——依 getClientRects 之物理相鄰,須跨 isolate 找真實鄰接)。 import type { FeatureLevel, ResolvedOptions } from "../types.js"; @@ -111,7 +118,7 @@ export class Finder * jz-char 僅含標點(jiya 只 charify biaodian),標點非 pillNeighbor,故透明後 descend * 回傳標點 → 不補隙,與 collectRuns 之既有透明遞迴一致。jz-inner 亦須列入,否則 * descend 進入 jz-char 後因 jz-inner 不透明而跳過其內標點。 */ - private transparentInline(el: Element): boolean + isTransparent(el: Element): boolean { if (this.pillMatches(el) || this.isolateMatches(el) || this.avoidsSelf(el)) { @@ -123,11 +130,15 @@ export class Finder || name === "JZ-CHAR" || name === "JZ-INNER"; } - /** 該元素**自身**是否為 avoid 邊界(內建 skip 名單 / SVG / contentEditable / - * data-jz-skip / skipAttribute / avoid 選擇器 / isSkipped)。isAvoided 沿祖先 - * 鏈逐層呼叫本判定;遍歷原語另須對「正要進入」之元素直接判自身——舊版僅查 - * 祖先,元素自身命中 avoid 時被當透明穿越(下游問題六:scope.avoid 對 .katex - * 形同虛設)。 */ + /** 該元素**自身**是否為 avoid 邊界。avoid 之七種來源,依序短路判定、任一命中即是: + * ① isSkipped 謂詞(消費端函數) ② skipTags 標籤名單(PRE/SAMP/SCRIPT…,可覆寫) + * ③ SVG 命名空間 ④ contentEditable + * ⑤ data-jz-skip 屬性(內建) ⑥ skipAttribute(消費端指定屬性名) + * ⑦ scope.avoid 選擇器 + * **此為自身判定**,供遍歷原語(collectRuns/jiyaAdjacency/canSplit/isTransparent)於 + * 下探時逐元素自判(祖先由遞迴涵蓋)。**一次性**問「某節點是否被 avoid(自身或祖先)」 + * 請改用 isAvoided(單一組合入口),勿只用本方法而漏判祖先、或只用 isAvoided 而漏判自身 + * (下游問題六/階段一 #2:漏一半致 avoid 對自身形同虛設)。 */ avoidsSelf(el: Element): boolean { if (this.userIsSkipped && this.userIsSkipped(el)) { return true; } @@ -143,11 +154,17 @@ export class Finder return false; } - /** 節點是否處於 avoid 子樹(祖先鏈上任一元素 avoidsSelf;自身不計——元素 - * 自身之邊界判定由遍歷原語以 avoidsSelf 執行)。 */ + /** 某節點是否被 avoid——**自身即邊界**(元素經 avoidsSelf 之七源;非元素節點僅判 + * isSkipped)**或**祖先鏈上任一為 avoid 邊界。此為「是否 avoid」之**單一組合入口(安全 + * 預設)**:一次性檢查用此,免除「avoidsSelf ‖ isAvoided」易漏一半之誤用。遍歷原語逐 + * 元素下探時則用 avoidsSelf(僅自身,祖先由遞迴涵蓋)。 */ isAvoided(node: Node): boolean { - if (this.userIsSkipped && this.userIsSkipped(node)) { return true; } + if (node.nodeType === 1) + { + if (this.avoidsSelf(node as Element)) { return true; } + } + else if (this.userIsSkipped && this.userIsSkipped(node)) { return true; } let p: Node | null = node.parentNode; while (p && p.nodeType === 1) { @@ -215,9 +232,9 @@ export class Finder } /** - * 收集 block 之邏輯文本 run(架構 §4.1,I7):樣式行內元素與 wrap 類 - * jz-* 透明穿越;block/pill/avoid/間隙 marker(jz-hws)中斷 run。 - * 回傳 text.length >= 2 之 run(短於 2 無間隙可言)。 + * 收集 block 之邏輯文本 run(架構 §4.1,I7):透明行內(isTransparent:styleInlines + * + wrap 類 jz-*)穿越、run 延續;block/pill/avoid 中斷 run;isolate 與**未知行內 + * 標籤**(嚴格白名單)中斷 run 且內部自成獨立 run。回傳 text.length >= 2 之 run。 */ collectRuns(block: Element): LogicalRun[] { @@ -249,26 +266,24 @@ export class Finder } if (node.nodeType !== 1) { return; } const el = node as Element; - // jz-hws 現包裹左側邊界字(以 margin-right 留隙)→ 透明遞迴,使其內 - // 之字仍計入邏輯 run;冪等改由「該字已在 jz-hws 內則不重複包裝」保證。 - if (this.avoidsSelf(el) || this.blockSet.has(el.nodeName)) + // avoid 自身 / block:斷 run、內部不入(avoid 內外皆不處理;block 另計)。 + if (this.avoidsSelf(el) || this.blockSet.has(el.nodeName)) { flush(); return; } + // 整體 pill:斷 run、內部完全不可見(不遞迴)。 + if (this.pillMatches(el)) { flush(); return; } + // 透明(styleInlines + wrap 類 jz-*,如 jz-hws/jz-jinze/jz-char/jz-inner): + // 遞迴穿越、run 延續。jz-hws 以 margin-right 包裹左側邊界字,其內之字仍計入邏輯 run。 + if (this.isTransparent(el)) { - flush(); + let c = el.firstChild; + while (c) { visit(c); c = c.nextSibling; } 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; } + // 隔離,及嚴格白名單下之**未知行內標籤**:斷 run,內部自成獨立 run(照常處理、 + // 不與外部相鄰)。透明須顯式登記 styleInlines(見檔頭三分類說明)。 + flush(); + let ic = el.firstChild; + while (ic) { visit(ic); ic = ic.nextSibling; } + flush(); }; let c = block.firstChild; @@ -310,7 +325,7 @@ export class Finder } if (n.nodeType === 1) { - if (!this.transparentInline(n as Element)) { return "blocked"; } + if (!this.isTransparent(n as Element)) { return "blocked"; } const r = descend(n as Element); if (r) { return r; } // 字或 blocked 皆上拋 } @@ -334,7 +349,7 @@ export class Finder if (n.nodeType === 1) { const e = n as Element; - if (!this.transparentInline(e)) + if (!this.isTransparent(e)) { return null; // pill/isolate/avoid/block — 邊界,止。 } @@ -346,7 +361,7 @@ export class Finder } n = direction === -1 ? n.previousSibling : n.nextSibling; } - if (!p || p.nodeType !== 1 || !this.transparentInline(p as Element)) + if (!p || p.nodeType !== 1 || !this.isTransparent(p as Element)) { return null; } diff --git a/src/index.ts b/src/index.ts index 4b35234..a2b7092 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,6 +43,10 @@ const DEFAULT_SKIP_TAGS = [ "SCRIPT", "STYLE", "TEXTAREA", "INPUT", ]; +// 執行序**不由本陣列決定**:runPasses(core/pass.ts)先分 charify/standalone 兩群、 +// 各群再按 pass.order 排序(charify 群整體先於 standalone 群)。故此處排列僅供閱讀分組, +// 真實序為 order:jiya(20)→jiyaAdjacency(25)→jinze(40)→spacing(50)→longWord(60)→ +// orphan(70)→lineEdge(90)→gapTrim(92)。 const PASSES: Pass[] = [ jiyaPass, jiyaAdjacencyPass, longWordPass, spacingPass, jinzePass, orphanPass, lineEdgePass, gapTrimPass, diff --git a/src/types.ts b/src/types.ts index 1fa267b..82165f3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -75,7 +75,7 @@ export interface JuzhenOptions /** 作用域限定(§3.6)。 */ scope?: { root?: Element | string; - include?: string | null; + include?: string; avoid?: string; }; diff --git a/src/typeset/jinze.ts b/src/typeset/jinze.ts index 6a2c30d..9313bfd 100644 --- a/src/typeset/jinze.ts +++ b/src/typeset/jinze.ts @@ -27,27 +27,80 @@ function isCharEl(n: Node | null): n is Element return !!n && n.nodeType === 1 && (n as Element).nodeName === "JZ-CHAR"; } -/** 相鄰兩節點之間是否禁止斷行(僅由 jz-char 之類別決定)。 - * 已知缺口(下游審計,待階段二):邊界標點若落於透明樣式行內元素邊緣(如 - * 「你好。世界」之句末點號位於 首位),因 jinze 僅處理 - * jz-char 之直接父(此處為 ),外層

之「好↔strong」綁定不被評估、 - * 致跨邊界避頭尾漏綁。完整修復須改 jinze 之父級處理模型(穿越透明包裹上攀), - * 與階段二「透明判定單一真相源」一併處理。 */ +/** el 首個「有意義」子節點(跳過空文本節點);無則 null。 */ +function firstMeaningfulChild(el: Element): Node | null +{ + for (let c = el.firstChild; c; c = c.nextSibling) + { + if (c.nodeType === 3 && (c as Text).data.length === 0) { continue; } + return c; + } + return null; +} + +/** el 末個「有意義」子節點(跳過空文本節點);無則 null。 */ +function lastMeaningfulChild(el: Element): Node | null +{ + for (let c = el.lastChild; c; c = c.previousSibling) + { + if (c.nodeType === 3 && (c as Text).data.length === 0) { continue; } + return c; + } + return null; +} + +/** node 是否位於 el 之邊緣(head=首/tail=末,跳過空文本)。 */ +function atEdge(node: Node, el: Element, side: "head" | "tail"): boolean +{ + return node === (side === "head" ? firstMeaningfulChild(el) : lastMeaningfulChild(el)); +} + +/** 單元之邊界 jz-char:自身即 jz-char,或其邊緣落於**透明**行內元素內之 jz-char——經 + * finder.isTransparent 逐層穿越透明元素下探定位(與邏輯流單一真相源一致)。遇非透明 + * (pill/isolate/avoid/block)或邊緣非 jz-char 即回 null。 */ +function edgeCharEl(node: Node, side: "head" | "tail", finder: Finder): Element | null +{ + let n: Node | null = node; + while (n && n.nodeType === 1) + { + if (isCharEl(n)) { return n; } + if (!finder.isTransparent(n as Element)) { return null; } + n = side === "head" ? firstMeaningfulChild(n as Element) : lastMeaningfulChild(n as Element); + } + return null; +} + +/** 相鄰兩節點之間是否禁止斷行。由「邊界 jz-char」之類別決定;邊界字可落於透明行內 + * 元素之邊緣(跨 等、與外部內容不同 DOM parent),故經 edgeCharEl 下探定位, + * 非僅認直接 jz-char——否則「你好。世界」之句末點號漏綁、誤置行首 + * (下游審計 #3)。綁定端由 processParent 之 isolateBoundaryToken 落實(可切則最小綁定、 + * 不可切則整綁),與此偵測同以透明判定為據。 */ function forbiddenBreak(a: Node, b: Node, finder: Finder): boolean { - if (isCharEl(b)) + const head = edgeCharEl(b, "head", finder); + if (head) { - const c = bdClassOf(b); - if (c && AVOID_HEAD.has(c) && finder.featureEnabledFor(b, PASS)) { return true; } + const c = bdClassOf(head); + if (c && AVOID_HEAD.has(c) && finder.featureEnabledFor(head, PASS)) { return true; } } - if (isCharEl(a)) + const tail = edgeCharEl(a, "tail", finder); + if (tail) { - const c = bdClassOf(a); - if (c && AVOID_TAIL.has(c) && finder.featureEnabledFor(a, PASS)) { return true; } + const c = bdClassOf(tail); + if (c && AVOID_TAIL.has(c) && finder.featureEnabledFor(tail, PASS)) { return true; } } return false; } +/** 節點在文檔樹中之深度(祖先數)。供 render 之「深者先處理」排序。 */ +function depthOf(node: Node): number +{ + let d = 0; + let p: Node | null = node.parentNode; + while (p) { d += 1; p = p.parentNode; } + return d; +} + // 「詞元」:CJK 字為單字詞元(可任意斷);連續非 CJK 非空白(拉丁詞/數字/ // 百分號/GPT-4/3.5 等)為一個不可內斷之詞元。綁定須以整個邊界詞元為單位, // 否則會把 200% 拆成 200|%,在其間製造斷點(修正:閉類綁定不應切斷數字/詞)。 @@ -170,16 +223,32 @@ export const jinzePass: StandalonePass = { enabled: (o) => o.features.jinze, render(ctx: RenderContext): void { + const { finder } = ctx; // 詞元邊界之 CJK 判定取自規則集(含 charClass 覆寫),與 spacing/jiya 一致。 const anyCjk = new RegExp("[" + ctx.options.ruleset.cjk + "]"); const parents = new Set(); ctx.root.querySelectorAll("jz-char").forEach((c) => { // 功能分級閘(§6.5.0a):禁則為段落級,text-level 子樹(標題/按鈕)跳過。 - if (!ctx.finder.levelAllows(c, "paragraph")) { return; } - if (c.parentNode) { parents.add(c.parentNode); } + if (!finder.levelAllows(c, "paragraph")) { return; } + // 收集 c 之直接父,並沿**透明**元素邊緣上攀:c 若位於某透明元素之邊緣,該元素 + // 於更外層為一單元、其邊界字(c)可與外層兄弟跨界綁定(如 好↔。世界), + // 故該外層 parent 亦須處理(下游審計 #3:僅處理直接父致跨透明邊界避頭尾漏綁)。 + let child: Node = c; + let parent: Node | null = c.parentNode; + while (parent && parent.nodeType === 1) + { + parents.add(parent); + const pe = parent as Element; + if (!finder.isTransparent(pe)) { break; } + if (!atEdge(child, pe, "head") && !atEdge(child, pe, "tail")) { break; } + child = pe; + parent = pe.parentNode; + } }); - for (const parent of parents) { processParent(parent, ctx.finder, anyCjk); } + // 深者先處理:透明子先於可能將其切分之外層 parent,避免子之內部綁定被外層切分擾動。 + const ordered = Array.from(parents).sort((a, b) => depthOf(b) - depthOf(a)); + for (const parent of ordered) { processParent(parent, finder, anyCjk); } }, revert(ctx: RenderContext): void { diff --git a/src/typeset/jiya.ts b/src/typeset/jiya.ts index fee3fb5..dfae061 100644 --- a/src/typeset/jiya.ts +++ b/src/typeset/jiya.ts @@ -131,8 +131,7 @@ export const jiyaAdjacencyPass: StandalonePass = { enabled: (o) => o.features.jiya, render(ctx: RenderContext): void { - const { finder, options } = ctx; - const blockSet = options.finder.blockTags; + const { finder } = ctx; let prev: Element | null = null; const squeeze = (L: Element, R: Element): void => @@ -166,17 +165,16 @@ export const jiyaAdjacencyPass: StandalonePass = { if (prev) { squeeze(prev, el); } prev = el; // jz-char 為原子,不下探(內含 jz-inner) } - else if (finder.pillMatches(el)) { prev = null; } // pill(整體):斷相鄰、不下探(內容對排版不可見) - else if (finder.avoidsSelf(el)) { prev = null; } // avoid 邊界(自身判定):斷、不下探 - else if (finder.isolateMatches(el)) + else if (finder.pillMatches(el) || finder.avoidsSelf(el)) { - prev = null; visit(el); prev = null; // 隔離:前後皆斷相鄰,內部自成脈絡(同 block) + prev = null; // 整體 pill/avoid:斷相鄰、不下探(內容不可見/不處理) } - else if (blockSet.has(nm)) + else if (finder.isTransparent(el)) { visit(el); } // 透明:穿越、相鄰性延續 + else { - prev = null; visit(el); prev = null; // block 邊界:前後皆斷相鄰,內部另計 + // 隔離/block/嚴格白名單下之未知行內:前後皆斷相鄰,內部自成脈絡(另計)。 + prev = null; visit(el); prev = null; } - else { visit(el); } // 行內(樣式行內 / pill 等):透明穿越、相鄰性延續 } }; visit(ctx.root); diff --git a/src/typeset/lineedge.ts b/src/typeset/lineedge.ts index 83eb774..80bec7c 100644 --- a/src/typeset/lineedge.ts +++ b/src/typeset/lineedge.ts @@ -245,7 +245,9 @@ const TRIM = "jz-hws-trim"; function trimGaps(root: Element, finder: Finder, wrapSet: Set): void { const gaps = Array.from(root.querySelectorAll("jz-hws")).filter( - (g) => finder.inScope(g) && !finder.isAvoided(g) && finder.levelAllows(g, "paragraph"), + (g) => finder.inScope(g) && !finder.isAvoided(g) + && finder.levelAllows(g, "paragraph") + && finder.featureEnabledFor(g, "spacing"), ); if (!gaps.length) { return; } diff --git a/src/typeset/orphan.ts b/src/typeset/orphan.ts index 2d297c0..5ea3fdb 100644 --- a/src/typeset/orphan.ts +++ b/src/typeset/orphan.ts @@ -146,11 +146,11 @@ export const orphanPass: StandalonePass = { finder.eachBlock(ctx.root, (block) => { - // 段落級閘 + per-node 功能閘 + avoid(自身或祖先命中皆不綁——舊版 - // 漏判,avoid 子樹內塊之裸文本直接子曾被計數並包入 jz-orphan)。 + // 段落級閘 + per-node 功能閘 + avoid(isAvoided 組合判定:自身或祖先命中皆 + // 不綁——舊版漏判,avoid 子樹內塊之裸文本直接子曾被計數並包入 jz-orphan)。 if (!finder.levelAllows(block, "paragraph")) { return; } if (!finder.featureEnabledFor(block, PASS)) { return; } - if (finder.avoidsSelf(block) || finder.isAvoided(block)) { return; } + if (finder.isAvoided(block)) { return; } // 僅處理葉段落(無塊級後代):含巢狀塊之容器,其實義字屬內層段落,另行處理。 const sel = Array.from(blockSet).map((t) => t.toLowerCase()).join(","); if (sel && block.querySelector(sel)) { return; } diff --git a/src/typeset/spacing.ts b/src/typeset/spacing.ts index 88988c1..e053fda 100644 --- a/src/typeset/spacing.ts +++ b/src/typeset/spacing.ts @@ -252,10 +252,9 @@ function processPills(root: Element, finder: Finder, boundarySelector: string, p { root.querySelectorAll(boundarySelector).forEach((pill) => { - // 自身即 avoid(如 pill 帶 data-jz-skip)亦不補間隙——isAvoided 只查祖先鏈、 - // 不含自身,須另判 avoidsSelf(下游問題六同型:漏此半則 avoid 對自身形同虛設)。 - if (finder.avoidsSelf(pill) || finder.isAvoided(pill) - || finder.insidePill(pill) || !finder.inScope(pill)) + // isAvoided 為組合判定(自身或祖先),涵蓋 pill 自身命中 avoid(如 data-jz-skip)之 + // 情形——下游問題六同型:僅查祖先則 avoid 對自身形同虛設。 + if (finder.isAvoided(pill) || finder.insidePill(pill) || !finder.inScope(pill)) { return; } @@ -335,8 +334,7 @@ export const spacingPass: StandalonePass = { // 遇巢狀 block 仍會 flush,故與上面逐 block 不重複;行內 root 通常無 block 子)。 if (!options.finder.blockTags.has(ctx.root.nodeName) && finder.featureEnabledFor(ctx.root, PASS) - && finder.inScope(ctx.root) - && !finder.avoidsSelf(ctx.root) && !finder.isAvoided(ctx.root)) + && finder.inScope(ctx.root) && !finder.isAvoided(ctx.root)) { processBlock(ctx.root, finder, R); } diff --git a/test/juzhen.test.mjs b/test/juzhen.test.mjs index 14b6d55..181465d 100644 --- a/test/juzhen.test.mjs +++ b/test/juzhen.test.mjs @@ -509,6 +509,24 @@ test("jinze 不可切元素退化整綁: 連結整體綁定(不克隆出 assert.ok(group && group.querySelector("a"), "整個 入綁(退化但安全)"); }); +test("jinze 跨透明邊界避頭: 首位句末點號綁至前字(你好。世界)", () => +{ + // 下游審計 #3:。位於 首位、與「好」不同 DOM parent,舊版僅處理 jz-char 之 + // 直接父(strong),外層 p 之「好↔strong」綁定不被評估致 。可誤置行首。修後於外層 + // 偵測(edgeCharEl 穿越透明元素)並綁定。 + const { document } = setupDom('

你好。世界結束

'); + const jz = createJuzhen({ spacing: false, longWord: false }); + jz.render(document.body); + const p = document.querySelector("p"); + const group = p.querySelector("jz-jinze"); + assert.ok(group, "產生跨邊界 jz-jinze 綁定組"); + assert.ok(group.textContent.includes("好") && group.textContent.includes("。"), + "。(strong 首位、避頭)與前字「好」綁入同一 nowrap 群組、不置行首"); + jz.revert(document.body); + assert.equal(p.textContent, "你好。世界結束", "revert 還原"); + assert.equal(countTag(p, "jz-jinze"), 0); +}); + test("jinze:revert 還原(無 jz-jinze/jz-char,文字回原樣)", () => { const { document } = setupDom('

他說好。」嗎

'); @@ -1073,6 +1091,23 @@ test("三分類互斥:pill 自身 avoid(data-jz-skip)→ 兩端不補間 "自身 avoid 之 pill 兩端不補間隙"); }); +test("嚴格白名單:未知行內標籤(寫字

"); + createJuzhen({ jiya: false, longWord: false, jinze: false }).render(a.document.body); + assert.equal(countTag(a.document.querySelector("p"), "jz-hws"), 0, + "預設 button 為邊界:內外不相鄰、無跨界中西隙"); + + // 顯式登記 styleInlines → 透明穿越:好↔Neo、Neo↔寫 兩處中西邊界補隙。 + const b = setupDom("

你好寫字

"); + createJuzhen({ jiya: false, longWord: false, jinze: false, styleInlines: [ "BUTTON" ] }) + .render(b.document.body); + assert.equal(countTag(b.document.querySelector("p"), "jz-hws"), 2, + "登記後透明穿越,跨界補中西隙"); +}); + test("問題六:相鄰兩 pill——descend 不刺穿,jz-hws 絕不插入公式內部", () => { const { document } = setupDom("

公式" + KATEX + KATEX + "文字

");