refactor: 專案整理階段二——透明判定單一真相源+跨透明邊界避頭尾+avoid 排除歸一

承階段一之下游審計。核心貫穿問題:「透明穿越」判定實有兩套口徑散落各處,抵觸 finder
檔頭「所有遍歷共用同一判定」之聲明。用戶抉擇:**嚴格白名單**(未知行內標籤=邊界,
透明須顯式登記 styleInlines)。

透明判定單一真相源(2a):
・transparentInline 提升為公開 isTransparent,作邏輯文本流之單一透明判定。
・collectRuns 改由 isTransparent 判定——未在 styleInlines 之未知行內標籤(如 <button>/自訂
  元素)當隔離式邊界(斷 run、內部自成脈絡);isolate 與未知行內併為同一分支。
・jiyaAdjacency 同改用 finder.isTransparent;adjacentLogicalChar 沿用(僅隨改名)。
・分層澄清(承 #4 教訓,不強行一統):邏輯文本流統一為 isTransparent;另兩層各有正當之
  不同邊界、刻意不併入——結構切分安全(split canSplit:更窄白名單,<a>/ABBR 不可切)與
  物理版面(lineedge:getClientRects 物理相鄰,須跨 isolate 找真實鄰接)。檔頭改分層表述。

跨透明元素邊界避頭尾綁定(2b,修下游審計 #3):
・避頭類標點若落於透明元素邊緣(「你好<strong>。世界</strong>」之 。位於 strong 首位、與
  「好」不同 DOM parent),舊版 forbiddenBreak 僅認直接 jz-char、render 僅處理 jz-char 之直接
  父(strong),外層 p 之「好↔strong」綁定從不被評估 → 。漏綁、可誤置行首。
・偵測:edgeCharEl 經 finder.isTransparent 穿越透明元素定位邊緣 jz-char。
・父級處理:render 沿透明元素邊緣上攀納入外層 parent,父集深者先處理。綁定端沿用
  isolateBoundaryToken(可切則最小綁定;邊界字為已 charify 之 jz-char 無法越過時退化整綁,
  安全、與 <a> 同型)。

avoid 排除機制歸一(2c):
・isAvoided 改單一組合入口(自身即邊界 或 祖先鏈任一邊界),免除「avoidsSelf ‖ isAvoided」
  易漏一半之誤用(階段一 #2 即此類);avoidsSelf 保留供遍歷原語逐元素下探自判(免 O(n²))。
・processPills/orphan/spacing root 收斂為 isAvoided(x);doc 補齊七種 avoid 來源與優先序。

一致性小項(2e):scope.include 型別去多餘 `| null`;PASSES 陣列補註(執行序由 pass.order
分群排序決定);gapTrim 補 featureEnabledFor("spacing"),與 lineEdge relayout 逐節點閘對稱。

評估後不做(2d):五處幂等判斷(jiya 祖先/longword 父/spacing 父/orphan 子/jinze 父)檢查
不同位置關係、無單一 isJz 形狀可全覆蓋;真正缺陷(jinze 二次 render 巢狀)已於階段一以 isJz
修復,其餘各自正確,強行歸一徒增風險(承 #4 教訓:正當差異不硬併)。

新增契約/回歸測試(button 嚴格白名單、跨透明邊界避頭);121 測試全綠、構建乾淨。
This commit is contained in:
2026-07-07 21:05:25 +08:00
parent 30a11b5d6e
commit e3296104a6
13 changed files with 435 additions and 186 deletions
+17 -11
View File
@@ -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 標籤名單(PRESAMPSCRIPT…,可覆寫)
* ③ SVG 命名空間 ④ contentEditable
* ⑤ data-jz-skip 屬性(內建) ⑥ skipAttribute(消費端指定屬性名)
* ⑦ scope.avoid 選擇器
* **此為自身判定**,供遍歷原語(collectRunsjiyaAdjacencycanSplitisTransparent)於
* 下探時逐元素自判(祖先由遞迴涵蓋)。**一次性**問「某節點是否被 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-* 透明穿越;blockpillavoid/間隙 markerjz-hws)中斷 run。
* 回傳 text.length >= 2 之 run(短於 2 無間隙可言)
* 收集 block 之邏輯文本 run(架構 §4.1,I7):透明行內(isTransparentstyleInlines
* wrap 類 jz-*)穿越、run 延續;blockpillavoid 中斷 runisolate 與**未知行內
* 標籤**(嚴格白名單)中斷 run 且內部自成獨立 run。回傳 text.length >= 2 之 run。
*/
collectRuns(block: Element): LogicalRun[];
/**
+111 -50
View File
@@ -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 標籤名單(PRESAMPSCRIPT…,可覆寫)
* ③ SVG 命名空間 ④ contentEditable
* ⑤ data-jz-skip 屬性(內建) ⑥ skipAttribute(消費端指定屬性名)
* ⑦ scope.avoid 選擇器
* **此為自身判定**,供遍歷原語(collectRunsjiyaAdjacencycanSplitisTransparent)於
* 下探時逐元素自判(祖先由遞迴涵蓋)。**一次性**問「某節點是否被 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-* 透明穿越;blockpillavoid/間隙 markerjz-hws)中斷 run。
* 回傳 text.length >= 2 之 run(短於 2 無間隙可言)
* 收集 block 之邏輯文本 run(架構 §4.1,I7):透明行內(isTransparentstyleInlines
* wrap 類 jz-*)穿越、run 延續;blockpillavoid 中斷 runisolate 與**未知行內
* 標籤**(嚴格白名單)中斷 run 且內部自成獨立 run。回傳 text.length >= 2 之 run。
*/
collectRuns(block) {
const runs = [];
@@ -222,7 +232,14 @@ var Juzhen = (() => {
flush();
return;
}
if (this.isolateMatches(el)) {
if (this.isTransparent(el)) {
let c2 = el.firstChild;
while (c2) {
visit(c2);
c2 = c2.nextSibling;
}
return;
}
flush();
let ic = el.firstChild;
while (ic) {
@@ -230,13 +247,6 @@ var Juzhen = (() => {
ic = ic.nextSibling;
}
flush();
return;
}
let c2 = el.firstChild;
while (c2) {
visit(c2);
c2 = c2.nextSibling;
}
};
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(", ");
+111 -50
View File
@@ -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 標籤名單(PRESAMPSCRIPT…,可覆寫)
* ③ SVG 命名空間 ④ contentEditable
* ⑤ data-jz-skip 屬性(內建) ⑥ skipAttribute(消費端指定屬性名)
* ⑦ scope.avoid 選擇器
* **此為自身判定**,供遍歷原語(collectRunsjiyaAdjacencycanSplitisTransparent)於
* 下探時逐元素自判(祖先由遞迴涵蓋)。**一次性**問「某節點是否被 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-* 透明穿越;blockpillavoid/間隙 markerjz-hws)中斷 run。
* 回傳 text.length >= 2 之 run(短於 2 無間隙可言)
* 收集 block 之邏輯文本 run(架構 §4.1,I7):透明行內(isTransparentstyleInlines
* wrap 類 jz-*)穿越、run 延續;blockpillavoid 中斷 runisolate 與**未知行內
* 標籤**(嚴格白名單)中斷 run 且內部自成獨立 run。回傳 text.length >= 2 之 run。
*/
collectRuns(block) {
const runs = [];
@@ -195,7 +205,14 @@ var Finder = class {
flush();
return;
}
if (this.isolateMatches(el)) {
if (this.isTransparent(el)) {
let c2 = el.firstChild;
while (c2) {
visit(c2);
c2 = c2.nextSibling;
}
return;
}
flush();
let ic = el.firstChild;
while (ic) {
@@ -203,13 +220,6 @@ var Finder = class {
ic = ic.nextSibling;
}
flush();
return;
}
let c2 = el.firstChild;
while (c2) {
visit(c2);
c2 = c2.nextSibling;
}
};
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(", ");
+1 -1
View File
@@ -58,7 +58,7 @@ export interface JuzhenOptions {
/** 作用域限定(§3.6)。 */
scope?: {
root?: Element | string;
include?: string | null;
include?: string;
avoid?: string;
};
spacing?: boolean;
+44 -29
View File
@@ -4,12 +4,19 @@
// 查找、per-node 功能閘(featureEnabledFor)。finder 不知具體排版規則,只提供
// 遍歷與安全變更原語。
//
// 行內元素對排版有且僅有**三種口徑**(§4.1,所有遍歷原語共用同一判定):
// 行內元素對排版有且僅有**三種口徑**(§4.1):
// 透明(styleInlines jz-* wrap)— 穿越、run 延續、可克隆切分;
// 隔離(isolateSelector,預設 mark)— 邊界斷 run/斷相鄰/不可切分、兩端補隙,
// 內部照常處理(自成脈絡);
// 整體(pillSelector)— 原子單位:內部對一切 pass 不可見,邊界同隔離。
// avoid(skip 名單/屬性/選擇器)疊加其上:自身即邊界、內外皆不處理。
//
// **未知行內標籤**(不在 styleInlines、非 pillisolateavoidblock,如自訂元素/
// web component)採**嚴格白名單**:一律當隔離式邊界(斷 run/斷相鄰、內部自成脈絡),
// 透明須顯式登記 styleInlines。**邏輯文本流**之遍歷原語(collectRunsadjacentLogicalChar
// jiya 之 jiyaAdjacency)共用 `isTransparent` 此**單一判定**。另兩層各有正當之不同邊界、
// 刻意不併入:**結構切分安全**core/split 之 canSplit——更窄白名單,<a>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 標籤名單(PRESAMPSCRIPT…,可覆寫)
* ③ SVG 命名空間 ④ contentEditable
* ⑤ data-jz-skip 屬性(內建) ⑥ skipAttribute(消費端指定屬性名)
* ⑦ scope.avoid 選擇器
* **此為自身判定**,供遍歷原語(collectRunsjiyaAdjacencycanSplitisTransparent)於
* 下探時逐元素自判(祖先由遞迴涵蓋)。**一次性**問「某節點是否被 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-* 透明穿越;blockpillavoid/間隙 markerjz-hws)中斷 run。
* 回傳 text.length >= 2 之 run(短於 2 無間隙可言)
* 收集 block 之邏輯文本 run(架構 §4.1,I7):透明行內(isTransparentstyleInlines
* wrap 類 jz-*)穿越、run 延續;blockpillavoid 中斷 runisolate 與**未知行內
* 標籤**(嚴格白名單)中斷 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-hwsjz-jinzejz-charjz-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(照常處理、不與外部相鄰)。
// 隔離,及嚴格白名單下之**未知行內標籤**:斷 run內部自成獨立 run(照常處理、
// 不與外部相鄰)。透明須顯式登記 styleInlines(見檔頭三分類說明)。
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; }
};
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; // pillisolateavoidblock — 邊界,止。
}
@@ -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;
}
+4
View File
@@ -43,6 +43,10 @@ const DEFAULT_SKIP_TAGS = [
"SCRIPT", "STYLE", "TEXTAREA", "INPUT",
];
// 執行序**不由本陣列決定**runPassescore/pass.ts)先分 charifystandalone 兩群、
// 各群再按 pass.order 排序(charify 群整體先於 standalone 群)。故此處排列僅供閱讀分組,
// 真實序為 orderjiya(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,
+1 -1
View File
@@ -75,7 +75,7 @@ export interface JuzhenOptions
/** 作用域限定(§3.6)。 */
scope?: {
root?: Element | string;
include?: string | null;
include?: string;
avoid?: string;
};
+84 -15
View File
@@ -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 之類別決定)。
* 已知缺口(下游審計,待階段二):邊界標點若落於透明樣式行內元素邊緣(如
* 「你好<strong>。世界</strong>」之句末點號位於 <strong> 首位),因 jinze 僅處理
* jz-char 之直接父(此處為 <strong>),外層 <p> 之「好↔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 逐層穿越透明元素下探定位(與邏輯流單一真相源一致)。遇非透明
* pillisolateavoidblock)或邊緣非 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」之類別決定;邊界字可落於透明行內
* 元素之邊緣(跨 <strong> 等、與外部內容不同 DOM parent),故經 edgeCharEl 下探定位,
* 非僅認直接 jz-char——否則「你好<strong>。世界</strong>」之句末點號漏綁、誤置行首
* (下游審計 #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 覆寫),與 spacingjiya 一致。
const anyCjk = new RegExp("[" + ctx.options.ruleset.cjk + "]");
const parents = new Set<Node>();
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)可與外層兄弟跨界綁定(如 好↔<strong>。世界),
// 故該外層 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
{
+7 -9
View File
@@ -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);
+3 -1
View File
@@ -245,7 +245,9 @@ const TRIM = "jz-hws-trim";
function trimGaps(root: Element, finder: Finder, wrapSet: Set<string>): 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; }
+3 -3
View File
@@ -146,11 +146,11 @@ export const orphanPass: StandalonePass = {
finder.eachBlock(ctx.root, (block) =>
{
// 段落級閘 per-node 功能閘 + avoid(自身或祖先命中皆不綁——舊版
// 漏判,avoid 子樹內塊之裸文本直接子曾被計數並包入 jz-orphan)。
// 段落級閘 per-node 功能閘 avoidisAvoided 組合判定:自身或祖先命中皆
// 不綁——舊版漏判,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; }
+4 -6
View File
@@ -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);
}
+35
View File
@@ -509,6 +509,24 @@ test("jinze 不可切元素退化整綁:<a> 連結整體綁定(不克隆出
assert.ok(group && group.querySelector("a"), "整個 <a> 入綁(退化但安全)");
});
test("jinze 跨透明邊界避頭:<strong> 首位句末點號綁至前字(你好<strong>。世界</strong>", () =>
{
// 下游審計 #3:。位於 <strong> 首位、與「好」不同 DOM parent,舊版僅處理 jz-char 之
// 直接父(strong),外層 p 之「好↔strong」綁定不被評估致 。可誤置行首。修後於外層
// 偵測(edgeCharEl 穿越透明元素)並綁定。
const { document } = setupDom('<p lang="zh-Hant">你好<strong>。世界</strong>結束</p>');
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("jinzerevert 還原(無 jz-jinzejz-char,文字回原樣)", () =>
{
const { document } = setupDom('<p lang="zh-Hant">他說好。」嗎</p>');
@@ -1073,6 +1091,23 @@ test("三分類互斥:pill 自身 avoiddata-jz-skip)→ 兩端不補間
"自身 avoid 之 pill 兩端不補間隙");
});
test("嚴格白名單:未知行內標籤(<button>)為邊界,登記 styleInlines 後轉透明(單一真相源)", () =>
{
// 邏輯文本流之三原語(collectRunsadjacentLogicalCharjiyaAdjacency)共用 isTransparent
// 未在 styleInlines 之行內標籤一律當隔離式邊界(斷 run/相鄰、內部自成脈絡)。
const a = setupDom("<p>你好<button>Neo</button>寫字</p>");
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("<p>你好<button>Neo</button>寫字</p>");
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("<p>公式" + KATEX + KATEX + "文字</p>");