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
+48 -33
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(照常處理、不與外部相鄰)。
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; // 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;
}