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:
+78
-12
@@ -83,24 +83,90 @@ export const BD_ALL = (
|
||||
BD_STOP + BD_PAUSE + BD_OPEN + BD_CLOSE + BD_MIDDLE + BD_LIGA
|
||||
);
|
||||
|
||||
const BD_SETS: ReadonlyArray<readonly [ string, string ]> = [
|
||||
[ "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<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 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;
|
||||
/** 追加為西文(半寬標點亦歸此,按西文處理間隙);併入 ansAfter/ansBefore。 */
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
+8
-2
@@ -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<Record<LangClass, PunctStyle>>;
|
||||
};
|
||||
|
||||
/** 自定義字符分類(擴充內置規則集)。把未分類字符或新符號歸入中文/西文/
|
||||
* 全寬標點各子類,以覆寫 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: {
|
||||
|
||||
+16
-15
@@ -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<Node>();
|
||||
ctx.root.querySelectorAll("jz-char").forEach((c) =>
|
||||
{
|
||||
if (c.parentNode) { parents.add(c.parentNode); }
|
||||
});
|
||||
for (const parent of parents) { processParent(parent, ctx.finder); }
|
||||
for (const parent of parents) { processParent(parent, ctx.finder, anyCjk); }
|
||||
},
|
||||
revert(ctx: RenderContext): void
|
||||
{
|
||||
|
||||
+2
-1
@@ -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)
|
||||
|
||||
+114
-88
@@ -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 + "]*)([" + LBR_BASIC + "])", "g");
|
||||
const RIGHT_BRACKET_AN = new RegExp("([" + RBR_BASIC + "])([" + ALNUM + "])", "g");
|
||||
const CJK_ANS = new RegExp("([" + CJK + "])([" + ANS_AFTER_CJK + "])", "g");
|
||||
const ANS_CJK = new RegExp("([" + ANS_BEFORE_CJK + "])([" + CJK + "])", "g");
|
||||
const S_A = new RegExp("(%)([" + ALPHA + "])", "g");
|
||||
/**
|
||||
* 由規則集編譯間隙正則。CJK/ANS 字集隨 options.charClass 而變,故 per-instance 編譯
|
||||
* (createJuzhen 各一次,非每 block);ALNUM/ALPHA/運算符/括號等結構性 ASCII 集為
|
||||
* 固定模組常數,不隨規則集變。
|
||||
* · STRIP_CJK_SPACE_CJK:CJK↔CJK 之單一作者空格(含跨 inline 標籤,collectRuns 已透明
|
||||
* 合併)→ 以 DEL 標記刪除、不留隙(1b;只匹配 ASCII 空格,全形 U+3000 不動)。
|
||||
* · DOTS_CJK:僅 ASCII 省略號 `...中文` 補隙,**不含**全角 U+2026(全角 …/…… 屬 CJK
|
||||
* 標點、後接 CJK 不應補隙;問題 B)。
|
||||
* · 原 pangu QUOTE_AN(收尾彎引號後接字母數字補隙)已移除——標點稽核發現它令 ” 成為唯一
|
||||
* 「後接拉丁/數字會補隙」之收尾標點,與 」』)》】〕 不一致(clreq 下全角收尾標點字身
|
||||
* 已含右側留白,不需額外隙)。
|
||||
*/
|
||||
function makeRules(rs: Ruleset): Rules
|
||||
{
|
||||
const CJK = rs.cjk;
|
||||
const ANS_AFTER = rs.ansAfter;
|
||||
const ANS_BEFORE = rs.ansBefore;
|
||||
return {
|
||||
anyCjk: new RegExp("[" + CJK + "]"),
|
||||
pillNeighbor: new RegExp("[" + ALNUM + CJK + "]"),
|
||||
STRIP_CJK_SPACE_ANS: new RegExp("([" + CJK + "]) ([" + ANS_AFTER + "])", "g"),
|
||||
STRIP_ANS_SPACE_CJK: new RegExp("([" + ANS_BEFORE + "]) ([" + CJK + "])", "g"),
|
||||
STRIP_CJK_SPACE_CJK: new RegExp("([" + CJK + "]) (?=[" + CJK + "])", "g"),
|
||||
DOTS_CJK: new RegExp("([\\.]{2,})([" + CJK + "])", "g"),
|
||||
CJK_PUNCTUATION: new RegExp("([" + CJK + "])([!;,\\?:]+)(?=[" + CJK + ALNUM + "])", "g"),
|
||||
AN_PUNCTUATION_CJK: new RegExp("([" + ALNUM + "])([!;,\\?]+)([" + CJK + "])", "g"),
|
||||
CJK_TILDE: new RegExp("([" + CJK + "])(~+)(?!=)(?=[" + CJK + ALNUM + "])", "g"),
|
||||
CJK_TILDE_EQUALS: new RegExp("([" + CJK + "])(~=)", "g"),
|
||||
CJK_PERIOD: new RegExp("([" + CJK + "])(\\.)(?![" + ALNUM + "\\./])(?=[" + CJK + ALNUM + "])", "g"),
|
||||
AN_PERIOD_CJK: new RegExp("([" + ALNUM + "])(\\.)([" + CJK + "])", "g"),
|
||||
AN_COLON_CJK: new RegExp("([" + ALNUM + "])(:)([" + CJK + "])", "g"),
|
||||
CJK_QUOTE: new RegExp("([" + CJK + "])([" + QUOTES_CLS + "])", "g"),
|
||||
QUOTE_CJK: new RegExp("([" + QUOTES_CLS + "])([" + CJK + "])", "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)不可被運算符間隙楔入:先遮罩,運算符
|
||||
// 過後、括號 / 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 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;
|
||||
s = s.replace(STRIP_CJK_SPACE_ANS, "$1$2");
|
||||
s = s.replace(STRIP_ANS_SPACE_CJK, "$1$2");
|
||||
s = s.replace(STRIP_CJK_SPACE_CJK, "$1" + DEL);
|
||||
s = s.replace(R.STRIP_CJK_SPACE_ANS, "$1$2");
|
||||
s = s.replace(R.STRIP_ANS_SPACE_CJK, "$1$2");
|
||||
s = s.replace(R.STRIP_CJK_SPACE_CJK, "$1" + DEL);
|
||||
|
||||
s = s.replace(DOTS_CJK, "$1" + MARK + "$2");
|
||||
s = s.replace(CJK_PUNCTUATION, "$1$2" + MARK);
|
||||
s = s.replace(AN_PUNCTUATION_CJK, "$1$2" + MARK + "$3");
|
||||
s = s.replace(CJK_TILDE, "$1$2" + MARK);
|
||||
s = s.replace(CJK_TILDE_EQUALS, "$1" + MARK + "$2" + MARK);
|
||||
s = s.replace(CJK_PERIOD, "$1$2" + MARK);
|
||||
s = s.replace(AN_PERIOD_CJK, "$1$2" + MARK + "$3");
|
||||
s = s.replace(AN_COLON_CJK, "$1$2" + MARK + "$3");
|
||||
s = s.replace(R.DOTS_CJK, "$1" + MARK + "$2");
|
||||
s = s.replace(R.CJK_PUNCTUATION, "$1$2" + MARK);
|
||||
s = s.replace(R.AN_PUNCTUATION_CJK, "$1$2" + MARK + "$3");
|
||||
s = s.replace(R.CJK_TILDE, "$1$2" + MARK);
|
||||
s = s.replace(R.CJK_TILDE_EQUALS, "$1" + MARK + "$2" + MARK);
|
||||
s = s.replace(R.CJK_PERIOD, "$1$2" + MARK);
|
||||
s = s.replace(R.AN_PERIOD_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(QUOTE_CJK, "$1" + MARK + "$2");
|
||||
s = s.replace(CJK_QUOTE_AN, "$1$2" + MARK + "$3");
|
||||
s = s.replace(R.CJK_QUOTE, "$1" + MARK + "$2");
|
||||
s = s.replace(R.QUOTE_CJK, "$1" + MARK + "$2");
|
||||
s = s.replace(R.CJK_QUOTE_AN, "$1$2" + MARK + "$3");
|
||||
|
||||
s = s.replace(CJK_HASH, "$1" + MARK + "$2");
|
||||
s = s.replace(HASH_CJK, "$1" + MARK + "$3");
|
||||
s = s.replace(R.CJK_HASH, "$1" + MARK + "$2");
|
||||
s = s.replace(R.HASH_CJK, "$1" + MARK + "$3");
|
||||
|
||||
const compounds: string[] = [];
|
||||
s = s.replace(COMPOUND_WORD, (m) =>
|
||||
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user