diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 12f1ed3..44645ff 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -257,6 +257,12 @@ Nvim peek/Claude 模板等 v1 消費端之過渡期。**最後一批 v1 消費 `jiyaAdjacency`/`isTransparent`)皆 `switch(category)` 分派,`canSplit` = `SPLITTABLE ∩ transparent` ——分類集中一處、優先序不再散落各原語各自短路(階段二後之清晰化)。 +**效能(S3 記憶化)**:分類含至多三次 `el.matches()`(pill/isolate/avoid 選擇器),且被上列各遍歷 +原語逐元素、跨 pass 重算——`category` 以 `categoryCache`(`WeakMap`)記憶化, +砍除跨 pass 重複(實測 pill/isolate 密集之頁面 `el.matches()` 約降三成)。安全性:單次 render 內一元素 +之分類恆定(class/skip 屬性/nodeName 皆不因 render 變更;jz-* 包裹按 nodeName 分類亦恆定),且 Finder +隨 `makeCtx` 每次 render 重建 → 快取僅存活單次 render、無跨 render 失效問題(持久化屬另議)。 + 判定**以元素為準、非標籤名**(`isTransparent(el) = category(el)==="transparent"`,前置排除 pill/ isolate/avoid 再查標籤集):KaTeX 全樹皆 ``,舊版按標籤名判透明曾令 `canSplit`/`descend` @@ -638,6 +644,10 @@ class 約定:`bd`(標點)、`bd-open/close/cop/stop/sep/liga`、`cjk`、`j - **選項**:`orphan?: boolean | { chars?: number }`(feature gate;**預設開 chars=2**;per-block 經 `blocks`/`data-jz`;段落級,`data-jz-level="text"` 子樹跳過)。lang 無關。 - **邊界**:段落實義字 < N → 不綁。極窄容器( { this.includeSelector = opts.scope.include; this.featureCache = /* @__PURE__ */ new WeakMap(); this.levelCache = /* @__PURE__ */ new WeakMap(); + this.categoryCache = /* @__PURE__ */ new WeakMap(); } /** 該元素是否匹配 pill 選擇器(三分類之「整體」:code/kbd/行內公式等)。 */ pillMatches(el) { @@ -73,8 +74,24 @@ var Juzhen = (() => { * (自身排除最高)。**邏輯文本流之遍歷原語**(collectRuns/jiyaAdjacency/adjacentLogicalChar * 之 isTransparent)皆據此**單一分類**分派;core/split 之 canSplit = SPLITTABLE 白名單 ∩ * transparent(結構切分為獨立層,故更窄)。 + * + * **記憶化**(S3):分類含至多三次 el.matches()(pill/isolate/avoid 選擇器),且被 + * collectRuns/jiyaAdjacency/jinze/orphan/split 各 pass 逐元素重算——以 categoryCache + * 記憶化砍除跨 pass 重複。安全性:單次 render 內一元素之分類**恆定**(class/skip 屬性/ + * nodeName 皆不因 render 變更;jz-* 包裹為新建元素、按 nodeName 分類亦恆定),且 Finder + * 隨 makeCtx 每次 render 重建 → 快取僅存活單次 render,無跨 render 失效問題(持久化屬 C1, + * 另議)。 */ category(el) { + const cached = this.categoryCache.get(el); + if (cached) { + return cached; + } + const result = this.computeCategory(el); + this.categoryCache.set(el, result); + return result; + } + computeCategory(el) { if (this.avoidsSelf(el)) { return "avoid"; } @@ -1692,6 +1709,20 @@ var Juzhen = (() => { const anyCjk = new RegExp("[" + options.ruleset.cjk + "]"); const biaodian = options.ruleset.biaodian; const chars = options.orphan.chars; + const sel = Array.from(blockSet).map((t) => t.toLowerCase()).join(","); + const nonLeaf = /* @__PURE__ */ new Set(); + if (sel) { + ctx.root.querySelectorAll(sel).forEach((b) => { + let p = b.parentElement; + while (p) { + if (blockSet.has(p.nodeName)) { + nonLeaf.add(p); + break; + } + p = p.parentElement; + } + }); + } finder.eachBlock(ctx.root, (block) => { if (!finder.levelAllows(block, "paragraph")) { return; @@ -1702,8 +1733,7 @@ var Juzhen = (() => { if (finder.isAvoided(block)) { return; } - const sel = Array.from(blockSet).map((t) => t.toLowerCase()).join(","); - if (sel && block.querySelector(sel)) { + if (nonLeaf.has(block)) { return; } processBlock(block, finder, blockSet, anyCjk, biaodian, chars); diff --git a/dist/juzhen.mjs b/dist/juzhen.mjs index 1fd2428..27dba41 100644 --- a/dist/juzhen.mjs +++ b/dist/juzhen.mjs @@ -15,6 +15,7 @@ var Finder = class { this.includeSelector = opts.scope.include; this.featureCache = /* @__PURE__ */ new WeakMap(); this.levelCache = /* @__PURE__ */ new WeakMap(); + this.categoryCache = /* @__PURE__ */ new WeakMap(); } /** 該元素是否匹配 pill 選擇器(三分類之「整體」:code/kbd/行內公式等)。 */ pillMatches(el) { @@ -46,8 +47,24 @@ var Finder = class { * (自身排除最高)。**邏輯文本流之遍歷原語**(collectRuns/jiyaAdjacency/adjacentLogicalChar * 之 isTransparent)皆據此**單一分類**分派;core/split 之 canSplit = SPLITTABLE 白名單 ∩ * transparent(結構切分為獨立層,故更窄)。 + * + * **記憶化**(S3):分類含至多三次 el.matches()(pill/isolate/avoid 選擇器),且被 + * collectRuns/jiyaAdjacency/jinze/orphan/split 各 pass 逐元素重算——以 categoryCache + * 記憶化砍除跨 pass 重複。安全性:單次 render 內一元素之分類**恆定**(class/skip 屬性/ + * nodeName 皆不因 render 變更;jz-* 包裹為新建元素、按 nodeName 分類亦恆定),且 Finder + * 隨 makeCtx 每次 render 重建 → 快取僅存活單次 render,無跨 render 失效問題(持久化屬 C1, + * 另議)。 */ category(el) { + const cached = this.categoryCache.get(el); + if (cached) { + return cached; + } + const result = this.computeCategory(el); + this.categoryCache.set(el, result); + return result; + } + computeCategory(el) { if (this.avoidsSelf(el)) { return "avoid"; } @@ -1665,6 +1682,20 @@ var orphanPass = { const anyCjk = new RegExp("[" + options.ruleset.cjk + "]"); const biaodian = options.ruleset.biaodian; const chars = options.orphan.chars; + const sel = Array.from(blockSet).map((t) => t.toLowerCase()).join(","); + const nonLeaf = /* @__PURE__ */ new Set(); + if (sel) { + ctx.root.querySelectorAll(sel).forEach((b) => { + let p = b.parentElement; + while (p) { + if (blockSet.has(p.nodeName)) { + nonLeaf.add(p); + break; + } + p = p.parentElement; + } + }); + } finder.eachBlock(ctx.root, (block) => { if (!finder.levelAllows(block, "paragraph")) { return; @@ -1675,8 +1706,7 @@ var orphanPass = { if (finder.isAvoided(block)) { return; } - const sel = Array.from(blockSet).map((t) => t.toLowerCase()).join(","); - if (sel && block.querySelector(sel)) { + if (nonLeaf.has(block)) { return; } processBlock(block, finder, blockSet, anyCjk, biaodian, chars); diff --git a/src/core/finder.ts b/src/core/finder.ts index 36c92e7..ae96aef 100644 --- a/src/core/finder.ts +++ b/src/core/finder.ts @@ -63,6 +63,7 @@ export class Finder private readonly levelTextSelector: string | null; private readonly featureCache: WeakMap>; private readonly levelCache: WeakMap; + private readonly categoryCache: WeakMap; constructor(opts: ResolvedOptions) { @@ -79,6 +80,7 @@ export class Finder this.includeSelector = opts.scope.include; this.featureCache = new WeakMap(); this.levelCache = new WeakMap(); + this.categoryCache = new WeakMap(); } /** 該元素是否匹配 pill 選擇器(三分類之「整體」:code/kbd/行內公式等)。 */ @@ -116,8 +118,24 @@ export class Finder * (自身排除最高)。**邏輯文本流之遍歷原語**(collectRuns/jiyaAdjacency/adjacentLogicalChar * 之 isTransparent)皆據此**單一分類**分派;core/split 之 canSplit = SPLITTABLE 白名單 ∩ * transparent(結構切分為獨立層,故更窄)。 + * + * **記憶化**(S3):分類含至多三次 el.matches()(pill/isolate/avoid 選擇器),且被 + * collectRuns/jiyaAdjacency/jinze/orphan/split 各 pass 逐元素重算——以 categoryCache + * 記憶化砍除跨 pass 重複。安全性:單次 render 內一元素之分類**恆定**(class/skip 屬性/ + * nodeName 皆不因 render 變更;jz-* 包裹為新建元素、按 nodeName 分類亦恆定),且 Finder + * 隨 makeCtx 每次 render 重建 → 快取僅存活單次 render,無跨 render 失效問題(持久化屬 C1, + * 另議)。 */ category(el: Element): InlineCategory + { + const cached = this.categoryCache.get(el); + if (cached) { return cached; } + const result = this.computeCategory(el); + this.categoryCache.set(el, result); + return result; + } + + private computeCategory(el: Element): InlineCategory { if (this.avoidsSelf(el)) { return "avoid"; } if (this.pillMatches(el)) { return "pill"; } diff --git a/src/typeset/orphan.ts b/src/typeset/orphan.ts index 5ea3fdb..b0d3905 100644 --- a/src/typeset/orphan.ts +++ b/src/typeset/orphan.ts @@ -144,6 +144,25 @@ export const orphanPass: StandalonePass = { const biaodian = options.ruleset.biaodian; const chars = options.orphan.chars; + // 「非葉塊」(含塊級後代之容器)預計算(S2):取代原逐塊 block.querySelector(sel) 掃整 + // 棵子樹之 O(Σ塊子樹)(深塊嵌套下 O(n²))。一趟上攀標記——某塊為另一塊之最近塊祖先 ⟺ + // 該塊含塊級後代。scope 無涉(與原 querySelector 同口徑:含域外後代塊亦算非葉),故據 + // root 全塊集(querySelectorAll,非 eachBlock 之 in-scope 子集)計算。sel 亦外提出迴圈。 + const sel = Array.from(blockSet).map((t) => t.toLowerCase()).join(","); + const nonLeaf = new Set(); + if (sel) + { + ctx.root.querySelectorAll(sel).forEach((b) => + { + let p = b.parentElement; + while (p) + { + if (blockSet.has(p.nodeName)) { nonLeaf.add(p); break; } + p = p.parentElement; + } + }); + } + finder.eachBlock(ctx.root, (block) => { // 段落級閘 + per-node 功能閘 + avoid(isAvoided 組合判定:自身或祖先命中皆 @@ -152,8 +171,7 @@ export const orphanPass: StandalonePass = { if (!finder.featureEnabledFor(block, PASS)) { return; } if (finder.isAvoided(block)) { return; } // 僅處理葉段落(無塊級後代):含巢狀塊之容器,其實義字屬內層段落,另行處理。 - const sel = Array.from(blockSet).map((t) => t.toLowerCase()).join(","); - if (sel && block.querySelector(sel)) { return; } + if (nonLeaf.has(block)) { return; } processBlock(block, finder, blockSet, anyCjk, biaodian, chars); }); }, diff --git a/test/juzhen.test.mjs b/test/juzhen.test.mjs index 90290f7..2339141 100644 --- a/test/juzhen.test.mjs +++ b/test/juzhen.test.mjs @@ -1077,6 +1077,23 @@ test("垂懸字 攀升守衛 revert:綁整 span 之退化路徑 render→rever assert.equal(p.textContent, "開頭較長的一段文字內容之後接續結尾文字", "textContent 還原"); }); +// S2 非葉塊預計算(取代逐塊 querySelector)等價性:含巢狀塊之容器(div>p)——容器為非葉、 +// 直文字不綁(實義字屬內層段落),僅內層葉塊

綁定。鎖定「非葉塊跳過」路徑之行為不變。 +test("垂懸字 S2 非葉塊:含巢狀塊之容器跳過、僅內層葉塊綁定", () => +{ + const { document } = setupDom( + '

容器直接文字結尾甲乙

內層段落的實義字丙丁。

', + ); + createJuzhen().render(document.body); + const div = document.querySelector("div"); + const p = document.querySelector("p"); + const pOrphan = p.querySelector("jz-orphan"); + assert.ok(pOrphan, "內層葉塊

應產生 jz-orphan"); + assert.equal(pOrphan.textContent, "丙丁。", "綁定

末 2 實義字+句號"); + const directOrphan = Array.from(div.children).some((c) => c.nodeName === "JZ-ORPHAN"); + assert.equal(directOrphan, false, "非葉容器

之直文字不應被綁入 jz-orphan"); +}); + // ----- 標點懸掛已移除(§6.5.6):hanging 選項保留為 no-op,永不產生 jz-hang-* ----- // 純負 margin 機制為機制級死路(寬度守恆矛盾,CSS 接觸不到斷行器);原生 // hanging-punctuation 為唯一正解但僅 Safari 支援、且與 inline-block 原子互斥。