feat: 垂懸字避免(方案 C,斷行層無測量,order 70)(§6.5.1)

段末行 CJK 實義字數 ≥ N(預設 2;標點/符號不計)。standalone 段落級 pass:
自段末向前數 N 個實義字,把「第 N 字起至段末」(含其間/尾隨標點)包入
<jz-orphan>(white-space:nowrap)。該整體不可斷 → 末行恒含之 ⇒ 末行實義字 ≥ N,
任意寬度/字型/瀏覽器成立,無需量測 layout。

要點:
- 邊界字定位跨 jz-jinze/行內元素攀爬:實義字落於行內元素內時綁定整個塊級
  直接子(不切割 nowrap 群、revert 乾淨);僅裸文本直接子才精確切分(最小綁定)。
- 僅處理葉段落(無塊級後代);段落實義字 < N 不綁;單行段方案 C 本就無害。
- 選項 orphan?: boolean | { chars? };**預設開 chars=2**(§6.5.6 建議);
  下游可 orphan:false 關閉。段落級,data-jz-level=text 子樹自動跳過。
- order=70:jinze(40)/spacing(50)/longWord(60) 後、layout pass(90/92) 前。

新增 _orphan.css(jz-orphan{white-space:nowrap});JzTag 加 jz-orphan。
9 項單元測試(末 N+尾標點、chars 選項、標點不計、實義字<N 不綁、跨 jinze、
關閉、冪等、revert、段落級閘)。共 85 測試全綠。

窄容器拖動「末行恒 ≥N」之 layout 驗證留待 demo(Step 4-5,瀏覽器)。
This commit is contained in:
2026-06-09 17:00:04 +08:00
parent c1fbabf5b0
commit 754b00da42
13 changed files with 521 additions and 21 deletions
+151
View File
@@ -0,0 +1,151 @@
// 垂懸字避免(架構 §6.5.1,方案 C:斷行層、無測量)。standalone 段落級 pass。
//
// 目標:段末行之 CJK 實義字數 ≥ N(預設 2;標點/符號不計)。單行段天然無害。
// 機制:自段末向前數 N 個 CJK 實義字,把「第 N 個實義字起至段末」(含其間/尾隨
// 標點)包入 <jz-orphan>CSS white-space:nowrap)。該整體不可斷 → 末行恒含之 ⇒
// 末行實義字 ≥ N,任意寬度/字型/瀏覽器成立,無需量測 layout(異於行端/懸掛)。
//
// 邊界字定位「跨 jz-jinze/jz-char 等行內包裹攀爬」:實義字若落於某行內元素(禁則群組
// 強調等)內,則綁定整個**塊級直接子**——不切割該元素(免破壞 jz-jinze 之 nowrap 群、
// 免 revert 後殘留半個元素)。代價是偶爾多綁少許字(保證仍成立、無害)。僅在實義字
// 直接位於塊之裸文本子節點時才精確切分該文本(最小綁定)。
//
// order=70jinze(40)spacing(50)longWord(60) 後、layout pass(90/92) 前。
import { createJz, revertPass } from "../core/dom.js";
import type { Finder } from "../core/finder.js";
import { classifyBiaodian } from "../core/unicode.js";
import type { RenderContext, Ruleset, StandalonePass } from "../types.js";
type Biaodian = Ruleset["biaodian"];
const PASS = "orphan";
/** 單一實義字之來源定位:所屬塊級直接子;若該直接子即裸文本節點,記其字內 offset。 */
interface SubstPos
{
directChild: Node;
textNode: Text | null; // 非 null 且 === directChild 時表示「裸文本直接子」,可精確切分
offset: number;
}
/** block 已含 jz-orphan(前次 render 未 revert)→ 冪等跳過。 */
function hasOrphan(block: Element): boolean
{
for (let c = block.firstChild; c; c = c.nextSibling)
{
if (c.nodeType === 1 && (c as Element).nodeName === "JZ-ORPHAN") { return true; }
}
return false;
}
/** 收集 block 內各 CJK 實義字之定位(文件序);descend 透明行內,遇塊/pillavoid 即止。 */
function collectSubst(
block: Element,
finder: Finder,
blockSet: Set<string>,
anyCjk: RegExp,
biaodian: Biaodian,
): SubstPos[]
{
const out: SubstPos[] = [];
const visit = (node: Node, directChild: Node): void =>
{
if (node.nodeType === 3)
{
const t = node as Text;
const d = t.data;
for (let i = 0; i < d.length; i += 1)
{
const ch = d.charAt(i);
// 實義字:屬 CJK 字集且非標點(標點/符號不計)。
if (anyCjk.test(ch) && !classifyBiaodian(ch, biaodian))
{
out.push({ directChild, textNode: t, offset: i });
}
}
return;
}
if (node.nodeType !== 1) { return; }
const el = node as Element;
if (finder.isAvoided(el)) { return; }
if (blockSet.has(el.nodeName) || finder.pillMatches(el)) { return; } // 邊界:不計、不下探
for (let c = el.firstChild; c; c = c.nextSibling) { visit(c, directChild); }
};
for (let dc = block.firstChild; dc; dc = dc.nextSibling) { visit(dc, dc); }
return out;
}
function processBlock(
block: Element,
finder: Finder,
blockSet: Set<string>,
anyCjk: RegExp,
biaodian: Biaodian,
chars: number,
): void
{
if (hasOrphan(block)) { return; }
const subst = collectSubst(block, finder, blockSet, anyCjk, biaodian);
// 段落實義字 < N → 不綁(無法保證、亦無意義)。單行段:方案 C 之 nowrap 本就無害。
if (subst.length < chars) { return; }
const boundary = subst[subst.length - chars]!;
let startChild: Node = boundary.directChild;
// 邊界落於裸文本直接子 → 精確切分(最小綁定);否則綁定整個直接子(含其內行內元素,
// 不切割,跨 jz-jinze 等攀爬)。
if (boundary.textNode && boundary.textNode === startChild)
{
if (boundary.offset > 0)
{
startChild = (boundary.textNode as Text).splitText(boundary.offset);
}
// offset === 0:實義字即文本起點,整個文本節點入綁,無需切分。
}
// 把 startChild 及其後全部直接子搬入 jz-orphan。
const orphan = createJz("jz-orphan", PASS, "wrap");
block.insertBefore(orphan, startChild);
let n: Node | null = startChild;
while (n)
{
const next: Node | null = n.nextSibling;
orphan.appendChild(n);
n = next;
}
}
export const orphanPass: StandalonePass = {
name: PASS,
kind: "standalone",
order: 70,
level: "paragraph",
enabled: (o) => o.features.orphan,
render(ctx: RenderContext): void
{
const { finder, options } = ctx;
const blockSet = options.finder.blockTags;
const anyCjk = new RegExp("[" + options.ruleset.cjk + "]");
const biaodian = options.ruleset.biaodian;
const chars = options.orphan.chars;
finder.eachBlock(ctx.root, (block) =>
{
// 段落級閘 per-node 功能閘。
if (!finder.levelAllows(block, "paragraph")) { return; }
if (!finder.featureEnabledFor(block, PASS)) { return; }
// 僅處理葉段落(無塊級後代):含巢狀塊之容器,其實義字屬內層段落,另行處理。
const sel = Array.from(blockSet).map((t) => t.toLowerCase()).join(",");
if (sel && block.querySelector(sel)) { return; }
processBlock(block, finder, blockSet, anyCjk, biaodian, chars);
});
},
revert(ctx: RenderContext): void
{
revertPass(PASS, ctx.root);
},
};