docs: changelog 2026-07-11 效能第一層——掃描提速(category 記憶化 + orphan 葉塊預計算)

新增 changelog 條目+md-twin 同步。下游效能訴求一之第一層:S3 category 以 WeakMap 記憶化砍除跨
pass 重複 el.matches()(實測約降三成)、S2 orphan 以上攀預計算「非葉塊」取代逐塊 querySelector 掃
子樹之 O(Σ塊子樹)。皆無狀態、保行為、無公開 API 變更;測試 129→130 全綠。並記亲驗數據與 insidePill
之下一機會。changelog HTML 經 build.sh 重建。
This commit is contained in:
2026-07-11 19:58:41 +08:00
parent cda0903089
commit fdad106980
2 changed files with 326 additions and 110 deletions
+297 -110
View File
@@ -1,4 +1,4 @@
<!-- built with theme=dracula, mode=report, at=2026-07-11T05:34:59Z -->
<!-- built with theme=dracula, mode=report, at=2026-07-11T11:58:21Z -->
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
@@ -2545,6 +2545,19 @@ main > *:nth-child(n+6) { animation-delay: 0.46s; }
<!-- BODY -->
<p class="lede">聚珍(Juzhen)/cjk-autospace 變更紀錄。<a href="./index.html">← 回文檔</a></p>
<h2>效能第一層:掃描提速(category 記憶化 + orphan 葉塊預計算) <span class="badge accent">2026-07-11</span></h2>
<p>下游效能訴求(掃描疑 O(n²))之第一層——無狀態、保行為、無公開 API 變更之確定收益。單元測試 129→130 全綠、輸出不變。</p>
<h3>優化</h3>
<ul>
<li><strong>S3 category 記憶化</strong><code>finder.category</code><code>WeakMap&lt;Element, InlineCategory&gt;</code> 記憶化,砍除 <code>collectRuns</code><code>jiyaAdjacency</code><code>jinze</code><code>split</code> 跨 pass 對同一元素之重複 <code>el.matches()</code>pillisolateavoid 選擇器)。實測 pillisolate 密集頁面 <code>el.matches()</code> 8204→5842(約降三成,命中率 55%)。安全:單次 render 內一元素之分類恆定、且 Finder 隨 <code>makeCtx</code> 每次 render 重建 → 快取僅存活單次 render、無跨 render 失效(持久化屬另議)。</li>
<li><strong>S2 orphan 葉塊預計算</strong>:以一趟上攀預計算「非葉塊」Set 取代逐塊 <code>block.querySelector(sel)</code> 掃整棵子樹之 O(Σ塊子樹)(深塊嵌套下 O(n²));某塊為另一塊之最近塊祖先 ⟺ 含塊級後代,與原 querySelector 同口徑、行為等價。</li>
</ul>
<h3>亲驗與待議</h3>
<ul>
<li>亲驗:暫禁 category 快取則 <code>computeCategory</code> 由 1041 增至 2322、<code>el.matches()</code> 由 5842 增至 8204——證快取真減負而非空操作(初次插桩誤打 <code>HTMLBodyElement.prototype</code> 而漏計,改補 <code>Element.prototype.matches</code> 後為準)。</li>
<li>新發現(下一機會):剩餘 <code>el.matches()</code> 之最大來源為 <code>insidePill</code> 之「逐文本節點沿祖先鏈 <code>pillMatches</code>」(<code>eachTextNode</code> 每文本節點一趟、未記憶化)。</li>
</ul>
<h2>splitTreeAfter 攀升對稱守衛(opaque 中間祖先不再被克隆肢解) <span class="badge accent">2026-07-11</span></h2>
<p>下游 <code>.selection-bar-message</code> 病例:垂懸字(orphan)之段末實義字落於 opaque <code>&lt;button&gt;</code>(巢於可切 <code>&lt;span&gt;</code>)時,<code>splitElementAt</code> 僅驗頂層 span 可切;<code>splitTreeAfter</code> 逐層克隆「文本節點→top」祖先鏈時,中間 opaque button 從未複查即被克隆肢解(複製出 <code>splitoff</code> button、破壞互動元素)。<code>firstText</code><code>lastText</code> 守下潛(遇非可切回 <code>null</code>),攀升卻無對稱守衛——此即真正病根,下游 per-instance 迴避僅治標。</p>
<h3>修復</h3>
@@ -2779,6 +2792,22 @@ main > *:nth-child(n+6) { animation-delay: 0.46s; }
<script type="text/markdown" id="md-source">
# 變更紀錄(Changelog)— 聚珍(Juzhen)/cjk-autospace
## 效能第一層:掃描提速(category 記憶化 + orphan 葉塊預計算)(2026-07-11
下游效能訴求(掃描疑 O(n²))之第一層——無狀態、保行為、無公開 API 變更。測試 129→130 全綠、輸出不變。
### 優化
- S3 category 記憶化:finder.category 以 WeakMap<Element,InlineCategory> 記憶化,砍除各遍歷原語跨 pass
對同一元素之重複 el.matches()。實測 pill/isolate 密集頁 el.matches() 8204→5842(約降三成、命中率 55%)。
安全:單次 render 內分類恆定、Finder 每次 render 重建 → 快取僅存活單次 render、無跨 render 失效。
- S2 orphan 葉塊預計算:以一趟上攀預計算「非葉塊」Set 取代逐塊 block.querySelector(sel) 掃子樹之
O(Σ塊子樹)(深塊嵌套 O(n²));某塊為另一塊之最近塊祖先 ⟺ 含塊級後代,與原 querySelector 同口徑、等價。
### 亲驗與待議
- 亲驗:暫禁 category 快取則 computeCategory 1041→2322、el.matches() 5842→8204(初次插桩誤打
HTMLBodyElement.prototype 漏計,改補 Element.prototype.matches 後為準)。
- 新發現(下一機會):剩餘 el.matches() 最大來源為 insidePill 之逐文本節點沿祖先鏈 pillMatches(未記憶化)。
## splitTreeAfter 攀升對稱守衛(opaque 中間祖先不再被克隆肢解)(2026-07-11)
下游 .selection-bar-message 病例:垂懸字段末實義字落於 opaque `<button>`(巢於可切 `<span>`)時,splitElementAt 僅驗頂層 spansplitTreeAfter 逐層克隆「文本節點→top」祖先鏈時中間 opaque button 從未複查即被克隆肢解(複製出 splitoff button)。firstTextlastText 守下潛,攀升卻無對稱守衛——真正病根,下游 per-instance 迴避僅治標。
@@ -2987,32 +3016,55 @@ var Juzhen = (() => {
}
return false;
}
/** 邏輯文本應**透明穿越**之行內元素:樣式行內標籤,以及聚珍自身之 wrap 類
* jz-*jz-hws 間隙、jz-jinze 禁則群組、jz-charjz-inner 標點原子)。穿越這些
* 才能正確找到跨包裝之邏輯鄰字(如 jinze 已把 pill 納入 jz-jinze 後,pill 之左
* 鄰字仍須可達;I7)。
* ⚠ 以**元素**而非標籤名判定:pillisolate/avoid 縱使標籤名在樣式集亦為邊界、
* 不可穿越(下游問題六:KaTeX 全樹皆 SPAN,名字判定令整棵公式被當透明下潛而
* 肢解;scope.avoidskipAttribute 命中自身者同理)。
* ⚠ jz-charjz-inner 必須透明:否則 adjacentLogicalChar 之 descend 遇標點原子既
* 不進入亦不終止、而是**跳過**它續找更前之兄弟(下游 Bug:`汉字。<code>` 經 jinze
* 綁定為 `汉<jz-jinze><jz-char></jz-char></jz-jinze><code>`pill 之左鄰字應
* 為「。」、不補間隙;舊邏輯卻越過 jz-char 取到「字」→ 誤包 jz-hws 於「字。」之間)。
* jz-char 僅含標點(jiya 只 charify biaodian),標點非 pillNeighbor,故透明後 descend
* 回傳標點 → 不補隙,與 collectRuns 之既有透明遞迴一致。jz-inner 亦須列入,否則
* descend 進入 jz-char 後因 jz-inner 不透明而跳過其內標點。 */
transparentInline(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 之 pillisolate → **avoid**
* (自身排除最高)。**邏輯文本流之遍歷原語**collectRunsjiyaAdjacencyadjacentLogicalChar
* 之 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-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;
return this.styleSet.has(name) || name === "JZ-HWS" || name === "JZ-JINZE" || name === "JZ-CHAR" || name === "JZ-INNER";
}
/** 該元素**自身**是否為 avoid 邊界(內建 skip 名單 / SVG / contentEditable /
* data-jz-skip / skipAttribute / avoid 選擇器 / isSkipped)。isAvoided 沿祖先
* 鏈逐層呼叫本判定;遍歷原語另須對「正要進入」之元素直接判自身——舊版僅查
* 祖先,元素自身命中 avoid 時被當透明穿越(下游問題六:scope.avoid 對 .katex
* 形同虛設)。 */
/** el 是否為邏輯文本流可透明穿越者(= category "transparent")。 */
isTransparent(el) {
return this.category(el) === "transparent";
}
/** 該元素**自身**是否為 avoid 邊界。avoid 之七種來源,依序短路判定、任一命中即是:
* ① isSkipped 謂詞(消費端函數) ② skipTags 標籤名單(PRESAMPSCRIPT…,可覆寫)
* ③ SVG 命名空間 ④ contentEditable
* ⑤ data-jz-skip 屬性(內建) ⑥ skipAttribute(消費端指定屬性名)
* ⑦ scope.avoid 選擇器
* **此為自身判定**,供遍歷原語(collectRunsjiyaAdjacencycanSplitisTransparent)於
* 下探時逐元素自判(祖先由遞迴涵蓋)。**一次性**問「某節點是否被 avoid(自身或祖先)」
* 請改用 isAvoided(單一組合入口),勿只用本方法而漏判祖先、或只用 isAvoided 而漏判自身
* (下游問題六/階段一 #2:漏一半致 avoid 對自身形同虛設)。 */
avoidsSelf(el) {
if (this.userIsSkipped && this.userIsSkipped(el)) {
return true;
@@ -3037,10 +3089,16 @@ var Juzhen = (() => {
}
return false;
}
/** 節點是否處於 avoid 子樹(祖先鏈上任一元素 avoidsSelf;自身不計——元素
* 自身之邊界判定由遍歷原語以 avoidsSelf 執行)。 */
/** 節點是否 avoid——**自身即邊界**元素 avoidsSelf 之七源;非元素節點僅判
* isSkipped**或**祖先鏈上任一為 avoid 邊界。此為「是否 avoid」之**單一組合入口(安全
* 預設)**:一次性檢查用此,免除「avoidsSelf ‖ isAvoided」易漏一半之誤用。遍歷原語逐
* 元素下探時則用 avoidsSelf(僅自身,祖先由遞迴涵蓋)。 */
isAvoided(node) {
if (this.userIsSkipped && this.userIsSkipped(node)) {
if (node.nodeType === 1) {
if (this.avoidsSelf(node)) {
return true;
}
} else if (this.userIsSkipped && this.userIsSkipped(node)) {
return true;
}
let p = node.parentNode;
@@ -3101,9 +3159,9 @@ var Juzhen = (() => {
}
}
/**
* 收集 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) {
const runs = [];
@@ -3136,28 +3194,30 @@ 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.isolateMatches(el)) {
flush();
let ic = el.firstChild;
while (ic) {
visit(ic);
ic = ic.nextSibling;
const recurse = () => {
let c2 = el.firstChild;
while (c2) {
visit(c2);
c2 = c2.nextSibling;
}
};
switch (this.category(el)) {
case "avoid":
case "block":
case "pill": {
flush();
return;
}
case "transparent": {
recurse();
return;
}
default: {
flush();
recurse();
flush();
return;
}
flush();
return;
}
let c2 = el.firstChild;
while (c2) {
visit(c2);
c2 = c2.nextSibling;
}
};
let c = block.firstChild;
@@ -3190,7 +3250,7 @@ var Juzhen = (() => {
return pickEnd(n2);
}
if (n2.nodeType === 1) {
if (!this.transparentInline(n2)) {
if (!this.isTransparent(n2)) {
return "blocked";
}
const r = descend(n2);
@@ -3215,7 +3275,7 @@ var Juzhen = (() => {
}
if (n.nodeType === 1) {
const e = n;
if (!this.transparentInline(e)) {
if (!this.isTransparent(e)) {
return null;
}
const r = descend(e);
@@ -3230,7 +3290,7 @@ var Juzhen = (() => {
}
n = direction === -1 ? n.previousSibling : n.nextSibling;
}
if (!p || p.nodeType !== 1 || !this.transparentInline(p)) {
if (!p || p.nodeType !== 1 || !this.isTransparent(p)) {
return null;
}
const pe = p;
@@ -3424,14 +3484,6 @@ var Juzhen = (() => {
}
}
}
if (opts.attrs) {
for (const k in opts.attrs) {
const v = opts.attrs[k];
if (v !== void 0) {
el.setAttribute(k, v);
}
}
}
if (opts.text !== void 0) {
el.textContent = opts.text;
}
@@ -3501,6 +3553,16 @@ var Juzhen = (() => {
}
return null;
}
function isJz(node, pass) {
if (!node || node.nodeType !== 1) {
return false;
}
const el = node;
if (!el.hasAttribute(PASS_ATTR)) {
return false;
}
return pass === void 0 || el.getAttribute(PASS_ATTR) === pass;
}
// src/core/locale.ts
var DEFAULT_POLICY = {
@@ -3553,7 +3615,32 @@ var Juzhen = (() => {
}
return DEFAULT_POLICY[langClass];
}
function isPunctStyle(v) {
return v === "quanjiao" || v === "kaiming" || v === "banjiao";
}
function resolveStyleOverride(node, stylePolicy) {
let el = node.nodeType === 1 ? node : node.parentElement;
while (el) {
const attr = el.getAttribute ? el.getAttribute("data-jz-style") : null;
if (isPunctStyle(attr)) {
return attr;
}
if (stylePolicy) {
for (const rule of stylePolicy) {
if (rule.selector && el.matches && el.matches(rule.selector)) {
return rule.style;
}
}
}
el = el.parentElement;
}
return null;
}
function resolveStyle(node, config) {
const override = resolveStyleOverride(node, config.stylePolicy);
if (override) {
return override;
}
const lang = effectiveLang(node, config.default);
return styleFor(classify(lang), config);
}
@@ -3562,7 +3649,6 @@ var Juzhen = (() => {
var CJK = "\u2E80-\u2EFF\u2F00-\u2FDF\u3040-\u309F\u30A0-\u30FA\u30FC-\u30FF\u3100-\u312F\u3200-\u32FF\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF";
var ALNUM = "A-Za-z0-9";
var ALPHA = "A-Za-z";
var ANY_CJK = new RegExp("[" + CJK + "]");
var WEST_SHARED = "0-9\\$%\\^&\\*\\-\\+\\\\=\\u0370-\\u03ff\\u0100-\\u024f\\u0400-\\u04ff\\u00a1-\\u00b6\\u00b8-\\u00ff\\u20a0-\\u20bf\\u2150-\\u21ff\\u2200-\\u22ff\\u2700-\\u27bf";
var ANS_AFTER_CJK = ALPHA + "@" + WEST_SHARED;
var ANS_BEFORE_CJK = ALPHA + WEST_SHARED;
@@ -3572,7 +3658,6 @@ var Juzhen = (() => {
var BD_CLOSE = "\u300D\u300F\uFF09\u300B\u3009\u3011\u3017\u3015\uFF3D\uFF5D\u201D\u2019";
var BD_MIDDLE = "\xB7\u30FB\u2027";
var BD_LIGA = "\u2026\u2014";
var BD_ALL = BD_STOP + BD_PAUSE + BD_OPEN + BD_CLOSE + BD_MIDDLE + BD_LIGA;
var BD_ORDER = [
"stop",
"pause",
@@ -3773,8 +3858,7 @@ var Juzhen = (() => {
level: "text",
enabled: (o) => o.features.jiya,
render(ctx) {
const { finder, options } = ctx;
const blockSet = options.finder.blockTags;
const { finder } = ctx;
let prev = null;
const squeeze = (L, R) => {
if (!finder.featureEnabledFor(L, PASS)) {
@@ -3810,20 +3894,24 @@ var Juzhen = (() => {
squeeze(prev, el);
}
prev = el;
} else if (finder.pillMatches(el)) {
prev = null;
} else if (finder.avoidsSelf(el)) {
prev = null;
} else if (finder.isolateMatches(el)) {
prev = null;
visit(el);
prev = null;
} else if (blockSet.has(nm)) {
prev = null;
visit(el);
prev = null;
} else {
visit(el);
switch (finder.category(el)) {
case "avoid":
case "pill": {
prev = null;
break;
}
case "transparent": {
visit(el);
break;
}
default: {
prev = null;
visit(el);
prev = null;
break;
}
}
}
}
};
@@ -3851,7 +3939,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) {
@@ -3912,6 +4000,14 @@ var Juzhen = (() => {
return s;
}
function splitTreeAfter(top, textNode, offset, pass, finder) {
for (let a = textNode.parentNode; a && a.nodeType === 1; a = a.parentNode) {
if (!canSplit(a, finder)) {
return null;
}
if (a === top) {
break;
}
}
if (offset <= 0 && firstText(top, finder) === textNode) {
return null;
}
@@ -3991,21 +4087,66 @@ var Juzhen = (() => {
function isCharEl(n) {
return !!n && n.nodeType === 1 && n.nodeName === "JZ-CHAR";
}
function firstMeaningfulChild(el) {
for (let c = el.firstChild; c; c = c.nextSibling) {
if (c.nodeType === 3 && c.data.length === 0) {
continue;
}
return c;
}
return null;
}
function lastMeaningfulChild(el) {
for (let c = el.lastChild; c; c = c.previousSibling) {
if (c.nodeType === 3 && c.data.length === 0) {
continue;
}
return c;
}
return null;
}
function atEdge(node, el, side) {
return node === (side === "head" ? firstMeaningfulChild(el) : lastMeaningfulChild(el));
}
function edgeCharEl(node, side, finder) {
let n = node;
while (n && n.nodeType === 1) {
if (isCharEl(n)) {
return n;
}
if (!finder.isTransparent(n)) {
return null;
}
n = side === "head" ? firstMeaningfulChild(n) : lastMeaningfulChild(n);
}
return null;
}
function forbiddenBreak(a, b, finder) {
if (isCharEl(b)) {
const c = bdClassOf(b);
if (c && AVOID_HEAD.has(c) && finder.featureEnabledFor(b, PASS2)) {
const head = edgeCharEl(b, "head", finder);
if (head) {
const c = bdClassOf(head);
if (c && AVOID_HEAD.has(c) && finder.featureEnabledFor(head, PASS2)) {
return true;
}
}
if (isCharEl(a)) {
const c = bdClassOf(a);
if (c && AVOID_TAIL.has(c) && finder.featureEnabledFor(a, PASS2)) {
const tail = edgeCharEl(a, "tail", finder);
if (tail) {
const c = bdClassOf(tail);
if (c && AVOID_TAIL.has(c) && finder.featureEnabledFor(tail, PASS2)) {
return true;
}
}
return false;
}
function depthOf(node) {
let d = 0;
let p = node.parentNode;
while (p) {
d += 1;
p = p.parentNode;
}
return d;
}
function isTokenChar2(ch, anyCjk) {
return !anyCjk.test(ch) && !/\s/.test(ch);
}
@@ -4046,6 +4187,9 @@ var Juzhen = (() => {
}
}
function processParent(parent, finder, anyCjk) {
if (isJz(parent, PASS2)) {
return;
}
for (let c = parent.firstChild; c; c = c.nextSibling) {
if (c.nodeType === 1 && c.nodeName === "JZ-JINZE") {
return;
@@ -4114,18 +4258,31 @@ var Juzhen = (() => {
level: "paragraph",
enabled: (o) => o.features.jinze,
render(ctx) {
const { finder } = ctx;
const anyCjk = new RegExp("[" + ctx.options.ruleset.cjk + "]");
const parents = /* @__PURE__ */ new Set();
ctx.root.querySelectorAll("jz-char").forEach((c) => {
if (!ctx.finder.levelAllows(c, "paragraph")) {
if (!finder.levelAllows(c, "paragraph")) {
return;
}
if (c.parentNode) {
parents.add(c.parentNode);
let child = c;
let parent = c.parentNode;
while (parent && parent.nodeType === 1) {
parents.add(parent);
const pe = parent;
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);
const ordered = Array.from(parents).sort((a, b) => depthOf(b) - depthOf(a));
for (const parent of ordered) {
processParent(parent, finder, anyCjk);
}
},
revert(ctx) {
@@ -4307,7 +4464,7 @@ var Juzhen = (() => {
var TRIM = "jz-hws-trim";
function trimGaps(root, finder, wrapSet) {
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;
@@ -4493,7 +4650,7 @@ var Juzhen = (() => {
if (!finder.featureEnabledFor(block, PASS4)) {
return;
}
if (finder.avoidsSelf(block) || finder.isAvoided(block)) {
if (finder.isAvoided(block)) {
return;
}
const sel = Array.from(blockSet).map((t) => t.toLowerCase()).join(",");
@@ -4826,8 +4983,6 @@ var Juzhen = (() => {
"HR"
];
var DEFAULT_SKIP_TAGS = [
"CODE",
"KBD",
"PRE",
"SAMP",
"TT",
@@ -4884,7 +5039,8 @@ var Juzhen = (() => {
locale: {
default: langOpt.default || "zh-Hant",
...langOpt.style ? { style: langOpt.style } : {},
policy: langOpt.policy || {}
policy: langOpt.policy || {},
stylePolicy: Array.isArray(langOpt.stylePolicy) ? langOpt.stylePolicy.slice() : []
},
ruleset: resolveRuleset(opts.charClass),
justifyAtoms: opts.justifyAtoms === false ? false : true,
@@ -4896,10 +5052,11 @@ var Juzhen = (() => {
},
features: {
spacing: feature(opts.spacing, true),
longWord: opts.longWord === false ? false : feature(opts.longWord, true),
// feature(v,dflt) 對 v===false 即回 false,故無需 ===false 特判(與 jiyajinze 同構)。
longWord: feature(opts.longWord, true),
jiya: feature(opts.jiya, true),
jinze: feature(opts.jinze, true),
orphan: opts.orphan === false ? false : feature(opts.orphan, true),
orphan: feature(opts.orphan, true),
biaodian: feature(opts.biaodian, false),
emphasis: feature(opts.emphasis, false),
ruby: feature(opts.ruby, false)
@@ -5205,6 +5362,24 @@ window.__JUZHEN_PUNCT__="quanjiao";window.__JUZHEN_JUSTIFY_ATOMS__=true;window._
if (hw === "margin") { halfWidth = "margin"; }
}
catch (e) { /* getComputedStyle 不可用時退預設 halt */ }
/* 段落標點樣式(模式注入):一般模式 quanjiao 全角式、academic kaiming 開明式。
非段落一律 kaiming(見下方 lang.stylePolicy)——與段落形成寬窄對比,標題/表頭/
UI chrome 之標點較正文緊。 */
var paraStyle = (typeof window.__JUZHEN_PUNCT__ === "string" ? window.__JUZHEN_PUNCT__ : "quanjiao");
/* 「非段落」結構選擇器——**單一來源**,同時餵給 level.text(決定跑哪些 pass)與
lang.stylePolicy(決定標點寬度),杜絕兩處漂移。**只列非段落 chrome 本身,切勿
納入包裹正文的容器**.deck-stage.deck.cols-2……),否則「最近祖先勝」會把整
棵子樹正文一併降級(曾誤納 .deck-stage 致 slides 全 deck 正文無禁則、標點落行首)。 */
var nonParaText = [
"h1, h2, h3, h4, h5, h6", // 全部標題(含 deck-section-slide-tp-toc-title
"button, [role=button], .btn", // 控制項
".subtitle, .section-subtitle, .deck-subtitle", // 副標題
".meta, .timestamp, .actions, .page", // report chrome
".cover-block, .slide-foot, .toc-list", // slides 封面/頁尾/目錄條目
".deck-nav, .deck-counter, .deck-author, .deck-institute, .deck-date, .deck-actions",
".title-page, .toc-page, .simple-title", // academic 前置 chrome(含 tp-st- 欄位)
"th", // 表頭:恒為標籤,非段落
].join(", ");
window.Juzhen.createJuzhen({
/* mark 不列 pill——隔離(isolateSelector 預設 "mark")即其正確語義:
兩端補隙、內部標點/中西邊界照常處理;列 pill 反令內部不可見。
@@ -5223,19 +5398,13 @@ window.__JUZHEN_PUNCT__="quanjiao";window.__JUZHEN_JUSTIFY_ATOMS__=true;window._
.lede.marginalia.references.appendix……)不命中、維持全功能。最近祖先勝,
故標題與 chrome 容器一旦命中,其後代(如 .slide-title 內的 .section-name
.slide-dot、.title-page 內的作者欄)一併降級。選擇器涵蓋三模式;某模式不存在之
class 自然不命中。**切記只列「非段落 chrome 本身」,切勿納入包裹正文的容器**
(如 .deck-stage.deck.cols-2)——否則「最近祖先勝」會把整個子樹正文一併降級、
喪失禁則/垂懸/長詞(曾誤納 .deck-stage 致 slides 全 deck 正文無禁則、標點落行首)。 */
class 自然不命中。**段落/非段落單軸**:作者以 `data-jz-level="text""paragraph"`
逐元素覆寫,同時改「跑哪些 pass」(此處 level 原生讀取,屬性優先、最近祖先勝)與
「標點寬度」(下方 stylePolicy 以同一屬性鏡像)。歧義元素(tdli/figcaptiondd
blockquote)預設段落作安全兜底(漏標時真段落不致喪失禁則/垂懸),作者應積極標記
非段落者為 text——多數 td/caption 實為非段落。 */
level: {
text: [
"h1, h2, h3, h4, h5, h6", // 全部標題(含 deck-section-slide-tp-toc-title
"button, [role=button], .btn", // 控制項
".subtitle, .section-subtitle, .deck-subtitle", // 副標題
".meta, .timestamp, .actions, .page", // report chrome
".cover-block, .slide-foot, .toc-list", // slides 封面/頁尾/目錄條目
".deck-nav, .deck-counter, .deck-author, .deck-institute, .deck-date, .deck-actions",
".title-page, .toc-page, .simple-title", // academic 前置 chrome(含 tp-st- 欄位)
].join(", "),
text: nonParaText,
},
/* orphan(垂懸字避免,聚珍 §6.5.1):段末行 CJK 實義字 < 2 把末 2
包入 `<jz-orphan white-space:nowrap>` 拉下首字,杜絕「孤字成行」。判定僅計
@@ -5251,9 +5420,27 @@ window.__JUZHEN_PUNCT__="quanjiao";window.__JUZHEN_JUSTIFY_ATOMS__=true;window._
但改於分頁「之後」排版(DEFER),故不踩 Paged.js chunker 對 inline-block
的錯誤量測。 */
justifyAtoms: (window.__JUZHEN_JUSTIFY_ATOMS__ !== false),
/* lang.stylePolicy(逐元素標點樣式,聚珍 §4.3):把段落/非段落單軸鏡像到標點
寬度。解析=自身向上找最近命中(近祖先勝、同層 data-jz-style 屬性優先、同層多
選擇器取陣列首條,見 lib locale.ts resolveStyleOverride)。**次序關鍵——顯式屬性
標記須先於結構標籤選擇器**,否則 `p[data-jz-level="text"]` 會被 ③ 的 p 規則搶先
判為段落。故四條依序:
① 顯式 text → kaiming(最高,壓過下方 p 規則);
② 顯式 paragraph → paraStyle(最高);
③ 結構段落 p → paraStyle(即便嵌於非段落容器內也還原);
④ 結構非段落 nonParaText → kaiming。
非段落一律 kaimingacademic 段落本即 kaiming(此處無視覺差、僅 level 有別),
一般模式段落 quanjiao、非段落 kaiming 形成寬窄對比。`data-jz-style` 屬性可作
一次性最高優先例外。 */
lang: {
default: "zh-Hans",
style: (typeof window.__JUZHEN_PUNCT__ === "string" ? window.__JUZHEN_PUNCT__ : "quanjiao"),
style: paraStyle,
stylePolicy: [
{ selector: "[data-jz-level='text']", style: "kaiming" },
{ selector: "[data-jz-level='paragraph']", style: paraStyle },
{ selector: "p", style: paraStyle },
{ selector: nonParaText, style: "kaiming" },
],
},
}).render(root);
}
+29
View File
@@ -25,6 +25,19 @@
<!-- BODY -->
<p class="lede">聚珍(Juzhen)/cjk-autospace 變更紀錄。<a href="./index.html">← 回文檔</a></p>
<h2>效能第一層:掃描提速(category 記憶化 + orphan 葉塊預計算) <span class="badge accent">2026-07-11</span></h2>
<p>下游效能訴求(掃描疑 O(n²))之第一層——無狀態、保行為、無公開 API 變更之確定收益。單元測試 129→130 全綠、輸出不變。</p>
<h3>優化</h3>
<ul>
<li><strong>S3 category 記憶化</strong><code>finder.category</code><code>WeakMap&lt;Element, InlineCategory&gt;</code> 記憶化,砍除 <code>collectRuns</code><code>jiyaAdjacency</code><code>jinze</code><code>split</code> 跨 pass 對同一元素之重複 <code>el.matches()</code>pillisolateavoid 選擇器)。實測 pillisolate 密集頁面 <code>el.matches()</code> 8204→5842(約降三成,命中率 55%)。安全:單次 render 內一元素之分類恆定、且 Finder 隨 <code>makeCtx</code> 每次 render 重建 → 快取僅存活單次 render、無跨 render 失效(持久化屬另議)。</li>
<li><strong>S2 orphan 葉塊預計算</strong>:以一趟上攀預計算「非葉塊」Set 取代逐塊 <code>block.querySelector(sel)</code> 掃整棵子樹之 O(Σ塊子樹)(深塊嵌套下 O(n²));某塊為另一塊之最近塊祖先 ⟺ 含塊級後代,與原 querySelector 同口徑、行為等價。</li>
</ul>
<h3>亲驗與待議</h3>
<ul>
<li>亲驗:暫禁 category 快取則 <code>computeCategory</code> 由 1041 增至 2322、<code>el.matches()</code> 由 5842 增至 8204——證快取真減負而非空操作(初次插桩誤打 <code>HTMLBodyElement.prototype</code> 而漏計,改補 <code>Element.prototype.matches</code> 後為準)。</li>
<li>新發現(下一機會):剩餘 <code>el.matches()</code> 之最大來源為 <code>insidePill</code> 之「逐文本節點沿祖先鏈 <code>pillMatches</code>」(<code>eachTextNode</code> 每文本節點一趟、未記憶化)。</li>
</ul>
<h2>splitTreeAfter 攀升對稱守衛(opaque 中間祖先不再被克隆肢解) <span class="badge accent">2026-07-11</span></h2>
<p>下游 <code>.selection-bar-message</code> 病例:垂懸字(orphan)之段末實義字落於 opaque <code>&lt;button&gt;</code>(巢於可切 <code>&lt;span&gt;</code>)時,<code>splitElementAt</code> 僅驗頂層 span 可切;<code>splitTreeAfter</code> 逐層克隆「文本節點→top」祖先鏈時,中間 opaque button 從未複查即被克隆肢解(複製出 <code>splitoff</code> button、破壞互動元素)。<code>firstText</code><code>lastText</code> 守下潛(遇非可切回 <code>null</code>),攀升卻無對稱守衛——此即真正病根,下游 per-instance 迴避僅治標。</p>
<h3>修復</h3>
@@ -243,6 +256,22 @@
<script type="text/markdown" id="md-source">
# 變更紀錄Changelog 聚珍Juzhencjk-autospace
## 效能第一層掃描提速category 記憶化 orphan 葉塊預計算2026-07-11
下游效能訴求掃描疑 O()之第一層無狀態保行為無公開 API 變更測試 129130 全綠輸出不變
### 優化
- S3 category 記憶化finder.category WeakMap<Element,InlineCategory> 記憶化砍除各遍歷原語跨 pass
對同一元素之重複 el.matches()實測 pill/isolate 密集頁 el.matches() 82045842約降三成命中率 55%
安全單次 render 內分類恆定Finder 每次 render 重建 快取僅存活單次 render無跨 render 失效
- S2 orphan 葉塊預計算以一趟上攀預計算非葉塊Set 取代逐塊 block.querySelector(sel) 掃子樹之
O(Σ塊子樹)深塊嵌套 O()某塊為另一塊之最近塊祖先 含塊級後代與原 querySelector 同口徑等價
### 亲驗與待議
- 亲驗暫禁 category 快取則 computeCategory 10412322el.matches() 58428204初次插桩誤打
HTMLBodyElement.prototype 漏計改補 Element.prototype.matches 後為準
- 新發現下一機會剩餘 el.matches() 最大來源為 insidePill 之逐文本節點沿祖先鏈 pillMatches未記憶化
## splitTreeAfter 攀升對稱守衛opaque 中間祖先不再被克隆肢解2026-07-11
下游 .selection-bar-message 病例垂懸字段末實義字落於 opaque `<button>`巢於可切 `<span>`splitElementAt 僅驗頂層 spansplitTreeAfter 逐層克隆文本節點top祖先鏈時中間 opaque button 從未複查即被克隆肢解複製出 splitoff buttonfirstTextlastText 守下潛攀升卻無對稱守衛真正病根下游 per-instance 迴避僅治標