// 禁則:行首行末避頭尾(架構 §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(); 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); }, };