// 禁則:行首行末避頭尾(架構 §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, isJz, revertPass } from "../core/dom.js"; import type { Finder } from "../core/finder.js"; import { isolateBoundaryToken } from "../core/split.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"; } /** el 首個「有意義」子節點(跳過空文本節點);無則 null。 */ function firstMeaningfulChild(el: Element): Node | null { for (let c = el.firstChild; c; c = c.nextSibling) { if (c.nodeType === 3 && (c as Text).data.length === 0) { continue; } return c; } return null; } /** el 末個「有意義」子節點(跳過空文本節點);無則 null。 */ function lastMeaningfulChild(el: Element): Node | null { for (let c = el.lastChild; c; c = c.previousSibling) { if (c.nodeType === 3 && (c as Text).data.length === 0) { continue; } return c; } return null; } /** node 是否位於 el 之邊緣(head=首/tail=末,跳過空文本)。 */ function atEdge(node: Node, el: Element, side: "head" | "tail"): boolean { return node === (side === "head" ? firstMeaningfulChild(el) : lastMeaningfulChild(el)); } /** 單元之邊界 jz-char:自身即 jz-char,或其邊緣落於**透明**行內元素內之 jz-char——經 * finder.isTransparent 逐層穿越透明元素下探定位(與邏輯流單一真相源一致)。遇非透明 * (pill/isolate/avoid/block)或邊緣非 jz-char 即回 null。 */ function edgeCharEl(node: Node, side: "head" | "tail", finder: Finder): Element | null { let n: Node | null = node; while (n && n.nodeType === 1) { if (isCharEl(n)) { return n; } if (!finder.isTransparent(n as Element)) { return null; } n = side === "head" ? firstMeaningfulChild(n as Element) : lastMeaningfulChild(n as Element); } return null; } /** 相鄰兩節點之間是否禁止斷行。由「邊界 jz-char」之類別決定;邊界字可落於透明行內 * 元素之邊緣(跨 等、與外部內容不同 DOM parent),故經 edgeCharEl 下探定位, * 非僅認直接 jz-char——否則「你好。世界」之句末點號漏綁、誤置行首 * (下游審計 #3)。綁定端由 processParent 之 isolateBoundaryToken 落實(可切則最小綁定、 * 不可切則整綁),與此偵測同以透明判定為據。 */ function forbiddenBreak(a: Node, b: Node, finder: Finder): boolean { const head = edgeCharEl(b, "head", finder); if (head) { const c = bdClassOf(head); if (c && AVOID_HEAD.has(c) && finder.featureEnabledFor(head, PASS)) { return true; } } const tail = edgeCharEl(a, "tail", finder); if (tail) { const c = bdClassOf(tail); if (c && AVOID_TAIL.has(c) && finder.featureEnabledFor(tail, PASS)) { return true; } } return false; } /** 節點在文檔樹中之深度(祖先數)。供 render 之「深者先處理」排序。 */ function depthOf(node: Node): number { let d = 0; let p: Node | null = node.parentNode; while (p) { d += 1; p = p.parentNode; } return d; } // 「詞元」:CJK 字為單字詞元(可任意斷);連續非 CJK 非空白(拉丁詞/數字/ // 百分號/GPT-4/3.5 等)為一個不可內斷之詞元。綁定須以整個邊界詞元為單位, // 否則會把 200% 拆成 200|%,在其間製造斷點(修正:閉類綁定不應切斷數字/詞)。 function isTokenChar(ch: string, anyCjk: RegExp): boolean { return !anyCjk.test(ch) && !/\s/.test(ch); } function leadingTokenEnd(d: string, anyCjk: RegExp): number { if (anyCjk.test(d.charAt(0))) { return 1; } let e = 1; while (e < d.length && isTokenChar(d.charAt(e), anyCjk)) { e += 1; } return e; } function trailingTokenStart(d: string, anyCjk: RegExp): number { const last = d.length - 1; if (anyCjk.test(d.charAt(last))) { return last; } let s = last; while (s > 0 && isTokenChar(d.charAt(s - 1), anyCjk)) { s -= 1; } return s; } /** 隔離文本節點之首/末「詞元」為獨立節點(供綁定,使中段仍可斷行)。 */ function splitBoundaries(t: Text, needFirst: boolean, needLast: boolean, anyCjk: RegExp): void { let node: Text = t; if (needFirst) { const e = leadingTokenEnd(node.data, anyCjk); if (e < node.data.length) { node = node.splitText(e); } } if (needLast) { const s = trailingTokenStart(node.data, anyCjk); if (s > 0) { node.splitText(s); } } } function processParent(parent: Node, finder: Finder, anyCjk: RegExp): void { // 冪等(I6):parent 自身即本 pass 產物——二次 render 未 revert 時,jz-char 之父 // 正是上輪產生之 jz-jinze,若不擋則對已綁定內容再包一層、巢狀增殖(下游審計實測 3→6)。 if (isJz(parent, PASS)) { return; } // 冪等:parent 已含 jz-jinze 子(上輪已處理此 parent)則跳過。 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]!; const needFirst = i > 0 && forbidden[i - 1]!; const needLast = i < n - 1 && forbidden[i]!; if (!needFirst && !needLast) { continue; } if (u.nodeType === 3) { splitBoundaries(u as Text, needFirst, needLast, anyCjk); } else if (u.nodeType === 1) { // 元素單元(樣式行內如 )最小綁定(下游 Bug:整元素入 nowrap inline-block // 致行末整體掉行):只隔離與 jz-char 相鄰側之邊界詞元,餘下留原位。雙側禁斷時先 // tail 後 head——head 作用於切剩之左半(仍為原元素 u)。不可切之元素(含 pill/ // isolate/avoid,由 canSplit 諮詢 finder 判定)退化整綁——pill 即「作為原子 // 鄰字單位整體綁入 jz-jinze」之期望行為(下游問題六)。 const el = u as Element; if (needLast) { isolateBoundaryToken(el, "tail", anyCjk, PASS, finder); } if (needFirst) { isolateBoundaryToken(el, "head", anyCjk, PASS, finder); } } } // 階段 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, level: "paragraph", enabled: (o) => o.features.jinze, render(ctx: RenderContext): void { const { finder } = ctx; // 詞元邊界之 CJK 判定取自規則集(含 charClass 覆寫),與 spacing/jiya 一致。 const anyCjk = new RegExp("[" + ctx.options.ruleset.cjk + "]"); const parents = new Set(); ctx.root.querySelectorAll("jz-char").forEach((c) => { // 功能分級閘(§6.5.0a):禁則為段落級,text-level 子樹(標題/按鈕)跳過。 if (!finder.levelAllows(c, "paragraph")) { return; } // 收集 c 之直接父,並沿**透明**元素邊緣上攀:c 若位於某透明元素之邊緣,該元素 // 於更外層為一單元、其邊界字(c)可與外層兄弟跨界綁定(如 好↔。世界), // 故該外層 parent 亦須處理(下游審計 #3:僅處理直接父致跨透明邊界避頭尾漏綁)。 let child: Node = c; let parent: Node | null = c.parentNode; while (parent && parent.nodeType === 1) { parents.add(parent); const pe = parent as Element; if (!finder.isTransparent(pe)) { break; } if (!atEdge(child, pe, "head") && !atEdge(child, pe, "tail")) { break; } child = pe; parent = pe.parentNode; } }); // 深者先處理:透明子先於可能將其切分之外層 parent,避免子之內部綁定被外層切分擾動。 const ordered = Array.from(parents).sort((a, b) => depthOf(b) - depthOf(a)); for (const parent of ordered) { processParent(parent, finder, anyCjk); } }, revert(ctx: RenderContext): void { revertPass(PASS, ctx.root); }, };