feat: 聚珍切片 1 — 中西間隙/標點擠壓/禁則 + v1 相容層

從零重寫之第一垂直切片(依 ARCHITECTURE.md 契約),涵蓋並超越 v1 全部功能。

核心基座(src/core):unicode 字元表+標點子類+字形側位;locale 語言與
全角/開明政策(全域 lang.style +逐 lang policy);dom(jz-* 工廠+可還原
標記);finder 遍歷引擎(avoid/scope.include/邏輯 run/pill 鄰字/分塊閘);
pass 聚合管線。

功能 pass(src/typeset):
- spacing:中西間隙採 margin 模型——包裹邊界左側字、以 margin-right 留隙,
  不注入空白字元(textContent 完全不變、複製緊湊、justify 不暴增、不撐框、
  行首乾淨)。含跨樣式邊界、pill、原檔空格移除。
- gapTrim(lineedge):layout pass,行末去隙,修正 justify 右緣內縮。
- longword:長英文詞 <span lang> + hyphens。
- jiya:標點 charify + 全角/開明/半角(全域 + 逐 lang);半形以字型 OpenType
  halt 渲染(按字形正確定位、不重疊,居中字形 !? 與繁體 locl 句逗點號皆正確);
  行內連續擠壓。
- jinze:行首行末避頭尾(閉類/點號綁前字、開類綁後字,以詞元為邊界、長引文
  中段可斷);亦為 justify 綁定載體。
- lineedge:行端擠壓 layout pass(括號引號;ResizeObserver 重算,瀏覽器專用)。

§8 justify:jz-char inline-block 原子,句末標點單份間距。
v1 相容層(compat/v1):createCjkAutospace().apply 映射,IIFE 全域別名。
構建:esbuild → ESM/IIFE/CSS;tsc → d.ts。測試:39 個 jsdom 案例。
demo:簡繁並列+justify+強制繁體開明+標點寬度量測,載 Noto webfont 作參考
(擠壓依賴字型 halt)。間隔號 · 不觸發中西間隙;200% 等詞元不被禁則切斷。
This commit is contained in:
2026-06-01 21:31:07 +08:00
parent 8098991b6b
commit 2d42c7ecf2
46 changed files with 7737 additions and 27 deletions
+161
View File
@@ -0,0 +1,161 @@
// 禁則:行首行末避頭尾(架構 §6.4)+ justify 綁定。standalone pass,須在
// charifyjiya)之後(依賴 jz-char)。
//
// 瀏覽器原生(UAX#14)本會避免「行首閉類/點號、行末開類」,但 jiya 把標點
// 原子化為 inline-block jz-char 後,原子邊界成為可斷點、破壞原生避頭尾。本 pass
// 補回:把「不可置行首」之標點(閉類/點號/間隔/連接省略)綁到其前一字,
// 「不可置行末」之標點(開類)綁到其後一字——以 nowrap 之 jz-jinze 包裹,且只
// 隔離單一邊界字(長引文內部仍可正常斷行,不致溢出)。亦為 justify 綁定載體。
//
// 兩階段:①依原始相鄰關係算「禁斷邊界」,對涉及之文本單元切出邊界字;
// ②重掃當前子節點,把以禁斷邊界相連之相鄰原子併入同一 jz-jinze。
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";
// 不可置於行首(避頭):閉括號引號、點號、間隔號、連接/省略號。
const AVOID_HEAD = new Set([ "bd-close", "bd-pause", "bd-stop", "bd-middle", "bd-liga" ]);
// 不可置於行末(避尾):開括號引號。
const AVOID_TAIL = new Set([ "bd-open" ]);
function isCharEl(n: Node | null): n is Element
{
return !!n && n.nodeType === 1 && (n as Element).nodeName === "JZ-CHAR";
}
/** 相鄰兩節點之間是否禁止斷行(僅由 jz-char 之類別決定)。 */
function forbiddenBreak(a: Node, b: Node, finder: Finder): boolean
{
if (isCharEl(b))
{
const c = bdClassOf(b);
if (c && AVOID_HEAD.has(c) && finder.featureEnabledFor(b, PASS)) { return true; }
}
if (isCharEl(a))
{
const c = bdClassOf(a);
if (c && AVOID_TAIL.has(c) && finder.featureEnabledFor(a, PASS)) { return true; }
}
return false;
}
// 「詞元」:CJK 字為單字詞元(可任意斷);連續非 CJK 非空白(拉丁詞/數字/
// 百分號/GPT-4/3.5 等)為一個不可內斷之詞元。綁定須以整個邊界詞元為單位,
// 否則會把 200% 拆成 200|%,在其間製造斷點(修正:閉類綁定不應切斷數字/詞)。
function isTokenChar(ch: string): boolean
{
return !ANY_CJK.test(ch) && !/\s/.test(ch);
}
function leadingTokenEnd(d: string): number
{
if (ANY_CJK.test(d.charAt(0))) { return 1; }
let e = 1;
while (e < d.length && isTokenChar(d.charAt(e))) { e += 1; }
return e;
}
function trailingTokenStart(d: string): number
{
const last = d.length - 1;
if (ANY_CJK.test(d.charAt(last))) { return last; }
let s = last;
while (s > 0 && isTokenChar(d.charAt(s - 1))) { s -= 1; }
return s;
}
/** 隔離文本節點之首/末「詞元」為獨立節點(供綁定,使中段仍可斷行)。 */
function splitBoundaries(t: Text, needFirst: boolean, needLast: boolean): void
{
let node: Text = t;
if (needFirst)
{
const e = leadingTokenEnd(node.data);
if (e < node.data.length) { node = node.splitText(e); }
}
if (needLast)
{
const s = trailingTokenStart(node.data);
if (s > 0) { node.splitText(s); }
}
}
function processParent(parent: Node, finder: Finder): void
{
// 冪等:已含 jz-jinze(前次 render 未 revert)則跳過。
for (let c = parent.firstChild; c; c = c.nextSibling)
{
if (c.nodeType === 1 && (c as Element).nodeName === "JZ-JINZE") { return; }
}
// 階段 1:原始單元(非空文本 / 元素)→ 算禁斷邊界 → 切邊界字。
const units: Node[] = [];
for (let c = parent.firstChild; c; c = c.nextSibling)
{
if (c.nodeType === 3 && (c as Text).data.length > 0) { units.push(c); }
else if (c.nodeType === 1) { units.push(c); }
}
const n = units.length;
if (n < 2) { return; }
const forbidden: boolean[] = [];
for (let i = 0; i < n - 1; i += 1)
{
forbidden.push(forbiddenBreak(units[i]!, units[i + 1]!, finder));
}
for (let i = 0; i < n; i += 1)
{
const u = units[i]!;
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); }
}
// 階段 2:重掃當前子節點,把禁斷相連之相鄰原子併成 jz-jinze。
const kids: Node[] = [];
for (let c = parent.firstChild; c; c = c.nextSibling) { kids.push(c); }
let i = 0;
while (i < kids.length)
{
const run: Node[] = [ kids[i]! ];
while (i + 1 < kids.length && forbiddenBreak(kids[i]!, kids[i + 1]!, finder))
{
run.push(kids[i + 1]!);
i += 1;
}
if (run.length >= 2)
{
const group = createJz("jz-jinze", PASS, "wrap");
parent.insertBefore(group, run[0]!);
for (const node of run) { group.appendChild(node); }
}
i += 1;
}
}
export const jinzePass: StandalonePass = {
name: PASS,
kind: "standalone",
order: 40,
enabled: (o) => o.features.jinze,
render(ctx: RenderContext): void
{
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); }
},
revert(ctx: RenderContext): void
{
revertPass(PASS, ctx.root);
},
};
+149
View File
@@ -0,0 +1,149 @@
// 標點擠壓(架構 §6.3)。完整 clreq 擠壓之「行內」部分——三條規則並集,
// 以方向性 jz-cl(收左半形空白)/jz-cr(收右)落地,CSS 以負 margin 渲染,
// 跨瀏覽器一致(I4),不依賴 halttext-spacing-trim。
//
// 機制 C baseline 寬度政策(依 langI3):
// 全角式 quanjiao — 一律不收;
// 開明式 kaiming — 句內點號(,、;:)+ 開類/閉類括號引號半形,
// 句末點號(。!?)保持全形;
// 半角式 banjiao — 全部標點半形。
// 機制 A 行內連續擠壓(jiyaAdjacencyPasslang 無關):相鄰標點之對向空白
// 對消一次(每相鄰對省半形),如 。」→ 。收右 → 1.5em。
// 機制 B 行端擠壓 → typeset/lineedge.tslayout pass,瀏覽器專用)。
//
// 同時為 §8:所有 jz-char 經 CSS 設 inline-block,成 justify 單一原子項(I5)。
import { bdClassOf, createJz, revertPass } from "../core/dom.js";
import { resolveStyle } from "../core/locale.js";
import type { PunctStyle } from "../core/locale.js";
import { blankSides, classifyBiaodian } from "../core/unicode.js";
import type { CharifyPass, RenderContext, StandalonePass, WrapRequest } from "../types.js";
const PASS = "jiya";
/** baseline 政策下,該標點是否擠成半形(機制 C)。半形以字型 halt 渲染,
* 方向由字型決定,故此處只需「半形與否」之布林。 */
function baselineHalf(style: PunctStyle, bdClass: string): boolean
{
if (style === "quanjiao") { return false; }
if (style === "banjiao")
{
// 半角式:所有可壓縮標點半形(句末亦半)。
return bdClass === "bd-pause" || bdClass === "bd-stop"
|| bdClass === "bd-open" || bdClass === "bd-close";
}
// kaiming:句內點號+括號引號半形,句末點號保持全形。
return bdClass === "bd-pause" || bdClass === "bd-open" || bdClass === "bd-close";
}
/** parent 已是 jz-char(本 pass 產物)→ 冪等跳過。 */
function inJzChar(node: Node): boolean
{
let p = node.parentNode;
while (p && p.nodeType === 1)
{
const name = (p as Element).nodeName;
if (name === "JZ-CHAR" || name === "JZ-INNER") { return true; }
p = p.parentNode;
}
return false;
}
// ----- charify baseline(機制 C-----
export const jiyaPass: CharifyPass = {
name: PASS,
kind: "charify",
order: 20,
enabled: (o) => o.features.jiya,
collect(node, ctx): WrapRequest[]
{
if (inJzChar(node)) { return []; }
if (!ctx.finder.featureEnabledFor(node, PASS)) { return []; }
const data = node.data;
const out: WrapRequest[] = [];
let style: PunctStyle | null = null;
for (let i = 0; i < data.length; i += 1)
{
const bdClass = classifyBiaodian(data.charAt(i));
if (!bdClass) { continue; }
if (style === null) { style = resolveStyle(node, ctx.options.locale); }
const classes = baselineHalf(style, bdClass) ? [ bdClass, "jz-half" ] : [ bdClass ];
out.push({
start: i,
end: i + 1,
pass: PASS,
build: (text) =>
{
const outer = createJz("jz-char", PASS, "wrap", { classes });
const inner = createJz("jz-inner", PASS, "wrap", { text });
outer.appendChild(inner);
return outer;
},
});
}
return out;
},
revert(ctx: RenderContext): void
{
revertPass(PASS, ctx.root);
},
};
// ----- 行內連續擠壓(機制 A-----
//
// 對 DOM 直接相鄰(nextSibling 即下一 jz-char、其間無文本)之標點對 (L, R):
// 收掉「指向彼此」之那一側——L 若有右空白則收 L(半形),否則 R 若有左空白
// 則收 R。每相鄰對只收一字 → 該對約 1.5em。鏈狀連續標點逐對處理。加 jz-half
// 冪等(並集),與 baseline 疊加(已半形則 no-op)。class 掛於 charify 之
// jz-char,隨其解包清除,本 pass 無獨立產物。
//
// 半形以字型 halt 渲染(居中字形如 ?!亦正確、不重疊);例:?!→ 只收 ?,
// !保持全形 1em。
export const jiyaAdjacencyPass: StandalonePass = {
name: "jiyaAdjacency",
kind: "standalone",
order: 25,
enabled: (o) => o.features.jiya,
render(ctx: RenderContext): void
{
const { finder } = ctx;
const parents = new Set<Node>();
ctx.root.querySelectorAll("jz-char").forEach((c) =>
{
if (c.parentNode) { parents.add(c.parentNode); }
});
for (const parent of parents)
{
let child: Node | null = parent.firstChild;
while (child)
{
const next = child.nextSibling;
if (
child.nodeType === 1 && (child as Element).nodeName === "JZ-CHAR"
&& next && next.nodeType === 1 && (next as Element).nodeName === "JZ-CHAR"
)
{
const L = child as Element;
const R = next as Element;
if (finder.featureEnabledFor(L, PASS))
{
const lSides = blankSides(bdClassOf(L));
const rSides = blankSides(bdClassOf(R));
if (lSides.right) { L.classList.add("jz-half"); }
else if (rSides.left) { R.classList.add("jz-half"); }
}
}
child = next;
}
}
},
revert(): void
{
// 無獨立產物:class 掛於 jiya charify 之 jz-char,隨其解包清除。
},
};
+196
View File
@@ -0,0 +1,196 @@
// 行端擠壓(架構 §6.4/§6.5 之行端部分;clreq 行首行尾擠壓)。機制 B。
// standalone layout pass:量測各 jz-char 之行盒位置,對「行首之開類」收左、
// 「行尾之閉類/點號」收右(加 jz-le-cljz-le-crCSS 同 -0.5em margin)。
// 掛 ResizeObserver 於 root,容器寬度變動時重算;字型載入後亦重算。
//
// ⚠ 純版面、無法於 jsdom 驗證(getClientRects 回傳 0);故無 ResizeObserver
// 之環境(jsdom)直接跳過,僅於真實瀏覽器生效(架構 §12/R1/R2)。須跨
// ChromeSafariFirefox 目視核對。
import { bdClassOf } from "../core/dom.js";
import type { Finder } from "../core/finder.js";
import type { RenderContext, StandalonePass } from "../types.js";
const LE = "jz-half-le"; // 行端半形(CSS 同 halt;獨立 class 以便重算時只清行端結果)
const EPS = 4; // px:判定「不同行」之 top 差容差。
// 每 root 各 layout pass(以 key 區分)一個 ResizeObserver。
const observers = new WeakMap<Element, Map<string, ResizeObserver>>();
function hasLayout(): boolean
{
return typeof ResizeObserver !== "undefined";
}
/** 安裝一個 layout 重算:立即跑一次,掛 ResizeObserver,字型載入後再跑。 */
function installLayout(root: Element, key: string, run: () => void): void
{
run();
let m = observers.get(root);
if (!m) { m = new Map(); observers.set(root, m); }
const existing = m.get(key);
if (existing) { existing.disconnect(); }
const ro = new ResizeObserver(() => run());
ro.observe(root);
m.set(key, ro);
const fonts = (root.ownerDocument as Document & { fonts?: { ready?: Promise<unknown> } }).fonts;
if (fonts && fonts.ready && typeof fonts.ready.then === "function")
{
fonts.ready.then(run, () => undefined);
}
}
function uninstallLayout(root: Element, key: string): void
{
const m = observers.get(root);
if (!m) { return; }
const ro = m.get(key);
if (ro) { ro.disconnect(); m.delete(key); }
}
// 我們的行內包裹:跨越它們找相鄰流內容(jz-char 可能被 jz-jinze 包住,其流
// 鄰居在群組之外)。遇 block 邊界即止(回 null,視為行端)。
const INLINE_WRAP = new Set([ "JZ-JINZE", "JZ-CHAR", "JZ-INNER", "JZ-HWS", "SPAN", "STRONG", "EM", "A", "B", "I", "U", "MARK" ]);
function rectOf(n: Node, side: "prev" | "next"): DOMRect | null
{
if (n.nodeType === 3 && (n as Text).data.length > 0)
{
const t = n as Text;
const doc = t.ownerDocument;
if (!doc) { return null; }
const range = doc.createRange();
const at = side === "prev" ? t.data.length - 1 : 0;
range.setStart(t, at);
range.setEnd(t, at + 1);
const rects = range.getClientRects();
if (rects.length) { return rects[side === "prev" ? rects.length - 1 : 0]!; }
}
else if (n.nodeType === 1)
{
const rects = (n as Element).getClientRects();
if (rects.length) { return rects[side === "prev" ? rects.length - 1 : 0]!; }
}
return null;
}
/** 取某節點側邊界字元之矩形(橫排:判定相鄰內容是否同行);跨我們的行內包裹
* 向上攀爬,遇 block 邊界回 null(視為行端)。 */
function edgeRect(start: Node | null, side: "prev" | "next", from: Node): DOMRect | null
{
let n = start;
let parentRef: Node | null = from.parentNode;
for (;;)
{
while (n)
{
const r = rectOf(n, side);
if (r) { return r; }
n = side === "prev" ? n.previousSibling : n.nextSibling;
}
// 本層無內容:若父為行內包裹,攀出續找其鄰居。
if (!parentRef || parentRef.nodeType !== 1) { return null; }
if (!INLINE_WRAP.has((parentRef as Element).nodeName)) { return null; }
n = side === "prev" ? parentRef.previousSibling : parentRef.nextSibling;
parentRef = parentRef.parentNode;
}
}
function firstRect(el: Element): DOMRect | null
{
const rects = el.getClientRects();
return rects.length ? rects[0]! : null;
}
function relayout(root: Element, finder: Finder): void
{
// 清除上次行端 class。
root.querySelectorAll("jz-char." + LE).forEach((e) => e.classList.remove(LE));
const chars = Array.from(root.querySelectorAll("jz-char")).filter(
(c) => finder.inScope(c) && !finder.isAvoided(c),
);
for (const el of chars)
{
if (!finder.featureEnabledFor(el, "jiya")) { continue; }
// 行端僅處理括號引號:開類於行首收左、閉類於行尾收右。
// **不**處理句末/句內點號(。!?,等)——行尾/行首之點號保持全形,
// 避免「孤立句末 。/?!之 ! 落在行末」被誤收為半形(實測之困惑來源)。
// `。」` 行尾仍為 1em:其中 。 由鄰接(在 」 前)收,非由行端收。
const bd = bdClassOf(el);
if (bd !== "bd-open" && bd !== "bd-close") { continue; }
const r = firstRect(el);
if (!r) { continue; }
if (bd === "bd-open")
{
const prev = edgeRect(el.previousSibling, "prev", el);
if (!prev || prev.top < r.top - EPS) { el.classList.add(LE); }
}
else
{
const next = edgeRect(el.nextSibling, "next", el);
if (!next || next.top > r.top + EPS) { el.classList.add(LE); }
}
}
}
export const lineEdgePass: StandalonePass = {
name: "jiyaLineEdge",
kind: "standalone",
order: 90,
enabled: (o) => o.features.jiya,
render(ctx: RenderContext): void
{
if (!hasLayout()) { return; } // jsdom 等無版面環境:跳過。
installLayout(ctx.root, "lineEdge", () => relayout(ctx.root, ctx.finder));
},
revert(ctx: RenderContext): void
{
uninstallLayout(ctx.root, "lineEdge");
ctx.root.querySelectorAll("jz-char." + LE).forEach((e) => e.classList.remove(LE));
},
};
// ----- 中西間隙之行末去隙(機制:margin-right 模型之 justify 修正)-----
//
// jz-hws 以 margin-right 留隙;若其包裹之左字落在**行末**(下一內容在更低行),
// 該尾隨 margin 在 justify 下會使右緣內縮 0.25em。行末本不需間隙,故量測後對
// 行末之 jz-hws 加 jz-hws-trimCSS margin-right:0),該行遂齊右、無內縮。
// 純版面、瀏覽器專用(jsdom 跳過)。
const TRIM = "jz-hws-trim";
function trimGaps(root: Element, finder: Finder): void
{
root.querySelectorAll("jz-hws." + TRIM).forEach((e) => e.classList.remove(TRIM));
const gaps = Array.from(root.querySelectorAll("jz-hws")).filter(
(g) => finder.inScope(g) && !finder.isAvoided(g),
);
for (const g of gaps)
{
const r = firstRect(g);
if (!r) { continue; }
const next = edgeRect(g.nextSibling, "next", g);
// 行末:後無內容,或後一字在更低行。
if (!next || next.top > r.top + EPS) { g.classList.add(TRIM); }
}
}
export const gapTrimPass: StandalonePass = {
name: "spacingGapTrim",
kind: "standalone",
order: 92,
enabled: (o) => o.features.spacing,
render(ctx: RenderContext): void
{
if (!hasLayout()) { return; }
installLayout(ctx.root, "gapTrim", () => trimGaps(ctx.root, ctx.finder));
},
revert(ctx: RenderContext): void
{
uninstallLayout(ctx.root, "gapTrim");
ctx.root.querySelectorAll("jz-hws." + TRIM).forEach((e) => e.classList.remove(TRIM));
},
};
+62
View File
@@ -0,0 +1,62 @@
// 長英文詞斷詞包裝(架構 §6.2,切片 1)。charify pass。
//
// 把 [A-Za-z]{N,} 之長串包進 <span lang="…">,配合消費端 CSS
// `[lang|="en"]{hyphens:auto}` 由瀏覽器原生斷詞渲染連字符(DOM 不含連字符
// → 複製不污染,I1)。沿用 v1 Pass C,改以 charify 共享走訪(I9)。
import { revertPass } from "../core/dom.js";
import type { CharifyPass, RenderContext, WrapRequest } from "../types.js";
const PASS = "longWord";
/** parent 已是帶 lang 之 span(作者標語或本 pass 產物)→ 冪等跳過。 */
function inLangSpan(node: Node): boolean
{
const p = node.parentNode;
if (!p || p.nodeType !== 1) { return false; }
const el = p as Element;
if (el.nodeName !== "SPAN") { return false; }
return el.hasAttribute("lang");
}
export const longWordPass: CharifyPass = {
name: PASS,
kind: "charify",
order: 60,
enabled: (o) => o.features.longWord,
collect(node, ctx): WrapRequest[]
{
if (inLangSpan(node)) { return []; }
if (!ctx.finder.featureEnabledFor(node, PASS)) { return []; }
const cfg = ctx.options.longWord;
const re = new RegExp("[A-Za-z]{" + cfg.minLength + ",}", "g");
const data = node.data;
const out: WrapRequest[] = [];
let m: RegExpExecArray | null;
while ((m = re.exec(data)) !== null)
{
const start = m.index;
const end = m.index + m[0].length;
out.push({
start,
end,
pass: PASS,
build: (text) =>
{
const span = (node.ownerDocument as Document).createElement("span");
span.setAttribute("lang", cfg.lang);
span.setAttribute("data-jz", PASS);
span.setAttribute("data-jz-kind", "wrap");
span.textContent = text;
return span;
},
});
}
return out;
},
revert(ctx: RenderContext): void
{
revertPass(PASS, ctx.root);
},
};
+280
View File
@@ -0,0 +1,280 @@
// 中西間隙(架構 §6.1,切片 1)。standalone pass。
//
// 沿用 v1cjk-autospace)已驗證之 Pangu 規則管線與 per-block 邏輯文本處理,
// 改用 finder 提供之 collectRunsadjacentLogicalChar,並把間隙 marker 換為
// copy-clean 之 jz-hwsuser-select 由 CSS 關閉,修正 Han.css 之選中缺陷,I1)。
//
// 涵蓋 v1 Pass A(跨樣式邊界間隙)與 Pass Bpill code/kbd 邊界間隙)。
// 規則正則為間隙專用,故與本 pass 同處;共用字集自 unicode.ts。
import { ALNUM, ALPHA, ANS_AFTER_CJK, ANS_BEFORE_CJK, ANY_CJK, CJK } 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";
const PASS = "spacing";
// 內部標記字元(PUA,不撞正常文本字形)。
const MARK = "";
const PH_OPEN = "";
const PH_CLOSE = "";
// ----- Pangu 規則正則(沿用 v1-----
const OPS_HYPHEN = "\\+\\*=&\\-";
const OPS_GRADE = "\\+\\-\\*";
const QUOTES_CLS = "`\"״";
const LBR_BASIC = "\\(\\[\\{";
const RBR_BASIC = "\\)\\]\\}";
const LBR_EXT = "\\(\\[\\{<>";
const RBR_EXT = "\\)\\]\\}<>";
const STRIP_CJK_SPACE_ANS = new RegExp("([" + CJK + "]) ([" + ANS_AFTER_CJK + "])", "g");
const STRIP_ANS_SPACE_CJK = new RegExp("([" + ANS_BEFORE_CJK + "]) ([" + CJK + "])", "g");
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");
const QUOTE_AN = new RegExp("([”])([" + ALNUM + "])", "g");
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");
// 複合詞(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
{
if (text.length <= 1 || !ANY_CJK.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(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(CJK_QUOTE, "$1" + MARK + "$2");
s = s.replace(QUOTE_CJK, "$1" + MARK + "$2");
s = s.replace(QUOTE_AN, "$1" + MARK + "$2");
s = s.replace(CJK_QUOTE_AN, "$1$2" + MARK + "$3");
s = s.replace(CJK_HASH, "$1" + MARK + "$2");
s = s.replace(HASH_CJK, "$1" + MARK + "$3");
const compounds: string[] = [];
s = s.replace(COMPOUND_WORD, (m) =>
{
compounds.push(m);
return PH_OPEN + (compounds.length - 1) + PH_CLOSE;
});
s = s.replace(SINGLE_LETTER_GRADE, "$1$2" + MARK + "$3");
s = s.replace(CJK_OPERATOR_ANS, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(ANS_OPERATOR_CJK, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(CJK_LESS_THAN, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(LESS_THAN_CJK, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(CJK_GREATER_THAN, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(GREATER_THAN_CJK, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(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(CJK_ANS, "$1" + MARK + "$2");
s = s.replace(ANS_CJK, "$1" + MARK + "$2");
s = s.replace(S_A, "$1" + MARK + "$2");
return s;
}
interface SpaceAction
{
before: { textNode: Text; offset: number } | undefined;
space: { textNode: Text; offset: number } | null;
}
// 把一個節點(已隔離之單字文本,或元素如 pill)包進 jz-hws——間隙由 CSS 之
// margin-right 提供。不注入空白字元:故 justify 不把間隙當詞間空白放大(邊界
// 只吃均勻字間量),margin 尾隨左側內容(換行時落行尾、永不落行首),且
// textContent 完全不變(內容仍在)。冪等:已在 jz-hws 內則跳過。
function wrapNodeInGap(node: Node): void
{
const parent = node.parentNode;
if (!parent || parent.nodeName === "JZ-HWS") { return; }
const gap = createJz("jz-hws", PASS, "wrap");
parent.insertBefore(gap, node);
gap.appendChild(node);
}
// 隔離 pos 處之單一字元並包入 jz-hws(左側字 + margin-right 留隙)。
function wrapGapLeft(pos: { textNode: Text; offset: number }): void
{
let tn = pos.textNode;
if (!tn.parentNode || tn.parentNode.nodeName === "JZ-HWS") { return; }
if (pos.offset > 0) { tn = tn.splitText(pos.offset); }
if (tn.data.length > 1) { tn.splitText(1); }
wrapNodeInGap(tn);
}
function processBlock(block: Element, finder: Finder): void
{
const runs = finder.collectRuns(block);
for (const run of runs)
{
const { text, map } = run;
const spaced = panguSpace(text);
if (spaced === text) { continue; }
const actions: SpaceAction[] = [];
let origIdx = 0;
let spacedIdx = 0;
while (spacedIdx < spaced.length)
{
const sc = spaced.charAt(spacedIdx);
if (sc === MARK)
{
const oc = origIdx < text.length ? text.charAt(origIdx) : null;
if (oc === " ")
{
actions.push({ before: map[origIdx - 1], space: map[origIdx]! });
origIdx += 1;
}
else
{
actions.push({ before: map[origIdx - 1], space: null });
}
spacedIdx += 1;
continue;
}
origIdx += 1;
spacedIdx += 1;
}
// 倒序執行,避免前面動作之 offset 被後面變更影響。
for (let i = actions.length - 1; i >= 0; i -= 1)
{
const act = actions[i]!;
if (!act.before) { continue; }
if (act.space)
{
const tn = act.space.textNode;
tn.data = tn.data.slice(0, act.space.offset) + tn.data.slice(act.space.offset + 1);
}
wrapGapLeft(act.before);
}
}
}
const PILL_NEIGHBOR = new RegExp("[A-Za-z0-9" + CJK + "]");
function processPills(root: Element, finder: Finder, pillSelector: string): void
{
root.querySelectorAll(pillSelector).forEach((pill) =>
{
if (finder.isAvoided(pill) || !finder.inScope(pill)) { return; }
if (!pill.parentNode) { return; }
const prev = finder.adjacentLogicalChar(pill, -1);
if (prev) { handlePillSide(prev, pill, -1); }
const next = finder.adjacentLogicalChar(pill, 1);
if (next) { handlePillSide(next, pill, 1); }
});
}
// dir -1:間隙在 adj 字(左)與 pill(右)之間 → margin 掛 adj 字(包裝該字)。
// dir +1:間隙在 pill(左)與 adj 字(右)之間 → margin 掛 pill(包裝 pill)。
function handlePillSide(adj: AdjacentChar, pill: Element, dir: -1 | 1): void
{
if (dir === -1)
{
if (PILL_NEIGHBOR.test(adj.ch))
{
wrapGapLeft({ textNode: adj.textNode, offset: adj.offset });
return;
}
if (adj.ch === " " && adj.offset >= 1)
{
const tn = adj.textNode;
const bc = tn.data.charAt(adj.offset - 1);
if (bc !== " " && PILL_NEIGHBOR.test(bc))
{
tn.data = tn.data.slice(0, adj.offset) + tn.data.slice(adj.offset + 1);
wrapGapLeft({ textNode: tn, offset: adj.offset - 1 });
}
}
}
else
{
if (PILL_NEIGHBOR.test(adj.ch))
{
wrapNodeInGap(pill);
return;
}
if (adj.ch === " " && adj.textNode.data.length >= 2)
{
const ac = adj.textNode.data.charAt(1);
if (ac !== " " && PILL_NEIGHBOR.test(ac))
{
adj.textNode.data = adj.textNode.data.slice(1);
wrapNodeInGap(pill);
}
}
}
}
export const spacingPass: StandalonePass = {
name: PASS,
kind: "standalone",
order: 50,
enabled: (o) => o.features.spacing,
render(ctx: RenderContext): void
{
const { finder, options } = ctx;
// Pass A:逐 block 處理(功能閘以 block 為粒度)。
ctx.finder.eachBlock(ctx.root, (block) =>
{
if (!finder.featureEnabledFor(block, PASS)) { return; }
processBlock(block, finder);
});
// Pass Bpill 邊界。
processPills(ctx.root, finder, options.finder.pillSelector);
},
revert(ctx: RenderContext): void
{
revertPass(PASS, ctx.root);
},
};