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:
Vendored
+17
-11
@@ -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[];
|
||||
/**
|
||||
|
||||
Vendored
+114
-53
@@ -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(", ");
|
||||
|
||||
Vendored
+114
-53
@@ -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(", ");
|
||||
|
||||
Vendored
+1
-1
@@ -58,7 +58,7 @@ export interface JuzhenOptions {
|
||||
/** 作用域限定(§3.6)。 */
|
||||
scope?: {
|
||||
root?: Element | string;
|
||||
include?: string | null;
|
||||
include?: string;
|
||||
avoid?: string;
|
||||
};
|
||||
spacing?: boolean;
|
||||
|
||||
+48
-33
@@ -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——更窄白名單,<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 標籤名單(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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-1
@@ -75,7 +75,7 @@ export interface JuzhenOptions
|
||||
/** 作用域限定(§3.6)。 */
|
||||
scope?: {
|
||||
root?: Element | string;
|
||||
include?: string | null;
|
||||
include?: string;
|
||||
avoid?: string;
|
||||
};
|
||||
|
||||
|
||||
+84
-15
@@ -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 逐層穿越透明元素下探定位(與邏輯流單一真相源一致)。遇非透明
|
||||
* (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」之類別決定;邊界字可落於透明行內
|
||||
* 元素之邊緣(跨 <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 覆寫),與 spacing/jiya 一致。
|
||||
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
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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("jinze:revert 還原(無 jz-jinze/jz-char,文字回原樣)", () =>
|
||||
{
|
||||
const { document } = setupDom('<p lang="zh-Hant">他說好。」嗎</p>');
|
||||
@@ -1073,6 +1091,23 @@ test("三分類互斥:pill 自身 avoid(data-jz-skip)→ 兩端不補間
|
||||
"自身 avoid 之 pill 兩端不補間隙");
|
||||
});
|
||||
|
||||
test("嚴格白名單:未知行內標籤(<button>)為邊界,登記 styleInlines 後轉透明(單一真相源)", () =>
|
||||
{
|
||||
// 邏輯文本流之三原語(collectRuns/adjacentLogicalChar/jiyaAdjacency)共用 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>");
|
||||
|
||||
Reference in New Issue
Block a user