diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 17fb5fb..16f57cd 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -92,6 +92,14 @@ jz.disconnect(); // 停止觀察
**暫停觀察**,故 juzhen 自身之 jz-* 注入絕不自觸發。實測編輯 400 段中之一段,局部恢復較全樹快數
百倍。**元素變化正確性**見 §4.1 末「快取生命週期與失效」。
+**增量粒度契約(下游說明八)**:增量更新須以**塊**為粒度——換塊內容(`textContent`/
+`innerHTML`)、增刪塊、改分類屬性,皆與全量重繪一致(差分複驗逐位元相同)。**不支持**對聚珍
+已切分之文本節點做外科式 `characterData` 直改(如持有 render **前**之 `p.firstChild` 引用、
+事後直改其 `.data`):charify 以 fragment **替換**原文本節點(§3.4),舊引用已脫離文檔或僅指
+碎片,直改令 `revert` 合併出之內容偏離作者意圖。此為後處理排版器之固有限制、非缺陷——聚珍對
+當前 DOM 之處理始終自洽;外科式文本 patch 之框架(vdom 等)須以塊為單位替換,或對受管區域
+`revert` 後再 patch。
+
`options` 形如:
```
@@ -654,6 +662,18 @@ class 約定:`bd`(標點)、`bd-open/close/cop/stop/sep/liga`、`cjk`、`j
**與本批關係**:先落地分級(pass 標 level、`levelFor`、`data-jz-level`/選項)→ orphan/hanging
註冊為段落級,自動只作用段落級元素、跳過 text-level 者。實作順序插於 6.5.5 之首。
+**Resolved 分類之 CSS 鉤子(下游建議七,`typeset/levelmark.ts`)**:消費端之段落級 CSS(首要
+`text-align:justify`)語義上與段落級 pass 應作用於**同一集合**,但 level 判定原僅存內部快取,
+消費端只能另立一份選擇器白名單、兩份分類必然漂移(實例:`.callout` 綁了避頭尾卻不在 justify
+名單)。`levelMark` pass(standalone,order 10,恆開)於 render 時把 resolved 級別寫回塊級元素:
+`data-jz-block-level="paragraph|text"`,`revert` 全數移除(I6)。**僅標「直接承載可處理文本」
+之塊**(存在非空白文本節點、其最近塊祖先即該塊、非 avoid/pill 內/域外)——純容器(body/ul
+/table…)不標,否則 body 恆被判 paragraph、justify 鉤子經繼承污染全頁;此集合恰為段落級 pass
+實際作用之塊集(`collectRuns` 對純容器收不到 run)。**輸入/輸出分離**:作者輸入 `data-jz-level`
+只讀不寫;輸出屬性本庫絕不讀取、亦不在 C2 `OBSERVE_ATTRS` 白名單(外部改動不觸發重處理);
+增量路徑(rerender/observe)revert 即清、render 即重標,天然刷新。本庫只供分類、不介入樣式
+(justify 非段落之純函數——窄欄邊注可 opt-out,由消費端 CSS 決定)。
+
#### 6.5.0 四機制之分工與邊界(核心約定,實作前必讀)
| 機制 | 作用層 | 唯一職責 | 手段 | 狀態 |
diff --git a/README.md b/README.md
index bdc94b8..3724722 100644
--- a/README.md
+++ b/README.md
@@ -91,6 +91,13 @@ jz.observe(document.body); // 之後 DOM 變動即自動增量恢復
> 須經 `revert`/`rerender`/`invalidate`/`observe` 任一使該子樹快取失效**,否則沿用舊分類。
> 新增/移除元素與純內容編輯無此顧慮(新元素重新計算、移除者自動回收)。
+> **增量之粒度為「塊」**:換塊內容(`textContent`/`innerHTML`)、增刪塊、改分類屬性——皆與
+> 全量重繪一致。**不支持**對聚珍已切分之文本節點做外科式 `characterData` 直改(如持有 render
+> **前**之 `p.firstChild` 引用、事後直改其 `.data`):render 後原文本節點已被 `jz-*` 切分/
+> 替換,該引用已非原整段,直改單一碎片會令 `revert` 合併出之內容偏離作者意圖。此為後處理排版
+> 器之固有限制(任何外科式文本 patch 之框架皆會與注入後之 DOM 失步)、非缺陷——聚珍對當前
+> DOM 之處理始終自洽。欲改文字,以塊為單位替換內容即可。
+
> **`revert` 之範圍**:`revert` 乾淨移除聚珍注入之全部結構(`jz-*`/`data-jz-*`),DOM 回到**邏輯
> 等價**態;但**不還原** `spacing` 依契約規範化之贅餘作者空格(CJK↔西文吸收為 margin 間隙、
> CJK↔CJK 剝除;見上表「中西間隙」)。即 `render→revert` 對含 CJK↔西文作者空格之源,還原後語義
@@ -311,6 +318,37 @@ web component 等)採**嚴格白名單**、一律當**邊界**(語義同隔
createJuzhen({ level: { text: "h1,h2,h3,button,.ui" } }); // 選擇器一鍵標記
```
+#### CSS 可查之 resolved 分類:`data-jz-block-level`(輸出屬性)
+
+`render` 時,聚珍把 **resolved** 級別寫回每個「直接承載可處理文本」之塊級元素:
+
+```html
+
…
+…
+
+```
+
+消費端之**段落級 CSS**(首要:CJK 兩端對齊)從此與聚珍的段落級 pass(禁則/垂懸)共用
+**單一真相源**,免另行維護一份必然漂移之選擇器白名單:
+
+```css
+[data-jz-block-level="paragraph"] { text-align: justify; }
+.marginalia[data-jz-block-level="paragraph"] { text-align: start; } /* 窄欄 opt-out */
+```
+
+規則:
+
+- **僅標直接承載文本之塊**——純容器(`body`/`ul`/`table`…,直接文本僅空白)不標,
+ 否則 `body` 恆被判段落級,justify 鉤子經繼承污染全頁。此集合恰為段落級 pass 實際
+ 作用之塊集。avoid 子樹/pill 內部之文本不計,故未被處理之塊一致不標。
+- **輸出專用、輸入輸出分離**:作者輸入屬性 `data-jz-level` 只讀不寫;本屬性由聚珍寫入、
+ 聚珍**絕不讀取**(消費端手寫無效),`revert` 時全數移除。增量模式(`observe`)下分類
+ 屬性變化自動刷新。
+- 是否對齊仍由消費端 CSS 決定(justify 非段落之純函數——窄欄邊注可 opt-out);聚珍
+ 只供分類、不介入樣式。
+
### 標點懸掛(`hanging`)— 已移除
原計劃之標點懸掛(行端標點凸出版心)經實測為**機制級死路、已移除**(負 margin 程式碼與
diff --git a/dist/juzhen.iife.js b/dist/juzhen.iife.js
index ac7dd14..4056358 100644
--- a/dist/juzhen.iife.js
+++ b/dist/juzhen.iife.js
@@ -1413,6 +1413,63 @@ var Juzhen = (() => {
}
};
+ // src/typeset/levelmark.ts
+ var ATTR = "data-jz-block-level";
+ var levelMarkPass = {
+ name: "levelMark",
+ kind: "standalone",
+ order: 10,
+ level: "text",
+ // 不受段落級閘限制:text 級之塊亦須標記(標記本身即分級輸出)
+ enabled: () => true,
+ // 分類鉤子非排版功能,恆開(純屬性輸出、零版面影響)
+ render(ctx) {
+ const { finder, options } = ctx;
+ const blockSet = options.finder.blockTags;
+ const nearest = /* @__PURE__ */ new Map();
+ const nearestBlock = (p) => {
+ const cached = nearest.get(p);
+ if (cached !== void 0) {
+ return cached;
+ }
+ let e = p;
+ let block = 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) {
+ if (ctx.root.hasAttribute(ATTR)) {
+ ctx.root.removeAttribute(ATTR);
+ }
+ ctx.root.querySelectorAll("[" + ATTR + "]").forEach((el) => el.removeAttribute(ATTR));
+ }
+ };
+
// src/typeset/lineedge.ts
var LE = "jz-half-le";
var ALL_EDGE = [LE];
@@ -2156,7 +2213,8 @@ var Juzhen = (() => {
jinzePass,
orphanPass,
lineEdgePass,
- gapTrimPass
+ gapTrimPass,
+ levelMarkPass
];
function feature(v, dflt) {
return v === void 0 ? dflt : !!v;
diff --git a/dist/juzhen.mjs b/dist/juzhen.mjs
index 2be70d3..0c7145f 100644
--- a/dist/juzhen.mjs
+++ b/dist/juzhen.mjs
@@ -1386,6 +1386,63 @@ var jinzePass = {
}
};
+// src/typeset/levelmark.ts
+var ATTR = "data-jz-block-level";
+var levelMarkPass = {
+ name: "levelMark",
+ kind: "standalone",
+ order: 10,
+ level: "text",
+ // 不受段落級閘限制:text 級之塊亦須標記(標記本身即分級輸出)
+ enabled: () => true,
+ // 分類鉤子非排版功能,恆開(純屬性輸出、零版面影響)
+ render(ctx) {
+ const { finder, options } = ctx;
+ const blockSet = options.finder.blockTags;
+ const nearest = /* @__PURE__ */ new Map();
+ const nearestBlock = (p) => {
+ const cached = nearest.get(p);
+ if (cached !== void 0) {
+ return cached;
+ }
+ let e = p;
+ let block = 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) {
+ if (ctx.root.hasAttribute(ATTR)) {
+ ctx.root.removeAttribute(ATTR);
+ }
+ ctx.root.querySelectorAll("[" + ATTR + "]").forEach((el) => el.removeAttribute(ATTR));
+ }
+};
+
// src/typeset/lineedge.ts
var LE = "jz-half-le";
var ALL_EDGE = [LE];
@@ -2129,7 +2186,8 @@ var PASSES = [
jinzePass,
orphanPass,
lineEdgePass,
- gapTrimPass
+ gapTrimPass,
+ levelMarkPass
];
function feature(v, dflt) {
return v === void 0 ? dflt : !!v;
diff --git a/dist/typeset/levelmark.d.ts b/dist/typeset/levelmark.d.ts
new file mode 100644
index 0000000..a1689be
--- /dev/null
+++ b/dist/typeset/levelmark.d.ts
@@ -0,0 +1,2 @@
+import type { StandalonePass } from "../types.js";
+export declare const levelMarkPass: StandalonePass;
diff --git a/src/index.ts b/src/index.ts
index 88b3a2d..459c5ee 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -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 = [
// 執行序**不由本陣列決定**:runPasses(core/pass.ts)先分 charify/standalone 兩群、
// 各群再按 pass.order 排序(charify 群整體先於 standalone 群)。故此處排列僅供閱讀分組,
-// 真實序為 order:jiya(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
diff --git a/src/typeset/levelmark.ts b/src/typeset/levelmark.ts
new file mode 100644
index 0000000..0467ae9
--- /dev/null
+++ b/src/typeset/levelmark.ts
@@ -0,0 +1,77 @@
+// 塊級分類鉤子(下游建議七):把 resolved 功能級別寫回 DOM,供消費端 CSS 查詢。
+//
+// 消費端的段落級樣式(首要:CJK 兩端對齊 text-align:justify)語義上應與本庫的
+// 段落級 pass(jinze 避頭尾/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=10:standalone 群之首(於 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.root:eachTextNode 之文本必在 root 內、鏈上元素亦然,故命中
+ // 之塊恆在 render 作用域內,revert(root) 必能移除;root 非塊時不越界外標。
+ const nearest = new Map();
+ 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));
+ },
+};
diff --git a/test/juzhen.test.mjs b/test/juzhen.test.mjs
index d333b95..be3a56b 100644
--- a/test/juzhen.test.mjs
+++ b/test/juzhen.test.mjs
@@ -1522,3 +1522,89 @@ test("C2 觀察:向葉塊追加子塊 → 撤除容器陳舊 jz-orphan(IV-3
assert.equal(directOrphan, false, "容器轉非葉後,其直接 jz-orphan 已撤除");
assert.ok(np.querySelector("jz-orphan"), "新塊 p 自身之 jz-orphan 正常");
});
+
+// ----- 塊級分類鉤子 data-jz-block-level(下游建議七)-----
+
+test("levelMark:直接承載文本之塊標 data-jz-block-level,純容器不標", () =>
+{
+ const { document } = setupDom(
+ ''
+ + "",
+ );
+ createJuzhen().render(document.body);
+ const m = document.getElementById("m");
+ assert.equal(m.getAttribute("data-jz-block-level"), "paragraph", "混合塊有直接文本 → 標");
+ assert.equal(m.querySelector("p").getAttribute("data-jz-block-level"), "paragraph");
+ assert.equal(document.querySelector("li").getAttribute("data-jz-block-level"), "paragraph");
+ assert.equal(
+ document.querySelector("ul").hasAttribute("data-jz-block-level"), false,
+ "純容器(直接文本僅空白)不標",
+ );
+ assert.equal(
+ document.body.hasAttribute("data-jz-block-level"), false,
+ "body 無直接文本不標(justify 鉤子不經繼承污染全頁)",
+ );
+});
+
+test("levelMark:level.text 選擇器/data-jz-level 屬性 → 標 text;作者輸入屬性不被觸碰", () =>
+{
+ const { document } = setupDom(
+ '標題文字
說明文字。
'
+ + '',
+ );
+ createJuzhen({ level: { text: "h1" } }).render(document.body);
+ assert.equal(document.querySelector("h1").getAttribute("data-jz-block-level"), "text", "選擇器命中");
+ const p = document.querySelector("p[data-jz-level]");
+ assert.equal(p.getAttribute("data-jz-block-level"), "text", "屬性命中");
+ assert.equal(p.getAttribute("data-jz-level"), "text", "作者輸入 data-jz-level 原樣保留");
+ assert.equal(
+ document.getElementById("n").getAttribute("data-jz-block-level"), "text",
+ "祖先 data-jz-level=text → 後代塊 resolved 為 text",
+ );
+});
+
+test("levelMark:avoid 子樹與 pill 內部文本不計——僅含此類內容之塊不標", () =>
+{
+ const { document } = setupDom(
+ "onlyPill
跳過內容。
",
+ );
+ createJuzhen().render(document.body);
+ assert.equal(
+ document.querySelector("div").hasAttribute("data-jz-block-level"), false,
+ "pill 內部不可見 → 塊不標",
+ );
+ assert.equal(
+ document.querySelector("p").hasAttribute("data-jz-block-level"), false,
+ "avoid 塊不標(與「未被處理」一致)",
+ );
+});
+
+test("levelMark:revert 全數移除 data-jz-block-level(I6),root 自身亦清", () =>
+{
+ const { document } = setupDom("直接文字。段落文字。
");
+ const jz = createJuzhen();
+ jz.render(document.body);
+ assert.equal(
+ document.body.getAttribute("data-jz-block-level"), "paragraph",
+ "body 有直接文本 → root 自身被標",
+ );
+ assert.ok(document.querySelector("p[data-jz-block-level]"), "段落已標");
+ jz.revert(document.body);
+ assert.equal(document.querySelectorAll("[data-jz-block-level]").length, 0, "子樹全清");
+ assert.equal(document.body.hasAttribute("data-jz-block-level"), false, "root 自身已清");
+});
+
+test("levelMark C2:class 變化令 level 翻轉 → 觀察自動刷新 resolved 標記", async () =>
+{
+ const { document } = setupDom('正文文字,標點。
');
+ const jz = createJuzhen({ level: { text: ".chrome" } });
+ jz.render(document.body);
+ jz.observe(document.body);
+ const p = document.querySelector("p");
+ assert.equal(p.getAttribute("data-jz-block-level"), "paragraph");
+ p.classList.add("chrome");
+ await tick();
+ jz.disconnect();
+ assert.equal(p.getAttribute("data-jz-block-level"), "text", "翻轉後 resolved 標記已刷新");
+ assert.equal(countTag(p, "jz-jinze"), 0, "text 級不再跑段落級禁則(重分類一致)");
+});