feat: 功能分級(文本級 vs 段落級)+ data-jz-level 標記(§6.5.0a)

整體計劃 §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 測試全綠。
This commit is contained in:
2026-06-09 16:52:00 +08:00
parent a00b35577e
commit c1fbabf5b0
15 changed files with 309 additions and 7 deletions
+15 -1
View File
@@ -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 黑名單 / 命中
+4 -2
View File
@@ -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 SCTC 皆有 halt、
* 挤壓收 0.5em,故與 。!? 同列為句末點號(下游回饋)。 */
export declare const BD_STOP = "\u3002\uFF0E\uFF01\uFF1F";
/** 句內點號:,、;:(開明式擠成半形;全角式保持全形)。 */
export declare const BD_PAUSE = "\uFF0C\u3001\uFF1B\uFF1A";
/** 開始括號/引號:「『(《〈【〖〔[{“‘。 */
+65 -1
View File
@@ -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,
+65 -1
View File
@@ -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,
+14
View File
@@ -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-charjz-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;
}
+45 -1
View File
@@ -4,7 +4,7 @@
// 行內元素透明、pillblockavoid 中斷)、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<Element, Record<string, boolean>>;
private readonly levelCache: WeakMap<Element, FeatureLevel>;
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.6per-node
* data-juzhen / data-juzhen-off /
+3
View File
@@ -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); }
}
+1
View File
@@ -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,
+16
View File
@@ -50,6 +50,15 @@ export interface JuzhenOptions
* spacingjiya */
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;
}
+3
View File
@@ -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<Node>();
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); }
+2
View File
@@ -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
{
+5 -1
View File
@@ -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
{
+1
View File
@@ -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[]
{
+10
View File
@@ -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 為行內/單一非塊元素(如消費端對
// <span> 標題呼叫 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 Bpill 邊界。
processPills(ctx.root, finder, options.finder.pillSelector, R.pillNeighbor);
},
+60
View File
@@ -642,3 +642,63 @@ test("charClass.biaodian:多子類同傳,各自生效(stoppause", ()
const pause = p.querySelector("jz-char.bd-pause");
assert.ok(pause && pause.classList.contains("jz-half"), "• → bd-pause 且開明式挤半形");
});
// ----- 功能分級(§6.5.0atext-level vs paragraph-level-----
test("分級:data-jz-level=text 之子樹只跑文本級——jiyaspacing 生效、jinze 跳過", () =>
{
const { document } = setupDom('<p data-jz-level="text" lang="zh-Hant">說「你好」用Neovim寫</p>');
createJuzhen().render(document.body);
const p = document.querySelector("p");
// 文本級:標點 charifyjz-char)、中西間隙(jz-hws)照常。
assert.ok(countTag(p, "jz-char") >= 1, "text-level 仍跑 jiyajz-char");
assert.ok(countTag(p, "jz-hws") >= 1, "text-level 仍跑 spacingjz-hws");
// 段落級:禁則跳過——無 jz-jinze(「」本會綁鄰字)。
assert.equal(countTag(p, "jz-jinze"), 0, "text-level 段落級禁則跳過(無 jz-jinze");
});
test("分級:data-jz-level=text 跳過段落級 longWordcharify 亦受閘)", () =>
{
const { document } = setupDom('<p data-jz-level="text">用 configuration 設定</p>');
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('<h1 lang="zh-Hant">說「你好」結束</h1><p lang="zh-Hant">說「你好」結束</p>');
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(
'<div data-jz-level="text" lang="zh-Hant">'
+ '<p data-jz-level="paragraph">說「你好」結束</p></div>',
);
createJuzhen().render(document.body);
const p = document.querySelector("p");
assert.ok(countTag(p, "jz-jinze") >= 1, "最近 data-jz-level=paragraph 勝 → 禁則恢復");
});
test("分級:spacing 文本級支持單元素 rootrender 於 <span>", () =>
{
const { document } = setupDom("<span>我用Neovim寫程式</span>");
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('<p lang="zh-Hant">說「你好」結束</p>');
createJuzhen().render(document.body);
assert.ok(countTag(document.querySelector("p"), "jz-jinze") >= 1, "預設段落級,禁則綁定");
});