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:
+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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user