feat: 逐元素標點樣式覆寫 data-jz-style + lang.stylePolicy(非段落用半角式等)

下游需求:非段落/特定範圍元素用半角式(banjiao),段落用全角/開明。原標點樣式僅由 lang
決定(lang.policy/lang.style),無逐元素覆寫。新增與 data-jz-level/level.text 同構之逐元素樣式
機制(banjiao 渲染早已就緒,此為純路由、無新壓縮邏輯、無新 CSS)。

設置格式(供下游):
・HTML 屬性:data-jz-style="banjiao|kaiming|quanjiao"——標於元素或其祖先,該子樹用此樣式。
・選項:lang: { stylePolicy: [{ selector, style }] }——如
  [{ selector: "h1,h2,button,.ui", style: "banjiao" }]。
・優先序:逐元素覆寫(近祖先勝、同層屬性優先於選擇器)> lang.policy > lang.style > 內建。

實作:resolveStyle 先查 resolveStyleOverride(自身及祖先鏈掃 data-jz-style 屬性/stylePolicy
選擇器命中),無則回退 lang。屬性值非法(拼錯)則忽略、續回退。types.ts 補 lang.stylePolicy
+導出 StyleRule;index.ts 正規化(stylePolicy 預設 [])。

冒烟三例通過(屬性 banjiao/選擇器 banjiao/屬性覆蓋選擇器);既有 121 測試無回歸(stylePolicy
預設空、行為不變)。回歸測試+文檔隨後補(e2e 階段)。
This commit is contained in:
2026-07-11 01:42:31 +08:00
parent 4f84d31785
commit c00fb3e482
7 changed files with 134 additions and 14 deletions
+13 -2
View File
@@ -2,14 +2,22 @@
export type PunctStyle = "quanjiao" | "kaiming" | "banjiao";
/** 語言分類。 */
export type LangClass = "zh-Hans" | "zh-Hant" | "ja" | "other";
/** 按選擇器逐元素覆寫標點樣式之規則(§4.3;如非段落/UI 元素用半角式)。 */
export interface StyleRule {
selector: string;
style: PunctStyle;
}
export interface LocaleConfig {
/** 無 lang 祖先時之預設語言。 */
default: string;
/** 全域標點擠壓預設(全角/開明/半角)。設則覆寫內建之逐語言預設,
* 但仍可被 policy 之逐 lang 設定覆寫。未設則回退內建預設。 */
style?: PunctStyle;
/** 語言分類 → 標點寬度政策之逐 lang 覆寫(最高優先)。 */
/** 語言分類 → 標點寬度政策之逐 lang 覆寫。 */
policy: Partial<Record<LangClass, PunctStyle>>;
/** 按選擇器**逐元素**覆寫標點樣式(優先於 lang;與 `data-jz-style` 屬性同一機制)。
* 近祖先勝、同層屬性優先於選擇器。空/未設則不覆寫、純由 lang 決定。 */
stylePolicy?: StyleRule[];
}
/**
* 由節點向上找最近帶 lang 屬性之祖先,回傳小寫值;無則回傳 fallback。
@@ -25,5 +33,8 @@ export declare function classify(lang: string): LangClass;
* 逐 lang policy 全域 style 內建逐語言預設 DEFAULT_POLICY。
*/
export declare function styleFor(langClass: LangClass, config: LocaleConfig): PunctStyle;
/** 解析節點之有效政策(lang → 分類 → 政策)。 */
/**
* 解析節點之有效標點樣式。優先序:**逐元素覆寫**(`data-jz-style` 屬性/`stylePolicy`
* 選擇器,近祖先勝)> 逐 lang `policy` 全域 `style` 內建逐語言預設。
*/
export declare function resolveStyle(node: Node, config: LocaleConfig): PunctStyle;
+27 -1
View File
@@ -664,7 +664,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);
}
@@ -2055,7 +2080,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,
+27 -1
View File
@@ -637,7 +637,32 @@ function styleFor(langClass, config) {
}
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);
}
@@ -2028,7 +2053,8 @@ function normalizeOptions(opts = {}) {
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,
+8 -4
View File
@@ -1,7 +1,7 @@
import type { Finder } from "./core/finder.js";
import type { LangClass, LocaleConfig, PunctStyle } from "./core/locale.js";
import type { LangClass, LocaleConfig, PunctStyle, StyleRule } from "./core/locale.js";
import type { Bias, BiasTable, BdSubclass, CharClassOverride, Ruleset } from "./core/unicode.js";
export type { LangClass, PunctStyle, Bias, BiasTable, BdSubclass, CharClassOverride, Ruleset };
export type { LangClass, PunctStyle, StyleRule, Bias, BiasTable, BdSubclass, CharClassOverride, Ruleset };
/** 長英文詞包裝設定。 */
export interface LongWordOptions {
minLength?: number;
@@ -34,10 +34,14 @@ export interface JuzhenOptions {
/** 無 lang 祖先時之預設語言標籤(預設 "zh-Hant")。 */
default?: string;
/** 全域標點擠壓預設:quanjiao(全角式)|kaiming(開明式)|
* banjiao(半角式)。覆寫內建逐語言預設,可被 policy 逐 lang 覆寫。 */
* banjiao(半角式)。覆寫內建逐語言預設,可被 policy 逐 langstylePolicy 逐元素覆寫。 */
style?: PunctStyle;
/** 逐 lang 覆寫(最高優先)。 */
/** 逐 lang 覆寫。 */
policy?: Partial<Record<LangClass, PunctStyle>>;
/** **逐元素**覆寫標點樣式(優先於 lang/policy/style):選擇器命中之元素及其子樹
* 用指定樣式,如非段落/UI 元素用半角式 `[{ selector: "h1,h2,button,.ui", style: "banjiao" }]`。
* 與 HTML 屬性 `data-jz-style="banjiao|kaiming|quanjiao"` 同一機制;近祖先勝、屬性優先於選擇器。 */
stylePolicy?: StyleRule[];
};
/** 自定義字符分類(擴充內置規則集)。把未分類字符或新符號歸入中文/西文/
* 全寬標點各子類,以覆寫 spacing/jiya 等規則之分類判定(追加語義)。 */
+50 -2
View File
@@ -9,6 +9,13 @@ export type PunctStyle = "quanjiao" | "kaiming" | "banjiao";
/** 語言分類。 */
export type LangClass = "zh-Hans" | "zh-Hant" | "ja" | "other";
/** 按選擇器逐元素覆寫標點樣式之規則(§4.3;如非段落/UI 元素用半角式)。 */
export interface StyleRule
{
selector: string;
style: PunctStyle;
}
export interface LocaleConfig
{
/** 無 lang 祖先時之預設語言。 */
@@ -16,8 +23,11 @@ export interface LocaleConfig
/** 全域標點擠壓預設(全角/開明/半角)。設則覆寫內建之逐語言預設,
* 但仍可被 policy 之逐 lang 設定覆寫。未設則回退內建預設。 */
style?: PunctStyle;
/** 語言分類 → 標點寬度政策之逐 lang 覆寫(最高優先)。 */
/** 語言分類 → 標點寬度政策之逐 lang 覆寫。 */
policy: Partial<Record<LangClass, PunctStyle>>;
/** 按選擇器**逐元素**覆寫標點樣式(優先於 lang;與 `data-jz-style` 屬性同一機制)。
* 近祖先勝、同層屬性優先於選擇器。空/未設則不覆寫、純由 lang 決定。 */
stylePolicy?: StyleRule[];
}
const DEFAULT_POLICY: Record<LangClass, PunctStyle> = {
@@ -76,9 +86,47 @@ export function styleFor(langClass: LangClass, config: LocaleConfig): PunctStyle
return DEFAULT_POLICY[langClass];
}
/** 解析節點之有效政策(lang → 分類 → 政策)。 */
/** 該字串是否為合法標點樣式。 */
function isPunctStyle(v: string | null): v is PunctStyle
{
return v === "quanjiao" || v === "kaiming" || v === "banjiao";
}
/**
* 逐元素之標點樣式覆寫(§4.3):自 node 向上找**最近**祖先(含自身)之樣式信號——
* `data-jz-style` 屬性(值須為合法樣式,優先),或 `stylePolicy` 選擇器命中——回傳其樣式;
* 皆無則 null(回退 lang 決定)。近祖先勝,同層屬性優先於選擇器。
*/
function resolveStyleOverride(node: Node, stylePolicy?: StyleRule[]): PunctStyle | null
{
let el: Element | null = node.nodeType === 1 ? (node as Element) : 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;
}
/**
* 解析節點之有效標點樣式。優先序:**逐元素覆寫**(`data-jz-style` 屬性/`stylePolicy`
* 選擇器,近祖先勝)> 逐 lang `policy` 全域 `style` 內建逐語言預設。
*/
export function resolveStyle(node: Node, config: LocaleConfig): PunctStyle
{
const override = resolveStyleOverride(node, config.stylePolicy);
if (override) { return override; }
const lang = effectiveLang(node, config.default);
return styleFor(classify(lang), config);
}
+1
View File
@@ -100,6 +100,7 @@ export function normalizeOptions(opts: JuzhenOptions = {}): ResolvedOptions
default: langOpt.default || "zh-Hant",
...(langOpt.style ? { style: langOpt.style } : {}),
policy: langOpt.policy || {},
stylePolicy: Array.isArray(langOpt.stylePolicy) ? langOpt.stylePolicy.slice() : [],
},
ruleset: resolveRuleset(opts.charClass),
justifyAtoms: opts.justifyAtoms === false ? false : true,
+8 -4
View File
@@ -1,10 +1,10 @@
// 公開選項型別與內部上下文型別(架構 §3.1/§3.3)。
import type { Finder } from "./core/finder.js";
import type { LangClass, LocaleConfig, PunctStyle } from "./core/locale.js";
import type { LangClass, LocaleConfig, PunctStyle, StyleRule } from "./core/locale.js";
import type { Bias, BiasTable, BdSubclass, CharClassOverride, Ruleset } from "./core/unicode.js";
export type { LangClass, PunctStyle, Bias, BiasTable, BdSubclass, CharClassOverride, Ruleset };
export type { LangClass, PunctStyle, StyleRule, Bias, BiasTable, BdSubclass, CharClassOverride, Ruleset };
/** 長英文詞包裝設定。 */
export interface LongWordOptions
@@ -47,10 +47,14 @@ export interface JuzhenOptions
/** 無 lang 祖先時之預設語言標籤(預設 "zh-Hant")。 */
default?: string;
/** 全域標點擠壓預設:quanjiao(全角式)|kaiming(開明式)|
* banjiao(半角式)。覆寫內建逐語言預設,可被 policy 逐 lang 覆寫。 */
* banjiao(半角式)。覆寫內建逐語言預設,可被 policy 逐 langstylePolicy 逐元素覆寫。 */
style?: PunctStyle;
/** 逐 lang 覆寫(最高優先)。 */
/** 逐 lang 覆寫。 */
policy?: Partial<Record<LangClass, PunctStyle>>;
/** **逐元素**覆寫標點樣式(優先於 lang/policy/style):選擇器命中之元素及其子樹
* 用指定樣式,如非段落/UI 元素用半角式 `[{ selector: "h1,h2,button,.ui", style: "banjiao" }]`。
* 與 HTML 屬性 `data-jz-style="banjiao|kaiming|quanjiao"` 同一機制;近祖先勝、屬性優先於選擇器。 */
stylePolicy?: StyleRule[];
};
/** 自定義字符分類(擴充內置規則集)。把未分類字符或新符號歸入中文/西文/