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:
@@ -0,0 +1,280 @@
|
||||
// 中西間隙(架構 §6.1,切片 1)。standalone pass。
|
||||
//
|
||||
// 沿用 v1(cjk-autospace)已驗證之 Pangu 規則管線與 per-block 邏輯文本處理,
|
||||
// 改用 finder 提供之 collectRuns/adjacentLogicalChar,並把間隙 marker 換為
|
||||
// copy-clean 之 jz-hws(user-select 由 CSS 關閉,修正 Han.css 之選中缺陷,I1)。
|
||||
//
|
||||
// 涵蓋 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 { 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 B:pill 邊界。
|
||||
processPills(ctx.root, finder, options.finder.pillSelector);
|
||||
},
|
||||
revert(ctx: RenderContext): void
|
||||
{
|
||||
revertPass(PASS, ctx.root);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user