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