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:
+297
-110
@@ -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<Element, InlineCategory></code> 記憶化,砍除 <code>collectRuns</code>/<code>jiyaAdjacency</code>/<code>jinze</code>/<code>split</code> 跨 pass 對同一元素之重複 <code>el.matches()</code>(pill/isolate/avoid 選擇器)。實測 pill/isolate 密集頁面 <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><button></code>(巢於可切 <code><span></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 僅驗頂層 span,splitTreeAfter 逐層克隆「文本節點→top」祖先鏈時中間 opaque button 從未複查即被克隆肢解(複製出 splitoff button)。firstText/lastText 守下潛,攀升卻無對稱守衛——真正病根,下游 per-instance 迴避僅治標。
|
||||
@@ -2987,32 +3016,55 @@ 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:`汉字。<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 之 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 遇標點原子既不進入亦不
|
||||
* 終止、而**跳過**它續找更前之字(`汉字。<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 標籤名單(PRE/SAMP/SCRIPT…,可覆寫)
|
||||
* ③ SVG 命名空間 ④ contentEditable
|
||||
* ⑤ data-jz-skip 屬性(內建) ⑥ skipAttribute(消費端指定屬性名)
|
||||
* ⑦ scope.avoid 選擇器
|
||||
* **此為自身判定**,供遍歷原語(collectRuns/jiyaAdjacency/canSplit/isTransparent)於
|
||||
* 下探時逐元素自判(祖先由遞迴涵蓋)。**一次性**問「某節點是否被 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-* 透明穿越;block/pill/avoid/間隙 marker(jz-hws)中斷 run。
|
||||
* 回傳 text.length >= 2 之 run(短於 2 無間隙可言)。
|
||||
* 收集 block 之邏輯文本 run(架構 §4.1,I7):透明行內(isTransparent:styleInlines
|
||||
* + wrap 類 jz-*)穿越、run 延續;block/pill/avoid 中斷 run;isolate 與**未知行內
|
||||
* 標籤**(嚴格白名單)中斷 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 特判(與 jiya/jinze 同構)。
|
||||
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 以同一屬性鏡像)。歧義元素(td/li/figcaption/dd/
|
||||
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。
|
||||
非段落一律 kaiming:academic 段落本即 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);
|
||||
}
|
||||
|
||||
@@ -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<Element, InlineCategory></code> 記憶化,砍除 <code>collectRuns</code>/<code>jiyaAdjacency</code>/<code>jinze</code>/<code>split</code> 跨 pass 對同一元素之重複 <code>el.matches()</code>(pill/isolate/avoid 選擇器)。實測 pill/isolate 密集頁面 <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><button></code>(巢於可切 <code><span></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)— 聚珍(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 僅驗頂層 span,splitTreeAfter 逐層克隆「文本節點→top」祖先鏈時中間 opaque button 從未複查即被克隆肢解(複製出 splitoff button)。firstText/lastText 守下潛,攀升卻無對稱守衛——真正病根,下游 per-instance 迴避僅治標。
|
||||
|
||||
Reference in New Issue
Block a user