Files
cjk-autospace/src/typeset/jinze.ts
T
admin e3296104a6 refactor: 專案整理階段二——透明判定單一真相源+跨透明邊界避頭尾+avoid 排除歸一
承階段一之下游審計。核心貫穿問題:「透明穿越」判定實有兩套口徑散落各處,抵觸 finder
檔頭「所有遍歷共用同一判定」之聲明。用戶抉擇:**嚴格白名單**(未知行內標籤=邊界,
透明須顯式登記 styleInlines)。

透明判定單一真相源(2a):
・transparentInline 提升為公開 isTransparent,作邏輯文本流之單一透明判定。
・collectRuns 改由 isTransparent 判定——未在 styleInlines 之未知行內標籤(如 <button>/自訂
  元素)當隔離式邊界(斷 run、內部自成脈絡);isolate 與未知行內併為同一分支。
・jiyaAdjacency 同改用 finder.isTransparent;adjacentLogicalChar 沿用(僅隨改名)。
・分層澄清(承 #4 教訓,不強行一統):邏輯文本流統一為 isTransparent;另兩層各有正當之
  不同邊界、刻意不併入——結構切分安全(split canSplit:更窄白名單,<a>/ABBR 不可切)與
  物理版面(lineedge:getClientRects 物理相鄰,須跨 isolate 找真實鄰接)。檔頭改分層表述。

跨透明元素邊界避頭尾綁定(2b,修下游審計 #3):
・避頭類標點若落於透明元素邊緣(「你好<strong>。世界</strong>」之 。位於 strong 首位、與
  「好」不同 DOM parent),舊版 forbiddenBreak 僅認直接 jz-char、render 僅處理 jz-char 之直接
  父(strong),外層 p 之「好↔strong」綁定從不被評估 → 。漏綁、可誤置行首。
・偵測:edgeCharEl 經 finder.isTransparent 穿越透明元素定位邊緣 jz-char。
・父級處理:render 沿透明元素邊緣上攀納入外層 parent,父集深者先處理。綁定端沿用
  isolateBoundaryToken(可切則最小綁定;邊界字為已 charify 之 jz-char 無法越過時退化整綁,
  安全、與 <a> 同型)。

avoid 排除機制歸一(2c):
・isAvoided 改單一組合入口(自身即邊界 或 祖先鏈任一邊界),免除「avoidsSelf ‖ isAvoided」
  易漏一半之誤用(階段一 #2 即此類);avoidsSelf 保留供遍歷原語逐元素下探自判(免 O(n²))。
・processPills/orphan/spacing root 收斂為 isAvoided(x);doc 補齊七種 avoid 來源與優先序。

一致性小項(2e):scope.include 型別去多餘 `| null`;PASSES 陣列補註(執行序由 pass.order
分群排序決定);gapTrim 補 featureEnabledFor("spacing"),與 lineEdge relayout 逐節點閘對稱。

評估後不做(2d):五處幂等判斷(jiya 祖先/longword 父/spacing 父/orphan 子/jinze 父)檢查
不同位置關係、無單一 isJz 形狀可全覆蓋;真正缺陷(jinze 二次 render 巢狀)已於階段一以 isJz
修復,其餘各自正確,強行歸一徒增風險(承 #4 教訓:正當差異不硬併)。

新增契約/回歸測試(button 嚴格白名單、跨透明邊界避頭);121 測試全綠、構建乾淨。
2026-07-07 21:05:25 +08:00

258 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 禁則:行首行末避頭尾(架構 §6.4)+ justify 綁定。standalone pass,須在
// charifyjiya)之後(依賴 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 逐層穿越透明元素下探定位(與邏輯流單一真相源一致)。遇非透明
* pillisolateavoidblock)或邊緣非 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」之類別決定;邊界字可落於透明行內
* 元素之邊緣(跨 <strong> 等、與外部內容不同 DOM parent),故經 edgeCharEl 下探定位,
* 非僅認直接 jz-char——否則「你好<strong>。世界</strong>」之句末點號漏綁、誤置行首
* (下游審計 #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)
{
// 元素單元(樣式行內如 <strong>)最小綁定(下游 Bug:整元素入 nowrap inline-block
// 致行末整體掉行):只隔離與 jz-char 相鄰側之邊界詞元,餘下留原位。雙側禁斷時先
// tail 後 head——head 作用於切剩之左半(仍為原元素 u)。不可切之元素(含 pill/
// isolateavoid,由 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 覆寫),與 spacingjiya 一致。
const anyCjk = new RegExp("[" + ctx.options.ruleset.cjk + "]");
const parents = new Set<Node>();
ctx.root.querySelectorAll("jz-char").forEach((c) =>
{
// 功能分級閘(§6.5.0a):禁則為段落級,text-level 子樹(標題/按鈕)跳過。
if (!finder.levelAllows(c, "paragraph")) { return; }
// 收集 c 之直接父,並沿**透明**元素邊緣上攀:c 若位於某透明元素之邊緣,該元素
// 於更外層為一單元、其邊界字(c)可與外層兄弟跨界綁定(如 好↔<strong>。世界),
// 故該外層 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);
},
};