From c1fbabf5b01be6d4e3b767dea20642555a6debbf Mon Sep 17 00:00:00 2001 From: commilitia Date: Tue, 9 Jun 2026 16:52:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8A=9F=E8=83=BD=E5=88=86=E7=B4=9A?= =?UTF-8?q?=EF=BC=88=E6=96=87=E6=9C=AC=E7=B4=9A=20vs=20=E6=AE=B5=E8=90=BD?= =?UTF-8?q?=E7=B4=9A=EF=BC=89=EF=BC=8B=20data-jz-level=20=E6=A8=99?= =?UTF-8?q?=E8=A8=98=EF=BC=88=C2=A76.5.0a=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 整體計劃 §6.5 之前置基礎設施。把已實作功能切為兩級: - 文本級(spacing 中西間隙、jiya 標點擠壓/鄰接):任意文本片段皆適用。 - 段落級(jinze 禁則、longWord 斷詞、lineEdge 行端、gapTrim):需行/段布局。 標記 API: - 預設塊級元素 → 段落級(全功能)。 - 元素屬性 data-jz-level="text"|"paragraph"(最近祖先勝)。 - 選項 level:{ text: "選擇器" }(如標題/按鈕一鍵降為文本級)。 實作: - 每個 pass 加 level 元數據;Finder.levelFor/levelAllows(含快取)。 - charify 段落級 pass(longWord)於共享走訪受閘;standalone 段落級 pass (jinze/lineEdge/gapTrim)逐元素受閘。文本級不受限。 - spacing 補單元素 root 支援:root 為行內/單一非塊元素時亦處理(標題等 之中西間隙、標點半形生效)。 6 項新單元測試(text-level 跳段落級、選擇器標記、巢狀最近勝、單元素 spacing、 預設段落級回歸)。共 76 測試全綠。 --- dist/core/finder.d.ts | 16 +++++++++- dist/core/unicode.d.ts | 6 ++-- dist/juzhen.iife.js | 66 ++++++++++++++++++++++++++++++++++++++++- dist/juzhen.mjs | 66 ++++++++++++++++++++++++++++++++++++++++- dist/types.d.ts | 14 +++++++++ src/core/finder.ts | 46 +++++++++++++++++++++++++++- src/core/pass.ts | 3 ++ src/index.ts | 1 + src/types.ts | 16 ++++++++++ src/typeset/jinze.ts | 3 ++ src/typeset/jiya.ts | 2 ++ src/typeset/lineedge.ts | 6 +++- src/typeset/longword.ts | 1 + src/typeset/spacing.ts | 10 +++++++ test/juzhen.test.mjs | 60 +++++++++++++++++++++++++++++++++++++ 15 files changed, 309 insertions(+), 7 deletions(-) diff --git a/dist/core/finder.d.ts b/dist/core/finder.d.ts index 3e67232..c0b7d61 100644 --- a/dist/core/finder.d.ts +++ b/dist/core/finder.d.ts @@ -1,4 +1,4 @@ -import type { ResolvedOptions } from "../types.js"; +import type { FeatureLevel, ResolvedOptions } from "../types.js"; /** 邏輯 run 中之單一字元來源位置。 */ export interface CharPos { textNode: Text; @@ -25,7 +25,9 @@ export declare class Finder { private readonly includeSelector; private readonly skipAttr; private readonly userIsSkipped; + private readonly levelTextSelector; private readonly featureCache; + private readonly levelCache; constructor(opts: ResolvedOptions); /** 該元素是否匹配 pill 選擇器(行內塊,如 code/kbd)。 */ pillMatches(el: Element): boolean; @@ -53,6 +55,18 @@ export declare class Finder { * marker 即止。direction = -1 取前鄰字,+1 取後鄰字。 */ adjacentLogicalChar(el: Element, direction: -1 | 1): AdjacentChar | null; + /** + * 節點之功能級別(§6.5.0a)。自 node 向上找**最近**之顯式信號:元素屬性 + * `data-jz-level="text|paragraph"`(最優先),或命中 `level.text` 選擇器(→ text); + * 皆無則預設 paragraph(全功能)。結果以解析後之元素為鍵快取。 + */ + levelFor(node: Node): FeatureLevel; + /** + * 某級別之 pass 是否可作用於該節點(§6.5.0a gate)。文本級 pass 不受限(恆 true); + * 段落級 pass 僅作用於段落級元素(text-level 子樹跳過)。與 featureEnabledFor 疊加: + * level 為粗粒度語義組(先判),blocks/屬性為細粒度逐功能(後調)。 + */ + levelAllows(node: Node, passLevel: FeatureLevel): boolean; /** * 解析節點對某功能之有效啟用狀態(§3.6,per-node 閘)。向上找最近帶 * 功能指令之祖先(data-juzhen 白名單 / data-juzhen-off 黑名單 / 命中 diff --git a/dist/core/unicode.d.ts b/dist/core/unicode.d.ts index f3a3815..cd004e7 100644 --- a/dist/core/unicode.d.ts +++ b/dist/core/unicode.d.ts @@ -11,8 +11,10 @@ export declare const ANY_CJK: RegExp; export declare const ANS_AFTER_CJK: string; /** CJK 之前可接間隙之西文集(去 @)。 */ export declare const ANS_BEFORE_CJK: string; -/** 句末點號:。!?(開明式與全角式皆保持全形,不擠壓)。 */ -export declare const BD_STOP = "\u3002\uFF01\uFF1F"; +/** 句末點號:。.!?(開明式與全角式皆保持全形,不擠壓;banjiao 擠半)。 + * 含全角句點 .(U+FF0E,異於表意句號 。U+3002)——實測 Noto CJK SC/TC 皆有 halt、 + * 挤壓收 0.5em,故與 。!? 同列為句末點號(下游回饋)。 */ +export declare const BD_STOP = "\u3002\uFF0E\uFF01\uFF1F"; /** 句內點號:,、;:(開明式擠成半形;全角式保持全形)。 */ export declare const BD_PAUSE = "\uFF0C\u3001\uFF1B\uFF1A"; /** 開始括號/引號:「『(《〈【〖〔[{“‘。 */ diff --git a/dist/juzhen.iife.js b/dist/juzhen.iife.js index 8efb7d5..301573e 100644 --- a/dist/juzhen.iife.js +++ b/dist/juzhen.iife.js @@ -37,9 +37,11 @@ var Juzhen = (() => { this.pillSelector = opts.finder.pillSelector; this.skipAttr = opts.finder.skipAttribute; this.userIsSkipped = opts.finder.isSkipped; + this.levelTextSelector = opts.levelText; this.avoidSelector = opts.scope.avoid; this.includeSelector = opts.scope.include; this.featureCache = /* @__PURE__ */ new WeakMap(); + this.levelCache = /* @__PURE__ */ new WeakMap(); } /** 該元素是否匹配 pill 選擇器(行內塊,如 code/kbd)。 */ pillMatches(el) { @@ -259,6 +261,48 @@ var Juzhen = (() => { p = pe.parentNode; } } + /** + * 節點之功能級別(§6.5.0a)。自 node 向上找**最近**之顯式信號:元素屬性 + * `data-jz-level="text|paragraph"`(最優先),或命中 `level.text` 選擇器(→ text); + * 皆無則預設 paragraph(全功能)。結果以解析後之元素為鍵快取。 + */ + levelFor(node) { + const start = node.nodeType === 1 ? node : node.parentElement; + if (!start) { + return "paragraph"; + } + const cached = this.levelCache.get(start); + if (cached) { + return cached; + } + let el = start; + let result = "paragraph"; + while (el) { + const attr = el.getAttribute ? el.getAttribute("data-jz-level") : null; + if (attr === "text" || attr === "paragraph") { + result = attr; + break; + } + if (this.levelTextSelector && el.matches && el.matches(this.levelTextSelector)) { + result = "text"; + break; + } + el = el.parentElement; + } + this.levelCache.set(start, result); + return result; + } + /** + * 某級別之 pass 是否可作用於該節點(§6.5.0a gate)。文本級 pass 不受限(恆 true); + * 段落級 pass 僅作用於段落級元素(text-level 子樹跳過)。與 featureEnabledFor 疊加: + * level 為粗粒度語義組(先判),blocks/屬性為細粒度逐功能(後調)。 + */ + levelAllows(node, passLevel) { + if (passLevel === "text") { + return true; + } + return this.levelFor(node) === "paragraph"; + } /** * 解析節點對某功能之有效啟用狀態(§3.6,per-node 閘)。向上找最近帶 * 功能指令之祖先(data-juzhen 白名單 / data-juzhen-off 黑名單 / 命中 @@ -331,6 +375,9 @@ var Juzhen = (() => { ctx.finder.eachTextNode(ctx.root, (node) => { let requests = []; for (const p of passes) { + if (!ctx.finder.levelAllows(node, p.level)) { + continue; + } const got = p.collect(node, ctx); if (got.length > 0) { requests = requests.concat(got); @@ -665,6 +712,7 @@ var Juzhen = (() => { name: PASS, kind: "charify", order: 20, + level: "text", enabled: (o) => o.features.jiya, collect(node, ctx) { if (inJzChar(node)) { @@ -718,6 +766,7 @@ var Juzhen = (() => { name: "jiyaAdjacency", kind: "standalone", order: 25, + level: "text", enabled: (o) => o.features.jiya, render(ctx) { const { finder, options } = ctx; @@ -890,11 +939,15 @@ var Juzhen = (() => { name: PASS2, kind: "standalone", order: 40, + level: "paragraph", enabled: (o) => o.features.jinze, render(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")) { + return; + } if (c.parentNode) { parents.add(c.parentNode); } @@ -1004,6 +1057,9 @@ var Juzhen = (() => { (c) => finder.inScope(c) && !finder.isAvoided(c) ); for (const el of chars) { + if (!finder.levelAllows(el, "paragraph")) { + continue; + } if (!finder.featureEnabledFor(el, "jiya")) { continue; } @@ -1032,6 +1088,7 @@ var Juzhen = (() => { name: "jiyaLineEdge", kind: "standalone", order: 90, + level: "paragraph", enabled: (o) => o.features.jiya, render(ctx) { if (!hasLayout()) { @@ -1047,7 +1104,7 @@ var Juzhen = (() => { var TRIM = "jz-hws-trim"; function trimGaps(root, finder) { const gaps = Array.from(root.querySelectorAll("jz-hws")).filter( - (g) => finder.inScope(g) && !finder.isAvoided(g) + (g) => finder.inScope(g) && !finder.isAvoided(g) && finder.levelAllows(g, "paragraph") ); if (!gaps.length) { return; @@ -1066,6 +1123,7 @@ var Juzhen = (() => { name: "spacingGapTrim", kind: "standalone", order: 92, + level: "paragraph", enabled: (o) => o.features.spacing, render(ctx) { if (!hasLayout()) { @@ -1096,6 +1154,7 @@ var Juzhen = (() => { name: PASS3, kind: "charify", order: 60, + level: "paragraph", enabled: (o) => o.features.longWord, collect(node, ctx) { if (inLangSpan(node)) { @@ -1347,6 +1406,7 @@ var Juzhen = (() => { name: PASS4, kind: "standalone", order: 50, + level: "text", enabled: (o) => o.features.spacing, render(ctx) { const { finder, options } = ctx; @@ -1357,6 +1417,9 @@ var Juzhen = (() => { } processBlock(block, finder, R); }); + if (!options.finder.blockTags.has(ctx.root.nodeName) && finder.featureEnabledFor(ctx.root, PASS4) && finder.inScope(ctx.root) && !finder.isAvoided(ctx.root)) { + processBlock(ctx.root, finder, R); + } processPills(ctx.root, finder, options.finder.pillSelector, R.pillNeighbor); }, revert(ctx) { @@ -1551,6 +1614,7 @@ var Juzhen = (() => { ruleset: resolveRuleset(opts.charClass), autospaceClass: opts.autospaceClass || "", justifyAtoms: opts.justifyAtoms === false ? false : true, + levelText: opts.level && opts.level.text || null, scope: { root: scope.root || null, include: scope.include || null, diff --git a/dist/juzhen.mjs b/dist/juzhen.mjs index 44785cf..62c039b 100644 --- a/dist/juzhen.mjs +++ b/dist/juzhen.mjs @@ -9,9 +9,11 @@ var Finder = class { this.pillSelector = opts.finder.pillSelector; this.skipAttr = opts.finder.skipAttribute; this.userIsSkipped = opts.finder.isSkipped; + this.levelTextSelector = opts.levelText; this.avoidSelector = opts.scope.avoid; this.includeSelector = opts.scope.include; this.featureCache = /* @__PURE__ */ new WeakMap(); + this.levelCache = /* @__PURE__ */ new WeakMap(); } /** 該元素是否匹配 pill 選擇器(行內塊,如 code/kbd)。 */ pillMatches(el) { @@ -231,6 +233,48 @@ var Finder = class { p = pe.parentNode; } } + /** + * 節點之功能級別(§6.5.0a)。自 node 向上找**最近**之顯式信號:元素屬性 + * `data-jz-level="text|paragraph"`(最優先),或命中 `level.text` 選擇器(→ text); + * 皆無則預設 paragraph(全功能)。結果以解析後之元素為鍵快取。 + */ + levelFor(node) { + const start = node.nodeType === 1 ? node : node.parentElement; + if (!start) { + return "paragraph"; + } + const cached = this.levelCache.get(start); + if (cached) { + return cached; + } + let el = start; + let result = "paragraph"; + while (el) { + const attr = el.getAttribute ? el.getAttribute("data-jz-level") : null; + if (attr === "text" || attr === "paragraph") { + result = attr; + break; + } + if (this.levelTextSelector && el.matches && el.matches(this.levelTextSelector)) { + result = "text"; + break; + } + el = el.parentElement; + } + this.levelCache.set(start, result); + return result; + } + /** + * 某級別之 pass 是否可作用於該節點(§6.5.0a gate)。文本級 pass 不受限(恆 true); + * 段落級 pass 僅作用於段落級元素(text-level 子樹跳過)。與 featureEnabledFor 疊加: + * level 為粗粒度語義組(先判),blocks/屬性為細粒度逐功能(後調)。 + */ + levelAllows(node, passLevel) { + if (passLevel === "text") { + return true; + } + return this.levelFor(node) === "paragraph"; + } /** * 解析節點對某功能之有效啟用狀態(§3.6,per-node 閘)。向上找最近帶 * 功能指令之祖先(data-juzhen 白名單 / data-juzhen-off 黑名單 / 命中 @@ -303,6 +347,9 @@ function runCharify(passes, ctx) { ctx.finder.eachTextNode(ctx.root, (node) => { let requests = []; for (const p of passes) { + if (!ctx.finder.levelAllows(node, p.level)) { + continue; + } const got = p.collect(node, ctx); if (got.length > 0) { requests = requests.concat(got); @@ -637,6 +684,7 @@ var jiyaPass = { name: PASS, kind: "charify", order: 20, + level: "text", enabled: (o) => o.features.jiya, collect(node, ctx) { if (inJzChar(node)) { @@ -690,6 +738,7 @@ var jiyaAdjacencyPass = { name: "jiyaAdjacency", kind: "standalone", order: 25, + level: "text", enabled: (o) => o.features.jiya, render(ctx) { const { finder, options } = ctx; @@ -862,11 +911,15 @@ var jinzePass = { name: PASS2, kind: "standalone", order: 40, + level: "paragraph", enabled: (o) => o.features.jinze, render(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")) { + return; + } if (c.parentNode) { parents.add(c.parentNode); } @@ -976,6 +1029,9 @@ function relayout(root, finder) { (c) => finder.inScope(c) && !finder.isAvoided(c) ); for (const el of chars) { + if (!finder.levelAllows(el, "paragraph")) { + continue; + } if (!finder.featureEnabledFor(el, "jiya")) { continue; } @@ -1004,6 +1060,7 @@ var lineEdgePass = { name: "jiyaLineEdge", kind: "standalone", order: 90, + level: "paragraph", enabled: (o) => o.features.jiya, render(ctx) { if (!hasLayout()) { @@ -1019,7 +1076,7 @@ var lineEdgePass = { var TRIM = "jz-hws-trim"; function trimGaps(root, finder) { const gaps = Array.from(root.querySelectorAll("jz-hws")).filter( - (g) => finder.inScope(g) && !finder.isAvoided(g) + (g) => finder.inScope(g) && !finder.isAvoided(g) && finder.levelAllows(g, "paragraph") ); if (!gaps.length) { return; @@ -1038,6 +1095,7 @@ var gapTrimPass = { name: "spacingGapTrim", kind: "standalone", order: 92, + level: "paragraph", enabled: (o) => o.features.spacing, render(ctx) { if (!hasLayout()) { @@ -1068,6 +1126,7 @@ var longWordPass = { name: PASS3, kind: "charify", order: 60, + level: "paragraph", enabled: (o) => o.features.longWord, collect(node, ctx) { if (inLangSpan(node)) { @@ -1319,6 +1378,7 @@ var spacingPass = { name: PASS4, kind: "standalone", order: 50, + level: "text", enabled: (o) => o.features.spacing, render(ctx) { const { finder, options } = ctx; @@ -1329,6 +1389,9 @@ var spacingPass = { } processBlock(block, finder, R); }); + if (!options.finder.blockTags.has(ctx.root.nodeName) && finder.featureEnabledFor(ctx.root, PASS4) && finder.inScope(ctx.root) && !finder.isAvoided(ctx.root)) { + processBlock(ctx.root, finder, R); + } processPills(ctx.root, finder, options.finder.pillSelector, R.pillNeighbor); }, revert(ctx) { @@ -1523,6 +1586,7 @@ function normalizeOptions(opts = {}) { ruleset: resolveRuleset(opts.charClass), autospaceClass: opts.autospaceClass || "", justifyAtoms: opts.justifyAtoms === false ? false : true, + levelText: opts.level && opts.level.text || null, scope: { root: scope.root || null, include: scope.include || null, diff --git a/dist/types.d.ts b/dist/types.d.ts index 972fefc..aa5667e 100644 --- a/dist/types.d.ts +++ b/dist/types.d.ts @@ -37,6 +37,14 @@ export interface JuzhenOptions { /** 自定義字符分類(擴充內置規則集)。把未分類字符或新符號歸入中文/西文/ * 全寬標點各子類,以覆寫 spacing/jiya 等規則之分類判定(追加語義)。 */ charClass?: CharClassOverride; + /** 功能分級標記(§6.5.0a)。段落級功能(禁則/行端/懸掛/垂懸字/長詞斷字) + * 僅作用於段落級元素;文本級功能(中西間隙/標點擠壓)任意文本皆適用。 + * - `text`:選擇器,命中之子樹**僅文本級**(標題/按鈕/UI 文字典型);亦可 + * 經元素屬性 `data-jz-level="text"`/`"paragraph"` 標記(屬性優先、最近祖先勝)。 + * 預設:塊級元素(blockTags)→ 段落級(全功能)。 */ + level?: { + text?: string; + }; /** 間隙標記沿用之 class(v1 相容;預設無額外 class)。 */ autospaceClass?: string; /** justify 原子化:jz-char/jz-jinze 是否以 inline-block 作對齊單一原子 @@ -74,6 +82,8 @@ export interface ResolvedOptions { ruleset: Ruleset; autospaceClass: string; justifyAtoms: boolean; + /** 功能分級之 text-level 選擇器(§6.5.0a);null 表示未設。 */ + levelText: string | null; scope: { root: Element | string | null; include: string | null; @@ -126,10 +136,14 @@ export interface WrapRequest { } /** pass 種類。 */ export type PassKind = "charify" | "standalone"; +/** 功能分級(§6.5.0a):text=任意文本片段皆適用;paragraph=需行/段布局。 */ +export type FeatureLevel = "text" | "paragraph"; interface BasePass { name: string; order: number; kind: PassKind; + /** 功能分級(§6.5.0a)。paragraph 級 pass 對 text-level 元素自動跳過。 */ + level: FeatureLevel; enabled: (options: ResolvedOptions) => boolean; revert: (ctx: RenderContext) => void; } diff --git a/src/core/finder.ts b/src/core/finder.ts index 983757a..d6f71db 100644 --- a/src/core/finder.ts +++ b/src/core/finder.ts @@ -4,7 +4,7 @@ // 行內元素透明、pill/block/avoid 中斷)、pill 鄰字查找、per-node 功能閘 // (featureEnabledFor)。finder 不知具體排版規則,只提供遍歷與安全變更原語。 -import type { ResolvedOptions } from "../types.js"; +import type { FeatureLevel, ResolvedOptions } from "../types.js"; /** 邏輯 run 中之單一字元來源位置。 */ export interface CharPos @@ -41,7 +41,9 @@ export class Finder private readonly includeSelector: string | null; private readonly skipAttr: string | null; private readonly userIsSkipped: ((node: Node) => boolean) | null; + private readonly levelTextSelector: string | null; private readonly featureCache: WeakMap>; + private readonly levelCache: WeakMap; constructor(opts: ResolvedOptions) { @@ -52,9 +54,11 @@ export class Finder this.pillSelector = opts.finder.pillSelector; this.skipAttr = opts.finder.skipAttribute; this.userIsSkipped = opts.finder.isSkipped; + this.levelTextSelector = opts.levelText; this.avoidSelector = opts.scope.avoid; this.includeSelector = opts.scope.include; this.featureCache = new WeakMap(); + this.levelCache = new WeakMap(); } /** 該元素是否匹配 pill 選擇器(行內塊,如 code/kbd)。 */ @@ -278,6 +282,46 @@ export class Finder } } + /** + * 節點之功能級別(§6.5.0a)。自 node 向上找**最近**之顯式信號:元素屬性 + * `data-jz-level="text|paragraph"`(最優先),或命中 `level.text` 選擇器(→ text); + * 皆無則預設 paragraph(全功能)。結果以解析後之元素為鍵快取。 + */ + levelFor(node: Node): FeatureLevel + { + const start = node.nodeType === 1 ? (node as Element) : node.parentElement; + if (!start) { return "paragraph"; } + const cached = this.levelCache.get(start); + if (cached) { return cached; } + + let el: Element | null = start; + let result: FeatureLevel = "paragraph"; + while (el) + { + const attr = el.getAttribute ? el.getAttribute("data-jz-level") : null; + if (attr === "text" || attr === "paragraph") { result = attr; break; } + if (this.levelTextSelector && el.matches && el.matches(this.levelTextSelector)) + { + result = "text"; + break; + } + el = el.parentElement; + } + this.levelCache.set(start, result); + return result; + } + + /** + * 某級別之 pass 是否可作用於該節點(§6.5.0a gate)。文本級 pass 不受限(恆 true); + * 段落級 pass 僅作用於段落級元素(text-level 子樹跳過)。與 featureEnabledFor 疊加: + * level 為粗粒度語義組(先判),blocks/屬性為細粒度逐功能(後調)。 + */ + levelAllows(node: Node, passLevel: FeatureLevel): boolean + { + if (passLevel === "text") { return true; } + return this.levelFor(node) === "paragraph"; + } + /** * 解析節點對某功能之有效啟用狀態(§3.6,per-node 閘)。向上找最近帶 * 功能指令之祖先(data-juzhen 白名單 / data-juzhen-off 黑名單 / 命中 diff --git a/src/core/pass.ts b/src/core/pass.ts index 51e7b70..dc6aff1 100644 --- a/src/core/pass.ts +++ b/src/core/pass.ts @@ -49,6 +49,9 @@ function runCharify(passes: CharifyPass[], ctx: RenderContext): void let requests: WrapRequest[] = []; for (const p of passes) { + // 功能分級閘(§6.5.0a):段落級 charify pass(如 longWord)對 text-level + // 子樹跳過;文本級(如 jiya)不受限。 + if (!ctx.finder.levelAllows(node, p.level)) { continue; } const got = p.collect(node, ctx); if (got.length > 0) { requests = requests.concat(got); } } diff --git a/src/index.ts b/src/index.ts index ff04be1..359258d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -86,6 +86,7 @@ export function normalizeOptions(opts: JuzhenOptions = {}): ResolvedOptions ruleset: resolveRuleset(opts.charClass), autospaceClass: opts.autospaceClass || "", justifyAtoms: opts.justifyAtoms === false ? false : true, + levelText: (opts.level && opts.level.text) || null, scope: { root: scope.root || null, include: scope.include || null, diff --git a/src/types.ts b/src/types.ts index ccc4bcc..181e47e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -50,6 +50,15 @@ export interface JuzhenOptions * 全寬標點各子類,以覆寫 spacing/jiya 等規則之分類判定(追加語義)。 */ charClass?: CharClassOverride; + /** 功能分級標記(§6.5.0a)。段落級功能(禁則/行端/懸掛/垂懸字/長詞斷字) + * 僅作用於段落級元素;文本級功能(中西間隙/標點擠壓)任意文本皆適用。 + * - `text`:選擇器,命中之子樹**僅文本級**(標題/按鈕/UI 文字典型);亦可 + * 經元素屬性 `data-jz-level="text"`/`"paragraph"` 標記(屬性優先、最近祖先勝)。 + * 預設:塊級元素(blockTags)→ 段落級(全功能)。 */ + level?: { + text?: string; + }; + /** 間隙標記沿用之 class(v1 相容;預設無額外 class)。 */ autospaceClass?: string; @@ -96,6 +105,8 @@ export interface ResolvedOptions ruleset: Ruleset; autospaceClass: string; justifyAtoms: boolean; + /** 功能分級之 text-level 選擇器(§6.5.0a);null 表示未設。 */ + levelText: string | null; scope: { root: Element | string | null; include: string | null; @@ -148,11 +159,16 @@ export interface WrapRequest /** pass 種類。 */ export type PassKind = "charify" | "standalone"; +/** 功能分級(§6.5.0a):text=任意文本片段皆適用;paragraph=需行/段布局。 */ +export type FeatureLevel = "text" | "paragraph"; + interface BasePass { name: string; order: number; kind: PassKind; + /** 功能分級(§6.5.0a)。paragraph 級 pass 對 text-level 元素自動跳過。 */ + level: FeatureLevel; enabled: (options: ResolvedOptions) => boolean; revert: (ctx: RenderContext) => void; } diff --git a/src/typeset/jinze.ts b/src/typeset/jinze.ts index 9e3384d..2866538 100644 --- a/src/typeset/jinze.ts +++ b/src/typeset/jinze.ts @@ -143,6 +143,7 @@ export const jinzePass: StandalonePass = { name: PASS, kind: "standalone", order: 40, + level: "paragraph", enabled: (o) => o.features.jinze, render(ctx: RenderContext): void { @@ -151,6 +152,8 @@ export const jinzePass: StandalonePass = { const parents = new Set(); ctx.root.querySelectorAll("jz-char").forEach((c) => { + // 功能分級閘(§6.5.0a):禁則為段落級,text-level 子樹(標題/按鈕)跳過。 + if (!ctx.finder.levelAllows(c, "paragraph")) { return; } if (c.parentNode) { parents.add(c.parentNode); } }); for (const parent of parents) { processParent(parent, ctx.finder, anyCjk); } diff --git a/src/typeset/jiya.ts b/src/typeset/jiya.ts index e1e9f31..0ac859a 100644 --- a/src/typeset/jiya.ts +++ b/src/typeset/jiya.ts @@ -55,6 +55,7 @@ export const jiyaPass: CharifyPass = { name: PASS, kind: "charify", order: 20, + level: "text", enabled: (o) => o.features.jiya, collect(node, ctx): WrapRequest[] { @@ -123,6 +124,7 @@ export const jiyaAdjacencyPass: StandalonePass = { name: "jiyaAdjacency", kind: "standalone", order: 25, + level: "text", enabled: (o) => o.features.jiya, render(ctx: RenderContext): void { diff --git a/src/typeset/lineedge.ts b/src/typeset/lineedge.ts index 90ba1bc..97e0255 100644 --- a/src/typeset/lineedge.ts +++ b/src/typeset/lineedge.ts @@ -122,6 +122,8 @@ function relayout(root: Element, finder: Finder): void for (const el of chars) { + // 行端為段落級(§6.5.0a):text-level 子樹(單行標題等)不收行端半形。 + if (!finder.levelAllows(el, "paragraph")) { continue; } if (!finder.featureEnabledFor(el, "jiya")) { continue; } // 行端僅處理括號引號:開類於行首收左、閉類於行尾收右。 // **不**處理句末/句內點號(。!?,等)——行尾/行首之點號保持全形, @@ -149,6 +151,7 @@ export const lineEdgePass: StandalonePass = { name: "jiyaLineEdge", kind: "standalone", order: 90, + level: "paragraph", enabled: (o) => o.features.jiya, render(ctx: RenderContext): void { @@ -174,7 +177,7 @@ const TRIM = "jz-hws-trim"; function trimGaps(root: Element, finder: Finder): void { const gaps = Array.from(root.querySelectorAll("jz-hws")).filter( - (g) => finder.inScope(g) && !finder.isAvoided(g), + (g) => finder.inScope(g) && !finder.isAvoided(g) && finder.levelAllows(g, "paragraph"), ); if (!gaps.length) { return; } @@ -207,6 +210,7 @@ export const gapTrimPass: StandalonePass = { name: "spacingGapTrim", kind: "standalone", order: 92, + level: "paragraph", enabled: (o) => o.features.spacing, render(ctx: RenderContext): void { diff --git a/src/typeset/longword.ts b/src/typeset/longword.ts index 32d17ed..bbde3df 100644 --- a/src/typeset/longword.ts +++ b/src/typeset/longword.ts @@ -23,6 +23,7 @@ export const longWordPass: CharifyPass = { name: PASS, kind: "charify", order: 60, + level: "paragraph", enabled: (o) => o.features.longWord, collect(node, ctx): WrapRequest[] { diff --git a/src/typeset/spacing.ts b/src/typeset/spacing.ts index 4ab2c69..e5a475e 100644 --- a/src/typeset/spacing.ts +++ b/src/typeset/spacing.ts @@ -306,6 +306,7 @@ export const spacingPass: StandalonePass = { name: PASS, kind: "standalone", order: 50, + level: "text", enabled: (o) => o.features.spacing, render(ctx: RenderContext): void { @@ -318,6 +319,15 @@ export const spacingPass: StandalonePass = { if (!finder.featureEnabledFor(block, PASS)) { return; } processBlock(block, finder, R); }); + // 文本級功能須支持單元素(§6.5.0a):root 為行內/單一非塊元素(如消費端對 + // 標題呼叫 render)時 eachBlock 不含之,補處理 root 自身(collectRuns + // 遇巢狀 block 仍會 flush,故與上面逐 block 不重複;行內 root 通常無 block 子)。 + if (!options.finder.blockTags.has(ctx.root.nodeName) + && finder.featureEnabledFor(ctx.root, PASS) + && finder.inScope(ctx.root) && !finder.isAvoided(ctx.root)) + { + processBlock(ctx.root, finder, R); + } // Pass B:pill 邊界。 processPills(ctx.root, finder, options.finder.pillSelector, R.pillNeighbor); }, diff --git a/test/juzhen.test.mjs b/test/juzhen.test.mjs index 7b46d6f..7d957a0 100644 --- a/test/juzhen.test.mjs +++ b/test/juzhen.test.mjs @@ -642,3 +642,63 @@ test("charClass.biaodian:多子類同傳,各自生效(stop/pause)", () const pause = p.querySelector("jz-char.bd-pause"); assert.ok(pause && pause.classList.contains("jz-half"), "• → bd-pause 且開明式挤半形"); }); + +// ----- 功能分級(§6.5.0a,text-level vs paragraph-level)----- + +test("分級:data-jz-level=text 之子樹只跑文本級——jiya/spacing 生效、jinze 跳過", () => +{ + const { document } = setupDom('

說「你好」用Neovim寫

'); + createJuzhen().render(document.body); + const p = document.querySelector("p"); + // 文本級:標點 charify(jz-char)、中西間隙(jz-hws)照常。 + assert.ok(countTag(p, "jz-char") >= 1, "text-level 仍跑 jiya(jz-char)"); + assert.ok(countTag(p, "jz-hws") >= 1, "text-level 仍跑 spacing(jz-hws)"); + // 段落級:禁則跳過——無 jz-jinze(「」本會綁鄰字)。 + assert.equal(countTag(p, "jz-jinze"), 0, "text-level 段落級禁則跳過(無 jz-jinze)"); +}); + +test("分級:data-jz-level=text 跳過段落級 longWord(charify 亦受閘)", () => +{ + const { document } = setupDom('

用 configuration 設定

'); + createJuzhen().render(document.body); + const p = document.querySelector("p"); + assert.equal(p.querySelectorAll('[data-jz="longWord"]').length, 0, "text-level 不跑 longWord(無 span[data-jz=longWord])"); +}); + +test("分級:選項 level.text 選擇器標記(h1 → text-level,禁則跳過)", () => +{ + const { document } = setupDom('

說「你好」結束

說「你好」結束

'); + createJuzhen({ level: { text: "h1" } }).render(document.body); + const h1 = document.querySelector("h1"); + const p = document.querySelector("p"); + assert.equal(countTag(h1, "jz-jinze"), 0, "h1 命中 level.text → 段落級禁則跳過"); + assert.ok(countTag(h1, "jz-char") >= 1, "h1 文本級 jiya 仍生效"); + assert.ok(countTag(p, "jz-jinze") >= 1, "未命中之 p 仍跑禁則"); +}); + +test("分級:data-jz-level=paragraph 巢狀於 text 子樹內,最近祖先勝(恢復全功能)", () => +{ + const { document } = setupDom( + '
' + + '

說「你好」結束

', + ); + createJuzhen().render(document.body); + const p = document.querySelector("p"); + assert.ok(countTag(p, "jz-jinze") >= 1, "最近 data-jz-level=paragraph 勝 → 禁則恢復"); +}); + +test("分級:spacing 文本級支持單元素 root(render 於 )", () => +{ + const { document } = setupDom("我用Neovim寫程式"); + const span = document.querySelector("span"); + createJuzhen({ jiya: false, jinze: false, longWord: false }).render(span); + assert.ok(countTag(span, "jz-hws") >= 1, "行內單元素 root 亦補中西間隙"); + assert.equal(span.textContent, "我用Neovim寫程式", "textContent 不變"); +}); + +test("分級:預設(無標記)塊級元素為段落級——禁則照常", () => +{ + const { document } = setupDom('

說「你好」結束

'); + createJuzhen().render(document.body); + assert.ok(countTag(document.querySelector("p"), "jz-jinze") >= 1, "預設段落級,禁則綁定"); +});