feat(levelmark): 塊級分類 CSS 鉤子 data-jz-block-level+增量塊粒度契約(下游建議七/說明八)

建議七:新增 levelMark pass(standalone,order 10,恆開)——render 時把 resolved
功能級別寫回「直接承載可處理文本」之塊級元素:data-jz-block-level="paragraph|text",
revert 全數移除(I6 之 data-jz-* 清單涵蓋)。消費端段落級 CSS(首要 text-align:
justify)自此與 jinze/orphan 共用單一真相源,免另立必然漂移之選擇器白名單。

- 僅標直接承載文本之塊:純容器(body/ul/table…,直接文本僅空白)不標——否則
  body 恆判 paragraph、justify 鉤子經繼承污染全頁;此集合恰為段落級 pass 實際
  作用之塊集。avoid 子樹/pill 內部文本不計,未被處理之塊一致不標。
- 輸入/輸出分離:作者輸入 data-jz-level 只讀不寫;輸出屬性本庫絕不讀取、亦不在
  C2 OBSERVE_ATTRS 白名單(外部改動不觸發);增量路徑 revert 即清、render 即重標。
- 實現:一次 eachTextNode 走訪+短程上攀定塊(per-run 快取),O(文本);levelFor
  已記憶化。上攀止於 ctx.root,不越界外標。

說明八:README/ARCHITECTURE §3.1 明載增量粒度契約——增量須以塊為粒度(換塊
內容、增刪塊、改分類屬性皆與全量重繪一致);不支持對聚珍已切分文本節點之外科式
characterData 直改(charify 以 fragment 替換原節點,舊引用已脫離文檔)——後處理
排版器之固有限制、非缺陷,以塊為單位替換內容即可。

測試 140→145(標記/純容器不標、text 解析三路(選擇器/屬性/祖先)、avoid/
pill 不標、revert 全清含 root、C2 class 翻轉自動刷新);tsc 聲明構建通過;
headless Chrome 整管線煙測零報錯、段落屬性實際落 DOM。
This commit is contained in:
2026-07-16 16:42:56 +08:00
parent 2f04ea4085
commit 8ee9895247
8 changed files with 345 additions and 5 deletions
+4 -3
View File
@@ -11,6 +11,7 @@ import { Finder } from "./core/finder.js";
import { runPasses, revertPasses } from "./core/pass.js";
import { jiyaAdjacencyPass, jiyaPass } from "./typeset/jiya.js";
import { jinzePass } from "./typeset/jinze.js";
import { levelMarkPass } from "./typeset/levelmark.js";
import { gapTrimPass, lineEdgePass } from "./typeset/lineedge.js";
import { longWordPass } from "./typeset/longword.js";
import { orphanPass } from "./typeset/orphan.js";
@@ -45,11 +46,11 @@ const DEFAULT_SKIP_TAGS = [
// 執行序**不由本陣列決定**runPassescore/pass.ts)先分 charifystandalone 兩群、
// 各群再按 pass.order 排序(charify 群整體先於 standalone 群)。故此處排列僅供閱讀分組,
// 真實序為 orderjiya(20)→jiyaAdjacency(25)→jinze(40)→spacing(50)→longWord(60)→
// orphan(70)→lineEdge(90)→gapTrim(92)。
// 真實序charify 群 jiya(20)→longWord(60)(共享單次 P1 走訪),再 standalone 群
// levelMark(10)→jiyaAdjacency(25)→jinze(40)→spacing(50)→orphan(70)→lineEdge(90)→gapTrim(92)。
const PASSES: Pass[] = [
jiyaPass, jiyaAdjacencyPass, longWordPass, spacingPass, jinzePass,
orphanPass, lineEdgePass, gapTrimPass,
orphanPass, lineEdgePass, gapTrimPass, levelMarkPass,
];
function feature(v: unknown, dflt: boolean): boolean
+77
View File
@@ -0,0 +1,77 @@
// 塊級分類鉤子(下游建議七):把 resolved 功能級別寫回 DOM,供消費端 CSS 查詢。
//
// 消費端的段落級樣式(首要:CJK 兩端對齊 text-align:justify)語義上應與本庫的
// 段落級 passjinze 避頭尾/orphan 垂懸)作用於同一集合;但 level 判定原本只存
// 於內部快取、不寫回 DOM,消費端只能另行維護一份 CSS 白名單,兩份分類必然漂移
// (下游實例:.callout 綁了避頭尾卻不在 justify 名單,避頭尾與兩端對齊不一致)。
// 本 pass 把單一真相源暴露出去:
//
// render → 對每個「直接承載可處理文本」之塊寫 data-jz-block-level="paragraph|text"
// revert → 全數移除(I6
//
// **僅標直接承載文本之塊**:純容器(body/ul/table…——直接文本僅空白)不標。
// 否則 body 恆被判 paragraph,消費端 `[data-jz-block-level="paragraph"]` 之
// justify 經繼承污染全頁,鉤子即廢。此集合恰為段落級 pass 實際作用之塊集
// collectRuns 對純容器收不到 run)。avoid 子樹/pill 內部/域外文本不計
// eachTextNode 同口徑),故未被處理之塊一致不標。
//
// 輸入/輸出分離:作者輸入屬性 data-jz-level 只讀不寫;本屬性為 resolved 輸出、
// 本庫絕不讀之(亦不在 C2 OBSERVE_ATTRS 白名單,外部改動不觸發重處理)。是否
// 對齊仍由消費端 CSS 決定(justify 非段落之純函數——窄欄邊注可 opt-out),本庫
// 只供分類、不介入樣式。
//
// order=10standalone 群之首(於 charify 群後執行;charify 之 jz-* 包裹皆行內
// 元素,不改變「文本之最近塊祖先」,故時點無影響)。
import type { RenderContext, StandalonePass } from "../types.js";
const ATTR = "data-jz-block-level";
export const levelMarkPass: StandalonePass = {
name: "levelMark",
kind: "standalone",
order: 10,
level: "text", // 不受段落級閘限制:text 級之塊亦須標記(標記本身即分級輸出)
enabled: () => true, // 分類鉤子非排版功能,恆開(純屬性輸出、零版面影響)
render(ctx: RenderContext): void
{
const { finder, options } = ctx;
const blockSet = options.finder.blockTags;
// 近塊定位之 per-run 快取(兄弟文本節點共用 parentElement,免重複上攀)。
// 上攀止於 ctx.rooteachTextNode 之文本必在 root 內、鏈上元素亦然,故命中
// 之塊恆在 render 作用域內,revert(root) 必能移除;root 非塊時不越界外標。
const nearest = new Map<Element, Element | null>();
const nearestBlock = (
(p: Element): Element | null =>
{
const cached = nearest.get(p);
if (cached !== undefined) { return cached; }
let e: Element | null = p;
let block: Element | null = null;
while (e)
{
if (blockSet.has(e.nodeName)) { block = e; break; }
if (e === ctx.root) { break; }
e = e.parentElement;
}
nearest.set(p, block);
return block;
}
);
finder.eachTextNode(ctx.root, (t) =>
{
if (!/\S/.test(t.data)) { return; } // 純空白(縮排/換行)不算承載文本
const p = t.parentElement;
const block = p ? nearestBlock(p) : null;
if (!block) { return; }
const level = finder.levelFor(block);
if (block.getAttribute(ATTR) !== level) { block.setAttribute(ATTR, level); }
});
},
revert(ctx: RenderContext): void
{
if (ctx.root.hasAttribute(ATTR)) { ctx.root.removeAttribute(ATTR); }
ctx.root.querySelectorAll("[" + ATTR + "]")
.forEach((el) => el.removeAttribute(ATTR));
},
};