refactor: 元素分類單一入口 finder.category + ResizeObserver 派發序不變量確認

承階段二之兩件收尾:確認 lineedge 雙 ResizeObserver 之派發序(審計唯一未闭環項),並把散落
各遍歷原語之元素分類優先序收斂為單一 finder.category。

ResizeObserver 派發序——**確認為非缺陷**(Chrome headless 實測):resize 時回調按 observer
**創建順序**派發(ResizeObserver 規範遍歷 observers 之插入序,非 observe 序;實測 seq=12——
反向 observe 仍按創建序)。lineEdgePass(order 90)之 observer 先於 gapTrimPass(order 92)建、
故每次 resize 皆先派發,gapTrim 之行末去隙判定恆基於 lineEdge **已套用**之半形版面(非陳舊態)。
以註解鎖為不變量(勿調換兩 pass 之 order/創建序),不需改碼。

元素分類單一入口:新增 `Finder.category(el): InlineCategory`——一元素恰屬 avoid/pill/isolate/
block/transparent/opaque 之一,優先序 `avoid > pill > isolate > block > transparent > opaque`
集中一處(原散落 collectRuns/jiyaAdjacency/canSplit/isTransparent 各自短路、易漂移)。
・isTransparent = `category === "transparent"`;collectRuns/jiyaAdjacency 改 `switch(category)`;
  canSplit = `SPLITTABLE ∩ transparent`(結構切分為獨立層、白名單更窄)。
・行為等價(121 全綠);多命中優先序(data-jz-skip pill→avoid、pill+isolate→pill、未知行內
  →opaque 邊界)由既有行為測試覆蓋。
・README 三分類要點+ARCHITECTURE §4.1 補優先序與 category 說明。
This commit is contained in:
2026-07-07 21:45:06 +08:00
parent 3e57c2c2cf
commit 4f84d31785
9 changed files with 271 additions and 143 deletions
+9 -1
View File
@@ -250,7 +250,15 @@ Nvim peekClaude 模板等 v1 消費端之過渡期。**最後一批 v1 消費
過濾 `insidePill`,不 charify/不插間隙/不切分/不下潛),禁則時作為原子鄰字 過濾 `insidePill`,不 charify/不插間隙/不切分/不下潛),禁則時作為原子鄰字
單位**整體綁入** `jz-jinze`。典型:`code``kbd`、行內公式 `.katex` 單位**整體綁入** `jz-jinze`。典型:`code``kbd`、行內公式 `.katex`
判定**以元素為準、非標籤名**`isTransparent(el)`,先排除 pillisolateavoid **單一分類 `finder.category(el)`(優先序)**:一元素恰屬六類之一,多選擇器同命中時依序短路
定案——`avoid pill isolate block transparent opaque``opaque`=未在 styleInlines
之未知行內標籤,嚴格白名單下當隔離式邊界)。故帶 `data-jz-skip` 之 pillisolate 判為 avoid
同命中 pill 與 isolate 判為 pillpill 覆蓋 isolate)。邏輯文本流之遍歷原語(`collectRuns`
`jiyaAdjacency``isTransparent`)皆 `switch(category)` 分派,`canSplit` `SPLITTABLE ∩ transparent`
——分類集中一處、優先序不再散落各原語各自短路(階段二後之清晰化)。
判定**以元素為準、非標籤名**`isTransparent(el) category(el)==="transparent"`,前置排除 pill
isolateavoid
再查標籤集):KaTeX 全樹皆 `<span>`,舊版按標籤名判透明曾令 `canSplit``descend` 再查標籤集):KaTeX 全樹皆 `<span>`,舊版按標籤名判透明曾令 `canSplit``descend`
整棵下潛——禁則把公式內側末字連同完整殼層逐層克隆切出(`splitoff`),上標漂移錯位 整棵下潛——禁則把公式內側末字連同完整殼層逐層克隆切出(`splitoff`),上標漂移錯位
(下游問題六);`pillSelector``skipAttribute``scope.avoid` 三層防護全被刺穿。 (下游問題六);`pillSelector``skipAttribute``scope.avoid` 三層防護全被刺穿。
+4
View File
@@ -186,6 +186,10 @@ web component 等)採**嚴格白名單**、一律當**邊界**(語義同隔
要點: 要點:
- **分類優先序(單一判定 `finder.category`)**:一元素恰屬其一,多選擇器同命中時依序短路定案——
`avoid pill isolate block transparent opaque``opaque`=未登記之未知行內標籤,
當隔離式邊界)。故帶 `data-jz-skip` 之 pillisolate 判為 avoid;同命中 pill 與 isolate 判為 pill。
邏輯文本流各原語(`collectRuns``jiyaAdjacency``adjacentLogicalChar`)皆據此**同一分類**分派。
- **判定以元素為準、非標籤名**:KaTeX 全樹皆 `<span>`,但 `.katex` 命中 `pillSelector` - **判定以元素為準、非標籤名**:KaTeX 全樹皆 `<span>`,但 `.katex` 命中 `pillSelector`
即為整體邊界——舊版按標籤名判透明曾整棵下潛、把公式內側末字連同殼層克隆切出(肢解)。 即為整體邊界——舊版按標籤名判透明曾整棵下潛、把公式內側末字連同殼層克隆切出(肢解)。
- **pill 行為覆蓋 isolate**:兩者邊界口徑完全相同(兩端補隙、斷 run/斷相鄰、不可切分, - **pill 行為覆蓋 isolate**:兩者邊界口徑完全相同(兩端補隙、斷 run/斷相鄰、不可切分,
+19 -14
View File
@@ -15,6 +15,8 @@ export interface AdjacentChar {
offset: number; offset: number;
ch: string; ch: string;
} }
/** 行內元素對排版之口徑分類(§4.1)——恰屬其一,優先序見 Finder.category。 */
export type InlineCategory = "avoid" | "pill" | "isolate" | "block" | "transparent" | "opaque";
export declare class Finder { export declare class Finder {
private readonly opts; private readonly opts;
private readonly styleSet; private readonly styleSet;
@@ -39,20 +41,23 @@ export declare class Finder {
* 一切 pass 不可見——不 charify、不收 run、不切分、不於其內插間隙(下游問題六: * 一切 pass 不可見——不 charify、不收 run、不切分、不於其內插間隙(下游問題六:
* .katex 內部曾被 jiyaspacing 直接變更)。 */ * .katex 內部曾被 jiyaspacing 直接變更)。 */
insidePill(node: Node): boolean; insidePill(node: Node): boolean;
/** 邏輯文本應**透明穿越**之行內元素:樣式行內標籤,以及聚珍自身之 wrap 類 /**
* jz-*jz-hws 間隙、jz-jinze 禁則群組、jz-charjz-inner 標點原子)。穿越這些 * 行內元素分類(§4.1)——一元素對排版之口徑,**恰屬其一**。**優先序**(依序短路,即多
* 才能正確找到跨包裝之邏輯鄰字(如 jinze 已把 pill 納入 jz-jinze 後,pill 之左 * 選擇器同命中時之定案規則):`avoid pill isolate block transparent opaque`
* 鄰字仍須可達;I7)。 * opaque=未在 styleInlines 之未知行內標籤,嚴格白名單下當隔離式邊界)。故:同時命中
* ⚠ 以**元素**而非標籤名判定:pillisolate/avoid 縱使標籤名在樣式集亦為邊界、 * pill 與 isolate → **pill**pill 覆蓋 isolate);帶 data-jz-skip 之 pillisolate → **avoid**
* 不可穿越(下游問題六:KaTeX 全樹皆 SPAN,名字判定令整棵公式被當透明下潛而 * (自身排除最高)。**邏輯文本流之遍歷原語**collectRunsjiyaAdjacencyadjacentLogicalChar
* 肢解;scope.avoidskipAttribute 命中自身者同理)。 * 之 isTransparent)皆據此**單一分類**分派;core/split 之 canSplit SPLITTABLE 白名單 ∩
* ⚠ jz-charjz-inner 必須透明:否則 adjacentLogicalChar 之 descend 遇標點原子既 * transparent(結構切分為獨立層,故更窄)。
* 不進入亦不終止、而是**跳過**它續找更前之兄弟(下游 Bug`汉字。<code>` 經 jinze */
* 綁定為 `汉<jz-jinze>字<jz-char>。</jz-char></jz-jinze><code>`pill 之左鄰字應 category(el: Element): InlineCategory;
* 為「。」、不補間隙;舊邏輯卻越過 jz-char 取到「字」→ 誤包 jz-hws 於「字。」之間)。 /** category 之透明判定末步:styleInlines 標籤,或聚珍自身之 wrap 類 jz-*jz-hws 間隙、
* jz-char 僅含標點(jiya 只 charify biaodian),標點非 pillNeighbor,故透明後 descend * jz-jinze 禁則群組、**jz-charjz-inner 標點原子**)。前四步已排除 avoid/pill/isolate/block。
* 回傳標點 → 不補隙,與 collectRuns 之既有透明遞迴一致。jz-inner 須列入否則 * jz-charjz-inner 須列入否則 adjacentLogicalChar 之 descend 遇標點原子既不進入亦不
* descend 進入 jz-char 後因 jz-inner 不透明而跳過其內標點。 */ * 終止、而**跳過**它續找更前之字(`汉字。<code>` 綁定後 pill 左鄰字應為「。」卻誤取「字」
* 補 jz-hws);jz-char 僅含標點(非 pillNeighbor),透明後 descend 回傳標點、不補隙。 */
private isTransparentTag;
/** el 是否為邏輯文本流可透明穿越者(= category "transparent")。 */
isTransparent(el: Element): boolean; isTransparent(el: Element): boolean;
/** 該元素**自身**是否為 avoid 邊界。avoid 之七種來源,依序短路判定、任一命中即是: /** 該元素**自身**是否為 avoid 邊界。avoid 之七種來源,依序短路判定、任一命中即是:
* ① isSkipped 謂詞(消費端函數) ② skipTags 標籤名單(PRESAMPSCRIPT…,可覆寫) * ① isSkipped 謂詞(消費端函數) ② skipTags 標籤名單(PRESAMPSCRIPT…,可覆寫)
+73 -42
View File
@@ -65,27 +65,46 @@ var Juzhen = (() => {
} }
return false; return false;
} }
/** 邏輯文本應**透明穿越**之行內元素:樣式行內標籤,以及聚珍自身之 wrap 類 /**
* jz-*jz-hws 間隙、jz-jinze 禁則群組、jz-charjz-inner 標點原子)。穿越這些 * 行內元素分類(§4.1)——一元素對排版之口徑,**恰屬其一**。**優先序**(依序短路,即多
* 才能正確找到跨包裝之邏輯鄰字(如 jinze 已把 pill 納入 jz-jinze 後,pill 之左 * 選擇器同命中時之定案規則):`avoid pill isolate block transparent opaque`
* 鄰字仍須可達;I7)。 * opaque=未在 styleInlines 之未知行內標籤,嚴格白名單下當隔離式邊界)。故:同時命中
* ⚠ 以**元素**而非標籤名判定:pillisolate/avoid 縱使標籤名在樣式集亦為邊界、 * pill 與 isolate → **pill**pill 覆蓋 isolate);帶 data-jz-skip 之 pillisolate → **avoid**
* 不可穿越(下游問題六:KaTeX 全樹皆 SPAN,名字判定令整棵公式被當透明下潛而 * (自身排除最高)。**邏輯文本流之遍歷原語**collectRunsjiyaAdjacencyadjacentLogicalChar
* 肢解;scope.avoidskipAttribute 命中自身者同理)。 * 之 isTransparent)皆據此**單一分類**分派;core/split 之 canSplit SPLITTABLE 白名單 ∩
* ⚠ jz-charjz-inner 必須透明:否則 adjacentLogicalChar 之 descend 遇標點原子既 * transparent(結構切分為獨立層,故更窄)。
* 不進入亦不終止、而是**跳過**它續找更前之兄弟(下游 Bug`汉字。<code>` 經 jinze */
* 綁定為 `汉<jz-jinze>字<jz-char>。</jz-char></jz-jinze><code>`pill 之左鄰字應 category(el) {
* 為「。」、不補間隙;舊邏輯卻越過 jz-char 取到「字」→ 誤包 jz-hws 於「字。」之間)。 if (this.avoidsSelf(el)) {
* jz-char 僅含標點(jiya 只 charify biaodian),標點非 pillNeighbor,故透明後 descend return "avoid";
* 回傳標點 → 不補隙,與 collectRuns 之既有透明遞迴一致。jz-inner 亦須列入,否則
* descend 進入 jz-char 後因 jz-inner 不透明而跳過其內標點。 */
isTransparent(el) {
if (this.pillMatches(el) || this.isolateMatches(el) || this.avoidsSelf(el)) {
return false;
} }
if (this.pillMatches(el)) {
return "pill";
}
if (this.isolateMatches(el)) {
return "isolate";
}
if (this.blockSet.has(el.nodeName)) {
return "block";
}
if (this.isTransparentTag(el)) {
return "transparent";
}
return "opaque";
}
/** category 之透明判定末步:styleInlines 標籤,或聚珍自身之 wrap 類 jz-*jz-hws 間隙、
* jz-jinze 禁則群組、**jz-charjz-inner 標點原子**)。前四步已排除 avoid/pill/isolate/block。
* jz-charjz-inner 必須列入:否則 adjacentLogicalChar 之 descend 遇標點原子既不進入亦不
* 終止、而**跳過**它續找更前之字(`汉字。<code>` 綁定後 pill 左鄰字應為「。」卻誤取「字」
* 補 jz-hws);jz-char 僅含標點(非 pillNeighbor),透明後 descend 回傳標點、不補隙。 */
isTransparentTag(el) {
const name = el.nodeName; const name = el.nodeName;
return this.styleSet.has(name) || name === "JZ-HWS" || name === "JZ-JINZE" || name === "JZ-CHAR" || name === "JZ-INNER"; return this.styleSet.has(name) || name === "JZ-HWS" || name === "JZ-JINZE" || name === "JZ-CHAR" || name === "JZ-INNER";
} }
/** el 是否為邏輯文本流可透明穿越者(= category "transparent")。 */
isTransparent(el) {
return this.category(el) === "transparent";
}
/** 該元素**自身**是否為 avoid 邊界。avoid 之七種來源,依序短路判定、任一命中即是: /** 該元素**自身**是否為 avoid 邊界。avoid 之七種來源,依序短路判定、任一命中即是:
* ① isSkipped 謂詞(消費端函數) ② skipTags 標籤名單(PRESAMPSCRIPT…,可覆寫) * ① isSkipped 謂詞(消費端函數) ② skipTags 標籤名單(PRESAMPSCRIPT…,可覆寫)
* ③ SVG 命名空間 ④ contentEditable * ③ SVG 命名空間 ④ contentEditable
@@ -224,29 +243,31 @@ var Juzhen = (() => {
return; return;
} }
const el = node; const el = node;
if (this.avoidsSelf(el) || this.blockSet.has(el.nodeName)) { const recurse = () => {
flush();
return;
}
if (this.pillMatches(el)) {
flush();
return;
}
if (this.isTransparent(el)) {
let c2 = el.firstChild; let c2 = el.firstChild;
while (c2) { while (c2) {
visit(c2); visit(c2);
c2 = c2.nextSibling; c2 = c2.nextSibling;
} }
return; };
switch (this.category(el)) {
case "avoid":
case "block":
case "pill": {
flush();
return;
}
case "transparent": {
recurse();
return;
}
default: {
flush();
recurse();
flush();
return;
}
} }
flush();
let ic = el.firstChild;
while (ic) {
visit(ic);
ic = ic.nextSibling;
}
flush();
}; };
let c = block.firstChild; let c = block.firstChild;
while (c) { while (c) {
@@ -897,14 +918,24 @@ var Juzhen = (() => {
squeeze(prev, el); squeeze(prev, el);
} }
prev = el; prev = el;
} else if (finder.pillMatches(el) || finder.avoidsSelf(el)) {
prev = null;
} else if (finder.isTransparent(el)) {
visit(el);
} else { } else {
prev = null; switch (finder.category(el)) {
visit(el); case "avoid":
prev = null; case "pill": {
prev = null;
break;
}
case "transparent": {
visit(el);
break;
}
default: {
prev = null;
visit(el);
prev = null;
break;
}
}
} }
} }
}; };
@@ -932,7 +963,7 @@ var Juzhen = (() => {
"SUP" "SUP"
]); ]);
function canSplit(el, finder) { function canSplit(el, finder) {
return SPLITTABLE.has(el.nodeName) && !el.hasAttribute("id") && !finder.pillMatches(el) && !finder.isolateMatches(el) && !finder.avoidsSelf(el); return SPLITTABLE.has(el.nodeName) && !el.hasAttribute("id") && finder.category(el) === "transparent";
} }
function firstText(el, finder) { function firstText(el, finder) {
for (let c = el.firstChild; c; c = c.nextSibling) { for (let c = el.firstChild; c; c = c.nextSibling) {
+73 -42
View File
@@ -38,27 +38,46 @@ var Finder = class {
} }
return false; return false;
} }
/** 邏輯文本應**透明穿越**之行內元素:樣式行內標籤,以及聚珍自身之 wrap 類 /**
* jz-*jz-hws 間隙、jz-jinze 禁則群組、jz-charjz-inner 標點原子)。穿越這些 * 行內元素分類(§4.1)——一元素對排版之口徑,**恰屬其一**。**優先序**(依序短路,即多
* 才能正確找到跨包裝之邏輯鄰字(如 jinze 已把 pill 納入 jz-jinze 後,pill 之左 * 選擇器同命中時之定案規則):`avoid pill isolate block transparent opaque`
* 鄰字仍須可達;I7)。 * opaque=未在 styleInlines 之未知行內標籤,嚴格白名單下當隔離式邊界)。故:同時命中
* ⚠ 以**元素**而非標籤名判定:pillisolate/avoid 縱使標籤名在樣式集亦為邊界、 * pill 與 isolate → **pill**pill 覆蓋 isolate);帶 data-jz-skip 之 pillisolate → **avoid**
* 不可穿越(下游問題六:KaTeX 全樹皆 SPAN,名字判定令整棵公式被當透明下潛而 * (自身排除最高)。**邏輯文本流之遍歷原語**collectRunsjiyaAdjacencyadjacentLogicalChar
* 肢解;scope.avoidskipAttribute 命中自身者同理)。 * 之 isTransparent)皆據此**單一分類**分派;core/split 之 canSplit SPLITTABLE 白名單 ∩
* ⚠ jz-charjz-inner 必須透明:否則 adjacentLogicalChar 之 descend 遇標點原子既 * transparent(結構切分為獨立層,故更窄)。
* 不進入亦不終止、而是**跳過**它續找更前之兄弟(下游 Bug`汉字。<code>` 經 jinze */
* 綁定為 `汉<jz-jinze>字<jz-char>。</jz-char></jz-jinze><code>`pill 之左鄰字應 category(el) {
* 為「。」、不補間隙;舊邏輯卻越過 jz-char 取到「字」→ 誤包 jz-hws 於「字。」之間)。 if (this.avoidsSelf(el)) {
* jz-char 僅含標點(jiya 只 charify biaodian),標點非 pillNeighbor,故透明後 descend return "avoid";
* 回傳標點 → 不補隙,與 collectRuns 之既有透明遞迴一致。jz-inner 亦須列入,否則
* descend 進入 jz-char 後因 jz-inner 不透明而跳過其內標點。 */
isTransparent(el) {
if (this.pillMatches(el) || this.isolateMatches(el) || this.avoidsSelf(el)) {
return false;
} }
if (this.pillMatches(el)) {
return "pill";
}
if (this.isolateMatches(el)) {
return "isolate";
}
if (this.blockSet.has(el.nodeName)) {
return "block";
}
if (this.isTransparentTag(el)) {
return "transparent";
}
return "opaque";
}
/** category 之透明判定末步:styleInlines 標籤,或聚珍自身之 wrap 類 jz-*jz-hws 間隙、
* jz-jinze 禁則群組、**jz-charjz-inner 標點原子**)。前四步已排除 avoid/pill/isolate/block。
* jz-charjz-inner 必須列入:否則 adjacentLogicalChar 之 descend 遇標點原子既不進入亦不
* 終止、而**跳過**它續找更前之字(`汉字。<code>` 綁定後 pill 左鄰字應為「。」卻誤取「字」
* 補 jz-hws);jz-char 僅含標點(非 pillNeighbor),透明後 descend 回傳標點、不補隙。 */
isTransparentTag(el) {
const name = el.nodeName; const name = el.nodeName;
return this.styleSet.has(name) || name === "JZ-HWS" || name === "JZ-JINZE" || name === "JZ-CHAR" || name === "JZ-INNER"; return this.styleSet.has(name) || name === "JZ-HWS" || name === "JZ-JINZE" || name === "JZ-CHAR" || name === "JZ-INNER";
} }
/** el 是否為邏輯文本流可透明穿越者(= category "transparent")。 */
isTransparent(el) {
return this.category(el) === "transparent";
}
/** 該元素**自身**是否為 avoid 邊界。avoid 之七種來源,依序短路判定、任一命中即是: /** 該元素**自身**是否為 avoid 邊界。avoid 之七種來源,依序短路判定、任一命中即是:
* ① isSkipped 謂詞(消費端函數) ② skipTags 標籤名單(PRESAMPSCRIPT…,可覆寫) * ① isSkipped 謂詞(消費端函數) ② skipTags 標籤名單(PRESAMPSCRIPT…,可覆寫)
* ③ SVG 命名空間 ④ contentEditable * ③ SVG 命名空間 ④ contentEditable
@@ -197,29 +216,31 @@ var Finder = class {
return; return;
} }
const el = node; const el = node;
if (this.avoidsSelf(el) || this.blockSet.has(el.nodeName)) { const recurse = () => {
flush();
return;
}
if (this.pillMatches(el)) {
flush();
return;
}
if (this.isTransparent(el)) {
let c2 = el.firstChild; let c2 = el.firstChild;
while (c2) { while (c2) {
visit(c2); visit(c2);
c2 = c2.nextSibling; c2 = c2.nextSibling;
} }
return; };
switch (this.category(el)) {
case "avoid":
case "block":
case "pill": {
flush();
return;
}
case "transparent": {
recurse();
return;
}
default: {
flush();
recurse();
flush();
return;
}
} }
flush();
let ic = el.firstChild;
while (ic) {
visit(ic);
ic = ic.nextSibling;
}
flush();
}; };
let c = block.firstChild; let c = block.firstChild;
while (c) { while (c) {
@@ -870,14 +891,24 @@ var jiyaAdjacencyPass = {
squeeze(prev, el); squeeze(prev, el);
} }
prev = el; prev = el;
} else if (finder.pillMatches(el) || finder.avoidsSelf(el)) {
prev = null;
} else if (finder.isTransparent(el)) {
visit(el);
} else { } else {
prev = null; switch (finder.category(el)) {
visit(el); case "avoid":
prev = null; case "pill": {
prev = null;
break;
}
case "transparent": {
visit(el);
break;
}
default: {
prev = null;
visit(el);
prev = null;
break;
}
}
} }
} }
}; };
@@ -905,7 +936,7 @@ var SPLITTABLE = /* @__PURE__ */ new Set([
"SUP" "SUP"
]); ]);
function canSplit(el, finder) { function canSplit(el, finder) {
return SPLITTABLE.has(el.nodeName) && !el.hasAttribute("id") && !finder.pillMatches(el) && !finder.isolateMatches(el) && !finder.avoidsSelf(el); return SPLITTABLE.has(el.nodeName) && !el.hasAttribute("id") && finder.category(el) === "transparent";
} }
function firstText(el, finder) { function firstText(el, finder) {
for (let c = el.firstChild; c; c = c.nextSibling) { for (let c = el.firstChild; c; c = c.nextSibling) {
+62 -33
View File
@@ -42,6 +42,10 @@ export interface AdjacentChar
ch: string; ch: string;
} }
/** 行內元素對排版之口徑分類(§4.1)——恰屬其一,優先序見 Finder.category。 */
export type InlineCategory =
| "avoid" | "pill" | "isolate" | "block" | "transparent" | "opaque";
const SVG_NS = "http://www.w3.org/2000/svg"; const SVG_NS = "http://www.w3.org/2000/svg";
export class Finder export class Finder
@@ -104,32 +108,44 @@ export class Finder
return false; return false;
} }
/** 邏輯文本應**透明穿越**之行內元素:樣式行內標籤,以及聚珍自身之 wrap 類 /**
* jz-*jz-hws 間隙、jz-jinze 禁則群組、jz-charjz-inner 標點原子)。穿越這些 * 行內元素分類(§4.1)——一元素對排版之口徑,**恰屬其一**。**優先序**(依序短路,即多
* 才能正確找到跨包裝之邏輯鄰字(如 jinze 已把 pill 納入 jz-jinze 後,pill 之左 * 選擇器同命中時之定案規則):`avoid pill isolate block transparent opaque`
* 鄰字仍須可達;I7)。 * opaque=未在 styleInlines 之未知行內標籤,嚴格白名單下當隔離式邊界)。故:同時命中
* ⚠ 以**元素**而非標籤名判定:pillisolate/avoid 縱使標籤名在樣式集亦為邊界、 * pill 與 isolate → **pill**pill 覆蓋 isolate);帶 data-jz-skip 之 pillisolate → **avoid**
* 不可穿越(下游問題六:KaTeX 全樹皆 SPAN,名字判定令整棵公式被當透明下潛而 * (自身排除最高)。**邏輯文本流之遍歷原語**collectRunsjiyaAdjacencyadjacentLogicalChar
* 肢解;scope.avoidskipAttribute 命中自身者同理)。 * 之 isTransparent)皆據此**單一分類**分派;core/split 之 canSplit SPLITTABLE 白名單 ∩
* ⚠ jz-charjz-inner 必須透明:否則 adjacentLogicalChar 之 descend 遇標點原子既 * transparent(結構切分為獨立層,故更窄)。
* 不進入亦不終止、而是**跳過**它續找更前之兄弟(下游 Bug`汉字。<code>` 經 jinze */
* 綁定為 `汉<jz-jinze>字<jz-char>。</jz-char></jz-jinze><code>`pill 之左鄰字應 category(el: Element): InlineCategory
* 為「。」、不補間隙;舊邏輯卻越過 jz-char 取到「字」→ 誤包 jz-hws 於「字。」之間)。 {
* jz-char 僅含標點(jiya 只 charify biaodian),標點非 pillNeighbor,故透明後 descend if (this.avoidsSelf(el)) { return "avoid"; }
* 回傳標點 → 不補隙,與 collectRuns 之既有透明遞迴一致。jz-inner 亦須列入,否則 if (this.pillMatches(el)) { return "pill"; }
* descend 進入 jz-char 後因 jz-inner 不透明而跳過其內標點。 */ if (this.isolateMatches(el)) { return "isolate"; }
isTransparent(el: Element): boolean if (this.blockSet.has(el.nodeName)) { return "block"; }
if (this.isTransparentTag(el)) { return "transparent"; }
return "opaque";
}
/** category 之透明判定末步:styleInlines 標籤,或聚珍自身之 wrap 類 jz-*jz-hws 間隙、
* jz-jinze 禁則群組、**jz-charjz-inner 標點原子**)。前四步已排除 avoid/pill/isolate/block。
* jz-charjz-inner 必須列入:否則 adjacentLogicalChar 之 descend 遇標點原子既不進入亦不
* 終止、而**跳過**它續找更前之字(`汉字。<code>` 綁定後 pill 左鄰字應為「。」卻誤取「字」
* 補 jz-hws);jz-char 僅含標點(非 pillNeighbor),透明後 descend 回傳標點、不補隙。 */
private isTransparentTag(el: Element): boolean
{ {
if (this.pillMatches(el) || this.isolateMatches(el) || this.avoidsSelf(el))
{
return false;
}
const name = el.nodeName; const name = el.nodeName;
return this.styleSet.has(name) return this.styleSet.has(name)
|| name === "JZ-HWS" || name === "JZ-JINZE" || name === "JZ-HWS" || name === "JZ-JINZE"
|| name === "JZ-CHAR" || name === "JZ-INNER"; || name === "JZ-CHAR" || name === "JZ-INNER";
} }
/** el 是否為邏輯文本流可透明穿越者(= category "transparent")。 */
isTransparent(el: Element): boolean
{
return this.category(el) === "transparent";
}
/** 該元素**自身**是否為 avoid 邊界。avoid 之七種來源,依序短路判定、任一命中即是: /** 該元素**自身**是否為 avoid 邊界。avoid 之七種來源,依序短路判定、任一命中即是:
* ① isSkipped 謂詞(消費端函數) ② skipTags 標籤名單(PRESAMPSCRIPT…,可覆寫) * ① isSkipped 謂詞(消費端函數) ② skipTags 標籤名單(PRESAMPSCRIPT…,可覆寫)
* ③ SVG 命名空間 ④ contentEditable * ③ SVG 命名空間 ④ contentEditable
@@ -266,24 +282,37 @@ export class Finder
} }
if (node.nodeType !== 1) { return; } if (node.nodeType !== 1) { return; }
const el = node as Element; const el = node as Element;
// avoid 自身 / block:斷 run、內部不入(avoid 內外皆不處理;block 另計)。 const recurse = (): void =>
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))
{ {
let c = el.firstChild; let c = el.firstChild;
while (c) { visit(c); c = c.nextSibling; } while (c) { visit(c); c = c.nextSibling; }
return; };
switch (this.category(el))
{
case "avoid":
case "block":
case "pill":
{
// 邊界、內部不入:avoid/pill 內部不可見;block 另由 eachBlock 處理。
flush();
return;
}
case "transparent":
{
// 透明:遞迴穿越、run 延續。jz-hws 以 margin-right 包裹左側邊界字,
// 其內之字仍計入邏輯 run。
recurse();
return;
}
default: // "isolate" | "opaque"(未知行內,嚴格白名單)
{
// 斷 run,內部自成獨立 run(照常處理、不與外部相鄰)。
flush();
recurse();
flush();
return;
}
} }
// 隔離,及嚴格白名單下之**未知行內標籤**:斷 run,內部自成獨立 run(照常處理、
// 不與外部相鄰)。透明須顯式登記 styleInlines(見檔頭三分類說明)。
flush();
let ic = el.firstChild;
while (ic) { visit(ic); ic = ic.nextSibling; }
flush();
}; };
let c = block.firstChild; let c = block.firstChild;
+5 -4
View File
@@ -38,14 +38,15 @@ interface Point
offset: number; offset: number;
} }
/** 元素是否可克隆切分(純樣式行內標籤、無 id、非 pill/isolateavoid 邊界)。 */ /** SPLITTABLE <a>ABBRRUBY id
* transparent avoidpillisolateblock SPLITTABLE styleInlines
* category transparent styleInlines
* 退 finder.category */
function canSplit(el: Element, finder: Finder): boolean function canSplit(el: Element, finder: Finder): boolean
{ {
return SPLITTABLE.has(el.nodeName) return SPLITTABLE.has(el.nodeName)
&& !el.hasAttribute("id") && !el.hasAttribute("id")
&& !finder.pillMatches(el) && finder.category(el) === "transparent";
&& !finder.isolateMatches(el)
&& !finder.avoidsSelf(el);
} }
/** el 內**首**個非空文本節點(僅下探可切標籤;遇非可切元素回 null=放棄切分)。 */ /** el 內**首**個非空文本節點(僅下探可切標籤;遇非可切元素回 null=放棄切分)。 */
+19 -7
View File
@@ -165,15 +165,27 @@ export const jiyaAdjacencyPass: StandalonePass = {
if (prev) { squeeze(prev, el); } if (prev) { squeeze(prev, el); }
prev = el; // jz-char 為原子,不下探(內含 jz-inner) prev = el; // jz-char 為原子,不下探(內含 jz-inner)
} }
else if (finder.pillMatches(el) || finder.avoidsSelf(el))
{
prev = null; // 整體 pill/avoid:斷相鄰、不下探(內容不可見/不處理)
}
else if (finder.isTransparent(el)) { visit(el); } // 透明:穿越、相鄰性延續
else else
{ {
// 隔離/block/嚴格白名單下之未知行內:前後皆斷相鄰,內部自成脈絡(另計)。 switch (finder.category(el)) // 與 collectRuns 共用同一分類(§4.1
prev = null; visit(el); prev = null; {
case "avoid":
case "pill":
{
prev = null; // 斷相鄰、不下探(內容不可見/不處理)
break;
}
case "transparent":
{
visit(el); // 穿越、相鄰性延續
break;
}
default: // isolateblockopaque(未知行內)
{
prev = null; visit(el); prev = null; // 斷相鄰、內部自成脈絡
break;
}
}
} }
} }
}; };
+7
View File
@@ -26,6 +26,13 @@ const HEAD_CLASSES = new Set([ "bd-open" ]);
const TAIL_CLASSES = new Set([ "bd-close" ]); const TAIL_CLASSES = new Set([ "bd-close" ]);
// 每 root 各 layout pass(以 key 區分)一個 ResizeObserver。 // 每 root 各 layout pass(以 key 區分)一個 ResizeObserver。
//
// **跨 observer 派發序不變量(已於 Chrome headless 實測確認)**resize 時 ResizeObserver
// 之回調按 **observer 創建順序** 派發(ResizeObserver 規範遍歷 observers 列表之插入序,非
// observe 序)。lineEdgePassorder 90)先於 gapTrimPassorder 92)執行、故其 observer 先
// 建,於每次 resize 皆先派發——確保 gapTrim 之行末去隙判定恆基於 lineEdge **已套用**之半形
// 版面(非陳舊態)。**勿調換兩 pass 之 order 或創建序**,否則 gapTrim 可能以 resize 前之半形
// 狀態誤判行末(且 ResizeObserver 只觀 root 尺寸、不因內部 class 變化重觸發,錯誤會持續)。
const observers = new WeakMap<Element, Map<string, ResizeObserver>>(); const observers = new WeakMap<Element, Map<string, ResizeObserver>>();
function hasLayout(): boolean function hasLayout(): boolean