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
+18 -1
View File
@@ -264,8 +264,25 @@ IIFE 構建額外掛 `globalThis.createCjkAutospace` 別名,使
- `quote` 彎引號:`“”‘’` - `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 之分類字集,重寫為 > 借鑑 Han 之 `regex/typeset.js`、`regex/unicode.js` 與 Heti 之分類字集,重寫為
> 現代 TS 常數表。 > 現代 TS 常數表+可配置規則集
### 4.3 `locale.ts`——語言與全角/開明政策 ### 4.3 `locale.ts`——語言與全角/開明政策
+38
View File
@@ -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 向下相容 ## v1 向下相容
```js ```js
+37 -3
View File
@@ -25,11 +25,45 @@ export declare const BD_MIDDLE = "\u00B7\u30FB\u2027";
export declare const BD_LIGA = "\u2026\u2014"; export declare const BD_LIGA = "\u2026\u2014";
/** 全體標點字元(供 charify 走訪偵測)。 */ /** 全體標點字元(供 charify 走訪偵測)。 */
export declare const BD_ALL: string; export declare const BD_ALL: string;
/** 全寬標點之六子類鍵。 */
export type BdSubclass = "stop" | "pause" | "open" | "close" | "middle" | "liga";
/** /**
* 將單一標點字元歸入其 bd-* 子類;非標點回傳 null * 字符分類規則集(§4.2):把「中文/西文/全寬標點各子類」各表為 regex 字元類片段
* 供 jiya 之 charify 為 jz-char 標記 class。 * 各 passspacing 之 CJKANS 正則、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<BdSubclass, string>;
}
/** 消費端字符分類覆寫(追加語義;見 README「自定義字符分類」)。 */
export interface CharClassOverride {
/** 追加為中文(CJK)。 */
cjk?: string;
/** 追加為西文(半寬標點亦歸此,按西文處理間隙);併入 ansAfteransBefore。 */
western?: string;
/** 追加為全寬標點,按子類。 */
biaodian?: Partial<Record<BdSubclass, string>>;
}
/** 內置默認規則集(即現狀硬編碼值)。 */
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<BdSubclass, string>): string | null;
/** 全體 bd-* 子類名(DOM 端讀取 jz-char 之 bd class 用)。 */ /** 全體 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"]; export declare const BD_CLASS_NAMES: readonly ["bd-stop", "bd-pause", "bd-open", "bd-close", "bd-middle", "bd-liga"];
/** /**
+1
View File
@@ -1,5 +1,6 @@
import type { JuzhenOptions, Pass, ResolvedOptions } from "./types.js"; import type { JuzhenOptions, Pass, ResolvedOptions } from "./types.js";
export type { JuzhenOptions, ResolvedOptions, Pass }; export type { JuzhenOptions, ResolvedOptions, Pass };
export type { CharClassOverride, BdSubclass, Ruleset, Bias, BiasTable } from "./types.js";
export { createCjkAutospace } from "./compat/v1.js"; export { createCjkAutospace } from "./compat/v1.js";
/** 正規化公開選項為內部 ResolvedOptions。 */ /** 正規化公開選項為內部 ResolvedOptions。 */
export declare function normalizeOptions(opts?: JuzhenOptions): ResolvedOptions; export declare function normalizeOptions(opts?: JuzhenOptions): ResolvedOptions;
+147 -101
View File
@@ -522,18 +522,52 @@ var Juzhen = (() => {
var BD_MIDDLE = "\xB7\u30FB\u2027"; var BD_MIDDLE = "\xB7\u30FB\u2027";
var BD_LIGA = "\u2026\u2014"; var BD_LIGA = "\u2026\u2014";
var BD_ALL = BD_STOP + BD_PAUSE + BD_OPEN + BD_CLOSE + BD_MIDDLE + BD_LIGA; var BD_ALL = BD_STOP + BD_PAUSE + BD_OPEN + BD_CLOSE + BD_MIDDLE + BD_LIGA;
var BD_SETS = [ var BD_ORDER = [
["bd-stop", BD_STOP], "stop",
["bd-pause", BD_PAUSE], "pause",
["bd-open", BD_OPEN], "open",
["bd-close", BD_CLOSE], "close",
["bd-middle", BD_MIDDLE], "middle",
["bd-liga", BD_LIGA] "liga"
]; ];
function classifyBiaodian(ch) { var DEFAULT_RULESET = {
for (const [name, set] of BD_SETS) { cjk: CJK,
if (set.indexOf(ch) >= 0) { ansAfter: ANS_AFTER_CJK,
return name; 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; return null;
@@ -642,9 +676,10 @@ var Juzhen = (() => {
let style = null; let style = null;
let langClass = null; let langClass = null;
const biasOverrides = ctx.options.jiyaConfig.bias; const biasOverrides = ctx.options.jiyaConfig.bias;
const biaodian = ctx.options.ruleset.biaodian;
for (let i = 0; i < data.length; i += 1) { for (let i = 0; i < data.length; i += 1) {
const ch = data.charAt(i); const ch = data.charAt(i);
const bdClass = classifyBiaodian(ch); const bdClass = classifyBiaodian(ch, biaodian);
if (!bdClass) { if (!bdClass) {
continue; continue;
} }
@@ -737,46 +772,46 @@ var Juzhen = (() => {
} }
return false; return false;
} }
function isTokenChar(ch) { function isTokenChar(ch, anyCjk) {
return !ANY_CJK.test(ch) && !/\s/.test(ch); return !anyCjk.test(ch) && !/\s/.test(ch);
} }
function leadingTokenEnd(d) { function leadingTokenEnd(d, anyCjk) {
if (ANY_CJK.test(d.charAt(0))) { if (anyCjk.test(d.charAt(0))) {
return 1; return 1;
} }
let e = 1; let e = 1;
while (e < d.length && isTokenChar(d.charAt(e))) { while (e < d.length && isTokenChar(d.charAt(e), anyCjk)) {
e += 1; e += 1;
} }
return e; return e;
} }
function trailingTokenStart(d) { function trailingTokenStart(d, anyCjk) {
const last = d.length - 1; const last = d.length - 1;
if (ANY_CJK.test(d.charAt(last))) { if (anyCjk.test(d.charAt(last))) {
return last; return last;
} }
let s = last; let s = last;
while (s > 0 && isTokenChar(d.charAt(s - 1))) { while (s > 0 && isTokenChar(d.charAt(s - 1), anyCjk)) {
s -= 1; s -= 1;
} }
return s; return s;
} }
function splitBoundaries(t, needFirst, needLast) { function splitBoundaries(t, needFirst, needLast, anyCjk) {
let node = t; let node = t;
if (needFirst) { if (needFirst) {
const e = leadingTokenEnd(node.data); const e = leadingTokenEnd(node.data, anyCjk);
if (e < node.data.length) { if (e < node.data.length) {
node = node.splitText(e); node = node.splitText(e);
} }
} }
if (needLast) { if (needLast) {
const s = trailingTokenStart(node.data); const s = trailingTokenStart(node.data, anyCjk);
if (s > 0) { if (s > 0) {
node.splitText(s); node.splitText(s);
} }
} }
} }
function processParent(parent, finder) { function processParent(parent, finder, anyCjk) {
for (let c = parent.firstChild; c; c = c.nextSibling) { for (let c = parent.firstChild; c; c = c.nextSibling) {
if (c.nodeType === 1 && c.nodeName === "JZ-JINZE") { if (c.nodeType === 1 && c.nodeName === "JZ-JINZE") {
return; return;
@@ -806,7 +841,7 @@ var Juzhen = (() => {
const needFirst = i2 > 0 && forbidden[i2 - 1]; const needFirst = i2 > 0 && forbidden[i2 - 1];
const needLast = i2 < n - 1 && forbidden[i2]; const needLast = i2 < n - 1 && forbidden[i2];
if (needFirst || needLast) { if (needFirst || needLast) {
splitBoundaries(u, needFirst, needLast); splitBoundaries(u, needFirst, needLast, anyCjk);
} }
} }
const kids = []; const kids = [];
@@ -836,6 +871,7 @@ var Juzhen = (() => {
order: 40, order: 40,
enabled: (o) => o.features.jinze, enabled: (o) => o.features.jinze,
render(ctx) { render(ctx) {
const anyCjk = new RegExp("[" + ctx.options.ruleset.cjk + "]");
const parents = /* @__PURE__ */ new Set(); const parents = /* @__PURE__ */ new Set();
ctx.root.querySelectorAll("jz-char").forEach((c) => { ctx.root.querySelectorAll("jz-char").forEach((c) => {
if (c.parentNode) { if (c.parentNode) {
@@ -843,7 +879,7 @@ var Juzhen = (() => {
} }
}); });
for (const parent of parents) { for (const parent of parents) {
processParent(parent, ctx.finder); processParent(parent, ctx.finder, anyCjk);
} }
}, },
revert(ctx) { revert(ctx) {
@@ -1089,79 +1125,88 @@ var Juzhen = (() => {
var LBR_EXT = "\\(\\[\\{<>"; var LBR_EXT = "\\(\\[\\{<>";
var RBR_EXT = "\\)\\]\\}<>"; var RBR_EXT = "\\)\\]\\}<>";
var DEL = String.fromCharCode(57347); var DEL = String.fromCharCode(57347);
var STRIP_CJK_SPACE_ANS = new RegExp("([" + CJK + "]) ([" + ANS_AFTER_CJK + "])", "g"); function makeRules(rs) {
var STRIP_ANS_SPACE_CJK = new RegExp("([" + ANS_BEFORE_CJK + "]) ([" + CJK + "])", "g"); const CJK2 = rs.cjk;
var STRIP_CJK_SPACE_CJK = new RegExp("([" + CJK + "]) (?=[" + CJK + "])", "g"); const ANS_AFTER = rs.ansAfter;
var DOTS_CJK = new RegExp("([\\.]{2,})([" + CJK + "])", "g"); const ANS_BEFORE = rs.ansBefore;
var CJK_PUNCTUATION = new RegExp("([" + CJK + "])([!;,\\?:]+)(?=[" + CJK + ALNUM + "])", "g"); return {
var AN_PUNCTUATION_CJK = new RegExp("([" + ALNUM + "])([!;,\\?]+)([" + CJK + "])", "g"); anyCjk: new RegExp("[" + CJK2 + "]"),
var CJK_TILDE = new RegExp("([" + CJK + "])(~+)(?!=)(?=[" + CJK + ALNUM + "])", "g"); pillNeighbor: new RegExp("[" + ALNUM + CJK2 + "]"),
var CJK_TILDE_EQUALS = new RegExp("([" + CJK + "])(~=)", "g"); STRIP_CJK_SPACE_ANS: new RegExp("([" + CJK2 + "]) ([" + ANS_AFTER + "])", "g"),
var CJK_PERIOD = new RegExp("([" + CJK + "])(\\.)(?![" + ALNUM + "\\./])(?=[" + CJK + ALNUM + "])", "g"); STRIP_ANS_SPACE_CJK: new RegExp("([" + ANS_BEFORE + "]) ([" + CJK2 + "])", "g"),
var AN_PERIOD_CJK = new RegExp("([" + ALNUM + "])(\\.)([" + CJK + "])", "g"); STRIP_CJK_SPACE_CJK: new RegExp("([" + CJK2 + "]) (?=[" + CJK2 + "])", "g"),
var AN_COLON_CJK = new RegExp("([" + ALNUM + "])(:)([" + CJK + "])", "g"); DOTS_CJK: new RegExp("([\\.]{2,})([" + CJK2 + "])", "g"),
var CJK_QUOTE = new RegExp("([" + CJK + "])([" + QUOTES_CLS + "])", "g"); CJK_PUNCTUATION: new RegExp("([" + CJK2 + "])([!;,\\?:]+)(?=[" + CJK2 + ALNUM + "])", "g"),
var QUOTE_CJK = new RegExp("([" + QUOTES_CLS + "])([" + CJK + "])", "g"); AN_PUNCTUATION_CJK: new RegExp("([" + ALNUM + "])([!;,\\?]+)([" + CJK2 + "])", "g"),
var CJK_QUOTE_AN = new RegExp("([" + CJK + '])(")([' + ALNUM + "])", "g"); CJK_TILDE: new RegExp("([" + CJK2 + "])(~+)(?!=)(?=[" + CJK2 + ALNUM + "])", "g"),
var CJK_HASH = new RegExp("([" + CJK + "])(#([^ ]))", "g"); CJK_TILDE_EQUALS: new RegExp("([" + CJK2 + "])(~=)", "g"),
var HASH_CJK = new RegExp("(([^ ])#)([" + CJK + "])", "g"); CJK_PERIOD: new RegExp("([" + CJK2 + "])(\\.)(?![" + ALNUM + "\\./])(?=[" + CJK2 + ALNUM + "])", "g"),
var SINGLE_LETTER_GRADE = new RegExp("\\b([" + ALPHA + "])([" + OPS_GRADE + "])([" + CJK + "])", "g"); AN_PERIOD_CJK: new RegExp("([" + ALNUM + "])(\\.)([" + CJK2 + "])", "g"),
var CJK_OPERATOR_ANS = new RegExp("([" + CJK + "])([" + OPS_HYPHEN + "])([" + ALNUM + "])", "g"); AN_COLON_CJK: new RegExp("([" + ALNUM + "])(:)([" + CJK2 + "])", "g"),
var ANS_OPERATOR_CJK = new RegExp("([" + ALNUM + "])([" + OPS_HYPHEN + "])([" + CJK + "])", "g"); CJK_QUOTE: new RegExp("([" + CJK2 + "])([" + QUOTES_CLS + "])", "g"),
var CJK_LESS_THAN = new RegExp("([" + CJK + "])(<)([" + ALNUM + "])", "g"); QUOTE_CJK: new RegExp("([" + QUOTES_CLS + "])([" + CJK2 + "])", "g"),
var LESS_THAN_CJK = new RegExp("([" + ALNUM + "])(<)([" + CJK + "])", "g"); CJK_QUOTE_AN: new RegExp("([" + CJK2 + '])(")([' + ALNUM + "])", "g"),
var CJK_GREATER_THAN = new RegExp("([" + CJK + "])(>)([" + ALNUM + "])", "g"); CJK_HASH: new RegExp("([" + CJK2 + "])(#([^ ]))", "g"),
var GREATER_THAN_CJK = new RegExp("([" + ALNUM + "])(>)([" + CJK + "])", "g"); HASH_CJK: new RegExp("(([^ ])#)([" + CJK2 + "])", "g"),
var CJK_LEFT_BRACKET = new RegExp("([" + CJK + "])([" + LBR_EXT + "])", "g"); SINGLE_LETTER_GRADE: new RegExp("\\b([" + ALPHA + "])([" + OPS_GRADE + "])([" + CJK2 + "])", "g"),
var RIGHT_BRACKET_CJK = new RegExp("([" + RBR_EXT + "])([" + CJK + "])", "g"); CJK_OPERATOR_ANS: new RegExp("([" + CJK2 + "])([" + OPS_HYPHEN + "])([" + ALNUM + "])", "g"),
var AN_LEFT_BRACKET = new RegExp("([" + ALNUM + "])(?<!\\.[" + ALNUM + "]*)([" + LBR_BASIC + "])", "g"); ANS_OPERATOR_CJK: new RegExp("([" + ALNUM + "])([" + OPS_HYPHEN + "])([" + CJK2 + "])", "g"),
var RIGHT_BRACKET_AN = new RegExp("([" + RBR_BASIC + "])([" + ALNUM + "])", "g"); CJK_LESS_THAN: new RegExp("([" + CJK2 + "])(<)([" + ALNUM + "])", "g"),
var CJK_ANS = new RegExp("([" + CJK + "])([" + ANS_AFTER_CJK + "])", "g"); LESS_THAN_CJK: new RegExp("([" + ALNUM + "])(<)([" + CJK2 + "])", "g"),
var ANS_CJK = new RegExp("([" + ANS_BEFORE_CJK + "])([" + CJK + "])", "g"); CJK_GREATER_THAN: new RegExp("([" + CJK2 + "])(>)([" + ALNUM + "])", "g"),
var S_A = new RegExp("(%)([" + ALPHA + "])", "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 + "])(?<!\\.[" + ALNUM + "]*)([" + LBR_BASIC + "])", "g"),
RIGHT_BRACKET_AN: new RegExp("([" + RBR_BASIC + "])([" + ALNUM + "])", "g"),
CJK_ANS: new RegExp("([" + CJK2 + "])([" + ANS_AFTER + "])", "g"),
ANS_CJK: new RegExp("([" + ANS_BEFORE + "])([" + CJK2 + "])", "g"),
S_A: new RegExp("(%)([" + ALPHA + "])", "g")
};
}
var COMPOUND_WORD = /\b(?:[A-Za-z0-9]*[a-z][A-Za-z0-9]*-[A-Za-z0-9]+|[A-Za-z0-9]+-[A-Za-z0-9]*[a-z][A-Za-z0-9]*|[A-Za-z]+-[0-9]+|[A-Za-z]+[0-9]+-[A-Za-z0-9]+)(?:-[A-Za-z0-9]+)*\b/g; var COMPOUND_WORD = /\b(?:[A-Za-z0-9]*[a-z][A-Za-z0-9]*-[A-Za-z0-9]+|[A-Za-z0-9]+-[A-Za-z0-9]*[a-z][A-Za-z0-9]*|[A-Za-z]+-[0-9]+|[A-Za-z]+[0-9]+-[A-Za-z0-9]+)(?:-[A-Za-z0-9]+)*\b/g;
var PH_PATTERN = new RegExp(PH_OPEN + "(\\d+)" + PH_CLOSE, "g"); var PH_PATTERN = new RegExp(PH_OPEN + "(\\d+)" + PH_CLOSE, "g");
function panguSpace(text) { function panguSpace(text, R) {
if (text.length <= 1 || !ANY_CJK.test(text)) { if (text.length <= 1 || !R.anyCjk.test(text)) {
return text; return text;
} }
let s = text; let s = text;
s = s.replace(STRIP_CJK_SPACE_ANS, "$1$2"); s = s.replace(R.STRIP_CJK_SPACE_ANS, "$1$2");
s = s.replace(STRIP_ANS_SPACE_CJK, "$1$2"); s = s.replace(R.STRIP_ANS_SPACE_CJK, "$1$2");
s = s.replace(STRIP_CJK_SPACE_CJK, "$1" + DEL); s = s.replace(R.STRIP_CJK_SPACE_CJK, "$1" + DEL);
s = s.replace(DOTS_CJK, "$1" + MARK + "$2"); s = s.replace(R.DOTS_CJK, "$1" + MARK + "$2");
s = s.replace(CJK_PUNCTUATION, "$1$2" + MARK); s = s.replace(R.CJK_PUNCTUATION, "$1$2" + MARK);
s = s.replace(AN_PUNCTUATION_CJK, "$1$2" + MARK + "$3"); s = s.replace(R.AN_PUNCTUATION_CJK, "$1$2" + MARK + "$3");
s = s.replace(CJK_TILDE, "$1$2" + MARK); s = s.replace(R.CJK_TILDE, "$1$2" + MARK);
s = s.replace(CJK_TILDE_EQUALS, "$1" + MARK + "$2" + MARK); s = s.replace(R.CJK_TILDE_EQUALS, "$1" + MARK + "$2" + MARK);
s = s.replace(CJK_PERIOD, "$1$2" + MARK); s = s.replace(R.CJK_PERIOD, "$1$2" + MARK);
s = s.replace(AN_PERIOD_CJK, "$1$2" + MARK + "$3"); s = s.replace(R.AN_PERIOD_CJK, "$1$2" + MARK + "$3");
s = s.replace(AN_COLON_CJK, "$1$2" + MARK + "$3"); s = s.replace(R.AN_COLON_CJK, "$1$2" + MARK + "$3");
s = s.replace(CJK_QUOTE, "$1" + MARK + "$2"); s = s.replace(R.CJK_QUOTE, "$1" + MARK + "$2");
s = s.replace(QUOTE_CJK, "$1" + MARK + "$2"); s = s.replace(R.QUOTE_CJK, "$1" + MARK + "$2");
s = s.replace(CJK_QUOTE_AN, "$1$2" + MARK + "$3"); s = s.replace(R.CJK_QUOTE_AN, "$1$2" + MARK + "$3");
s = s.replace(CJK_HASH, "$1" + MARK + "$2"); s = s.replace(R.CJK_HASH, "$1" + MARK + "$2");
s = s.replace(HASH_CJK, "$1" + MARK + "$3"); s = s.replace(R.HASH_CJK, "$1" + MARK + "$3");
const compounds = []; const compounds = [];
s = s.replace(COMPOUND_WORD, (m) => { s = s.replace(COMPOUND_WORD, (m) => {
compounds.push(m); compounds.push(m);
return PH_OPEN + (compounds.length - 1) + PH_CLOSE; return PH_OPEN + (compounds.length - 1) + PH_CLOSE;
}); });
s = s.replace(SINGLE_LETTER_GRADE, "$1$2" + MARK + "$3"); s = s.replace(R.SINGLE_LETTER_GRADE, "$1$2" + MARK + "$3");
s = s.replace(CJK_OPERATOR_ANS, "$1" + MARK + "$2" + MARK + "$3"); s = s.replace(R.CJK_OPERATOR_ANS, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(ANS_OPERATOR_CJK, "$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(R.CJK_LESS_THAN, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(LESS_THAN_CJK, "$1" + MARK + "$2" + MARK + "$3"); s = s.replace(R.LESS_THAN_CJK, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(CJK_GREATER_THAN, "$1" + MARK + "$2" + MARK + "$3"); s = s.replace(R.CJK_GREATER_THAN, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(GREATER_THAN_CJK, "$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(PH_PATTERN, (_m, idx) => compounds[Number(idx)] || "");
s = s.replace(CJK_LEFT_BRACKET, "$1" + MARK + "$2"); s = s.replace(R.CJK_LEFT_BRACKET, "$1" + MARK + "$2");
s = s.replace(RIGHT_BRACKET_CJK, "$1" + MARK + "$2"); s = s.replace(R.RIGHT_BRACKET_CJK, "$1" + MARK + "$2");
s = s.replace(AN_LEFT_BRACKET, "$1" + MARK + "$2"); s = s.replace(R.AN_LEFT_BRACKET, "$1" + MARK + "$2");
s = s.replace(RIGHT_BRACKET_AN, "$1" + MARK + "$2"); s = s.replace(R.RIGHT_BRACKET_AN, "$1" + MARK + "$2");
s = s.replace(CJK_ANS, "$1" + MARK + "$2"); s = s.replace(R.CJK_ANS, "$1" + MARK + "$2");
s = s.replace(ANS_CJK, "$1" + MARK + "$2"); s = s.replace(R.ANS_CJK, "$1" + MARK + "$2");
s = s.replace(S_A, "$1" + MARK + "$2"); s = s.replace(R.S_A, "$1" + MARK + "$2");
return s; return s;
} }
function wrapNodeInGap(node) { function wrapNodeInGap(node) {
@@ -1186,11 +1231,11 @@ var Juzhen = (() => {
} }
wrapNodeInGap(tn); wrapNodeInGap(tn);
} }
function processBlock(block, finder) { function processBlock(block, finder, R) {
const runs = finder.collectRuns(block); const runs = finder.collectRuns(block);
for (const run of runs) { for (const run of runs) {
const { text, map } = run; const { text, map } = run;
const spaced = panguSpace(text); const spaced = panguSpace(text, R);
if (spaced === text) { if (spaced === text) {
continue; continue;
} }
@@ -1231,8 +1276,7 @@ var Juzhen = (() => {
} }
} }
} }
var PILL_NEIGHBOR = new RegExp("[A-Za-z0-9" + CJK + "]"); function processPills(root, finder, pillSelector, pillNeighbor) {
function processPills(root, finder, pillSelector) {
root.querySelectorAll(pillSelector).forEach((pill) => { root.querySelectorAll(pillSelector).forEach((pill) => {
if (finder.isAvoided(pill) || !finder.inScope(pill)) { if (finder.isAvoided(pill) || !finder.inScope(pill)) {
return; return;
@@ -1242,36 +1286,36 @@ var Juzhen = (() => {
} }
const prev = finder.adjacentLogicalChar(pill, -1); const prev = finder.adjacentLogicalChar(pill, -1);
if (prev) { if (prev) {
handlePillSide(prev, pill, -1); handlePillSide(prev, pill, -1, pillNeighbor);
} }
const next = finder.adjacentLogicalChar(pill, 1); const next = finder.adjacentLogicalChar(pill, 1);
if (next) { 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 (dir === -1) {
if (PILL_NEIGHBOR.test(adj.ch)) { if (pillNeighbor.test(adj.ch)) {
wrapGapLeft({ textNode: adj.textNode, offset: adj.offset }); wrapGapLeft({ textNode: adj.textNode, offset: adj.offset });
return; return;
} }
if (adj.ch === " " && adj.offset >= 1) { if (adj.ch === " " && adj.offset >= 1) {
const tn = adj.textNode; const tn = adj.textNode;
const bc = tn.data.charAt(adj.offset - 1); 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); tn.data = tn.data.slice(0, adj.offset) + tn.data.slice(adj.offset + 1);
wrapGapLeft({ textNode: tn, offset: adj.offset - 1 }); wrapGapLeft({ textNode: tn, offset: adj.offset - 1 });
} }
} }
} else { } else {
if (PILL_NEIGHBOR.test(adj.ch)) { if (pillNeighbor.test(adj.ch)) {
wrapNodeInGap(pill); wrapNodeInGap(pill);
return; return;
} }
if (adj.ch === " " && adj.textNode.data.length >= 2) { if (adj.ch === " " && adj.textNode.data.length >= 2) {
const ac = adj.textNode.data.charAt(1); 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); adj.textNode.data = adj.textNode.data.slice(1);
wrapNodeInGap(pill); wrapNodeInGap(pill);
} }
@@ -1285,13 +1329,14 @@ var Juzhen = (() => {
enabled: (o) => o.features.spacing, enabled: (o) => o.features.spacing,
render(ctx) { render(ctx) {
const { finder, options } = ctx; const { finder, options } = ctx;
const R = makeRules(options.ruleset);
ctx.finder.eachBlock(ctx.root, (block) => { ctx.finder.eachBlock(ctx.root, (block) => {
if (!finder.featureEnabledFor(block, PASS4)) { if (!finder.featureEnabledFor(block, PASS4)) {
return; 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) { revert(ctx) {
revertPass(PASS4, ctx.root); revertPass(PASS4, ctx.root);
@@ -1482,6 +1527,7 @@ var Juzhen = (() => {
...langOpt.style ? { style: langOpt.style } : {}, ...langOpt.style ? { style: langOpt.style } : {},
policy: langOpt.policy || {} policy: langOpt.policy || {}
}, },
ruleset: resolveRuleset(opts.charClass),
autospaceClass: opts.autospaceClass || "", autospaceClass: opts.autospaceClass || "",
justifyAtoms: opts.justifyAtoms === false ? false : true, justifyAtoms: opts.justifyAtoms === false ? false : true,
scope: { scope: {
+147 -101
View File
@@ -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_MIDDLE = "\xB7\u30FB\u2027";
var BD_LIGA = "\u2026\u2014"; var BD_LIGA = "\u2026\u2014";
var BD_ALL = BD_STOP + BD_PAUSE + BD_OPEN + BD_CLOSE + BD_MIDDLE + BD_LIGA; var BD_ALL = BD_STOP + BD_PAUSE + BD_OPEN + BD_CLOSE + BD_MIDDLE + BD_LIGA;
var BD_SETS = [ var BD_ORDER = [
["bd-stop", BD_STOP], "stop",
["bd-pause", BD_PAUSE], "pause",
["bd-open", BD_OPEN], "open",
["bd-close", BD_CLOSE], "close",
["bd-middle", BD_MIDDLE], "middle",
["bd-liga", BD_LIGA] "liga"
]; ];
function classifyBiaodian(ch) { var DEFAULT_RULESET = {
for (const [name, set] of BD_SETS) { cjk: CJK,
if (set.indexOf(ch) >= 0) { ansAfter: ANS_AFTER_CJK,
return name; 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; return null;
@@ -614,9 +648,10 @@ var jiyaPass = {
let style = null; let style = null;
let langClass = null; let langClass = null;
const biasOverrides = ctx.options.jiyaConfig.bias; const biasOverrides = ctx.options.jiyaConfig.bias;
const biaodian = ctx.options.ruleset.biaodian;
for (let i = 0; i < data.length; i += 1) { for (let i = 0; i < data.length; i += 1) {
const ch = data.charAt(i); const ch = data.charAt(i);
const bdClass = classifyBiaodian(ch); const bdClass = classifyBiaodian(ch, biaodian);
if (!bdClass) { if (!bdClass) {
continue; continue;
} }
@@ -709,46 +744,46 @@ function forbiddenBreak(a, b, finder) {
} }
return false; return false;
} }
function isTokenChar(ch) { function isTokenChar(ch, anyCjk) {
return !ANY_CJK.test(ch) && !/\s/.test(ch); return !anyCjk.test(ch) && !/\s/.test(ch);
} }
function leadingTokenEnd(d) { function leadingTokenEnd(d, anyCjk) {
if (ANY_CJK.test(d.charAt(0))) { if (anyCjk.test(d.charAt(0))) {
return 1; return 1;
} }
let e = 1; let e = 1;
while (e < d.length && isTokenChar(d.charAt(e))) { while (e < d.length && isTokenChar(d.charAt(e), anyCjk)) {
e += 1; e += 1;
} }
return e; return e;
} }
function trailingTokenStart(d) { function trailingTokenStart(d, anyCjk) {
const last = d.length - 1; const last = d.length - 1;
if (ANY_CJK.test(d.charAt(last))) { if (anyCjk.test(d.charAt(last))) {
return last; return last;
} }
let s = last; let s = last;
while (s > 0 && isTokenChar(d.charAt(s - 1))) { while (s > 0 && isTokenChar(d.charAt(s - 1), anyCjk)) {
s -= 1; s -= 1;
} }
return s; return s;
} }
function splitBoundaries(t, needFirst, needLast) { function splitBoundaries(t, needFirst, needLast, anyCjk) {
let node = t; let node = t;
if (needFirst) { if (needFirst) {
const e = leadingTokenEnd(node.data); const e = leadingTokenEnd(node.data, anyCjk);
if (e < node.data.length) { if (e < node.data.length) {
node = node.splitText(e); node = node.splitText(e);
} }
} }
if (needLast) { if (needLast) {
const s = trailingTokenStart(node.data); const s = trailingTokenStart(node.data, anyCjk);
if (s > 0) { if (s > 0) {
node.splitText(s); node.splitText(s);
} }
} }
} }
function processParent(parent, finder) { function processParent(parent, finder, anyCjk) {
for (let c = parent.firstChild; c; c = c.nextSibling) { for (let c = parent.firstChild; c; c = c.nextSibling) {
if (c.nodeType === 1 && c.nodeName === "JZ-JINZE") { if (c.nodeType === 1 && c.nodeName === "JZ-JINZE") {
return; return;
@@ -778,7 +813,7 @@ function processParent(parent, finder) {
const needFirst = i2 > 0 && forbidden[i2 - 1]; const needFirst = i2 > 0 && forbidden[i2 - 1];
const needLast = i2 < n - 1 && forbidden[i2]; const needLast = i2 < n - 1 && forbidden[i2];
if (needFirst || needLast) { if (needFirst || needLast) {
splitBoundaries(u, needFirst, needLast); splitBoundaries(u, needFirst, needLast, anyCjk);
} }
} }
const kids = []; const kids = [];
@@ -808,6 +843,7 @@ var jinzePass = {
order: 40, order: 40,
enabled: (o) => o.features.jinze, enabled: (o) => o.features.jinze,
render(ctx) { render(ctx) {
const anyCjk = new RegExp("[" + ctx.options.ruleset.cjk + "]");
const parents = /* @__PURE__ */ new Set(); const parents = /* @__PURE__ */ new Set();
ctx.root.querySelectorAll("jz-char").forEach((c) => { ctx.root.querySelectorAll("jz-char").forEach((c) => {
if (c.parentNode) { if (c.parentNode) {
@@ -815,7 +851,7 @@ var jinzePass = {
} }
}); });
for (const parent of parents) { for (const parent of parents) {
processParent(parent, ctx.finder); processParent(parent, ctx.finder, anyCjk);
} }
}, },
revert(ctx) { revert(ctx) {
@@ -1061,79 +1097,88 @@ var RBR_BASIC = "\\)\\]\\}";
var LBR_EXT = "\\(\\[\\{<>"; var LBR_EXT = "\\(\\[\\{<>";
var RBR_EXT = "\\)\\]\\}<>"; var RBR_EXT = "\\)\\]\\}<>";
var DEL = String.fromCharCode(57347); var DEL = String.fromCharCode(57347);
var STRIP_CJK_SPACE_ANS = new RegExp("([" + CJK + "]) ([" + ANS_AFTER_CJK + "])", "g"); function makeRules(rs) {
var STRIP_ANS_SPACE_CJK = new RegExp("([" + ANS_BEFORE_CJK + "]) ([" + CJK + "])", "g"); const CJK2 = rs.cjk;
var STRIP_CJK_SPACE_CJK = new RegExp("([" + CJK + "]) (?=[" + CJK + "])", "g"); const ANS_AFTER = rs.ansAfter;
var DOTS_CJK = new RegExp("([\\.]{2,})([" + CJK + "])", "g"); const ANS_BEFORE = rs.ansBefore;
var CJK_PUNCTUATION = new RegExp("([" + CJK + "])([!;,\\?:]+)(?=[" + CJK + ALNUM + "])", "g"); return {
var AN_PUNCTUATION_CJK = new RegExp("([" + ALNUM + "])([!;,\\?]+)([" + CJK + "])", "g"); anyCjk: new RegExp("[" + CJK2 + "]"),
var CJK_TILDE = new RegExp("([" + CJK + "])(~+)(?!=)(?=[" + CJK + ALNUM + "])", "g"); pillNeighbor: new RegExp("[" + ALNUM + CJK2 + "]"),
var CJK_TILDE_EQUALS = new RegExp("([" + CJK + "])(~=)", "g"); STRIP_CJK_SPACE_ANS: new RegExp("([" + CJK2 + "]) ([" + ANS_AFTER + "])", "g"),
var CJK_PERIOD = new RegExp("([" + CJK + "])(\\.)(?![" + ALNUM + "\\./])(?=[" + CJK + ALNUM + "])", "g"); STRIP_ANS_SPACE_CJK: new RegExp("([" + ANS_BEFORE + "]) ([" + CJK2 + "])", "g"),
var AN_PERIOD_CJK = new RegExp("([" + ALNUM + "])(\\.)([" + CJK + "])", "g"); STRIP_CJK_SPACE_CJK: new RegExp("([" + CJK2 + "]) (?=[" + CJK2 + "])", "g"),
var AN_COLON_CJK = new RegExp("([" + ALNUM + "])(:)([" + CJK + "])", "g"); DOTS_CJK: new RegExp("([\\.]{2,})([" + CJK2 + "])", "g"),
var CJK_QUOTE = new RegExp("([" + CJK + "])([" + QUOTES_CLS + "])", "g"); CJK_PUNCTUATION: new RegExp("([" + CJK2 + "])([!;,\\?:]+)(?=[" + CJK2 + ALNUM + "])", "g"),
var QUOTE_CJK = new RegExp("([" + QUOTES_CLS + "])([" + CJK + "])", "g"); AN_PUNCTUATION_CJK: new RegExp("([" + ALNUM + "])([!;,\\?]+)([" + CJK2 + "])", "g"),
var CJK_QUOTE_AN = new RegExp("([" + CJK + '])(")([' + ALNUM + "])", "g"); CJK_TILDE: new RegExp("([" + CJK2 + "])(~+)(?!=)(?=[" + CJK2 + ALNUM + "])", "g"),
var CJK_HASH = new RegExp("([" + CJK + "])(#([^ ]))", "g"); CJK_TILDE_EQUALS: new RegExp("([" + CJK2 + "])(~=)", "g"),
var HASH_CJK = new RegExp("(([^ ])#)([" + CJK + "])", "g"); CJK_PERIOD: new RegExp("([" + CJK2 + "])(\\.)(?![" + ALNUM + "\\./])(?=[" + CJK2 + ALNUM + "])", "g"),
var SINGLE_LETTER_GRADE = new RegExp("\\b([" + ALPHA + "])([" + OPS_GRADE + "])([" + CJK + "])", "g"); AN_PERIOD_CJK: new RegExp("([" + ALNUM + "])(\\.)([" + CJK2 + "])", "g"),
var CJK_OPERATOR_ANS = new RegExp("([" + CJK + "])([" + OPS_HYPHEN + "])([" + ALNUM + "])", "g"); AN_COLON_CJK: new RegExp("([" + ALNUM + "])(:)([" + CJK2 + "])", "g"),
var ANS_OPERATOR_CJK = new RegExp("([" + ALNUM + "])([" + OPS_HYPHEN + "])([" + CJK + "])", "g"); CJK_QUOTE: new RegExp("([" + CJK2 + "])([" + QUOTES_CLS + "])", "g"),
var CJK_LESS_THAN = new RegExp("([" + CJK + "])(<)([" + ALNUM + "])", "g"); QUOTE_CJK: new RegExp("([" + QUOTES_CLS + "])([" + CJK2 + "])", "g"),
var LESS_THAN_CJK = new RegExp("([" + ALNUM + "])(<)([" + CJK + "])", "g"); CJK_QUOTE_AN: new RegExp("([" + CJK2 + '])(")([' + ALNUM + "])", "g"),
var CJK_GREATER_THAN = new RegExp("([" + CJK + "])(>)([" + ALNUM + "])", "g"); CJK_HASH: new RegExp("([" + CJK2 + "])(#([^ ]))", "g"),
var GREATER_THAN_CJK = new RegExp("([" + ALNUM + "])(>)([" + CJK + "])", "g"); HASH_CJK: new RegExp("(([^ ])#)([" + CJK2 + "])", "g"),
var CJK_LEFT_BRACKET = new RegExp("([" + CJK + "])([" + LBR_EXT + "])", "g"); SINGLE_LETTER_GRADE: new RegExp("\\b([" + ALPHA + "])([" + OPS_GRADE + "])([" + CJK2 + "])", "g"),
var RIGHT_BRACKET_CJK = new RegExp("([" + RBR_EXT + "])([" + CJK + "])", "g"); CJK_OPERATOR_ANS: new RegExp("([" + CJK2 + "])([" + OPS_HYPHEN + "])([" + ALNUM + "])", "g"),
var AN_LEFT_BRACKET = new RegExp("([" + ALNUM + "])(?<!\\.[" + ALNUM + "]*)([" + LBR_BASIC + "])", "g"); ANS_OPERATOR_CJK: new RegExp("([" + ALNUM + "])([" + OPS_HYPHEN + "])([" + CJK2 + "])", "g"),
var RIGHT_BRACKET_AN = new RegExp("([" + RBR_BASIC + "])([" + ALNUM + "])", "g"); CJK_LESS_THAN: new RegExp("([" + CJK2 + "])(<)([" + ALNUM + "])", "g"),
var CJK_ANS = new RegExp("([" + CJK + "])([" + ANS_AFTER_CJK + "])", "g"); LESS_THAN_CJK: new RegExp("([" + ALNUM + "])(<)([" + CJK2 + "])", "g"),
var ANS_CJK = new RegExp("([" + ANS_BEFORE_CJK + "])([" + CJK + "])", "g"); CJK_GREATER_THAN: new RegExp("([" + CJK2 + "])(>)([" + ALNUM + "])", "g"),
var S_A = new RegExp("(%)([" + ALPHA + "])", "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 + "])(?<!\\.[" + ALNUM + "]*)([" + LBR_BASIC + "])", "g"),
RIGHT_BRACKET_AN: new RegExp("([" + RBR_BASIC + "])([" + ALNUM + "])", "g"),
CJK_ANS: new RegExp("([" + CJK2 + "])([" + ANS_AFTER + "])", "g"),
ANS_CJK: new RegExp("([" + ANS_BEFORE + "])([" + CJK2 + "])", "g"),
S_A: new RegExp("(%)([" + ALPHA + "])", "g")
};
}
var COMPOUND_WORD = /\b(?:[A-Za-z0-9]*[a-z][A-Za-z0-9]*-[A-Za-z0-9]+|[A-Za-z0-9]+-[A-Za-z0-9]*[a-z][A-Za-z0-9]*|[A-Za-z]+-[0-9]+|[A-Za-z]+[0-9]+-[A-Za-z0-9]+)(?:-[A-Za-z0-9]+)*\b/g; var COMPOUND_WORD = /\b(?:[A-Za-z0-9]*[a-z][A-Za-z0-9]*-[A-Za-z0-9]+|[A-Za-z0-9]+-[A-Za-z0-9]*[a-z][A-Za-z0-9]*|[A-Za-z]+-[0-9]+|[A-Za-z]+[0-9]+-[A-Za-z0-9]+)(?:-[A-Za-z0-9]+)*\b/g;
var PH_PATTERN = new RegExp(PH_OPEN + "(\\d+)" + PH_CLOSE, "g"); var PH_PATTERN = new RegExp(PH_OPEN + "(\\d+)" + PH_CLOSE, "g");
function panguSpace(text) { function panguSpace(text, R) {
if (text.length <= 1 || !ANY_CJK.test(text)) { if (text.length <= 1 || !R.anyCjk.test(text)) {
return text; return text;
} }
let s = text; let s = text;
s = s.replace(STRIP_CJK_SPACE_ANS, "$1$2"); s = s.replace(R.STRIP_CJK_SPACE_ANS, "$1$2");
s = s.replace(STRIP_ANS_SPACE_CJK, "$1$2"); s = s.replace(R.STRIP_ANS_SPACE_CJK, "$1$2");
s = s.replace(STRIP_CJK_SPACE_CJK, "$1" + DEL); s = s.replace(R.STRIP_CJK_SPACE_CJK, "$1" + DEL);
s = s.replace(DOTS_CJK, "$1" + MARK + "$2"); s = s.replace(R.DOTS_CJK, "$1" + MARK + "$2");
s = s.replace(CJK_PUNCTUATION, "$1$2" + MARK); s = s.replace(R.CJK_PUNCTUATION, "$1$2" + MARK);
s = s.replace(AN_PUNCTUATION_CJK, "$1$2" + MARK + "$3"); s = s.replace(R.AN_PUNCTUATION_CJK, "$1$2" + MARK + "$3");
s = s.replace(CJK_TILDE, "$1$2" + MARK); s = s.replace(R.CJK_TILDE, "$1$2" + MARK);
s = s.replace(CJK_TILDE_EQUALS, "$1" + MARK + "$2" + MARK); s = s.replace(R.CJK_TILDE_EQUALS, "$1" + MARK + "$2" + MARK);
s = s.replace(CJK_PERIOD, "$1$2" + MARK); s = s.replace(R.CJK_PERIOD, "$1$2" + MARK);
s = s.replace(AN_PERIOD_CJK, "$1$2" + MARK + "$3"); s = s.replace(R.AN_PERIOD_CJK, "$1$2" + MARK + "$3");
s = s.replace(AN_COLON_CJK, "$1$2" + MARK + "$3"); s = s.replace(R.AN_COLON_CJK, "$1$2" + MARK + "$3");
s = s.replace(CJK_QUOTE, "$1" + MARK + "$2"); s = s.replace(R.CJK_QUOTE, "$1" + MARK + "$2");
s = s.replace(QUOTE_CJK, "$1" + MARK + "$2"); s = s.replace(R.QUOTE_CJK, "$1" + MARK + "$2");
s = s.replace(CJK_QUOTE_AN, "$1$2" + MARK + "$3"); s = s.replace(R.CJK_QUOTE_AN, "$1$2" + MARK + "$3");
s = s.replace(CJK_HASH, "$1" + MARK + "$2"); s = s.replace(R.CJK_HASH, "$1" + MARK + "$2");
s = s.replace(HASH_CJK, "$1" + MARK + "$3"); s = s.replace(R.HASH_CJK, "$1" + MARK + "$3");
const compounds = []; const compounds = [];
s = s.replace(COMPOUND_WORD, (m) => { s = s.replace(COMPOUND_WORD, (m) => {
compounds.push(m); compounds.push(m);
return PH_OPEN + (compounds.length - 1) + PH_CLOSE; return PH_OPEN + (compounds.length - 1) + PH_CLOSE;
}); });
s = s.replace(SINGLE_LETTER_GRADE, "$1$2" + MARK + "$3"); s = s.replace(R.SINGLE_LETTER_GRADE, "$1$2" + MARK + "$3");
s = s.replace(CJK_OPERATOR_ANS, "$1" + MARK + "$2" + MARK + "$3"); s = s.replace(R.CJK_OPERATOR_ANS, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(ANS_OPERATOR_CJK, "$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(R.CJK_LESS_THAN, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(LESS_THAN_CJK, "$1" + MARK + "$2" + MARK + "$3"); s = s.replace(R.LESS_THAN_CJK, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(CJK_GREATER_THAN, "$1" + MARK + "$2" + MARK + "$3"); s = s.replace(R.CJK_GREATER_THAN, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(GREATER_THAN_CJK, "$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(PH_PATTERN, (_m, idx) => compounds[Number(idx)] || "");
s = s.replace(CJK_LEFT_BRACKET, "$1" + MARK + "$2"); s = s.replace(R.CJK_LEFT_BRACKET, "$1" + MARK + "$2");
s = s.replace(RIGHT_BRACKET_CJK, "$1" + MARK + "$2"); s = s.replace(R.RIGHT_BRACKET_CJK, "$1" + MARK + "$2");
s = s.replace(AN_LEFT_BRACKET, "$1" + MARK + "$2"); s = s.replace(R.AN_LEFT_BRACKET, "$1" + MARK + "$2");
s = s.replace(RIGHT_BRACKET_AN, "$1" + MARK + "$2"); s = s.replace(R.RIGHT_BRACKET_AN, "$1" + MARK + "$2");
s = s.replace(CJK_ANS, "$1" + MARK + "$2"); s = s.replace(R.CJK_ANS, "$1" + MARK + "$2");
s = s.replace(ANS_CJK, "$1" + MARK + "$2"); s = s.replace(R.ANS_CJK, "$1" + MARK + "$2");
s = s.replace(S_A, "$1" + MARK + "$2"); s = s.replace(R.S_A, "$1" + MARK + "$2");
return s; return s;
} }
function wrapNodeInGap(node) { function wrapNodeInGap(node) {
@@ -1158,11 +1203,11 @@ function wrapGapLeft(pos) {
} }
wrapNodeInGap(tn); wrapNodeInGap(tn);
} }
function processBlock(block, finder) { function processBlock(block, finder, R) {
const runs = finder.collectRuns(block); const runs = finder.collectRuns(block);
for (const run of runs) { for (const run of runs) {
const { text, map } = run; const { text, map } = run;
const spaced = panguSpace(text); const spaced = panguSpace(text, R);
if (spaced === text) { if (spaced === text) {
continue; continue;
} }
@@ -1203,8 +1248,7 @@ function processBlock(block, finder) {
} }
} }
} }
var PILL_NEIGHBOR = new RegExp("[A-Za-z0-9" + CJK + "]"); function processPills(root, finder, pillSelector, pillNeighbor) {
function processPills(root, finder, pillSelector) {
root.querySelectorAll(pillSelector).forEach((pill) => { root.querySelectorAll(pillSelector).forEach((pill) => {
if (finder.isAvoided(pill) || !finder.inScope(pill)) { if (finder.isAvoided(pill) || !finder.inScope(pill)) {
return; return;
@@ -1214,36 +1258,36 @@ function processPills(root, finder, pillSelector) {
} }
const prev = finder.adjacentLogicalChar(pill, -1); const prev = finder.adjacentLogicalChar(pill, -1);
if (prev) { if (prev) {
handlePillSide(prev, pill, -1); handlePillSide(prev, pill, -1, pillNeighbor);
} }
const next = finder.adjacentLogicalChar(pill, 1); const next = finder.adjacentLogicalChar(pill, 1);
if (next) { 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 (dir === -1) {
if (PILL_NEIGHBOR.test(adj.ch)) { if (pillNeighbor.test(adj.ch)) {
wrapGapLeft({ textNode: adj.textNode, offset: adj.offset }); wrapGapLeft({ textNode: adj.textNode, offset: adj.offset });
return; return;
} }
if (adj.ch === " " && adj.offset >= 1) { if (adj.ch === " " && adj.offset >= 1) {
const tn = adj.textNode; const tn = adj.textNode;
const bc = tn.data.charAt(adj.offset - 1); 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); tn.data = tn.data.slice(0, adj.offset) + tn.data.slice(adj.offset + 1);
wrapGapLeft({ textNode: tn, offset: adj.offset - 1 }); wrapGapLeft({ textNode: tn, offset: adj.offset - 1 });
} }
} }
} else { } else {
if (PILL_NEIGHBOR.test(adj.ch)) { if (pillNeighbor.test(adj.ch)) {
wrapNodeInGap(pill); wrapNodeInGap(pill);
return; return;
} }
if (adj.ch === " " && adj.textNode.data.length >= 2) { if (adj.ch === " " && adj.textNode.data.length >= 2) {
const ac = adj.textNode.data.charAt(1); 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); adj.textNode.data = adj.textNode.data.slice(1);
wrapNodeInGap(pill); wrapNodeInGap(pill);
} }
@@ -1257,13 +1301,14 @@ var spacingPass = {
enabled: (o) => o.features.spacing, enabled: (o) => o.features.spacing,
render(ctx) { render(ctx) {
const { finder, options } = ctx; const { finder, options } = ctx;
const R = makeRules(options.ruleset);
ctx.finder.eachBlock(ctx.root, (block) => { ctx.finder.eachBlock(ctx.root, (block) => {
if (!finder.featureEnabledFor(block, PASS4)) { if (!finder.featureEnabledFor(block, PASS4)) {
return; 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) { revert(ctx) {
revertPass(PASS4, ctx.root); revertPass(PASS4, ctx.root);
@@ -1454,6 +1499,7 @@ function normalizeOptions(opts = {}) {
...langOpt.style ? { style: langOpt.style } : {}, ...langOpt.style ? { style: langOpt.style } : {},
policy: langOpt.policy || {} policy: langOpt.policy || {}
}, },
ruleset: resolveRuleset(opts.charClass),
autospaceClass: opts.autospaceClass || "", autospaceClass: opts.autospaceClass || "",
justifyAtoms: opts.justifyAtoms === false ? false : true, justifyAtoms: opts.justifyAtoms === false ? false : true,
scope: { scope: {
+7 -2
View File
@@ -1,7 +1,7 @@
import type { Finder } from "./core/finder.js"; import type { Finder } from "./core/finder.js";
import type { LangClass, LocaleConfig, PunctStyle } from "./core/locale.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 { export interface LongWordOptions {
minLength?: number; minLength?: number;
@@ -34,6 +34,9 @@ export interface JuzhenOptions {
/** 逐 lang 覆寫(最高優先)。 */ /** 逐 lang 覆寫(最高優先)。 */
policy?: Partial<Record<LangClass, PunctStyle>>; policy?: Partial<Record<LangClass, PunctStyle>>;
}; };
/** 自定義字符分類(擴充內置規則集)。把未分類字符或新符號歸入中文/西文/
* 全寬標點各子類,以覆寫 spacing/jiya 等規則之分類判定(追加語義)。 */
charClass?: CharClassOverride;
/** 間隙標記沿用之 class(v1 相容;預設無額外 class)。 */ /** 間隙標記沿用之 class(v1 相容;預設無額外 class)。 */
autospaceClass?: string; autospaceClass?: string;
/** justify 原子化:jz-charjz-jinze 是否以 inline-block 作對齊單一原子 /** justify 原子化:jz-charjz-jinze 是否以 inline-block 作對齊單一原子
@@ -67,6 +70,8 @@ export interface JuzhenOptions {
/** 正規化後之內部選項。 */ /** 正規化後之內部選項。 */
export interface ResolvedOptions { export interface ResolvedOptions {
locale: LocaleConfig; locale: LocaleConfig;
/** 最終字符分類規則集(內置默認 + charClass 覆寫)。 */
ruleset: Ruleset;
autospaceClass: string; autospaceClass: string;
justifyAtoms: boolean; justifyAtoms: boolean;
scope: { scope: {
+78 -12
View File
@@ -83,24 +83,90 @@ export const BD_ALL = (
BD_STOP + BD_PAUSE + BD_OPEN + BD_CLOSE + BD_MIDDLE + BD_LIGA BD_STOP + BD_PAUSE + BD_OPEN + BD_CLOSE + BD_MIDDLE + BD_LIGA
); );
const BD_SETS: ReadonlyArray<readonly [ string, string ]> = [ /** 全寬標點之六子類鍵。 */
[ "bd-stop", BD_STOP ], export type BdSubclass = "stop" | "pause" | "open" | "close" | "middle" | "liga";
[ "bd-pause", BD_PAUSE ],
[ "bd-open", BD_OPEN ], const BD_ORDER: ReadonlyArray<BdSubclass> = [
[ "bd-close", BD_CLOSE ], "stop", "pause", "open", "close", "middle", "liga",
[ "bd-middle", BD_MIDDLE ],
[ "bd-liga", BD_LIGA ],
]; ];
/** /**
* 將單一標點字元歸入其 bd-* 子類;非標點回傳 null * 字符分類規則集(§4.2):把「中文/西文/全寬標點各子類」各表為 regex 字元類片段
* 供 jiya 之 charify 為 jz-char 標記 class。 * 各 passspacing 之 CJKANS 正則、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<BdSubclass, string>;
}
/** 消費端字符分類覆寫(追加語義;見 README「自定義字符分類」)。 */
export interface CharClassOverride
{
/** 追加為中文(CJK)。 */
cjk?: string;
/** 追加為西文(半寬標點亦歸此,按西文處理間隙);併入 ansAfteransBefore。 */
western?: string;
/** 追加為全寬標點,按子類。 */
biaodian?: Partial<Record<BdSubclass, string>>;
}
/** 內置默認規則集(即現狀硬編碼值)。 */
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<BdSubclass, string> = { ...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<BdSubclass, string> = DEFAULT_RULESET.biaodian,
): string | null
{
for (const sub of BD_ORDER)
{
if (biaodian[sub].indexOf(ch) >= 0) { return "bd-" + sub; }
} }
return null; return null;
} }
+3
View File
@@ -14,9 +14,11 @@ import { jinzePass } from "./typeset/jinze.js";
import { gapTrimPass, lineEdgePass } from "./typeset/lineedge.js"; import { gapTrimPass, lineEdgePass } from "./typeset/lineedge.js";
import { longWordPass } from "./typeset/longword.js"; import { longWordPass } from "./typeset/longword.js";
import { spacingPass } from "./typeset/spacing.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"; import type { BiasTable, JiyaOptions, JuzhenOptions, LongWordOptions, Pass, RenderContext, ResolvedOptions } from "./types.js";
export type { JuzhenOptions, ResolvedOptions, Pass }; export type { JuzhenOptions, ResolvedOptions, Pass };
export type { CharClassOverride, BdSubclass, Ruleset, Bias, BiasTable } from "./types.js";
export { createCjkAutospace } from "./compat/v1.js"; export { createCjkAutospace } from "./compat/v1.js";
const DEFAULT_STYLE_INLINES = [ const DEFAULT_STYLE_INLINES = [
@@ -81,6 +83,7 @@ export function normalizeOptions(opts: JuzhenOptions = {}): ResolvedOptions
...(langOpt.style ? { style: langOpt.style } : {}), ...(langOpt.style ? { style: langOpt.style } : {}),
policy: langOpt.policy || {}, policy: langOpt.policy || {},
}, },
ruleset: resolveRuleset(opts.charClass),
autospaceClass: opts.autospaceClass || "", autospaceClass: opts.autospaceClass || "",
justifyAtoms: opts.justifyAtoms === false ? false : true, justifyAtoms: opts.justifyAtoms === false ? false : true,
scope: { scope: {
+8 -2
View File
@@ -2,9 +2,9 @@
import type { Finder } from "./core/finder.js"; import type { Finder } from "./core/finder.js";
import type { LangClass, LocaleConfig, PunctStyle } from "./core/locale.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 export interface LongWordOptions
@@ -46,6 +46,10 @@ export interface JuzhenOptions
policy?: Partial<Record<LangClass, PunctStyle>>; policy?: Partial<Record<LangClass, PunctStyle>>;
}; };
/** 西
* spacingjiya */
charClass?: CharClassOverride;
/** 間隙標記沿用之 class(v1 相容;預設無額外 class)。 */ /** 間隙標記沿用之 class(v1 相容;預設無額外 class)。 */
autospaceClass?: string; autospaceClass?: string;
@@ -88,6 +92,8 @@ export interface JuzhenOptions
export interface ResolvedOptions export interface ResolvedOptions
{ {
locale: LocaleConfig; locale: LocaleConfig;
/** 最終字符分類規則集(內置默認 + charClass 覆寫)。 */
ruleset: Ruleset;
autospaceClass: string; autospaceClass: string;
justifyAtoms: boolean; justifyAtoms: boolean;
scope: { scope: {
+16 -15
View File
@@ -12,7 +12,6 @@
import { bdClassOf, createJz, revertPass } from "../core/dom.js"; import { bdClassOf, createJz, revertPass } from "../core/dom.js";
import type { Finder } from "../core/finder.js"; import type { Finder } from "../core/finder.js";
import { ANY_CJK } from "../core/unicode.js";
import type { RenderContext, StandalonePass } from "../types.js"; import type { RenderContext, StandalonePass } from "../types.js";
const PASS = "jinze"; const PASS = "jinze";
@@ -46,45 +45,45 @@ function forbiddenBreak(a: Node, b: Node, finder: Finder): boolean
// 「詞元」:CJK 字為單字詞元(可任意斷);連續非 CJK 非空白(拉丁詞/數字/ // 「詞元」:CJK 字為單字詞元(可任意斷);連續非 CJK 非空白(拉丁詞/數字/
// 百分號/GPT-4/3.5 等)為一個不可內斷之詞元。綁定須以整個邊界詞元為單位, // 百分號/GPT-4/3.5 等)為一個不可內斷之詞元。綁定須以整個邊界詞元為單位,
// 否則會把 200% 拆成 200|%,在其間製造斷點(修正:閉類綁定不應切斷數字/詞)。 // 否則會把 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; 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; return e;
} }
function trailingTokenStart(d: string): number function trailingTokenStart(d: string, anyCjk: RegExp): number
{ {
const last = d.length - 1; 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; 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; 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; let node: Text = t;
if (needFirst) if (needFirst)
{ {
const e = leadingTokenEnd(node.data); const e = leadingTokenEnd(node.data, anyCjk);
if (e < node.data.length) { node = node.splitText(e); } if (e < node.data.length) { node = node.splitText(e); }
} }
if (needLast) if (needLast)
{ {
const s = trailingTokenStart(node.data); const s = trailingTokenStart(node.data, anyCjk);
if (s > 0) { node.splitText(s); } 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)則跳過。 // 冪等:已含 jz-jinze(前次 render 未 revert)則跳過。
for (let c = parent.firstChild; c; c = c.nextSibling) 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; } if (u.nodeType !== 3) { continue; }
const needFirst = i > 0 && forbidden[i - 1]!; const needFirst = i > 0 && forbidden[i - 1]!;
const needLast = i < n - 1 && forbidden[i]!; 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。 // 階段 2:重掃當前子節點,把禁斷相連之相鄰原子併成 jz-jinze。
@@ -147,12 +146,14 @@ export const jinzePass: StandalonePass = {
enabled: (o) => o.features.jinze, enabled: (o) => o.features.jinze,
render(ctx: RenderContext): void render(ctx: RenderContext): void
{ {
// 詞元邊界之 CJK 判定取自規則集(含 charClass 覆寫),與 spacingjiya 一致。
const anyCjk = new RegExp("[" + ctx.options.ruleset.cjk + "]");
const parents = new Set<Node>(); const parents = new Set<Node>();
ctx.root.querySelectorAll("jz-char").forEach((c) => ctx.root.querySelectorAll("jz-char").forEach((c) =>
{ {
if (c.parentNode) { parents.add(c.parentNode); } 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 revert(ctx: RenderContext): void
{ {
+2 -1
View File
@@ -66,11 +66,12 @@ export const jiyaPass: CharifyPass = {
let style: PunctStyle | null = null; let style: PunctStyle | null = null;
let langClass: LangClass | null = null; let langClass: LangClass | null = null;
const biasOverrides = ctx.options.jiyaConfig.bias; const biasOverrides = ctx.options.jiyaConfig.bias;
const biaodian = ctx.options.ruleset.biaodian;
for (let i = 0; i < data.length; i += 1) for (let i = 0; i < data.length; i += 1)
{ {
const ch = data.charAt(i); const ch = data.charAt(i);
const bdClass = classifyBiaodian(ch); const bdClass = classifyBiaodian(ch, biaodian);
if (!bdClass) { continue; } if (!bdClass) { continue; }
if (style === null) { style = resolveStyle(node, ctx.options.locale); } if (style === null) { style = resolveStyle(node, ctx.options.locale); }
if (langClass === null) if (langClass === null)
+114 -88
View File
@@ -7,10 +7,10 @@
// 涵蓋 v1 Pass A(跨樣式邊界間隙)與 Pass Bpill code/kbd 邊界間隙)。 // 涵蓋 v1 Pass A(跨樣式邊界間隙)與 Pass Bpill code/kbd 邊界間隙)。
// 規則正則為間隙專用,故與本 pass 同處;共用字集自 unicode.ts。 // 規則正則為間隙專用,故與本 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 { createJz, revertPass } from "../core/dom.js";
import type { AdjacentChar, Finder } from "../core/finder.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"; const PASS = "spacing";
@@ -32,77 +32,103 @@ const RBR_EXT = "\\)\\]\\}<>";
// 與空格 1:1 替換以保持 spacedtext 對齊(processBlock 據此刪空格而不插 jz-hws)。 // 與空格 1:1 替換以保持 spacedtext 對齊(processBlock 據此刪空格而不插 jz-hws)。
const DEL = String.fromCharCode(0xE003); const DEL = String.fromCharCode(0xE003);
const STRIP_CJK_SPACE_ANS = new RegExp("([" + CJK + "]) ([" + ANS_AFTER_CJK + "])", "g"); /** 由規則集編譯之 per-instance 間隙正則集。 */
const STRIP_ANS_SPACE_CJK = new RegExp("([" + ANS_BEFORE_CJK + "]) ([" + CJK + "])", "g"); interface Rules
// CJK↔CJK 之單一作者空格(含跨樣式行內標籤,collectRuns 已透明合併):中文不用 {
// 詞間空格,此類空格幾乎必為贅餘 → 以 DEL 標記刪除(1b,邊界正規化一致性)。 anyCjk: RegExp; pillNeighbor: RegExp;
// 用 lookahead 使連續 CJK 空格逐一命中;只匹配 ASCII 空格,全形空格 U+3000 不動。 STRIP_CJK_SPACE_ANS: RegExp; STRIP_ANS_SPACE_CJK: RegExp; STRIP_CJK_SPACE_CJK: RegExp;
const STRIP_CJK_SPACE_CJK = new RegExp("([" + CJK + "]) (?=[" + CJK + "])", "g"); 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: * CJKANS options.charClass per-instance
// 原 alternation `|…` 誤把全角納入,致 `……留` 間冒出 jz-hws)。 * createJuzhen blockALNUMALPHA ASCII
const DOTS_CJK = new RegExp("([\\.]{2,})([" + CJK + "])", "g"); *
const CJK_PUNCTUATION = new RegExp("([" + CJK + "])([!;,\\?:]+)(?=[" + CJK + ALNUM + "])", "g"); * · STRIP_CJK_SPACE_CJKCJKCJK inline collectRuns
const AN_PUNCTUATION_CJK = new RegExp("([" + ALNUM + "])([!;,\\?]+)([" + CJK + "])", "g"); * DEL 1b ASCII U+3000
const CJK_TILDE = new RegExp("([" + CJK + "])(~+)(?!=)(?=[" + CJK + ALNUM + "])", "g"); * · DOTS_CJK ASCII `...中文` **** U+2026 CJK
const CJK_TILDE_EQUALS = new RegExp("([" + CJK + "])(~=)", "g"); * CJK B
const CJK_PERIOD = new RegExp("([" + CJK + "])(\\.)(?![" + ALNUM + "\\./])(?=[" + CJK + ALNUM + "])", "g"); * · pangu QUOTE_AN
const AN_PERIOD_CJK = new RegExp("([" + ALNUM + "])(\\.)([" + CJK + "])", "g"); * clreq
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"); function makeRules(rs: Ruleset): Rules
// 註:原 pangu 之 QUOTE_AN`([”])([ALNUM])` → 收尾彎引號後接字母數字補隙)已移除。 {
// 標點稽核發現它令收尾彎引號 ” 成為唯一「後接拉丁/數字會補隙」之收尾標點,與所有其他 const CJK = rs.cjk;
// 收尾標點(」』)》】〕…)行為不一致(後者皆不補);clreq 下全角收尾標點字身已內含 const ANS_AFTER = rs.ansAfter;
// 右側留白,後接拉丁不需額外隙。故移除以求一致、消除誤隙(下游回饋之標點稽核)。 const ANS_BEFORE = rs.ansBefore;
const CJK_QUOTE_AN = new RegExp("([" + CJK + "])(\")([" + ALNUM + "])", "g"); return {
const CJK_HASH = new RegExp("([" + CJK + "])(#([^ ]))", "g"); anyCjk: new RegExp("[" + CJK + "]"),
const HASH_CJK = new RegExp("(([^ ])#)([" + CJK + "])", "g"); pillNeighbor: new RegExp("[" + ALNUM + CJK + "]"),
const SINGLE_LETTER_GRADE = new RegExp("\\b([" + ALPHA + "])([" + OPS_GRADE + "])([" + CJK + "])", "g"); STRIP_CJK_SPACE_ANS: new RegExp("([" + CJK + "]) ([" + ANS_AFTER + "])", "g"),
const CJK_OPERATOR_ANS = new RegExp("([" + CJK + "])([" + OPS_HYPHEN + "])([" + ALNUM + "])", "g"); STRIP_ANS_SPACE_CJK: new RegExp("([" + ANS_BEFORE + "]) ([" + CJK + "])", "g"),
const ANS_OPERATOR_CJK = new RegExp("([" + ALNUM + "])([" + OPS_HYPHEN + "])([" + CJK + "])", "g"); STRIP_CJK_SPACE_CJK: new RegExp("([" + CJK + "]) (?=[" + CJK + "])", "g"),
const CJK_LESS_THAN = new RegExp("([" + CJK + "])(<)([" + ALNUM + "])", "g"); DOTS_CJK: new RegExp("([\\.]{2,})([" + CJK + "])", "g"),
const LESS_THAN_CJK = new RegExp("([" + ALNUM + "])(<)([" + CJK + "])", "g"); CJK_PUNCTUATION: new RegExp("([" + CJK + "])([!;,\\?:]+)(?=[" + CJK + ALNUM + "])", "g"),
const CJK_GREATER_THAN = new RegExp("([" + CJK + "])(>)([" + ALNUM + "])", "g"); AN_PUNCTUATION_CJK: new RegExp("([" + ALNUM + "])([!;,\\?]+)([" + CJK + "])", "g"),
const GREATER_THAN_CJK = new RegExp("([" + ALNUM + "])(>)([" + CJK + "])", "g"); CJK_TILDE: new RegExp("([" + CJK + "])(~+)(?!=)(?=[" + CJK + ALNUM + "])", "g"),
const CJK_LEFT_BRACKET = new RegExp("([" + CJK + "])([" + LBR_EXT + "])", "g"); CJK_TILDE_EQUALS: new RegExp("([" + CJK + "])(~=)", "g"),
const RIGHT_BRACKET_CJK = new RegExp("([" + RBR_EXT + "])([" + CJK + "])", "g"); CJK_PERIOD: new RegExp("([" + CJK + "])(\\.)(?![" + ALNUM + "\\./])(?=[" + CJK + ALNUM + "])", "g"),
const AN_LEFT_BRACKET = new RegExp("([" + ALNUM + "])(?<!\\.[" + ALNUM + "]*)([" + LBR_BASIC + "])", "g"); AN_PERIOD_CJK: new RegExp("([" + ALNUM + "])(\\.)([" + CJK + "])", "g"),
const RIGHT_BRACKET_AN = new RegExp("([" + RBR_BASIC + "])([" + ALNUM + "])", "g"); AN_COLON_CJK: new RegExp("([" + ALNUM + "])(:)([" + CJK + "])", "g"),
const CJK_ANS = new RegExp("([" + CJK + "])([" + ANS_AFTER_CJK + "])", "g"); CJK_QUOTE: new RegExp("([" + CJK + "])([" + QUOTES_CLS + "])", "g"),
const ANS_CJK = new RegExp("([" + ANS_BEFORE_CJK + "])([" + CJK + "])", "g"); QUOTE_CJK: new RegExp("([" + QUOTES_CLS + "])([" + CJK + "])", "g"),
const S_A = new RegExp("(%)([" + ALPHA + "])", "g"); CJK_QUOTE_AN: new RegExp("([" + CJK + "])(\")([" + ALNUM + "])", "g"),
CJK_HASH: new RegExp("([" + CJK + "])(#([^ ]))", "g"),
HASH_CJK: new RegExp("(([^ ])#)([" + CJK + "])", "g"),
SINGLE_LETTER_GRADE: new RegExp("\\b([" + ALPHA + "])([" + OPS_GRADE + "])([" + CJK + "])", "g"),
CJK_OPERATOR_ANS: new RegExp("([" + CJK + "])([" + OPS_HYPHEN + "])([" + ALNUM + "])", "g"),
ANS_OPERATOR_CJK: new RegExp("([" + ALNUM + "])([" + OPS_HYPHEN + "])([" + CJK + "])", "g"),
CJK_LESS_THAN: new RegExp("([" + CJK + "])(<)([" + ALNUM + "])", "g"),
LESS_THAN_CJK: new RegExp("([" + ALNUM + "])(<)([" + CJK + "])", "g"),
CJK_GREATER_THAN: new RegExp("([" + CJK + "])(>)([" + 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 + "])(?<!\\.[" + ALNUM + "]*)([" + LBR_BASIC + "])", "g"),
RIGHT_BRACKET_AN: new RegExp("([" + RBR_BASIC + "])([" + ALNUM + "])", "g"),
CJK_ANS: new RegExp("([" + CJK + "])([" + ANS_AFTER + "])", "g"),
ANS_CJK: new RegExp("([" + ANS_BEFORE + "])([" + CJK + "])", "g"),
S_A: new RegExp("(%)([" + ALPHA + "])", "g"),
};
}
// 複合詞(GPT-4 / state-of-the-art)不可被運算符間隙楔入:先遮罩,運算符 // 複合詞(GPT-4 / state-of-the-art)不可被運算符間隙楔入:先遮罩,運算符
// 過後、括號 / CJK_ANS / ANS_CJK 過前還原。 // 過後、括號 / CJK_ANS / ANS_CJK 過前還原。
const COMPOUND_WORD = /\b(?:[A-Za-z0-9]*[a-z][A-Za-z0-9]*-[A-Za-z0-9]+|[A-Za-z0-9]+-[A-Za-z0-9]*[a-z][A-Za-z0-9]*|[A-Za-z]+-[0-9]+|[A-Za-z]+[0-9]+-[A-Za-z0-9]+)(?:-[A-Za-z0-9]+)*\b/g; const COMPOUND_WORD = /\b(?:[A-Za-z0-9]*[a-z][A-Za-z0-9]*-[A-Za-z0-9]+|[A-Za-z0-9]+-[A-Za-z0-9]*[a-z][A-Za-z0-9]*|[A-Za-z]+-[0-9]+|[A-Za-z]+[0-9]+-[A-Za-z0-9]+)(?:-[A-Za-z0-9]+)*\b/g;
const PH_PATTERN = new RegExp(PH_OPEN + "(\\d+)" + PH_CLOSE, "g"); const PH_PATTERN = new RegExp(PH_OPEN + "(\\d+)" + PH_CLOSE, "g");
function panguSpace(text: string): string function panguSpace(text: string, R: Rules): string
{ {
if (text.length <= 1 || !ANY_CJK.test(text)) { return text; } if (text.length <= 1 || !R.anyCjk.test(text)) { return text; }
let s = text; let s = text;
s = s.replace(STRIP_CJK_SPACE_ANS, "$1$2"); s = s.replace(R.STRIP_CJK_SPACE_ANS, "$1$2");
s = s.replace(STRIP_ANS_SPACE_CJK, "$1$2"); s = s.replace(R.STRIP_ANS_SPACE_CJK, "$1$2");
s = s.replace(STRIP_CJK_SPACE_CJK, "$1" + DEL); s = s.replace(R.STRIP_CJK_SPACE_CJK, "$1" + DEL);
s = s.replace(DOTS_CJK, "$1" + MARK + "$2"); s = s.replace(R.DOTS_CJK, "$1" + MARK + "$2");
s = s.replace(CJK_PUNCTUATION, "$1$2" + MARK); s = s.replace(R.CJK_PUNCTUATION, "$1$2" + MARK);
s = s.replace(AN_PUNCTUATION_CJK, "$1$2" + MARK + "$3"); s = s.replace(R.AN_PUNCTUATION_CJK, "$1$2" + MARK + "$3");
s = s.replace(CJK_TILDE, "$1$2" + MARK); s = s.replace(R.CJK_TILDE, "$1$2" + MARK);
s = s.replace(CJK_TILDE_EQUALS, "$1" + MARK + "$2" + MARK); s = s.replace(R.CJK_TILDE_EQUALS, "$1" + MARK + "$2" + MARK);
s = s.replace(CJK_PERIOD, "$1$2" + MARK); s = s.replace(R.CJK_PERIOD, "$1$2" + MARK);
s = s.replace(AN_PERIOD_CJK, "$1$2" + MARK + "$3"); s = s.replace(R.AN_PERIOD_CJK, "$1$2" + MARK + "$3");
s = s.replace(AN_COLON_CJK, "$1$2" + MARK + "$3"); s = s.replace(R.AN_COLON_CJK, "$1$2" + MARK + "$3");
s = s.replace(CJK_QUOTE, "$1" + MARK + "$2"); s = s.replace(R.CJK_QUOTE, "$1" + MARK + "$2");
s = s.replace(QUOTE_CJK, "$1" + MARK + "$2"); s = s.replace(R.QUOTE_CJK, "$1" + MARK + "$2");
s = s.replace(CJK_QUOTE_AN, "$1$2" + MARK + "$3"); s = s.replace(R.CJK_QUOTE_AN, "$1$2" + MARK + "$3");
s = s.replace(CJK_HASH, "$1" + MARK + "$2"); s = s.replace(R.CJK_HASH, "$1" + MARK + "$2");
s = s.replace(HASH_CJK, "$1" + MARK + "$3"); s = s.replace(R.HASH_CJK, "$1" + MARK + "$3");
const compounds: string[] = []; const compounds: string[] = [];
s = s.replace(COMPOUND_WORD, (m) => s = s.replace(COMPOUND_WORD, (m) =>
@@ -111,25 +137,25 @@ function panguSpace(text: string): string
return PH_OPEN + (compounds.length - 1) + PH_CLOSE; return PH_OPEN + (compounds.length - 1) + PH_CLOSE;
}); });
s = s.replace(SINGLE_LETTER_GRADE, "$1$2" + MARK + "$3"); s = s.replace(R.SINGLE_LETTER_GRADE, "$1$2" + MARK + "$3");
s = s.replace(CJK_OPERATOR_ANS, "$1" + MARK + "$2" + MARK + "$3"); s = s.replace(R.CJK_OPERATOR_ANS, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(ANS_OPERATOR_CJK, "$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(R.CJK_LESS_THAN, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(LESS_THAN_CJK, "$1" + MARK + "$2" + MARK + "$3"); s = s.replace(R.LESS_THAN_CJK, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(CJK_GREATER_THAN, "$1" + MARK + "$2" + MARK + "$3"); s = s.replace(R.CJK_GREATER_THAN, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(GREATER_THAN_CJK, "$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(PH_PATTERN, (_m, idx: string) => compounds[Number(idx)] || "");
s = s.replace(CJK_LEFT_BRACKET, "$1" + MARK + "$2"); s = s.replace(R.CJK_LEFT_BRACKET, "$1" + MARK + "$2");
s = s.replace(RIGHT_BRACKET_CJK, "$1" + MARK + "$2"); s = s.replace(R.RIGHT_BRACKET_CJK, "$1" + MARK + "$2");
s = s.replace(AN_LEFT_BRACKET, "$1" + MARK + "$2"); s = s.replace(R.AN_LEFT_BRACKET, "$1" + MARK + "$2");
s = s.replace(RIGHT_BRACKET_AN, "$1" + MARK + "$2"); s = s.replace(R.RIGHT_BRACKET_AN, "$1" + MARK + "$2");
s = s.replace(CJK_ANS, "$1" + MARK + "$2"); s = s.replace(R.CJK_ANS, "$1" + MARK + "$2");
s = s.replace(ANS_CJK, "$1" + MARK + "$2"); s = s.replace(R.ANS_CJK, "$1" + MARK + "$2");
s = s.replace(S_A, "$1" + MARK + "$2"); s = s.replace(R.S_A, "$1" + MARK + "$2");
return s; return s;
} }
@@ -163,13 +189,13 @@ function wrapGapLeft(pos: { textNode: Text; offset: number }): void
wrapNodeInGap(tn); wrapNodeInGap(tn);
} }
function processBlock(block: Element, finder: Finder): void function processBlock(block: Element, finder: Finder, R: Rules): void
{ {
const runs = finder.collectRuns(block); const runs = finder.collectRuns(block);
for (const run of runs) for (const run of runs)
{ {
const { text, map } = run; const { text, map } = run;
const spaced = panguSpace(text); const spaced = panguSpace(text, R);
if (spaced === text) { continue; } if (spaced === text) { continue; }
const actions: SpaceAction[] = []; 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, pillNeighbor: RegExp): void
function processPills(root: Element, finder: Finder, pillSelector: string): void
{ {
root.querySelectorAll(pillSelector).forEach((pill) => root.querySelectorAll(pillSelector).forEach((pill) =>
{ {
@@ -230,20 +254,20 @@ function processPills(root: Element, finder: Finder, pillSelector: string): void
if (!pill.parentNode) { return; } if (!pill.parentNode) { return; }
const prev = finder.adjacentLogicalChar(pill, -1); 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); 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:間隙在 adj 字(左)與 pill(右)之間 → margin 掛 adj 字(包裝該字)。
// dir +1:間隙在 pill(左)與 adj 字(右)之間 → margin 掛 pill(包裝 pill)。 // 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 (dir === -1)
{ {
if (PILL_NEIGHBOR.test(adj.ch)) if (pillNeighbor.test(adj.ch))
{ {
wrapGapLeft({ textNode: adj.textNode, offset: adj.offset }); wrapGapLeft({ textNode: adj.textNode, offset: adj.offset });
return; return;
@@ -252,7 +276,7 @@ function handlePillSide(adj: AdjacentChar, pill: Element, dir: -1 | 1): void
{ {
const tn = adj.textNode; const tn = adj.textNode;
const bc = tn.data.charAt(adj.offset - 1); 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); tn.data = tn.data.slice(0, adj.offset) + tn.data.slice(adj.offset + 1);
wrapGapLeft({ textNode: tn, offset: 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 else
{ {
if (PILL_NEIGHBOR.test(adj.ch)) if (pillNeighbor.test(adj.ch))
{ {
wrapNodeInGap(pill); wrapNodeInGap(pill);
return; return;
@@ -269,7 +293,7 @@ function handlePillSide(adj: AdjacentChar, pill: Element, dir: -1 | 1): void
if (adj.ch === " " && adj.textNode.data.length >= 2) if (adj.ch === " " && adj.textNode.data.length >= 2)
{ {
const ac = adj.textNode.data.charAt(1); 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); adj.textNode.data = adj.textNode.data.slice(1);
wrapNodeInGap(pill); wrapNodeInGap(pill);
@@ -286,14 +310,16 @@ export const spacingPass: StandalonePass = {
render(ctx: RenderContext): void render(ctx: RenderContext): void
{ {
const { finder, options } = ctx; const { finder, options } = ctx;
// 由規則集編譯間隙正則(per-instance;含 charClass 覆寫)。
const R = makeRules(options.ruleset);
// Pass A:逐 block 處理(功能閘以 block 為粒度)。 // Pass A:逐 block 處理(功能閘以 block 為粒度)。
ctx.finder.eachBlock(ctx.root, (block) => ctx.finder.eachBlock(ctx.root, (block) =>
{ {
if (!finder.featureEnabledFor(block, PASS)) { return; } if (!finder.featureEnabledFor(block, PASS)) { return; }
processBlock(block, finder); processBlock(block, finder, R);
}); });
// Pass Bpill 邊界。 // Pass Bpill 邊界。
processPills(ctx.root, finder, options.finder.pillSelector); processPills(ctx.root, finder, options.finder.pillSelector, R.pillNeighbor);
}, },
revert(ctx: RenderContext): void revert(ctx: RenderContext): void
{ {
+85
View File
@@ -508,3 +508,88 @@ test("CSS:聚珍不內建 font-family(字型交消費端),半形顯式
assert.equal(/font-family/.test(css), false, "聚珍 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"); assert.match(css, /font-feature-settings:\s*"locl"\s+1,\s*"halt"\s+1/, "半形 feature 應顯式保留 locl");
}); });
// ----- charClass:自定義字符分類規則集(向下兼容+擴充)-----
test("charClass:預設不傳時,英文全角句號 . 未分類(不 charify、不挤、不補隙)", () =>
{
const { document } = setupDom('<p lang="zh-Hans">中.文</p>');
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('<p lang="zh-Hans">中.文.</p>');
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('<p lang="zh-Hans">中★文</p>');
// 先確認預設不補隙
createJuzhen({}).render(document.body);
assert.equal(countTag(document.querySelector("p"), "jz-hws"), 0, "預設 ★ 非西文、不補隙");
const dom2 = setupDom('<p lang="zh-Hans">中★文</p>');
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('<p lang="zh-Hans">A</p>');
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('<p lang="zh-Hans">中★文</p>');
createJuzhen({ charClass: { western: "★" } }).render(a.document.body);
assert.ok(countTag(a.document.querySelector("p"), "jz-hws") >= 1, "覆寫實例:★ 補隙");
// 再建無覆寫之實例:★ 應仍未分類、不補隙(前實例之覆寫未洩漏至默認)。
const b = setupDom('<p lang="zh-Hans">中★文</p>');
createJuzhen({}).render(b.document.body);
assert.equal(countTag(b.document.querySelector("p"), "jz-hws"), 0,
"無覆寫實例:★ 仍未分類、不補隙(默認規則集未被前實例污染)");
});
test("charClass.biaodian.pause:未分類符號歸句內點號 → 開明式挤半形(jz-half)", () =>
{
const { document } = setupDom('<p lang="zh-Hans">中•文</p>');
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('<p lang="zh-Hant">甲•乙</p>');
createJuzhen({ charClass: { biaodian: { open: "•" } } }).render(document.body);
assert.ok(countTag(document.querySelector("p"), "jz-jinze") >= 1, "開類 • 與後字綁定(避尾)為 jz-jinze");
});
test("charClass.western:追加不破壞內置——€(內置)與 ★(追加)皆補隙", () =>
{
const { document } = setupDom('<p lang="zh-Hans">價€元★好</p>');
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:多子類同傳,各自生效(stoppause", () =>
{
const { document } = setupDom('<p lang="zh-Hans">甲.乙•丙</p>');
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 且開明式挤半形");
});