feat: 字符分類規則集抽象(charClass,可配置;向下兼容)

把散落於各 pass 之硬編碼字符分類(中文 CJK/西文 ANS/全寬標點 BD_* 子類)統一抽象為
規則集 Ruleset:內置默認即現狀值,消費端經 options.charClass 擴充,以覆寫 spacing/
jiya/jinze 對特定字符之分類判定。

動機:英文全角句號 .(U+FF0E,異於中文句號 。U+3002)實證為**完全未分類**——不被
charify、不挤壓、與 CJK 間不補隙。與其每次硬編碼補字符(如先前 € 之西文集),不如給
消費端可配置之分類鉤子。

API(追加語義):
  charClass?: {
    cjk?: string;        // 追加為中文
    western?: string;    // 追加為西文(半寬標點亦歸此)
    biaodian?: { stop?, pause?, open?, close?, middle?, liga? };  // 追加為全寬標點,按子類
  }

實作:
- core/unicode.ts:新增 Ruleset/CharClassOverride/BdSubclass 型別、DEFAULT_RULESET、
  resolveRuleset(追加合併);classifyBiaodian(ch, biaodian) 改吃規則集(預設參數向下兼容)。
- types.ts:JuzhenOptions.charClass;ResolvedOptions.ruleset;index 再匯出公開型別。
- index.ts:resolveRuleset(opts.charClass) → ResolvedOptions.ruleset。
- typeset/spacing.ts:所有 CJK/ANS 依賴正則移入 makeRules(ruleset),per-instance 編譯
  (非每 block);ALNUM/ALPHA/運算符等結構性 ASCII 仍為固定常數。
- typeset/jiya.ts:classifyBiaodian(ch, ruleset.biaodian)。
- typeset/jinze.ts:詞元邊界之 anyCjk 由 ruleset.cjk 建。

向下兼容:不傳 charClass 則 ruleset === DEFAULT_RULESET、行為與重構前完全一致。
v1 為追加語義,不支援重分類/移除內置字符(範圍式集合難挖洞、需求罕見,列後續)。

驗證:實 Chromium end-to-end——charClass.biaodian.stop="." → . charify 為 bd-stop
(开明式句末維持全角,比照 。);western="★"/cjk="〇" → 與對側正確補/不補中西間隙。
測試 66 通過(含 9 條 charClass 新測——默認未分類/各子類 charify/實例隔離不可變
(resolveRuleset 不污染 DEFAULT)/追加不破壞內置/多子類組合;57 既有不變)。

- README「自定義字符分類」節+ARCHITECTURE §4.2 規則集抽象。dist 重建。
This commit is contained in:
2026-06-04 16:22:14 +08:00
parent 5b9c6b5fa1
commit b821cd1134
14 changed files with 701 additions and 326 deletions
+16 -15
View File
@@ -12,7 +12,6 @@
import { bdClassOf, createJz, revertPass } from "../core/dom.js";
import type { Finder } from "../core/finder.js";
import { ANY_CJK } from "../core/unicode.js";
import type { RenderContext, StandalonePass } from "../types.js";
const PASS = "jinze";
@@ -46,45 +45,45 @@ function forbiddenBreak(a: Node, b: Node, finder: Finder): boolean
// 「詞元」:CJK 字為單字詞元(可任意斷);連續非 CJK 非空白(拉丁詞/數字/
// 百分號/GPT-4/3.5 等)為一個不可內斷之詞元。綁定須以整個邊界詞元為單位,
// 否則會把 200% 拆成 200|%,在其間製造斷點(修正:閉類綁定不應切斷數字/詞)。
function isTokenChar(ch: string): boolean
function isTokenChar(ch: string, anyCjk: RegExp): boolean
{
return !ANY_CJK.test(ch) && !/\s/.test(ch);
return !anyCjk.test(ch) && !/\s/.test(ch);
}
function leadingTokenEnd(d: string): number
function leadingTokenEnd(d: string, anyCjk: RegExp): number
{
if (ANY_CJK.test(d.charAt(0))) { return 1; }
if (anyCjk.test(d.charAt(0))) { return 1; }
let e = 1;
while (e < d.length && isTokenChar(d.charAt(e))) { e += 1; }
while (e < d.length && isTokenChar(d.charAt(e), anyCjk)) { e += 1; }
return e;
}
function trailingTokenStart(d: string): number
function trailingTokenStart(d: string, anyCjk: RegExp): number
{
const last = d.length - 1;
if (ANY_CJK.test(d.charAt(last))) { return last; }
if (anyCjk.test(d.charAt(last))) { return last; }
let s = last;
while (s > 0 && isTokenChar(d.charAt(s - 1))) { s -= 1; }
while (s > 0 && isTokenChar(d.charAt(s - 1), anyCjk)) { s -= 1; }
return s;
}
/** 隔離文本節點之首/末「詞元」為獨立節點(供綁定,使中段仍可斷行)。 */
function splitBoundaries(t: Text, needFirst: boolean, needLast: boolean): void
function splitBoundaries(t: Text, needFirst: boolean, needLast: boolean, anyCjk: RegExp): void
{
let node: Text = t;
if (needFirst)
{
const e = leadingTokenEnd(node.data);
const e = leadingTokenEnd(node.data, anyCjk);
if (e < node.data.length) { node = node.splitText(e); }
}
if (needLast)
{
const s = trailingTokenStart(node.data);
const s = trailingTokenStart(node.data, anyCjk);
if (s > 0) { node.splitText(s); }
}
}
function processParent(parent: Node, finder: Finder): void
function processParent(parent: Node, finder: Finder, anyCjk: RegExp): void
{
// 冪等:已含 jz-jinze(前次 render 未 revert)則跳過。
for (let c = parent.firstChild; c; c = c.nextSibling)
@@ -114,7 +113,7 @@ function processParent(parent: Node, finder: Finder): void
if (u.nodeType !== 3) { continue; }
const needFirst = i > 0 && forbidden[i - 1]!;
const needLast = i < n - 1 && forbidden[i]!;
if (needFirst || needLast) { splitBoundaries(u as Text, needFirst, needLast); }
if (needFirst || needLast) { splitBoundaries(u as Text, needFirst, needLast, anyCjk); }
}
// 階段 2:重掃當前子節點,把禁斷相連之相鄰原子併成 jz-jinze。
@@ -147,12 +146,14 @@ export const jinzePass: StandalonePass = {
enabled: (o) => o.features.jinze,
render(ctx: RenderContext): void
{
// 詞元邊界之 CJK 判定取自規則集(含 charClass 覆寫),與 spacingjiya 一致。
const anyCjk = new RegExp("[" + ctx.options.ruleset.cjk + "]");
const parents = new Set<Node>();
ctx.root.querySelectorAll("jz-char").forEach((c) =>
{
if (c.parentNode) { parents.add(c.parentNode); }
});
for (const parent of parents) { processParent(parent, ctx.finder); }
for (const parent of parents) { processParent(parent, ctx.finder, anyCjk); }
},
revert(ctx: RenderContext): void
{