e3296104a6
承階段一之下游審計。核心貫穿問題:「透明穿越」判定實有兩套口徑散落各處,抵觸 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 測試全綠、構建乾淨。
258 lines
11 KiB
TypeScript
258 lines
11 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, 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」之類別決定;邊界字可落於透明行內
|
||
* 元素之邊緣(跨 <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/
|
||
// 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<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);
|
||
},
|
||
};
|