From b821cd11347d1b9faf7955afcfa4fcb13ec93d6f Mon Sep 17 00:00:00 2001 From: commilitia Date: Thu, 4 Jun 2026 16:22:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AD=97=E7=AC=A6=E5=88=86=E9=A1=9E?= =?UTF-8?q?=E8=A6=8F=E5=89=87=E9=9B=86=E6=8A=BD=E8=B1=A1=EF=BC=88charClass?= =?UTF-8?q?=EF=BC=8C=E5=8F=AF=E9=85=8D=E7=BD=AE=EF=BC=9B=E5=90=91=E4=B8=8B?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把散落於各 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 重建。 --- ARCHITECTURE.md | 19 +++- README.md | 38 +++++++ dist/core/unicode.d.ts | 40 ++++++- dist/index.d.ts | 1 + dist/juzhen.iife.js | 248 ++++++++++++++++++++++++----------------- dist/juzhen.mjs | 248 ++++++++++++++++++++++++----------------- dist/types.d.ts | 9 +- src/core/unicode.ts | 90 +++++++++++++-- src/index.ts | 3 + src/types.ts | 10 +- src/typeset/jinze.ts | 31 +++--- src/typeset/jiya.ts | 3 +- src/typeset/spacing.ts | 202 ++++++++++++++++++--------------- test/juzhen.test.mjs | 85 ++++++++++++++ 14 files changed, 701 insertions(+), 326 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ae429f9..1ddb113 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -264,8 +264,25 @@ IIFE 構建額外掛 `globalThis.createCjkAutospace` 別名,使 - `quote` 彎引號:`“”‘’` - **規則正則**:中西間隙邊界、擠壓相鄰組合、禁則對象等,皆以此表組裝。 +**規則集抽象(`Ruleset`/`charClass`,可配置)**:字符分類不再各 pass 硬編碼,而統一為 +一個**規則集**——`{ cjk, ansAfter, ansBefore, biaodian{6 子類} }`,各為 regex 字元類片段。 +`DEFAULT_RULESET` 即內置默認(上述 CJK/西文/BD_* 值)。消費端經 `options.charClass` +(`{ cjk?, western?, biaodian? }`)**擴充**,`resolveRuleset(override)` 以**追加**語義合併 +為最終規則集,置於 `ResolvedOptions.ruleset`。各 pass 由此取分類: + +- `spacing`:`makeRules(ruleset)` 由 `cjk`/`ansAfter`/`ansBefore` **per-instance 編譯** + 全部間隙正則(createJuzhen 各一次,非每 block);`ALNUM`/`ALPHA`/運算符/括號等結構性 + ASCII 集為固定常數、不隨規則集變。 +- `jiya`:`classifyBiaodian(ch, ruleset.biaodian)` 決定 charify 與 bd-* 子類。 +- `jinze`:`anyCjk = RegExp(ruleset.cjk)` 決定詞元邊界之 CJK 判定。 + +**設計取捨**:① **向下兼容**——不傳 `charClass` 則 `ruleset === DEFAULT_RULESET`,行為與 +重構前完全一致(現有測試全綠)。② **追加語義(v1)**——擴充內置類(把未分類字符如英文全角 +句號 `.`U+FF0E 歸入、或納新符號);不支援重分類/移除內置字符(範圍式集合難以挖洞、且需 +求罕見,列為後續)。③ 結構性 ASCII 不開放(非「字符類型」)。 + > 借鑑 Han 之 `regex/typeset.js`、`regex/unicode.js` 與 Heti 之分類字集,重寫為 -> 現代 TS 常數表。 +> 現代 TS 常數表+可配置規則集。 ### 4.3 `locale.ts`——語言與全角/開明政策 diff --git a/README.md b/README.md index a8ab2e2..78ca751 100644 --- a/README.md +++ b/README.md @@ -262,6 +262,44 @@ JS 只注入 `jz-*` 結構,**所有視覺尺寸在 `juzhen.css`**——故 CSS --- +## 自定義字符分類(`charClass`) + +每個字符的分類(中文/西文、全寬標點各子類)抽象為**規則集**:內置默認規則集即聚珍 +的分類表,消費端可經 `options.charClass` **擴充**,以覆寫 `spacing`/`jiya`/`jinze` +等規則對特定字符之判定。語義為**追加**——把當前未分類之字符、或新符號歸入對應類。 + +```js +createJuzhen({ + charClass: { + cjk: "〇", // 追加為中文(CJK):與西文邊界補中西間隙、禁則/justify 視為單字 + western: "★☆", // 追加為西文(半寬標點亦歸此):與 CJK 邊界補間隙 + biaodian: { // 追加為全寬標點,按子類(jiya charify/擠壓、禁則避頭尾) + stop: ".", // 句末(。!?類) + pause: "", // 句內(,、;:類) + open: "", close: "", // 開/閉括號引號 + middle: "", liga: "", // 間隔號/省略破折 + }, + }, +}); +``` + +**典型用例**:英文全角句號 `.`(U+FF0E,有別於中文句號 `。` U+3002)預設**未分類** +——不被 charify、不挤壓、與 CJK 間不補隙。若內容用 `.` 作句末標點,以 +`charClass: { biaodian: { stop: "." } }` 歸為全寬句末標點,即比照 `。` 處理(開明式挤半形、 +全角式佔全形、行首避頭尾)。 + +要點: + +- **向下兼容**:不傳 `charClass` 時,分類與行為與內置默認**完全一致**。 +- **追加語義(v1)**:擴充內置類;**不**支援把內置字符「重分類/移除」(屬罕見需求,後續再議)。 + 勿把同一字符同時加入相衝之兩類。 +- **結構性 ASCII**(字母數字 `A-Za-z0-9`、運算符 `+-*/` 等)為固定集、不開放覆寫——它們 + 服務於運算符/複合詞等內部規則,非「字符類型」。 +- `margin` 後備模式下若新增開/閉括號,其墨色偏側未必命中內建參考表,可一併以 + `jiya.bias` 指定(見「字型適配工作流」)。 + +--- + ## v1 向下相容 ```js diff --git a/dist/core/unicode.d.ts b/dist/core/unicode.d.ts index e1b1ca4..f3a3815 100644 --- a/dist/core/unicode.d.ts +++ b/dist/core/unicode.d.ts @@ -25,11 +25,45 @@ export declare const BD_MIDDLE = "\u00B7\u30FB\u2027"; export declare const BD_LIGA = "\u2026\u2014"; /** 全體標點字元(供 charify 走訪偵測)。 */ export declare const BD_ALL: string; +/** 全寬標點之六子類鍵。 */ +export type BdSubclass = "stop" | "pause" | "open" | "close" | "middle" | "liga"; /** - * 將單一標點字元歸入其 bd-* 子類;非標點回傳 null。 - * 供 jiya 之 charify 為 jz-char 標記 class。 + * 字符分類規則集(§4.2):把「中文/西文/全寬標點各子類」各表為 regex 字元類片段。 + * 各 pass(spacing 之 CJK/ANS 正則、jiya 之 classifyBiaodian、jinze 之 anyCjk)皆由此 + * 取分類,不再各自硬編碼。內置默認見 DEFAULT_RULESET;消費端經 options.charClass 擴充 + * (resolveRuleset)。 */ -export declare function classifyBiaodian(ch: string): string | null; +export interface Ruleset { + /** 中文(CJK 表意/假名/注音等)——中西間隙之「中」側、禁則/justify 原子。 */ + cjk: string; + /** 西文,CJK 之後可接隙者(含 @)。 */ + ansAfter: string; + /** 西文,CJK 之前可接隙者(去 @)。 */ + ansBefore: string; + /** 全寬標點,按子類——jiya charify/擠壓、禁則避頭尾、行端。 */ + biaodian: Record; +} +/** 消費端字符分類覆寫(追加語義;見 README「自定義字符分類」)。 */ +export interface CharClassOverride { + /** 追加為中文(CJK)。 */ + cjk?: string; + /** 追加為西文(半寬標點亦歸此,按西文處理間隙);併入 ansAfter/ansBefore。 */ + western?: string; + /** 追加為全寬標點,按子類。 */ + biaodian?: Partial>; +} +/** 內置默認規則集(即現狀硬編碼值)。 */ +export declare const DEFAULT_RULESET: Ruleset; +/** + * 合併消費端覆寫,回傳最終規則集。v1 為**追加**語義:把 override 字元併入內置對應類 + * (未分類字符→歸類、新符號→納入)。不傳則回傳 DEFAULT_RULESET(零行為變更)。 + */ +export declare function resolveRuleset(override?: CharClassOverride): Ruleset; +/** + * 將單一標點字元歸入其 bd-* 子類;非標點回傳 null。供 jiya charify 標記 class。 + * biaodian 取自規則集(預設 DEFAULT_RULESET.biaodian,向下兼容無參數呼叫)。 + */ +export declare function classifyBiaodian(ch: string, biaodian?: Record): string | null; /** 全體 bd-* 子類名(DOM 端讀取 jz-char 之 bd class 用)。 */ export declare const BD_CLASS_NAMES: readonly ["bd-stop", "bd-pause", "bd-open", "bd-close", "bd-middle", "bd-liga"]; /** diff --git a/dist/index.d.ts b/dist/index.d.ts index 644ae79..d5b91b2 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -1,5 +1,6 @@ import type { JuzhenOptions, Pass, ResolvedOptions } from "./types.js"; export type { JuzhenOptions, ResolvedOptions, Pass }; +export type { CharClassOverride, BdSubclass, Ruleset, Bias, BiasTable } from "./types.js"; export { createCjkAutospace } from "./compat/v1.js"; /** 正規化公開選項為內部 ResolvedOptions。 */ export declare function normalizeOptions(opts?: JuzhenOptions): ResolvedOptions; diff --git a/dist/juzhen.iife.js b/dist/juzhen.iife.js index 18a279e..ea6002d 100644 --- a/dist/juzhen.iife.js +++ b/dist/juzhen.iife.js @@ -522,18 +522,52 @@ var Juzhen = (() => { var BD_MIDDLE = "\xB7\u30FB\u2027"; var BD_LIGA = "\u2026\u2014"; var BD_ALL = BD_STOP + BD_PAUSE + BD_OPEN + BD_CLOSE + BD_MIDDLE + BD_LIGA; - var BD_SETS = [ - ["bd-stop", BD_STOP], - ["bd-pause", BD_PAUSE], - ["bd-open", BD_OPEN], - ["bd-close", BD_CLOSE], - ["bd-middle", BD_MIDDLE], - ["bd-liga", BD_LIGA] + var BD_ORDER = [ + "stop", + "pause", + "open", + "close", + "middle", + "liga" ]; - function classifyBiaodian(ch) { - for (const [name, set] of BD_SETS) { - if (set.indexOf(ch) >= 0) { - return name; + var DEFAULT_RULESET = { + cjk: CJK, + ansAfter: ANS_AFTER_CJK, + ansBefore: ANS_BEFORE_CJK, + biaodian: { + stop: BD_STOP, + pause: BD_PAUSE, + open: BD_OPEN, + close: BD_CLOSE, + middle: BD_MIDDLE, + liga: BD_LIGA + } + }; + function resolveRuleset(override) { + if (!override) { + return DEFAULT_RULESET; + } + const west = override.western || ""; + const bd = { ...DEFAULT_RULESET.biaodian }; + if (override.biaodian) { + for (const sub of BD_ORDER) { + const extra = override.biaodian[sub]; + if (extra) { + bd[sub] = bd[sub] + extra; + } + } + } + return { + cjk: CJK + (override.cjk || ""), + ansAfter: ANS_AFTER_CJK + west, + ansBefore: ANS_BEFORE_CJK + west, + biaodian: bd + }; + } + function classifyBiaodian(ch, biaodian = DEFAULT_RULESET.biaodian) { + for (const sub of BD_ORDER) { + if (biaodian[sub].indexOf(ch) >= 0) { + return "bd-" + sub; } } return null; @@ -642,9 +676,10 @@ var Juzhen = (() => { let style = null; let langClass = null; const biasOverrides = ctx.options.jiyaConfig.bias; + const biaodian = ctx.options.ruleset.biaodian; for (let i = 0; i < data.length; i += 1) { const ch = data.charAt(i); - const bdClass = classifyBiaodian(ch); + const bdClass = classifyBiaodian(ch, biaodian); if (!bdClass) { continue; } @@ -737,46 +772,46 @@ var Juzhen = (() => { } return false; } - function isTokenChar(ch) { - return !ANY_CJK.test(ch) && !/\s/.test(ch); + function isTokenChar(ch, anyCjk) { + return !anyCjk.test(ch) && !/\s/.test(ch); } - function leadingTokenEnd(d) { - if (ANY_CJK.test(d.charAt(0))) { + function leadingTokenEnd(d, anyCjk) { + if (anyCjk.test(d.charAt(0))) { return 1; } let e = 1; - while (e < d.length && isTokenChar(d.charAt(e))) { + while (e < d.length && isTokenChar(d.charAt(e), anyCjk)) { e += 1; } return e; } - function trailingTokenStart(d) { + function trailingTokenStart(d, anyCjk) { const last = d.length - 1; - if (ANY_CJK.test(d.charAt(last))) { + if (anyCjk.test(d.charAt(last))) { return last; } let s = last; - while (s > 0 && isTokenChar(d.charAt(s - 1))) { + while (s > 0 && isTokenChar(d.charAt(s - 1), anyCjk)) { s -= 1; } return s; } - function splitBoundaries(t, needFirst, needLast) { + function splitBoundaries(t, needFirst, needLast, anyCjk) { let node = 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, finder) { + function processParent(parent, finder, anyCjk) { for (let c = parent.firstChild; c; c = c.nextSibling) { if (c.nodeType === 1 && c.nodeName === "JZ-JINZE") { return; @@ -806,7 +841,7 @@ var Juzhen = (() => { const needFirst = i2 > 0 && forbidden[i2 - 1]; const needLast = i2 < n - 1 && forbidden[i2]; if (needFirst || needLast) { - splitBoundaries(u, needFirst, needLast); + splitBoundaries(u, needFirst, needLast, anyCjk); } } const kids = []; @@ -836,6 +871,7 @@ var Juzhen = (() => { order: 40, 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 (c.parentNode) { @@ -843,7 +879,7 @@ var Juzhen = (() => { } }); for (const parent of parents) { - processParent(parent, ctx.finder); + processParent(parent, ctx.finder, anyCjk); } }, revert(ctx) { @@ -1089,79 +1125,88 @@ var Juzhen = (() => { var LBR_EXT = "\\(\\[\\{<>"; var RBR_EXT = "\\)\\]\\}<>"; var DEL = String.fromCharCode(57347); - var STRIP_CJK_SPACE_ANS = new RegExp("([" + CJK + "]) ([" + ANS_AFTER_CJK + "])", "g"); - var STRIP_ANS_SPACE_CJK = new RegExp("([" + ANS_BEFORE_CJK + "]) ([" + CJK + "])", "g"); - var STRIP_CJK_SPACE_CJK = new RegExp("([" + CJK + "]) (?=[" + CJK + "])", "g"); - var DOTS_CJK = new RegExp("([\\.]{2,})([" + CJK + "])", "g"); - var CJK_PUNCTUATION = new RegExp("([" + CJK + "])([!;,\\?:]+)(?=[" + CJK + ALNUM + "])", "g"); - var AN_PUNCTUATION_CJK = new RegExp("([" + ALNUM + "])([!;,\\?]+)([" + CJK + "])", "g"); - var CJK_TILDE = new RegExp("([" + CJK + "])(~+)(?!=)(?=[" + CJK + ALNUM + "])", "g"); - var CJK_TILDE_EQUALS = new RegExp("([" + CJK + "])(~=)", "g"); - var CJK_PERIOD = new RegExp("([" + CJK + "])(\\.)(?![" + ALNUM + "\\./])(?=[" + CJK + ALNUM + "])", "g"); - var AN_PERIOD_CJK = new RegExp("([" + ALNUM + "])(\\.)([" + CJK + "])", "g"); - var AN_COLON_CJK = new RegExp("([" + ALNUM + "])(:)([" + CJK + "])", "g"); - var CJK_QUOTE = new RegExp("([" + CJK + "])([" + QUOTES_CLS + "])", "g"); - var QUOTE_CJK = new RegExp("([" + QUOTES_CLS + "])([" + CJK + "])", "g"); - var CJK_QUOTE_AN = new RegExp("([" + CJK + '])(")([' + ALNUM + "])", "g"); - var CJK_HASH = new RegExp("([" + CJK + "])(#([^ ]))", "g"); - var HASH_CJK = new RegExp("(([^ ])#)([" + CJK + "])", "g"); - var SINGLE_LETTER_GRADE = new RegExp("\\b([" + ALPHA + "])([" + OPS_GRADE + "])([" + CJK + "])", "g"); - var CJK_OPERATOR_ANS = new RegExp("([" + CJK + "])([" + OPS_HYPHEN + "])([" + ALNUM + "])", "g"); - var ANS_OPERATOR_CJK = new RegExp("([" + ALNUM + "])([" + OPS_HYPHEN + "])([" + CJK + "])", "g"); - var CJK_LESS_THAN = new RegExp("([" + CJK + "])(<)([" + ALNUM + "])", "g"); - var LESS_THAN_CJK = new RegExp("([" + ALNUM + "])(<)([" + CJK + "])", "g"); - var CJK_GREATER_THAN = new RegExp("([" + CJK + "])(>)([" + ALNUM + "])", "g"); - var GREATER_THAN_CJK = new RegExp("([" + ALNUM + "])(>)([" + CJK + "])", "g"); - var CJK_LEFT_BRACKET = new RegExp("([" + CJK + "])([" + LBR_EXT + "])", "g"); - var RIGHT_BRACKET_CJK = new RegExp("([" + RBR_EXT + "])([" + CJK + "])", "g"); - var AN_LEFT_BRACKET = new RegExp("([" + ALNUM + "])(?)([" + ALNUM + "])", "g"), + GREATER_THAN_CJK: new RegExp("([" + ALNUM + "])(>)([" + CJK2 + "])", "g"), + CJK_LEFT_BRACKET: new RegExp("([" + CJK2 + "])([" + LBR_EXT + "])", "g"), + RIGHT_BRACKET_CJK: new RegExp("([" + RBR_EXT + "])([" + CJK2 + "])", "g"), + AN_LEFT_BRACKET: new RegExp("([" + ALNUM + "])(? { compounds.push(m); return PH_OPEN + (compounds.length - 1) + PH_CLOSE; }); - s = s.replace(SINGLE_LETTER_GRADE, "$1$2" + MARK + "$3"); - s = s.replace(CJK_OPERATOR_ANS, "$1" + MARK + "$2" + MARK + "$3"); - s = s.replace(ANS_OPERATOR_CJK, "$1" + MARK + "$2" + MARK + "$3"); - s = s.replace(CJK_LESS_THAN, "$1" + MARK + "$2" + MARK + "$3"); - s = s.replace(LESS_THAN_CJK, "$1" + MARK + "$2" + MARK + "$3"); - s = s.replace(CJK_GREATER_THAN, "$1" + MARK + "$2" + MARK + "$3"); - s = s.replace(GREATER_THAN_CJK, "$1" + MARK + "$2" + MARK + "$3"); + s = s.replace(R.SINGLE_LETTER_GRADE, "$1$2" + MARK + "$3"); + s = s.replace(R.CJK_OPERATOR_ANS, "$1" + MARK + "$2" + MARK + "$3"); + s = s.replace(R.ANS_OPERATOR_CJK, "$1" + MARK + "$2" + MARK + "$3"); + s = s.replace(R.CJK_LESS_THAN, "$1" + MARK + "$2" + MARK + "$3"); + s = s.replace(R.LESS_THAN_CJK, "$1" + MARK + "$2" + MARK + "$3"); + s = s.replace(R.CJK_GREATER_THAN, "$1" + MARK + "$2" + MARK + "$3"); + s = s.replace(R.GREATER_THAN_CJK, "$1" + MARK + "$2" + MARK + "$3"); s = s.replace(PH_PATTERN, (_m, idx) => compounds[Number(idx)] || ""); - s = s.replace(CJK_LEFT_BRACKET, "$1" + MARK + "$2"); - s = s.replace(RIGHT_BRACKET_CJK, "$1" + MARK + "$2"); - s = s.replace(AN_LEFT_BRACKET, "$1" + MARK + "$2"); - s = s.replace(RIGHT_BRACKET_AN, "$1" + MARK + "$2"); - s = s.replace(CJK_ANS, "$1" + MARK + "$2"); - s = s.replace(ANS_CJK, "$1" + MARK + "$2"); - s = s.replace(S_A, "$1" + MARK + "$2"); + s = s.replace(R.CJK_LEFT_BRACKET, "$1" + MARK + "$2"); + s = s.replace(R.RIGHT_BRACKET_CJK, "$1" + MARK + "$2"); + s = s.replace(R.AN_LEFT_BRACKET, "$1" + MARK + "$2"); + s = s.replace(R.RIGHT_BRACKET_AN, "$1" + MARK + "$2"); + s = s.replace(R.CJK_ANS, "$1" + MARK + "$2"); + s = s.replace(R.ANS_CJK, "$1" + MARK + "$2"); + s = s.replace(R.S_A, "$1" + MARK + "$2"); return s; } function wrapNodeInGap(node) { @@ -1186,11 +1231,11 @@ var Juzhen = (() => { } wrapNodeInGap(tn); } - function processBlock(block, finder) { + function processBlock(block, finder, R) { const runs = finder.collectRuns(block); for (const run of runs) { const { text, map } = run; - const spaced = panguSpace(text); + const spaced = panguSpace(text, R); if (spaced === text) { continue; } @@ -1231,8 +1276,7 @@ var Juzhen = (() => { } } } - var PILL_NEIGHBOR = new RegExp("[A-Za-z0-9" + CJK + "]"); - function processPills(root, finder, pillSelector) { + function processPills(root, finder, pillSelector, pillNeighbor) { root.querySelectorAll(pillSelector).forEach((pill) => { if (finder.isAvoided(pill) || !finder.inScope(pill)) { return; @@ -1242,36 +1286,36 @@ var Juzhen = (() => { } const prev = finder.adjacentLogicalChar(pill, -1); if (prev) { - handlePillSide(prev, pill, -1); + handlePillSide(prev, pill, -1, pillNeighbor); } const next = finder.adjacentLogicalChar(pill, 1); if (next) { - handlePillSide(next, pill, 1); + handlePillSide(next, pill, 1, pillNeighbor); } }); } - function handlePillSide(adj, pill, dir) { + function handlePillSide(adj, pill, dir, pillNeighbor) { if (dir === -1) { - if (PILL_NEIGHBOR.test(adj.ch)) { + if (pillNeighbor.test(adj.ch)) { wrapGapLeft({ textNode: adj.textNode, offset: adj.offset }); return; } if (adj.ch === " " && adj.offset >= 1) { const tn = adj.textNode; const bc = tn.data.charAt(adj.offset - 1); - if (bc !== " " && PILL_NEIGHBOR.test(bc)) { + if (bc !== " " && pillNeighbor.test(bc)) { tn.data = tn.data.slice(0, adj.offset) + tn.data.slice(adj.offset + 1); wrapGapLeft({ textNode: tn, offset: adj.offset - 1 }); } } } else { - if (PILL_NEIGHBOR.test(adj.ch)) { + if (pillNeighbor.test(adj.ch)) { wrapNodeInGap(pill); return; } if (adj.ch === " " && adj.textNode.data.length >= 2) { const ac = adj.textNode.data.charAt(1); - if (ac !== " " && PILL_NEIGHBOR.test(ac)) { + if (ac !== " " && pillNeighbor.test(ac)) { adj.textNode.data = adj.textNode.data.slice(1); wrapNodeInGap(pill); } @@ -1285,13 +1329,14 @@ var Juzhen = (() => { enabled: (o) => o.features.spacing, render(ctx) { const { finder, options } = ctx; + const R = makeRules(options.ruleset); ctx.finder.eachBlock(ctx.root, (block) => { if (!finder.featureEnabledFor(block, PASS4)) { return; } - processBlock(block, finder); + processBlock(block, finder, R); }); - processPills(ctx.root, finder, options.finder.pillSelector); + processPills(ctx.root, finder, options.finder.pillSelector, R.pillNeighbor); }, revert(ctx) { revertPass(PASS4, ctx.root); @@ -1482,6 +1527,7 @@ var Juzhen = (() => { ...langOpt.style ? { style: langOpt.style } : {}, policy: langOpt.policy || {} }, + ruleset: resolveRuleset(opts.charClass), autospaceClass: opts.autospaceClass || "", justifyAtoms: opts.justifyAtoms === false ? false : true, scope: { diff --git a/dist/juzhen.mjs b/dist/juzhen.mjs index b3a0961..7361bfb 100644 --- a/dist/juzhen.mjs +++ b/dist/juzhen.mjs @@ -494,18 +494,52 @@ var BD_CLOSE = "\u300D\u300F\uFF09\u300B\u3009\u3011\u3017\u3015\uFF3D\uFF5D\u20 var BD_MIDDLE = "\xB7\u30FB\u2027"; var BD_LIGA = "\u2026\u2014"; var BD_ALL = BD_STOP + BD_PAUSE + BD_OPEN + BD_CLOSE + BD_MIDDLE + BD_LIGA; -var BD_SETS = [ - ["bd-stop", BD_STOP], - ["bd-pause", BD_PAUSE], - ["bd-open", BD_OPEN], - ["bd-close", BD_CLOSE], - ["bd-middle", BD_MIDDLE], - ["bd-liga", BD_LIGA] +var BD_ORDER = [ + "stop", + "pause", + "open", + "close", + "middle", + "liga" ]; -function classifyBiaodian(ch) { - for (const [name, set] of BD_SETS) { - if (set.indexOf(ch) >= 0) { - return name; +var DEFAULT_RULESET = { + cjk: CJK, + ansAfter: ANS_AFTER_CJK, + ansBefore: ANS_BEFORE_CJK, + biaodian: { + stop: BD_STOP, + pause: BD_PAUSE, + open: BD_OPEN, + close: BD_CLOSE, + middle: BD_MIDDLE, + liga: BD_LIGA + } +}; +function resolveRuleset(override) { + if (!override) { + return DEFAULT_RULESET; + } + const west = override.western || ""; + const bd = { ...DEFAULT_RULESET.biaodian }; + if (override.biaodian) { + for (const sub of BD_ORDER) { + const extra = override.biaodian[sub]; + if (extra) { + bd[sub] = bd[sub] + extra; + } + } + } + return { + cjk: CJK + (override.cjk || ""), + ansAfter: ANS_AFTER_CJK + west, + ansBefore: ANS_BEFORE_CJK + west, + biaodian: bd + }; +} +function classifyBiaodian(ch, biaodian = DEFAULT_RULESET.biaodian) { + for (const sub of BD_ORDER) { + if (biaodian[sub].indexOf(ch) >= 0) { + return "bd-" + sub; } } return null; @@ -614,9 +648,10 @@ var jiyaPass = { let style = null; let langClass = null; const biasOverrides = ctx.options.jiyaConfig.bias; + const biaodian = ctx.options.ruleset.biaodian; for (let i = 0; i < data.length; i += 1) { const ch = data.charAt(i); - const bdClass = classifyBiaodian(ch); + const bdClass = classifyBiaodian(ch, biaodian); if (!bdClass) { continue; } @@ -709,46 +744,46 @@ function forbiddenBreak(a, b, finder) { } return false; } -function isTokenChar(ch) { - return !ANY_CJK.test(ch) && !/\s/.test(ch); +function isTokenChar(ch, anyCjk) { + return !anyCjk.test(ch) && !/\s/.test(ch); } -function leadingTokenEnd(d) { - if (ANY_CJK.test(d.charAt(0))) { +function leadingTokenEnd(d, anyCjk) { + if (anyCjk.test(d.charAt(0))) { return 1; } let e = 1; - while (e < d.length && isTokenChar(d.charAt(e))) { + while (e < d.length && isTokenChar(d.charAt(e), anyCjk)) { e += 1; } return e; } -function trailingTokenStart(d) { +function trailingTokenStart(d, anyCjk) { const last = d.length - 1; - if (ANY_CJK.test(d.charAt(last))) { + if (anyCjk.test(d.charAt(last))) { return last; } let s = last; - while (s > 0 && isTokenChar(d.charAt(s - 1))) { + while (s > 0 && isTokenChar(d.charAt(s - 1), anyCjk)) { s -= 1; } return s; } -function splitBoundaries(t, needFirst, needLast) { +function splitBoundaries(t, needFirst, needLast, anyCjk) { let node = 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, finder) { +function processParent(parent, finder, anyCjk) { for (let c = parent.firstChild; c; c = c.nextSibling) { if (c.nodeType === 1 && c.nodeName === "JZ-JINZE") { return; @@ -778,7 +813,7 @@ function processParent(parent, finder) { const needFirst = i2 > 0 && forbidden[i2 - 1]; const needLast = i2 < n - 1 && forbidden[i2]; if (needFirst || needLast) { - splitBoundaries(u, needFirst, needLast); + splitBoundaries(u, needFirst, needLast, anyCjk); } } const kids = []; @@ -808,6 +843,7 @@ var jinzePass = { order: 40, 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 (c.parentNode) { @@ -815,7 +851,7 @@ var jinzePass = { } }); for (const parent of parents) { - processParent(parent, ctx.finder); + processParent(parent, ctx.finder, anyCjk); } }, revert(ctx) { @@ -1061,79 +1097,88 @@ var RBR_BASIC = "\\)\\]\\}"; var LBR_EXT = "\\(\\[\\{<>"; var RBR_EXT = "\\)\\]\\}<>"; var DEL = String.fromCharCode(57347); -var STRIP_CJK_SPACE_ANS = new RegExp("([" + CJK + "]) ([" + ANS_AFTER_CJK + "])", "g"); -var STRIP_ANS_SPACE_CJK = new RegExp("([" + ANS_BEFORE_CJK + "]) ([" + CJK + "])", "g"); -var STRIP_CJK_SPACE_CJK = new RegExp("([" + CJK + "]) (?=[" + CJK + "])", "g"); -var DOTS_CJK = new RegExp("([\\.]{2,})([" + CJK + "])", "g"); -var CJK_PUNCTUATION = new RegExp("([" + CJK + "])([!;,\\?:]+)(?=[" + CJK + ALNUM + "])", "g"); -var AN_PUNCTUATION_CJK = new RegExp("([" + ALNUM + "])([!;,\\?]+)([" + CJK + "])", "g"); -var CJK_TILDE = new RegExp("([" + CJK + "])(~+)(?!=)(?=[" + CJK + ALNUM + "])", "g"); -var CJK_TILDE_EQUALS = new RegExp("([" + CJK + "])(~=)", "g"); -var CJK_PERIOD = new RegExp("([" + CJK + "])(\\.)(?![" + ALNUM + "\\./])(?=[" + CJK + ALNUM + "])", "g"); -var AN_PERIOD_CJK = new RegExp("([" + ALNUM + "])(\\.)([" + CJK + "])", "g"); -var AN_COLON_CJK = new RegExp("([" + ALNUM + "])(:)([" + CJK + "])", "g"); -var CJK_QUOTE = new RegExp("([" + CJK + "])([" + QUOTES_CLS + "])", "g"); -var QUOTE_CJK = new RegExp("([" + QUOTES_CLS + "])([" + CJK + "])", "g"); -var CJK_QUOTE_AN = new RegExp("([" + CJK + '])(")([' + ALNUM + "])", "g"); -var CJK_HASH = new RegExp("([" + CJK + "])(#([^ ]))", "g"); -var HASH_CJK = new RegExp("(([^ ])#)([" + CJK + "])", "g"); -var SINGLE_LETTER_GRADE = new RegExp("\\b([" + ALPHA + "])([" + OPS_GRADE + "])([" + CJK + "])", "g"); -var CJK_OPERATOR_ANS = new RegExp("([" + CJK + "])([" + OPS_HYPHEN + "])([" + ALNUM + "])", "g"); -var ANS_OPERATOR_CJK = new RegExp("([" + ALNUM + "])([" + OPS_HYPHEN + "])([" + CJK + "])", "g"); -var CJK_LESS_THAN = new RegExp("([" + CJK + "])(<)([" + ALNUM + "])", "g"); -var LESS_THAN_CJK = new RegExp("([" + ALNUM + "])(<)([" + CJK + "])", "g"); -var CJK_GREATER_THAN = new RegExp("([" + CJK + "])(>)([" + ALNUM + "])", "g"); -var GREATER_THAN_CJK = new RegExp("([" + ALNUM + "])(>)([" + CJK + "])", "g"); -var CJK_LEFT_BRACKET = new RegExp("([" + CJK + "])([" + LBR_EXT + "])", "g"); -var RIGHT_BRACKET_CJK = new RegExp("([" + RBR_EXT + "])([" + CJK + "])", "g"); -var AN_LEFT_BRACKET = new RegExp("([" + ALNUM + "])(?)([" + ALNUM + "])", "g"), + GREATER_THAN_CJK: new RegExp("([" + ALNUM + "])(>)([" + CJK2 + "])", "g"), + CJK_LEFT_BRACKET: new RegExp("([" + CJK2 + "])([" + LBR_EXT + "])", "g"), + RIGHT_BRACKET_CJK: new RegExp("([" + RBR_EXT + "])([" + CJK2 + "])", "g"), + AN_LEFT_BRACKET: new RegExp("([" + ALNUM + "])(? { compounds.push(m); return PH_OPEN + (compounds.length - 1) + PH_CLOSE; }); - s = s.replace(SINGLE_LETTER_GRADE, "$1$2" + MARK + "$3"); - s = s.replace(CJK_OPERATOR_ANS, "$1" + MARK + "$2" + MARK + "$3"); - s = s.replace(ANS_OPERATOR_CJK, "$1" + MARK + "$2" + MARK + "$3"); - s = s.replace(CJK_LESS_THAN, "$1" + MARK + "$2" + MARK + "$3"); - s = s.replace(LESS_THAN_CJK, "$1" + MARK + "$2" + MARK + "$3"); - s = s.replace(CJK_GREATER_THAN, "$1" + MARK + "$2" + MARK + "$3"); - s = s.replace(GREATER_THAN_CJK, "$1" + MARK + "$2" + MARK + "$3"); + s = s.replace(R.SINGLE_LETTER_GRADE, "$1$2" + MARK + "$3"); + s = s.replace(R.CJK_OPERATOR_ANS, "$1" + MARK + "$2" + MARK + "$3"); + s = s.replace(R.ANS_OPERATOR_CJK, "$1" + MARK + "$2" + MARK + "$3"); + s = s.replace(R.CJK_LESS_THAN, "$1" + MARK + "$2" + MARK + "$3"); + s = s.replace(R.LESS_THAN_CJK, "$1" + MARK + "$2" + MARK + "$3"); + s = s.replace(R.CJK_GREATER_THAN, "$1" + MARK + "$2" + MARK + "$3"); + s = s.replace(R.GREATER_THAN_CJK, "$1" + MARK + "$2" + MARK + "$3"); s = s.replace(PH_PATTERN, (_m, idx) => compounds[Number(idx)] || ""); - s = s.replace(CJK_LEFT_BRACKET, "$1" + MARK + "$2"); - s = s.replace(RIGHT_BRACKET_CJK, "$1" + MARK + "$2"); - s = s.replace(AN_LEFT_BRACKET, "$1" + MARK + "$2"); - s = s.replace(RIGHT_BRACKET_AN, "$1" + MARK + "$2"); - s = s.replace(CJK_ANS, "$1" + MARK + "$2"); - s = s.replace(ANS_CJK, "$1" + MARK + "$2"); - s = s.replace(S_A, "$1" + MARK + "$2"); + s = s.replace(R.CJK_LEFT_BRACKET, "$1" + MARK + "$2"); + s = s.replace(R.RIGHT_BRACKET_CJK, "$1" + MARK + "$2"); + s = s.replace(R.AN_LEFT_BRACKET, "$1" + MARK + "$2"); + s = s.replace(R.RIGHT_BRACKET_AN, "$1" + MARK + "$2"); + s = s.replace(R.CJK_ANS, "$1" + MARK + "$2"); + s = s.replace(R.ANS_CJK, "$1" + MARK + "$2"); + s = s.replace(R.S_A, "$1" + MARK + "$2"); return s; } function wrapNodeInGap(node) { @@ -1158,11 +1203,11 @@ function wrapGapLeft(pos) { } wrapNodeInGap(tn); } -function processBlock(block, finder) { +function processBlock(block, finder, R) { const runs = finder.collectRuns(block); for (const run of runs) { const { text, map } = run; - const spaced = panguSpace(text); + const spaced = panguSpace(text, R); if (spaced === text) { continue; } @@ -1203,8 +1248,7 @@ function processBlock(block, finder) { } } } -var PILL_NEIGHBOR = new RegExp("[A-Za-z0-9" + CJK + "]"); -function processPills(root, finder, pillSelector) { +function processPills(root, finder, pillSelector, pillNeighbor) { root.querySelectorAll(pillSelector).forEach((pill) => { if (finder.isAvoided(pill) || !finder.inScope(pill)) { return; @@ -1214,36 +1258,36 @@ function processPills(root, finder, pillSelector) { } const prev = finder.adjacentLogicalChar(pill, -1); if (prev) { - handlePillSide(prev, pill, -1); + handlePillSide(prev, pill, -1, pillNeighbor); } const next = finder.adjacentLogicalChar(pill, 1); if (next) { - handlePillSide(next, pill, 1); + handlePillSide(next, pill, 1, pillNeighbor); } }); } -function handlePillSide(adj, pill, dir) { +function handlePillSide(adj, pill, dir, pillNeighbor) { if (dir === -1) { - if (PILL_NEIGHBOR.test(adj.ch)) { + if (pillNeighbor.test(adj.ch)) { wrapGapLeft({ textNode: adj.textNode, offset: adj.offset }); return; } if (adj.ch === " " && adj.offset >= 1) { const tn = adj.textNode; const bc = tn.data.charAt(adj.offset - 1); - if (bc !== " " && PILL_NEIGHBOR.test(bc)) { + if (bc !== " " && pillNeighbor.test(bc)) { tn.data = tn.data.slice(0, adj.offset) + tn.data.slice(adj.offset + 1); wrapGapLeft({ textNode: tn, offset: adj.offset - 1 }); } } } else { - if (PILL_NEIGHBOR.test(adj.ch)) { + if (pillNeighbor.test(adj.ch)) { wrapNodeInGap(pill); return; } if (adj.ch === " " && adj.textNode.data.length >= 2) { const ac = adj.textNode.data.charAt(1); - if (ac !== " " && PILL_NEIGHBOR.test(ac)) { + if (ac !== " " && pillNeighbor.test(ac)) { adj.textNode.data = adj.textNode.data.slice(1); wrapNodeInGap(pill); } @@ -1257,13 +1301,14 @@ var spacingPass = { enabled: (o) => o.features.spacing, render(ctx) { const { finder, options } = ctx; + const R = makeRules(options.ruleset); ctx.finder.eachBlock(ctx.root, (block) => { if (!finder.featureEnabledFor(block, PASS4)) { return; } - processBlock(block, finder); + processBlock(block, finder, R); }); - processPills(ctx.root, finder, options.finder.pillSelector); + processPills(ctx.root, finder, options.finder.pillSelector, R.pillNeighbor); }, revert(ctx) { revertPass(PASS4, ctx.root); @@ -1454,6 +1499,7 @@ function normalizeOptions(opts = {}) { ...langOpt.style ? { style: langOpt.style } : {}, policy: langOpt.policy || {} }, + ruleset: resolveRuleset(opts.charClass), autospaceClass: opts.autospaceClass || "", justifyAtoms: opts.justifyAtoms === false ? false : true, scope: { diff --git a/dist/types.d.ts b/dist/types.d.ts index 16565ce..972fefc 100644 --- a/dist/types.d.ts +++ b/dist/types.d.ts @@ -1,7 +1,7 @@ import type { Finder } from "./core/finder.js"; import type { LangClass, LocaleConfig, PunctStyle } from "./core/locale.js"; -import type { Bias, BiasTable } from "./core/unicode.js"; -export type { LangClass, PunctStyle, Bias, BiasTable }; +import type { Bias, BiasTable, BdSubclass, CharClassOverride, Ruleset } from "./core/unicode.js"; +export type { LangClass, PunctStyle, Bias, BiasTable, BdSubclass, CharClassOverride, Ruleset }; /** 長英文詞包裝設定。 */ export interface LongWordOptions { minLength?: number; @@ -34,6 +34,9 @@ export interface JuzhenOptions { /** 逐 lang 覆寫(最高優先)。 */ policy?: Partial>; }; + /** 自定義字符分類(擴充內置規則集)。把未分類字符或新符號歸入中文/西文/ + * 全寬標點各子類,以覆寫 spacing/jiya 等規則之分類判定(追加語義)。 */ + charClass?: CharClassOverride; /** 間隙標記沿用之 class(v1 相容;預設無額外 class)。 */ autospaceClass?: string; /** justify 原子化:jz-char/jz-jinze 是否以 inline-block 作對齊單一原子 @@ -67,6 +70,8 @@ export interface JuzhenOptions { /** 正規化後之內部選項。 */ export interface ResolvedOptions { locale: LocaleConfig; + /** 最終字符分類規則集(內置默認 + charClass 覆寫)。 */ + ruleset: Ruleset; autospaceClass: string; justifyAtoms: boolean; scope: { diff --git a/src/core/unicode.ts b/src/core/unicode.ts index 90d4fff..194168a 100644 --- a/src/core/unicode.ts +++ b/src/core/unicode.ts @@ -83,24 +83,90 @@ export const BD_ALL = ( BD_STOP + BD_PAUSE + BD_OPEN + BD_CLOSE + BD_MIDDLE + BD_LIGA ); -const BD_SETS: ReadonlyArray = [ - [ "bd-stop", BD_STOP ], - [ "bd-pause", BD_PAUSE ], - [ "bd-open", BD_OPEN ], - [ "bd-close", BD_CLOSE ], - [ "bd-middle", BD_MIDDLE ], - [ "bd-liga", BD_LIGA ], +/** 全寬標點之六子類鍵。 */ +export type BdSubclass = "stop" | "pause" | "open" | "close" | "middle" | "liga"; + +const BD_ORDER: ReadonlyArray = [ + "stop", "pause", "open", "close", "middle", "liga", ]; /** - * 將單一標點字元歸入其 bd-* 子類;非標點回傳 null。 - * 供 jiya 之 charify 為 jz-char 標記 class。 + * 字符分類規則集(§4.2):把「中文/西文/全寬標點各子類」各表為 regex 字元類片段。 + * 各 pass(spacing 之 CJK/ANS 正則、jiya 之 classifyBiaodian、jinze 之 anyCjk)皆由此 + * 取分類,不再各自硬編碼。內置默認見 DEFAULT_RULESET;消費端經 options.charClass 擴充 + * (resolveRuleset)。 */ -export function classifyBiaodian(ch: string): string | null +export interface Ruleset { - for (const [ name, set ] of BD_SETS) + /** 中文(CJK 表意/假名/注音等)——中西間隙之「中」側、禁則/justify 原子。 */ + cjk: string; + /** 西文,CJK 之後可接隙者(含 @)。 */ + ansAfter: string; + /** 西文,CJK 之前可接隙者(去 @)。 */ + ansBefore: string; + /** 全寬標點,按子類——jiya charify/擠壓、禁則避頭尾、行端。 */ + biaodian: Record; +} + +/** 消費端字符分類覆寫(追加語義;見 README「自定義字符分類」)。 */ +export interface CharClassOverride +{ + /** 追加為中文(CJK)。 */ + cjk?: string; + /** 追加為西文(半寬標點亦歸此,按西文處理間隙);併入 ansAfter/ansBefore。 */ + western?: string; + /** 追加為全寬標點,按子類。 */ + biaodian?: Partial>; +} + +/** 內置默認規則集(即現狀硬編碼值)。 */ +export const DEFAULT_RULESET: Ruleset = { + cjk: CJK, + ansAfter: ANS_AFTER_CJK, + ansBefore: ANS_BEFORE_CJK, + biaodian: { + stop: BD_STOP, pause: BD_PAUSE, open: BD_OPEN, + close: BD_CLOSE, middle: BD_MIDDLE, liga: BD_LIGA, + }, +}; + +/** + * 合併消費端覆寫,回傳最終規則集。v1 為**追加**語義:把 override 字元併入內置對應類 + * (未分類字符→歸類、新符號→納入)。不傳則回傳 DEFAULT_RULESET(零行為變更)。 + */ +export function resolveRuleset(override?: CharClassOverride): Ruleset +{ + if (!override) { return DEFAULT_RULESET; } + const west = override.western || ""; + const bd: Record = { ...DEFAULT_RULESET.biaodian }; + if (override.biaodian) { - if (set.indexOf(ch) >= 0) { return name; } + for (const sub of BD_ORDER) + { + const extra = override.biaodian[sub]; + if (extra) { bd[sub] = bd[sub] + extra; } + } + } + return { + cjk: CJK + (override.cjk || ""), + ansAfter: ANS_AFTER_CJK + west, + ansBefore: ANS_BEFORE_CJK + west, + biaodian: bd, + }; +} + +/** + * 將單一標點字元歸入其 bd-* 子類;非標點回傳 null。供 jiya charify 標記 class。 + * biaodian 取自規則集(預設 DEFAULT_RULESET.biaodian,向下兼容無參數呼叫)。 + */ +export function classifyBiaodian( + ch: string, + biaodian: Record = DEFAULT_RULESET.biaodian, +): string | null +{ + for (const sub of BD_ORDER) + { + if (biaodian[sub].indexOf(ch) >= 0) { return "bd-" + sub; } } return null; } diff --git a/src/index.ts b/src/index.ts index 6d0daf3..ff04be1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,9 +14,11 @@ import { jinzePass } from "./typeset/jinze.js"; import { gapTrimPass, lineEdgePass } from "./typeset/lineedge.js"; import { longWordPass } from "./typeset/longword.js"; import { spacingPass } from "./typeset/spacing.js"; +import { resolveRuleset } from "./core/unicode.js"; import type { BiasTable, JiyaOptions, JuzhenOptions, LongWordOptions, Pass, RenderContext, ResolvedOptions } from "./types.js"; export type { JuzhenOptions, ResolvedOptions, Pass }; +export type { CharClassOverride, BdSubclass, Ruleset, Bias, BiasTable } from "./types.js"; export { createCjkAutospace } from "./compat/v1.js"; const DEFAULT_STYLE_INLINES = [ @@ -81,6 +83,7 @@ export function normalizeOptions(opts: JuzhenOptions = {}): ResolvedOptions ...(langOpt.style ? { style: langOpt.style } : {}), policy: langOpt.policy || {}, }, + ruleset: resolveRuleset(opts.charClass), autospaceClass: opts.autospaceClass || "", justifyAtoms: opts.justifyAtoms === false ? false : true, scope: { diff --git a/src/types.ts b/src/types.ts index 8a2243b..ccc4bcc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,9 +2,9 @@ import type { Finder } from "./core/finder.js"; import type { LangClass, LocaleConfig, PunctStyle } from "./core/locale.js"; -import type { Bias, BiasTable } from "./core/unicode.js"; +import type { Bias, BiasTable, BdSubclass, CharClassOverride, Ruleset } from "./core/unicode.js"; -export type { LangClass, PunctStyle, Bias, BiasTable }; +export type { LangClass, PunctStyle, Bias, BiasTable, BdSubclass, CharClassOverride, Ruleset }; /** 長英文詞包裝設定。 */ export interface LongWordOptions @@ -46,6 +46,10 @@ export interface JuzhenOptions policy?: Partial>; }; + /** 自定義字符分類(擴充內置規則集)。把未分類字符或新符號歸入中文/西文/ + * 全寬標點各子類,以覆寫 spacing/jiya 等規則之分類判定(追加語義)。 */ + charClass?: CharClassOverride; + /** 間隙標記沿用之 class(v1 相容;預設無額外 class)。 */ autospaceClass?: string; @@ -88,6 +92,8 @@ export interface JuzhenOptions export interface ResolvedOptions { locale: LocaleConfig; + /** 最終字符分類規則集(內置默認 + charClass 覆寫)。 */ + ruleset: Ruleset; autospaceClass: string; justifyAtoms: boolean; scope: { diff --git a/src/typeset/jinze.ts b/src/typeset/jinze.ts index de387d4..9e3384d 100644 --- a/src/typeset/jinze.ts +++ b/src/typeset/jinze.ts @@ -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 覆寫),與 spacing/jiya 一致。 + const anyCjk = new RegExp("[" + ctx.options.ruleset.cjk + "]"); const parents = new Set(); 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 { diff --git a/src/typeset/jiya.ts b/src/typeset/jiya.ts index 4ebda07..b04c181 100644 --- a/src/typeset/jiya.ts +++ b/src/typeset/jiya.ts @@ -66,11 +66,12 @@ export const jiyaPass: CharifyPass = { let style: PunctStyle | null = null; let langClass: LangClass | null = null; const biasOverrides = ctx.options.jiyaConfig.bias; + const biaodian = ctx.options.ruleset.biaodian; for (let i = 0; i < data.length; i += 1) { const ch = data.charAt(i); - const bdClass = classifyBiaodian(ch); + const bdClass = classifyBiaodian(ch, biaodian); if (!bdClass) { continue; } if (style === null) { style = resolveStyle(node, ctx.options.locale); } if (langClass === null) diff --git a/src/typeset/spacing.ts b/src/typeset/spacing.ts index f8e3986..4ab2c69 100644 --- a/src/typeset/spacing.ts +++ b/src/typeset/spacing.ts @@ -7,10 +7,10 @@ // 涵蓋 v1 Pass A(跨樣式邊界間隙)與 Pass B(pill code/kbd 邊界間隙)。 // 規則正則為間隙專用,故與本 pass 同處;共用字集自 unicode.ts。 -import { ALNUM, ALPHA, ANS_AFTER_CJK, ANS_BEFORE_CJK, ANY_CJK, CJK } from "../core/unicode.js"; +import { ALNUM, ALPHA } from "../core/unicode.js"; import { createJz, revertPass } from "../core/dom.js"; import type { AdjacentChar, Finder } from "../core/finder.js"; -import type { RenderContext, StandalonePass } from "../types.js"; +import type { RenderContext, Ruleset, StandalonePass } from "../types.js"; const PASS = "spacing"; @@ -32,77 +32,103 @@ const RBR_EXT = "\\)\\]\\}<>"; // 與空格 1:1 替換以保持 spaced/text 對齊(processBlock 據此刪空格而不插 jz-hws)。 const DEL = String.fromCharCode(0xE003); -const STRIP_CJK_SPACE_ANS = new RegExp("([" + CJK + "]) ([" + ANS_AFTER_CJK + "])", "g"); -const STRIP_ANS_SPACE_CJK = new RegExp("([" + ANS_BEFORE_CJK + "]) ([" + CJK + "])", "g"); -// CJK↔CJK 之單一作者空格(含跨樣式行內標籤,collectRuns 已透明合併):中文不用 -// 詞間空格,此類空格幾乎必為贅餘 → 以 DEL 標記刪除(1b,邊界正規化一致性)。 -// 用 lookahead 使連續 CJK 空格逐一命中;只匹配 ASCII 空格,全形空格 U+3000 不動。 -const STRIP_CJK_SPACE_CJK = new RegExp("([" + CJK + "]) (?=[" + CJK + "])", "g"); +/** 由規則集編譯之 per-instance 間隙正則集。 */ +interface Rules +{ + anyCjk: RegExp; pillNeighbor: RegExp; + STRIP_CJK_SPACE_ANS: RegExp; STRIP_ANS_SPACE_CJK: RegExp; STRIP_CJK_SPACE_CJK: RegExp; + DOTS_CJK: RegExp; CJK_PUNCTUATION: RegExp; AN_PUNCTUATION_CJK: RegExp; + CJK_TILDE: RegExp; CJK_TILDE_EQUALS: RegExp; CJK_PERIOD: RegExp; + AN_PERIOD_CJK: RegExp; AN_COLON_CJK: RegExp; CJK_QUOTE: RegExp; QUOTE_CJK: RegExp; + CJK_QUOTE_AN: RegExp; CJK_HASH: RegExp; HASH_CJK: RegExp; SINGLE_LETTER_GRADE: RegExp; + CJK_OPERATOR_ANS: RegExp; ANS_OPERATOR_CJK: RegExp; + CJK_LESS_THAN: RegExp; LESS_THAN_CJK: RegExp; CJK_GREATER_THAN: RegExp; GREATER_THAN_CJK: RegExp; + CJK_LEFT_BRACKET: RegExp; RIGHT_BRACKET_CJK: RegExp; AN_LEFT_BRACKET: RegExp; RIGHT_BRACKET_AN: RegExp; + CJK_ANS: RegExp; ANS_CJK: RegExp; S_A: RegExp; +} -// 僅 ASCII 省略號 `...中文` 補隙;**不含**全角省略號 U+2026——全角 …/…… 屬 CJK -// 標點(已由 jiya charify 為 bd-liga),後接 CJK 不應有中西間隙(下游回報問題 B: -// 原 alternation `|…` 誤把全角納入,致 `……留` 間冒出 jz-hws)。 -const DOTS_CJK = new RegExp("([\\.]{2,})([" + CJK + "])", "g"); -const CJK_PUNCTUATION = new RegExp("([" + CJK + "])([!;,\\?:]+)(?=[" + CJK + ALNUM + "])", "g"); -const AN_PUNCTUATION_CJK = new RegExp("([" + ALNUM + "])([!;,\\?]+)([" + CJK + "])", "g"); -const CJK_TILDE = new RegExp("([" + CJK + "])(~+)(?!=)(?=[" + CJK + ALNUM + "])", "g"); -const CJK_TILDE_EQUALS = new RegExp("([" + CJK + "])(~=)", "g"); -const CJK_PERIOD = new RegExp("([" + CJK + "])(\\.)(?![" + ALNUM + "\\./])(?=[" + CJK + ALNUM + "])", "g"); -const AN_PERIOD_CJK = new RegExp("([" + ALNUM + "])(\\.)([" + CJK + "])", "g"); -const AN_COLON_CJK = new RegExp("([" + ALNUM + "])(:)([" + CJK + "])", "g"); -const CJK_QUOTE = new RegExp("([" + CJK + "])([" + QUOTES_CLS + "])", "g"); -const QUOTE_CJK = new RegExp("([" + QUOTES_CLS + "])([" + CJK + "])", "g"); -// 註:原 pangu 之 QUOTE_AN(`([”])([ALNUM])` → 收尾彎引號後接字母數字補隙)已移除。 -// 標點稽核發現它令收尾彎引號 ” 成為唯一「後接拉丁/數字會補隙」之收尾標點,與所有其他 -// 收尾標點(」』)》】〕…)行為不一致(後者皆不補);clreq 下全角收尾標點字身已內含 -// 右側留白,後接拉丁不需額外隙。故移除以求一致、消除誤隙(下游回饋之標點稽核)。 -const CJK_QUOTE_AN = new RegExp("([" + CJK + "])(\")([" + ALNUM + "])", "g"); -const CJK_HASH = new RegExp("([" + CJK + "])(#([^ ]))", "g"); -const HASH_CJK = new RegExp("(([^ ])#)([" + CJK + "])", "g"); -const SINGLE_LETTER_GRADE = new RegExp("\\b([" + ALPHA + "])([" + OPS_GRADE + "])([" + CJK + "])", "g"); -const CJK_OPERATOR_ANS = new RegExp("([" + CJK + "])([" + OPS_HYPHEN + "])([" + ALNUM + "])", "g"); -const ANS_OPERATOR_CJK = new RegExp("([" + ALNUM + "])([" + OPS_HYPHEN + "])([" + CJK + "])", "g"); -const CJK_LESS_THAN = new RegExp("([" + CJK + "])(<)([" + ALNUM + "])", "g"); -const LESS_THAN_CJK = new RegExp("([" + ALNUM + "])(<)([" + CJK + "])", "g"); -const CJK_GREATER_THAN = new RegExp("([" + CJK + "])(>)([" + ALNUM + "])", "g"); -const GREATER_THAN_CJK = new RegExp("([" + ALNUM + "])(>)([" + CJK + "])", "g"); -const CJK_LEFT_BRACKET = new RegExp("([" + CJK + "])([" + LBR_EXT + "])", "g"); -const RIGHT_BRACKET_CJK = new RegExp("([" + RBR_EXT + "])([" + CJK + "])", "g"); -const AN_LEFT_BRACKET = new RegExp("([" + ALNUM + "])(?)([" + ALNUM + "])", "g"), + GREATER_THAN_CJK: new RegExp("([" + ALNUM + "])(>)([" + CJK + "])", "g"), + CJK_LEFT_BRACKET: new RegExp("([" + CJK + "])([" + LBR_EXT + "])", "g"), + RIGHT_BRACKET_CJK: new RegExp("([" + RBR_EXT + "])([" + CJK + "])", "g"), + AN_LEFT_BRACKET: new RegExp("([" + ALNUM + "])(? @@ -111,25 +137,25 @@ function panguSpace(text: string): string return PH_OPEN + (compounds.length - 1) + PH_CLOSE; }); - s = s.replace(SINGLE_LETTER_GRADE, "$1$2" + MARK + "$3"); - s = s.replace(CJK_OPERATOR_ANS, "$1" + MARK + "$2" + MARK + "$3"); - s = s.replace(ANS_OPERATOR_CJK, "$1" + MARK + "$2" + MARK + "$3"); + s = s.replace(R.SINGLE_LETTER_GRADE, "$1$2" + MARK + "$3"); + s = s.replace(R.CJK_OPERATOR_ANS, "$1" + MARK + "$2" + MARK + "$3"); + s = s.replace(R.ANS_OPERATOR_CJK, "$1" + MARK + "$2" + MARK + "$3"); - s = s.replace(CJK_LESS_THAN, "$1" + MARK + "$2" + MARK + "$3"); - s = s.replace(LESS_THAN_CJK, "$1" + MARK + "$2" + MARK + "$3"); - s = s.replace(CJK_GREATER_THAN, "$1" + MARK + "$2" + MARK + "$3"); - s = s.replace(GREATER_THAN_CJK, "$1" + MARK + "$2" + MARK + "$3"); + s = s.replace(R.CJK_LESS_THAN, "$1" + MARK + "$2" + MARK + "$3"); + s = s.replace(R.LESS_THAN_CJK, "$1" + MARK + "$2" + MARK + "$3"); + s = s.replace(R.CJK_GREATER_THAN, "$1" + MARK + "$2" + MARK + "$3"); + s = s.replace(R.GREATER_THAN_CJK, "$1" + MARK + "$2" + MARK + "$3"); s = s.replace(PH_PATTERN, (_m, idx: string) => compounds[Number(idx)] || ""); - s = s.replace(CJK_LEFT_BRACKET, "$1" + MARK + "$2"); - s = s.replace(RIGHT_BRACKET_CJK, "$1" + MARK + "$2"); - s = s.replace(AN_LEFT_BRACKET, "$1" + MARK + "$2"); - s = s.replace(RIGHT_BRACKET_AN, "$1" + MARK + "$2"); + s = s.replace(R.CJK_LEFT_BRACKET, "$1" + MARK + "$2"); + s = s.replace(R.RIGHT_BRACKET_CJK, "$1" + MARK + "$2"); + s = s.replace(R.AN_LEFT_BRACKET, "$1" + MARK + "$2"); + s = s.replace(R.RIGHT_BRACKET_AN, "$1" + MARK + "$2"); - s = s.replace(CJK_ANS, "$1" + MARK + "$2"); - s = s.replace(ANS_CJK, "$1" + MARK + "$2"); - s = s.replace(S_A, "$1" + MARK + "$2"); + s = s.replace(R.CJK_ANS, "$1" + MARK + "$2"); + s = s.replace(R.ANS_CJK, "$1" + MARK + "$2"); + s = s.replace(R.S_A, "$1" + MARK + "$2"); return s; } @@ -163,13 +189,13 @@ function wrapGapLeft(pos: { textNode: Text; offset: number }): void wrapNodeInGap(tn); } -function processBlock(block: Element, finder: Finder): void +function processBlock(block: Element, finder: Finder, R: Rules): void { const runs = finder.collectRuns(block); for (const run of runs) { const { text, map } = run; - const spaced = panguSpace(text); + const spaced = panguSpace(text, R); if (spaced === text) { continue; } const actions: SpaceAction[] = []; @@ -220,9 +246,7 @@ function processBlock(block: Element, finder: Finder): void } } -const PILL_NEIGHBOR = new RegExp("[A-Za-z0-9" + CJK + "]"); - -function processPills(root: Element, finder: Finder, pillSelector: string): void +function processPills(root: Element, finder: Finder, pillSelector: string, pillNeighbor: RegExp): void { root.querySelectorAll(pillSelector).forEach((pill) => { @@ -230,20 +254,20 @@ function processPills(root: Element, finder: Finder, pillSelector: string): void if (!pill.parentNode) { return; } const prev = finder.adjacentLogicalChar(pill, -1); - if (prev) { handlePillSide(prev, pill, -1); } + if (prev) { handlePillSide(prev, pill, -1, pillNeighbor); } const next = finder.adjacentLogicalChar(pill, 1); - if (next) { handlePillSide(next, pill, 1); } + if (next) { handlePillSide(next, pill, 1, pillNeighbor); } }); } // dir -1:間隙在 adj 字(左)與 pill(右)之間 → margin 掛 adj 字(包裝該字)。 // dir +1:間隙在 pill(左)與 adj 字(右)之間 → margin 掛 pill(包裝 pill)。 -function handlePillSide(adj: AdjacentChar, pill: Element, dir: -1 | 1): void +function handlePillSide(adj: AdjacentChar, pill: Element, dir: -1 | 1, pillNeighbor: RegExp): void { if (dir === -1) { - if (PILL_NEIGHBOR.test(adj.ch)) + if (pillNeighbor.test(adj.ch)) { wrapGapLeft({ textNode: adj.textNode, offset: adj.offset }); return; @@ -252,7 +276,7 @@ function handlePillSide(adj: AdjacentChar, pill: Element, dir: -1 | 1): void { const tn = adj.textNode; const bc = tn.data.charAt(adj.offset - 1); - if (bc !== " " && PILL_NEIGHBOR.test(bc)) + if (bc !== " " && pillNeighbor.test(bc)) { tn.data = tn.data.slice(0, adj.offset) + tn.data.slice(adj.offset + 1); wrapGapLeft({ textNode: tn, offset: adj.offset - 1 }); @@ -261,7 +285,7 @@ function handlePillSide(adj: AdjacentChar, pill: Element, dir: -1 | 1): void } else { - if (PILL_NEIGHBOR.test(adj.ch)) + if (pillNeighbor.test(adj.ch)) { wrapNodeInGap(pill); return; @@ -269,7 +293,7 @@ function handlePillSide(adj: AdjacentChar, pill: Element, dir: -1 | 1): void if (adj.ch === " " && adj.textNode.data.length >= 2) { const ac = adj.textNode.data.charAt(1); - if (ac !== " " && PILL_NEIGHBOR.test(ac)) + if (ac !== " " && pillNeighbor.test(ac)) { adj.textNode.data = adj.textNode.data.slice(1); wrapNodeInGap(pill); @@ -286,14 +310,16 @@ export const spacingPass: StandalonePass = { render(ctx: RenderContext): void { const { finder, options } = ctx; + // 由規則集編譯間隙正則(per-instance;含 charClass 覆寫)。 + const R = makeRules(options.ruleset); // Pass A:逐 block 處理(功能閘以 block 為粒度)。 ctx.finder.eachBlock(ctx.root, (block) => { if (!finder.featureEnabledFor(block, PASS)) { return; } - processBlock(block, finder); + processBlock(block, finder, R); }); // Pass B:pill 邊界。 - processPills(ctx.root, finder, options.finder.pillSelector); + processPills(ctx.root, finder, options.finder.pillSelector, R.pillNeighbor); }, revert(ctx: RenderContext): void { diff --git a/test/juzhen.test.mjs b/test/juzhen.test.mjs index 72ed6de..b4fb8f7 100644 --- a/test/juzhen.test.mjs +++ b/test/juzhen.test.mjs @@ -508,3 +508,88 @@ test("CSS:聚珍不內建 font-family(字型交消費端),半形顯式 assert.equal(/font-family/.test(css), false, "聚珍 CSS 不應內建任何 font-family"); assert.match(css, /font-feature-settings:\s*"locl"\s+1,\s*"halt"\s+1/, "半形 feature 應顯式保留 locl"); }); + +// ----- charClass:自定義字符分類規則集(向下兼容+擴充)----- + +test("charClass:預設不傳時,英文全角句號 . 未分類(不 charify、不挤、不補隙)", () => +{ + const { document } = setupDom('

中.文

'); + createJuzhen({ lang: { style: "kaiming" } }).render(document.body); + const p = document.querySelector("p"); + assert.equal(countTag(p, "jz-char"), 0, ". 預設未分類為標點,不 charify"); + assert.equal(countTag(p, "jz-hws"), 0, ". 預設非西文,無中西間隙"); +}); + +test("charClass.biaodian.stop:把 . 歸為全寬句末標點 → 被 jiya charify 為 bd-stop", () => +{ + const { document } = setupDom('

中.文.

'); + createJuzhen({ lang: { style: "kaiming" }, charClass: { biaodian: { stop: "." } } }).render(document.body); + const p = document.querySelector("p"); + assert.ok(countTag(p, "jz-char.bd-stop") >= 1, ". 應 charify 為 bd-stop"); +}); + +test("charClass.western:把未分類符號 ★ 歸為西文 → 與 CJK 邊界補 jz-hws", () => +{ + const { document } = setupDom('

中★文

'); + // 先確認預設不補隙 + createJuzhen({}).render(document.body); + assert.equal(countTag(document.querySelector("p"), "jz-hws"), 0, "預設 ★ 非西文、不補隙"); + const dom2 = setupDom('

中★文

'); + createJuzhen({ charClass: { western: "★" } }).render(dom2.document.body); + assert.ok(countTag(dom2.document.querySelector("p"), "jz-hws") >= 1, "★ 歸西文後,中★/★文 邊界補隙"); +}); + +test("charClass.cjk:把符號歸為中文 → 與西文邊界補 jz-hws", () => +{ + const { document } = setupDom('

〇A

'); + createJuzhen({ charClass: { cjk: "〇" } }).render(document.body); + assert.ok(countTag(document.querySelector("p"), "jz-hws") >= 1, "〇 歸中文後,〇↔A 為中西邊界、補隙"); +}); + +test("charClass:實例隔離——覆寫不污染內置默認(resolveRuleset 不 mutate DEFAULT_RULESET)", () => +{ + // 先建帶覆寫之實例(★ 視為西文)。 + const a = setupDom('

中★文

'); + createJuzhen({ charClass: { western: "★" } }).render(a.document.body); + assert.ok(countTag(a.document.querySelector("p"), "jz-hws") >= 1, "覆寫實例:★ 補隙"); + // 再建無覆寫之實例:★ 應仍未分類、不補隙(前實例之覆寫未洩漏至默認)。 + const b = setupDom('

中★文

'); + createJuzhen({}).render(b.document.body); + assert.equal(countTag(b.document.querySelector("p"), "jz-hws"), 0, + "無覆寫實例:★ 仍未分類、不補隙(默認規則集未被前實例污染)"); +}); + +test("charClass.biaodian.pause:未分類符號歸句內點號 → 開明式挤半形(jz-half)", () => +{ + const { document } = setupDom('

中•文

'); + createJuzhen({ lang: { style: "kaiming" }, charClass: { biaodian: { pause: "•" } } }).render(document.body); + const c = document.querySelector("jz-char.bd-pause"); + assert.ok(c, "• charify 為 bd-pause"); + assert.ok(c.classList.contains("jz-half"), "開明式句內點號挤半形(jz-half)"); +}); + +test("charClass.biaodian.open:歸開括號 → 禁則避尾,與後字綁為 jz-jinze", () => +{ + const { document } = setupDom('

甲•乙

'); + createJuzhen({ charClass: { biaodian: { open: "•" } } }).render(document.body); + assert.ok(countTag(document.querySelector("p"), "jz-jinze") >= 1, "開類 • 與後字綁定(避尾)為 jz-jinze"); +}); + +test("charClass.western:追加不破壞內置——€(內置)與 ★(追加)皆補隙", () => +{ + const { document } = setupDom('

價€元★好

'); + createJuzhen({ jiya: false, longWord: false, jinze: false, charClass: { western: "★" } }).render(document.body); + const n = countTag(document.querySelector("p"), "jz-hws"); + // 價↔€、€↔元、元↔★、★↔好 四處中西邊界皆補隙(內置 € 不因追加而失)。 + assert.ok(n >= 4, "內置 € 與追加 ★ 之四邊界皆補隙,實得 " + n); +}); + +test("charClass.biaodian:多子類同傳,各自生效(stop/pause)", () => +{ + const { document } = setupDom('

甲.乙•丙

'); + createJuzhen({ lang: { style: "kaiming" }, charClass: { biaodian: { stop: ".", pause: "•" } } }).render(document.body); + const p = document.querySelector("p"); + assert.ok(countTag(p, "jz-char.bd-stop") >= 1, ". → bd-stop"); + const pause = p.querySelector("jz-char.bd-pause"); + assert.ok(pause && pause.classList.contains("jz-half"), "• → bd-pause 且開明式挤半形"); +});