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
+136
View File
@@ -0,0 +1,136 @@
// jz-* 元素工廠與還原(架構 §4.4/§5)。
//
// 所有注入結構皆帶 data-jz="<pass>" 標記(冪等 + 可定址還原,I6),並以
// data-jz-kind 區分「marker(純注入,還原時移除)」與「wrap(包裝原內容,
// 還原時解包)」。視覺尺寸一律在 CSS(I2);本模組只造結構。
/** jz-* 自訂元素名(皆含連字號,合法 custom element,預設 inline)。 */
export type JzTag =
| "jz-hws"
| "jz-char"
| "jz-inner"
| "jz-cs"
| "jz-jinze"
| "jz-em"
| "jz-ruby"
| "jz-rb"
| "jz-rt";
type JzKind = "marker" | "wrap";
export interface CreateOptions
{
/** 追加之 class(空白分隔或陣列)。 */
classes?: string | string[];
/** 文本內容。 */
text?: string;
/** 其餘屬性。 */
attrs?: Record<string, string>;
}
const KIND_ATTR = "data-jz-kind";
const PASS_ATTR = "data-jz";
function doc(): Document
{
if (typeof document === "undefined")
{
throw new Error("juzhen: 無 document(需於瀏覽器或 jsdom 環境執行)。");
}
return document;
}
/** 造一個帶 pass 標記之 jz-* 元素。 */
export function createJz(
tag: JzTag,
pass: string,
kind: JzKind,
opts: CreateOptions = {},
): HTMLElement
{
const el = doc().createElement(tag);
el.setAttribute(PASS_ATTR, pass);
el.setAttribute(KIND_ATTR, kind);
if (opts.classes)
{
const list = Array.isArray(opts.classes) ? opts.classes : [ opts.classes ];
for (const c of list) { if (c) { el.classList.add(c); } }
}
if (opts.attrs)
{
for (const k in opts.attrs)
{
const v = opts.attrs[k];
if (v !== undefined) { el.setAttribute(k, v); }
}
}
if (opts.text !== undefined) { el.textContent = opts.text; }
return el;
}
/** 造一個 copy-clean 間隙標記(內含真實空白,user-select 由 CSS 關閉)。 */
export function createMarker(tag: JzTag, pass: string): HTMLElement
{
const el = createJz(tag, pass, "marker", { text: " " });
el.setAttribute("aria-hidden", "true");
return el;
}
/**
* 還原某 pass 之全部產物(I6)。以 data-jz 定址,逆文件序處理(先內層後
* 外層),marker 移除、wrap 解包,最後 normalize 受影響父節點合併文本。
*/
export function revertPass(pass: string, root: ParentNode): void
{
const nodes = Array.from(
root.querySelectorAll<HTMLElement>('[' + PASS_ATTR + '="' + pass + '"]'),
);
// 逆序:子元素先於父元素處理,避免解包父後子引用失效。
const touched = new Set<Node>();
for (let i = nodes.length - 1; i >= 0; i -= 1)
{
const el = nodes[i]!;
const parent = el.parentNode;
if (!parent) { continue; }
touched.add(parent);
const kind = el.getAttribute(KIND_ATTR);
if (kind === "marker")
{
parent.removeChild(el);
}
else
{
// wrap:把子節點搬出後移除外殼。
while (el.firstChild)
{
parent.insertBefore(el.firstChild, el);
}
parent.removeChild(el);
}
}
for (const p of touched)
{
if ((p as Element).normalize) { (p as Element).normalize(); }
}
}
const BD_CLASSES = [ "bd-open", "bd-close", "bd-pause", "bd-stop", "bd-middle", "bd-liga" ];
/** 讀取 jz-char 之 bd-* 子類(無則 null)。 */
export function bdClassOf(el: Element): string | null
{
for (const c of BD_CLASSES)
{
if (el.classList.contains(c)) { return c; }
}
return null;
}
/** 該元素是否為某/任一 pass 之 jz 產物。 */
export function isJz(node: Node | null, pass?: string): boolean
{
if (!node || node.nodeType !== 1) { return false; }
const el = node as Element;
if (!el.hasAttribute(PASS_ATTR)) { return false; }
return pass === undefined || el.getAttribute(PASS_ATTR) === pass;
}
+332
View File
@@ -0,0 +1,332 @@
// 文本遍歷引擎(架構 §4.1,取代 v1 之內聯 DFS / Fibre.js 角色)。
//
// 所有 pass 之共同地基:avoid 名單、scope.include、邏輯文本 run 收集(跨樣式
// 行內元素透明、pillblockavoid 中斷)、pill 鄰字查找、per-node 功能閘
// featureEnabledFor)。finder 不知具體排版規則,只提供遍歷與安全變更原語。
import type { ResolvedOptions } from "../types.js";
/** 邏輯 run 中之單一字元來源位置。 */
export interface CharPos
{
textNode: Text;
offset: number;
}
/** 一段邏輯文本:剝離樣式後之連續字元,及其逐字回映射。 */
export interface LogicalRun
{
text: string;
map: CharPos[];
}
/** pill 鄰接之邏輯鄰字。 */
export interface AdjacentChar
{
textNode: Text;
offset: number;
ch: string;
}
const SVG_NS = "http://www.w3.org/2000/svg";
export class Finder
{
private readonly opts: ResolvedOptions;
private readonly styleSet: Set<string>;
private readonly blockSet: Set<string>;
private readonly skipSet: Set<string>;
private readonly pillSelector: string;
private readonly avoidSelector: string | null;
private readonly includeSelector: string | null;
private readonly skipAttr: string | null;
private readonly userIsSkipped: ((node: Node) => boolean) | null;
private readonly featureCache: WeakMap<Element, Record<string, boolean>>;
constructor(opts: ResolvedOptions)
{
this.opts = opts;
this.styleSet = opts.finder.styleInlines;
this.blockSet = opts.finder.blockTags;
this.skipSet = opts.finder.skipTags;
this.pillSelector = opts.finder.pillSelector;
this.skipAttr = opts.finder.skipAttribute;
this.userIsSkipped = opts.finder.isSkipped;
this.avoidSelector = opts.scope.avoid;
this.includeSelector = opts.scope.include;
this.featureCache = new WeakMap();
}
/** 該元素是否匹配 pill 選擇器(行內塊,如 code/kbd)。 */
pillMatches(el: Element): boolean
{
return !!(el.matches && this.pillSelector && el.matches(this.pillSelector));
}
/** 節點是否處於 avoid 子樹(內建 skip 名單 / SVG / contentEditable /
* data-jz-skip / 使用者 avoid 選擇器 / skipAttribute / isSkipped)。 */
isAvoided(node: Node): boolean
{
if (this.userIsSkipped && this.userIsSkipped(node)) { return true; }
let p: Node | null = node.parentNode;
while (p && p.nodeType === 1)
{
const el = p as Element;
if (this.skipSet.has(el.nodeName)) { return true; }
if ((el as { namespaceURI?: string }).namespaceURI === SVG_NS) { return true; }
if ((el as HTMLElement).isContentEditable) { return true; }
if (el.hasAttribute("data-jz-skip")) { return true; }
if (this.skipAttr && el.hasAttribute(this.skipAttr)) { return true; }
if (this.avoidSelector && el.matches && el.matches(this.avoidSelector))
{
return true;
}
p = p.parentNode;
}
return false;
}
/** scope.include 啟用判定:未設則恆 true;設則須有命中祖先(opt-in)。 */
inScope(node: Node): boolean
{
if (!this.includeSelector) { return true; }
const el = node.nodeType === 1
? (node as Element)
: node.parentElement;
if (!el) { return false; }
return !!(el.closest && el.closest(this.includeSelector));
}
/** 走訪 root 下所有「可處理」文本節點(已過濾 avoid / 非作用域)。 */
eachTextNode(root: Element, cb: (node: Text) => void): void
{
const ownerDoc = root.ownerDocument || (typeof document !== "undefined" ? document : null);
if (!ownerDoc) { return; }
const walker = ownerDoc.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const nodes: Text[] = [];
let n = walker.nextNode();
while (n)
{
const t = n as Text;
if (!this.isAvoided(t) && this.inScope(t)) { nodes.push(t); }
n = walker.nextNode();
}
// 先收集後回呼:容許 cb 變更 DOM 而不擾動走訪。
for (const node of nodes) { cb(node); }
}
/** 走訪 root 下所有 block 元素(含 root 本身若為 block)。 */
eachBlock(root: Element, cb: (block: Element) => void): void
{
const sel = Array.from(this.blockSet)
.map((t) => t.toLowerCase())
.join(",");
const blocks: Element[] = [];
if (sel)
{
root.querySelectorAll(sel).forEach((el) => blocks.push(el));
}
if (root.nodeType === 1 && this.blockSet.has(root.nodeName))
{
blocks.push(root);
}
for (const b of blocks)
{
if (this.includeSelector && !(b.closest && b.closest(this.includeSelector)))
{
continue;
}
cb(b);
}
}
/**
* 收集 block 之邏輯文本 run(架構 §4.1,I7):樣式行內元素與 wrap 類
* jz-* 透明穿越;blockpillavoid/間隙 markerjz-hws)中斷 run。
* 回傳 text.length >= 2 之 run(短於 2 無間隙可言)。
*/
collectRuns(block: Element): LogicalRun[]
{
const runs: LogicalRun[] = [];
let curText = "";
let curMap: CharPos[] = [];
const flush = (): void =>
{
if (curText.length >= 2) { runs.push({ text: curText, map: curMap }); }
curText = "";
curMap = [];
};
const visit = (node: Node | null): void =>
{
if (!node) { return; }
if (node.nodeType === 3)
{
const t = node as Text;
if (this.isAvoided(t) || !this.inScope(t)) { return; }
const data = t.data;
for (let i = 0; i < data.length; i += 1)
{
curText += data.charAt(i);
curMap.push({ textNode: t, offset: i });
}
return;
}
if (node.nodeType !== 1) { return; }
const el = node as Element;
// jz-hws 現包裹左側邊界字(以 margin-right 留隙)→ 透明遞迴,使其內
// 之字仍計入邏輯 run;冪等改由「該字已在 jz-hws 內則不重複包裝」保證。
if (this.isAvoided(el)) { flush(); return; }
if (this.skipSet.has(el.nodeName) || this.blockSet.has(el.nodeName))
{
flush();
return;
}
if (this.pillMatches(el)) { flush(); return; }
// 樣式行內 / 未知行內 / wrap 類 jz-* / span[lang]:透明遞迴。
let c = el.firstChild;
while (c) { visit(c); c = c.nextSibling; }
};
let c = block.firstChild;
while (c) { visit(c); c = c.nextSibling; }
flush();
return runs;
}
/**
* pill 之邏輯鄰字(架構 §4.1):經樣式鏈遞迴,遇 pillblockavoid/間隙
* marker 即止。direction = -1 取前鄰字,+1 取後鄰字。
*/
adjacentLogicalChar(el: Element, direction: -1 | 1): AdjacentChar | null
{
const pickEnd = (tn: Text): AdjacentChar =>
{
if (direction === -1)
{
return {
textNode: tn,
offset: tn.data.length - 1,
ch: tn.data.charAt(tn.data.length - 1),
};
}
return { textNode: tn, offset: 0, ch: tn.data.charAt(0) };
};
const descend = (into: Element): AdjacentChar | null =>
{
let n: Node | null = direction === -1 ? into.lastChild : into.firstChild;
while (n)
{
if (n.nodeType === 3 && (n as Text).data.length > 0)
{
return pickEnd(n as Text);
}
if (
n.nodeType === 1
&& (this.styleSet.has((n as Element).nodeName) || (n as Element).nodeName === "JZ-HWS")
)
{
const r = descend(n as Element);
if (r) { return r; }
}
n = direction === -1 ? n.previousSibling : n.nextSibling;
}
return null;
};
let n: Node | null = direction === -1 ? el.previousSibling : el.nextSibling;
let p: Node | null = el.parentNode;
for (;;)
{
while (n)
{
if (n.nodeType === 3)
{
if ((n as Text).data.length > 0) { return pickEnd(n as Text); }
n = direction === -1 ? n.previousSibling : n.nextSibling;
continue;
}
if (n.nodeType === 1)
{
const e = n as Element;
if (this.isAvoided(e)) { return null; }
if (this.styleSet.has(e.nodeName) || e.nodeName === "JZ-HWS")
{
const r = descend(e);
if (r) { return r; }
n = direction === -1 ? e.previousSibling : e.nextSibling;
continue;
}
return null; // pill 或 block — 止。
}
n = direction === -1 ? n.previousSibling : n.nextSibling;
}
if (!p || p.nodeType !== 1 || !this.styleSet.has((p as Element).nodeName))
{
return null;
}
const pe = p as Element;
n = direction === -1 ? pe.previousSibling : pe.nextSibling;
p = pe.parentNode;
}
}
/**
* 解析節點對某功能之有效啟用狀態(§3.6,per-node 閘)。向上找最近帶
* 功能指令之祖先(data-juzhen 白名單 / data-juzhen-off 黑名單 / 命中
* blocks 選擇器),無則回退全域 features。結果以元素為鍵快取。
*/
featureEnabledFor(node: Node, name: string): boolean
{
const el = node.nodeType === 1 ? (node as Element) : node.parentElement;
const globalOn = this.globalFeature(name);
if (!el) { return globalOn; }
const cached = this.featureCache.get(el);
if (cached && name in cached) { return cached[name]!; }
const result = this.resolveFeature(el, name, globalOn);
const bucket = cached || {};
bucket[name] = result;
this.featureCache.set(el, bucket);
return result;
}
private globalFeature(name: string): boolean
{
const f = this.opts.features as Record<string, boolean>;
return !!f[name];
}
private resolveFeature(start: Element, name: string, globalOn: boolean): boolean
{
let el: Element | null = start;
while (el)
{
// 屬性指令最近者優先。
if (el.hasAttribute("data-juzhen"))
{
const list = (el.getAttribute("data-juzhen") || "").split(/\s+/).filter(Boolean);
return list.indexOf(name) >= 0;
}
if (el.hasAttribute("data-juzhen-off"))
{
const off = (el.getAttribute("data-juzhen-off") || "").split(/\s+/).filter(Boolean);
if (off.indexOf(name) >= 0) { return false; }
// 黑名單未列出者:續向上找,最終回退全域。
}
// blocks 選擇器(程式式白名單)。
for (const rule of this.opts.blocks)
{
if (rule.selector && el.matches && el.matches(rule.selector))
{
return rule.features.indexOf(name) >= 0;
}
}
el = el.parentElement;
}
return globalOn;
}
}
+84
View File
@@ -0,0 +1,84 @@
// 語言與全角/開明政策(架構 §4.3)。
//
// 解析節點之有效 lang,歸類,並依政策決定標點寬度(全角式/開明式/半角式)。
// CSS 端以 :lang() 落實同一政策(I2/I3);本模組供 JS pass 在需要時查詢。
/** 標點寬度政策。 */
export type PunctStyle = "quanjiao" | "kaiming" | "banjiao";
/** 語言分類。 */
export type LangClass = "zh-Hans" | "zh-Hant" | "ja" | "other";
export interface LocaleConfig
{
/** 無 lang 祖先時之預設語言。 */
default: string;
/** 全域標點擠壓預設(全角/開明/半角)。設則覆寫內建之逐語言預設,
* 但仍可被 policy 之逐 lang 設定覆寫。未設則回退內建預設。 */
style?: PunctStyle;
/** 語言分類 → 標點寬度政策之逐 lang 覆寫(最高優先)。 */
policy: Partial<Record<LangClass, PunctStyle>>;
}
const DEFAULT_POLICY: Record<LangClass, PunctStyle> = {
"zh-Hant": "quanjiao",
"zh-Hans": "kaiming",
"ja": "quanjiao",
"other": "quanjiao",
};
/**
* 由節點向上找最近帶 lang 屬性之祖先,回傳小寫值;無則回傳 fallback。
*/
export function effectiveLang(node: Node, fallback: string): string
{
let p: Node | null = node.parentNode;
while (p && p.nodeType === 1)
{
const el = p as Element;
const v = el.getAttribute("lang");
if (v) { return v.toLowerCase(); }
p = p.parentNode;
}
return (fallback || "").toLowerCase();
}
/**
* 將 BCP-47 語言標籤歸類。前綴以子標籤邊界比對('zh-tw' 命中 zh-Hant
* 'zhx' 不命中 zh)。香港/台灣繁體歸 zh-Hant;含 HantHans 子標籤者優先。
*/
export function classify(lang: string): LangClass
{
const l = (lang || "").toLowerCase();
if (l === "ja" || l.indexOf("ja-") === 0) { return "ja"; }
if (l === "zh" || l.indexOf("zh-") === 0 || l.indexOf("zh") === 0)
{
// 顯式子標籤優先。
if (l.indexOf("hant") >= 0) { return "zh-Hant"; }
if (l.indexOf("hans") >= 0) { return "zh-Hans"; }
// 地區推斷:台灣/香港/澳門 → 繁體;其餘(含 cn/sg)→ 簡體。
if (/-(tw|hk|mo)\b/.test(l)) { return "zh-Hant"; }
if (l === "zh") { return "zh-Hant"; }
return "zh-Hans";
}
return "other";
}
/**
* 回傳該語言分類在給定 config 下之標點寬度政策。優先序:
* 逐 lang policy 全域 style 內建逐語言預設 DEFAULT_POLICY。
*/
export function styleFor(langClass: LangClass, config: LocaleConfig): PunctStyle
{
const perLang = config.policy ? config.policy[langClass] : undefined;
if (perLang) { return perLang; }
if (config.style) { return config.style; }
return DEFAULT_POLICY[langClass];
}
/** 解析節點之有效政策(lang → 分類 → 政策)。 */
export function resolveStyle(node: Node, config: LocaleConfig): PunctStyle
{
const lang = effectiveLang(node, config.default);
return styleFor(classify(lang), config);
}
+94
View File
@@ -0,0 +1,94 @@
// Pass 管線執行器(架構 §3.2/§3.4)。
//
// 兩類 passcharify(共享單次 P1 文本走訪,collect 純判定 + 批次 apply)與
// standalone(自跑 render)。聚合僅為執行優化:邏輯上仍一功能一 pass、可獨立
// 開關與 revertI6I9)。
//
// 執行順序:先 charify 群(一次走訪建立 jz-char/span 結構),後 standalone 群
// 依 order 升序。理由見 §3.2——間隙/禁則須在標點結構穩定後處理。
import type { CharifyPass, Pass, RenderContext, StandalonePass, WrapRequest } from "../types.js";
interface NodeWork
{
node: Text;
requests: WrapRequest[];
}
/** 對 ctx.root 套用 passes 中已啟用者。 */
export function runPasses(passes: Pass[], ctx: RenderContext): void
{
const enabled = passes.filter((p) => p.enabled(ctx.options));
const charify = enabled
.filter((p): p is CharifyPass => p.kind === "charify")
.sort((a, b) => a.order - b.order);
const standalone = enabled
.filter((p): p is StandalonePass => p.kind === "standalone")
.sort((a, b) => a.order - b.order);
if (charify.length > 0) { runCharify(charify, ctx); }
for (const p of standalone) { p.render(ctx); }
}
/** 還原 passes 之全部產物(逆 orderrevert 對無產物者安全)。 */
export function revertPasses(passes: Pass[], ctx: RenderContext): void
{
const ordered = passes.slice().sort((a, b) => b.order - a.order);
for (const p of ordered) { p.revert(ctx); }
}
// ----- charify 階段:單次走訪收集,批次 apply -----
function runCharify(passes: CharifyPass[], ctx: RenderContext): void
{
const work: NodeWork[] = [];
ctx.finder.eachTextNode(ctx.root, (node) =>
{
let requests: WrapRequest[] = [];
for (const p of passes)
{
const got = p.collect(node, ctx);
if (got.length > 0) { requests = requests.concat(got); }
}
if (requests.length > 0) { work.push({ node, requests }); }
});
for (const item of work) { applyRequests(item.node, item.requests); }
}
/**
* 把單一文本節點依 requests 重建為 fragment:間隙以原文,命中段以 build()
* 之元素。requests 互不重疊;防禦性地丟棄與已採用段重疊者。
*/
function applyRequests(node: Text, requests: WrapRequest[]): void
{
const parent = node.parentNode;
if (!parent) { return; }
const sorted = requests.slice().sort((a, b) => a.start - b.start || a.end - b.end);
const data = node.data;
const ownerDoc = node.ownerDocument;
if (!ownerDoc) { return; }
const frag = ownerDoc.createDocumentFragment();
let cursor = 0;
for (const req of sorted)
{
if (req.start < cursor) { continue; } // 重疊,跳過。
if (req.start < 0 || req.end > data.length || req.end <= req.start) { continue; }
if (req.start > cursor)
{
frag.appendChild(ownerDoc.createTextNode(data.slice(cursor, req.start)));
}
frag.appendChild(req.build(data.slice(req.start, req.end)));
cursor = req.end;
}
if (cursor === 0) { return; } // 無有效段。
if (cursor < data.length)
{
frag.appendChild(ownerDoc.createTextNode(data.slice(cursor)));
}
parent.replaceChild(frag, node);
}
+120
View File
@@ -0,0 +1,120 @@
// 字元分類與規則字集(架構 §4.2)。
//
// 集中所有 Unicode 範圍與標點子類,供各 pass 共用,杜絕分類散落不一致。
// CJK/西文字集沿用 v1cjk-autospace)已驗證之 Pangu 範圍;標點子類為新增,
// 對應 CSS classjz-char 之 bd-* 標記)。
//
// 這些常數以「regex 字元類片段」形式給出(已就 RegExp 字元類做必要轉義),
// 故可直接 `new RegExp("[" + X + "]")`。
/** CJK 表意文字/假名/注音等範圍(沿用並擴充 v1 之 PANGU_CJK)。 */
export const CJK = (
"⺀-⻿⼀-⿟" // CJK 部首補充 + 康熙部首
+ "぀-ゟ゠-ヺー-ヿ" // 平假名 + 片假名
+ "㄀-ㄯ㈀-㋿" // 注音符號 + 圈號 CJK
+ "㐀-䶿一-鿿豈-﫿" // 擴展 A + 統一表意 + 相容
);
/** 西文字母與數字。 */
export const ALNUM = "A-Za-z0-9";
/** 西文字母。 */
export const ALPHA = "A-Za-z";
/** 任一 CJK 字元測試。 */
export const ANY_CJK = new RegExp("[" + CJK + "]");
// ----- Pangu 中西邊界用之擴充西文字集(沿用 v1)-----
// 拉丁補充以 ¡-¶¸-ÿ 表達(U+00A100B6, 00B800FF),刻意跳過 U+00B7 中間點
// `·`——它是 CJK 間隔號(姓名分隔,如 卡尔·马克思),屬 CJK 標點而非西文,
// 不應觸發中西間隙(否則圓點側會多一個空白)。
/** CJK 之後可接間隙之西文集(含希臘字母、運算符、拉丁補充、箭號、裝飾符)。 */
export const ANS_AFTER_CJK = (
ALPHA + "Ͱ-Ͽ0-9@\\$%\\^&\\*\\-\\+\\\\=¡-¶¸-ÿ⅐-↏✀-➿"
);
/** CJK 之前可接間隙之西文集(同上,去 @)。 */
export const ANS_BEFORE_CJK = (
ALPHA + "Ͱ-Ͽ0-9\\$%\\^&\\*\\-\\+\\\\=¡-¶¸-ÿ⅐-↏✀-➿"
);
// ----- 標點(biaodian)子類。對應 jz-char 之 bd-* class -----
//
// 注意:分類依「字形角色」,而開明式/全角式之別依「句法角色」。slice 1 之
// 擠壓集(句內點號)由 BD_PAUSE 表達——此即 v1 Pass D 所擠之 ,、;:。
/** 句末點號:。!?(開明式與全角式皆保持全形,不擠壓)。 */
export const BD_STOP = "。!?";
/** 句內點號:,、;:(開明式擠成半形;全角式保持全形)。 */
export const BD_PAUSE = ",、;:";
/** 開始括號/引號:「『(《〈【〖〔[{“‘。 */
export const BD_OPEN = "「『(《〈【〖〔[{“‘";
/** 結束括號/引號:」』)》〉】〗〕]}”’。 */
export const BD_CLOSE = "」』)》〉】〗〕]}”’";
/** 間隔/分隔號:·・‧。 */
export const BD_MIDDLE = "·・‧";
/** 連續省略/破折:…—。 */
export const BD_LIGA = "…—";
/** 全體標點字元(供 charify 走訪偵測)。 */
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 ],
];
/**
* 將單一標點字元歸入其 bd-* 子類;非標點回傳 null。
* 供 jiya 之 charify 為 jz-char 標記 class。
*/
export function classifyBiaodian(ch: string): string | null
{
for (const [ name, set ] of BD_SETS)
{
if (set.indexOf(ch) >= 0) { return name; }
}
return null;
}
/** 全體 bd-* 子類名(DOM 端讀取 jz-char 之 bd class 用)。 */
export const BD_CLASS_NAMES = [
"bd-stop", "bd-pause", "bd-open", "bd-close", "bd-middle", "bd-liga",
] as const;
/**
* 標點之「可壓縮空白」側(橫排)。字面偏左者空白在右(句內/句末/閉類),
* 開類字面偏右、空白在左。間隔號/連接號不壓縮。擠壓即收掉對應側之半形空白。
*/
export function blankSides(bdClass: string | null): { left: boolean; right: boolean }
{
switch (bdClass)
{
case "bd-open":
{
return { left: true, right: false };
}
case "bd-close":
case "bd-pause":
case "bd-stop":
{
return { left: false, right: true };
}
default:
{
return { left: false, right: false };
}
}
}