2d42c7ecf2
從零重寫之第一垂直切片(依 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% 等詞元不被禁則切斷。
162 lines
5.6 KiB
TypeScript
162 lines
5.6 KiB
TypeScript
// 禁則:行首行末避頭尾(架構 §6.4)+ justify 綁定。standalone pass,須在
|
||
// charify(jiya)之後(依賴 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);
|
||
},
|
||
};
|