fix: 行內排版透明性——pill 前標點誤隙+行內元素整體掉行+審計(Bug1/Bug2/T4-T6)

統一「行內元素對排版透明」原則在各遍歷原語之口徑,修復兩個下游 Bug 及三項同根隱患。

Bug1(pill 前標點誤加間隙):
  isTransparentInline 補 JZ-CHAR/JZ-INNER。`漢字。<code>` 經禁則綁定後,
  adjacentLogicalChar 之 descend 遇不透明 jz-char 既不進入亦不終止、而跳過它越過
  標點取到「字」誤補 jz-hws;透明後回傳標點「。」(非 pill 鄰字)→ 不補隙,與
  collectRuns 既有透明遞迴對齊。

Bug2(行內樣式元素整體掉行):
  新增 core/split.ts 之 isolateBoundaryToken/splitElementAt——沿祖先鏈逐層把邊界
  詞元剝入淺克隆(保留標籤/class/style/lang、去 id;標 data-jz-kind="splitoff"),
  jinze/orphan 改最小綁定:`<strong>長串</strong>,` 只綁末字+逗號(非整串入 nowrap
  inline-block 致行末整體掉行);段末長 strong 不再整綁溢出容器。revertPass 增延後
  階段把 splitoff 併回原元素(先解包 wrap、後併 splitoff),render→revert 還原一致。
  純樣式標籤白名單可切,a/abbr/ruby/帶 id 退化整綁。

審計(同根隱患):
  T4 lineedge edgeRect 攀爬集改由 options.finder.styleInlines 派生(棄硬編碼子集,
     免遺漏 ins/del/sub 等致行端誤判)。
  T5 jiyaAdjacency 遇 pill(pillMatches)斷相鄰、不穿越(內容對排版不可見)。
  T6 spacing processPills 補 per-node 功能閘(data-juzhen-off=spacing 區域內不補隙)。

文檔:README 切片表(spacing/jinze/orphan 三行)+ARCHITECTURE §4.1 透明性原則、
  §4.4 splitoff 還原、§6.4 行內元素最小綁定、§5 jz-orphan 詞彙。

測試 90→102(Bug1×4、Bug2×6、T5/T6×2)。headless Chrome 實證 Bug2:段末 strong
最小切分、orphan 寬 60px 不溢出版心。
This commit is contained in:
2026-06-11 16:06:17 +08:00
parent f3fe2399a7
commit 2890549f44
14 changed files with 985 additions and 89 deletions
+32 -1
View File
@@ -80,6 +80,10 @@ export function createMarker(tag: JzTag, pass: string): HTMLElement
/**
* 還原某 pass 之全部產物(I6)。以 data-jz 定址,逆文件序處理(先內層後
* 外層),marker 移除、wrap 解包,最後 normalize 受影響父節點合併文本。
*
* splitoff(克隆切分之邊界詞元,見 core/split.ts)須在 wrap 全部解包**之後**才併回:
* 否則其前兄弟可能仍是 wrap 殼(如 jz-jinze)而非原元素。故主迴圈跳過 splitoff,於
* 解包後第二階段以文件序逐一併回前兄弟(同標籤元素),還原為單一元素(I6 一致)。
*/
export function revertPass(pass: string, root: ParentNode): void
{
@@ -93,8 +97,9 @@ export function revertPass(pass: string, root: ParentNode): void
const el = nodes[i]!;
const parent = el.parentNode;
if (!parent) { continue; }
touched.add(parent);
const kind = el.getAttribute(KIND_ATTR);
if (kind === "splitoff") { continue; } // 延後階段處理。
touched.add(parent);
if (kind === "marker")
{
parent.removeChild(el);
@@ -109,6 +114,32 @@ export function revertPass(pass: string, root: ParentNode): void
parent.removeChild(el);
}
}
// 第二階段:splitoff 併回(此時 wrap 已全解包,前兄弟即原元素)。文件序,使同一
// 原元素之多個 splitoff 依序併入。
const splitoffs = Array.from(
root.querySelectorAll<HTMLElement>(
'[' + PASS_ATTR + '="' + pass + '"][' + KIND_ATTR + '="splitoff"]',
),
);
for (const so of splitoffs)
{
const parent = so.parentNode;
if (!parent) { continue; }
touched.add(parent);
const prev = so.previousSibling;
if (prev && prev.nodeType === 1 && (prev as Element).nodeName === so.nodeName)
{
// 併入前兄弟(同標籤原元素)尾部。
while (so.firstChild) { prev.appendChild(so.firstChild); }
parent.removeChild(so);
}
else
{
// 前兄弟非預期同標籤元素(罕見,結構曾被外力改動):原地解包,仍移除標記。
while (so.firstChild) { parent.insertBefore(so.firstChild, so); }
parent.removeChild(so);
}
}
for (const p of touched)
{
if ((p as Element).normalize) { (p as Element).normalize(); }
+13 -3
View File
@@ -68,11 +68,21 @@ export class Finder
}
/** 邏輯文本應**透明穿越**之行內元素:樣式行內標籤,以及聚珍自身之 wrap 類
* jz-*jz-hws 間隙、jz-jinze 禁則群組)。穿越這些才能正確找到跨包裝之邏輯
* 鄰字(如 jinze 已把 pill 納入 jz-jinze 後,pill 之左鄰字仍須可達;I7)。 */
* jz-*jz-hws 間隙、jz-jinze 禁則群組、jz-charjz-inner 標點原子)。穿越這些
* 才能正確找到跨包裝之邏輯鄰字(如 jinze 已把 pill 納入 jz-jinze 後,pill 之左
* 鄰字仍須可達;I7)。
* ⚠ jz-charjz-inner 必須透明:否則 adjacentLogicalChar 之 descend 遇標點原子既
* 不進入亦不終止、而是**跳過**它續找更前之兄弟(下游 Bug`汉字。<code>` 經 jinze
* 綁定為 `汉<jz-jinze>字<jz-char>。</jz-char></jz-jinze><code>`pill 之左鄰字應
* 為「。」、不補間隙;舊邏輯卻越過 jz-char 取到「字」→ 誤包 jz-hws 於「字。」之間)。
* jz-char 僅含標點(jiya 只 charify biaodian),標點非 pillNeighbor,故透明後 descend
* 回傳標點 → 不補隙,與 collectRuns 之既有透明遞迴一致。jz-inner 亦須列入,否則
* descend 進入 jz-char 後因 jz-inner 不透明而跳過其內標點。 */
private isTransparentInline(name: string): boolean
{
return this.styleSet.has(name) || name === "JZ-HWS" || name === "JZ-JINZE";
return this.styleSet.has(name)
|| name === "JZ-HWS" || name === "JZ-JINZE"
|| name === "JZ-CHAR" || name === "JZ-INNER";
}
/** 節點是否處於 avoid 子樹(內建 skip 名單 / SVG / contentEditable /
+206
View File
@@ -0,0 +1,206 @@
// 行內邊界詞元隔離(架構 §6.4/§6.5.1;下游 Bug:行內樣式標記須對排版透明)。
//
// 禁則(jinze)與垂懸字(orphan)須把「邊界字/詞元」綁進不可斷之群組(jz-jinze
// 之 nowrap、jz-orphan 之 nowrap)。若該邊界字落於樣式行內元素(<strong><em> 等)
// 內,舊版**整個元素**入綁——`<strong>很長一串粗體</strong>` 會把整串粗體塞進
// nowrap inline-block,行末放不下即整體掉行、上行留巨大空隙;orphan 更可致長元素溢出
// 容器。本模組把邊界詞元**克隆切分**出來,只綁該克隆(最小綁定),原元素其餘部分留在
// 原位,渲染視覺無縫(相鄰同樣式行內元素)。
//
// 機制:splitTreeAfter 沿「文本節點 → 邊界元素」之祖先鏈逐層把「切點之後」之內容剝入
// 一個淺克隆(保留標籤/classstyle/lang/dir,去 id),克隆作為原元素之後兄弟。克隆
// 標 data-jz-kind="splitoff"data-jz="<pass>",供 revertPass 之延後階段併回原元素(先解
// 包 wrap、後併 splitoff,確保前兄弟為原元素而非 wrap 殼;I6 還原一致)。
//
// 僅純樣式行內標籤可切(白名單);帶語義/引用者(A 連結會克隆出兩個錨點、ABBR 之
// title、RUBY 結構、帶 id 者)一律回退整元素綁定——退化但安全。
const PASS_ATTR = "data-jz";
const KIND_ATTR = "data-jz-kind";
// 可安全克隆切分之純樣式行內標籤(切分不改變語義、相鄰兩份視覺無縫)。
// 刻意排除:A(克隆出兩個連結)、ABBR/Q(title/引號語義重複)、RUBY 系(結構性)、
// 帶 id 者(id 重複)——皆回退整元素綁定。
const SPLITTABLE = new Set([
"STRONG", "EM", "B", "I", "U", "S", "SPAN", "MARK",
"SMALL", "INS", "DEL", "SUB", "SUP",
]);
interface Point
{
textNode: Text;
offset: number;
}
/** 元素是否可克隆切分(純樣式行內標籤、無 id)。 */
function canSplit(el: Element): boolean
{
return SPLITTABLE.has(el.nodeName) && !el.hasAttribute("id");
}
/** el 內**首**個非空文本節點(僅下探可切標籤;遇非可切元素回 null=放棄切分)。 */
function firstText(el: Element): Text | null
{
for (let c = el.firstChild; c; c = c.nextSibling)
{
if (c.nodeType === 3 && (c as Text).data.length > 0) { return c as Text; }
if (c.nodeType === 1)
{
if (!canSplit(c as Element)) { return null; }
const r = firstText(c as Element);
if (r) { return r; }
}
}
return null;
}
/** el 內**末**個非空文本節點(僅下探可切標籤;遇非可切元素回 null=放棄切分)。 */
function lastText(el: Element): Text | null
{
for (let c = el.lastChild; c; c = c.previousSibling)
{
if (c.nodeType === 3 && (c as Text).data.length > 0) { return c as Text; }
if (c.nodeType === 1)
{
if (!canSplit(c as Element)) { return null; }
const r = lastText(c as Element);
if (r) { return r; }
}
}
return null;
}
function isTokenChar(ch: string, anyCjk: RegExp): boolean
{
return !anyCjk.test(ch) && !/\s/.test(ch);
}
/** 文本節點之首詞元結束位置(CJK 字=1;連續非 CJK 非空白=整詞)。 */
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;
}
/**
* 把 top 自切點(textNode, offset)起至末尾之內容剝入一個淺克隆鏈,克隆作為 top 之
* 後兄弟插入並標記 splitoff。回傳該頂層克隆;若切點位於 top 內容之最起點(左側無內容、
* 即整個 top 都在切點之後)則回 null(呼叫端據此綁整個 top)。
*
* 沿祖先鏈逐層剝離:每層把「切點側之首節點及其後兄弟」搬入該層元素之淺克隆,空克隆
* 跳過(避免殘留空元素破壞 revert 還原)。
*/
function splitTreeAfter(top: Element, textNode: Text, offset: number, pass: string): Element | null
{
// 切點位於 top 最起點(首文本節點之 offset 0)→ 左側無內容,不切。
if (offset <= 0 && firstText(top) === textNode) { return null; }
// 該文本層之右側首節點。
let rightAtLevel: Node | null;
if (offset >= textNode.data.length)
{
rightAtLevel = textNode.nextSibling;
}
else if (offset <= 0)
{
rightAtLevel = textNode;
}
else
{
rightAtLevel = textNode.splitText(offset);
}
let ancestor: Node | null = textNode.parentNode;
let topClone: Element | null = null;
while (ancestor && ancestor.nodeType === 1)
{
const anc = ancestor as Element;
let clone: Element | null = null;
if (rightAtLevel)
{
clone = anc.cloneNode(false) as Element;
clone.removeAttribute("id");
clone.setAttribute(PASS_ATTR, pass);
clone.setAttribute(KIND_ATTR, "splitoff");
let n: Node | null = rightAtLevel;
while (n)
{
const next: Node | null = n.nextSibling;
clone.appendChild(n);
n = next;
}
}
if (anc === top)
{
if (!clone) { return null; }
if (top.parentNode) { top.parentNode.insertBefore(clone, top.nextSibling); }
topClone = clone;
break;
}
if (clone)
{
if (anc.parentNode) { anc.parentNode.insertBefore(clone, anc.nextSibling); }
rightAtLevel = clone; // 下一層之右側起點:克隆(緊隨 anc,原後兄弟在克隆之後)
}
else
{
rightAtLevel = anc.nextSibling; // 本子樹無右側內容;上一層右側起點為 anc 之後兄弟
}
ancestor = anc.parentNode;
}
return topClone;
}
/**
* 隔離 el 之邊界詞元供綁定(最小綁定)。side="tail" 取末詞元、="head" 取首詞元。
* 回傳**應綁定之節點**
* · 可切且確實切分 → tail 回克隆(末詞元)、head 回原 el(已只剩首詞元,其餘入克隆);
* · 不可切/無法定位切點/整元素即該詞元 → 回 el 本身(綁整個元素,退化但安全)。
* 切出之克隆標 splitoff,由 revertPass 併回;故 render→revert 還原一致(I6)。
*/
export function isolateBoundaryToken(
el: Element,
side: "head" | "tail",
anyCjk: RegExp,
pass: string,
): Element
{
if (!canSplit(el)) { return el; }
const text = side === "tail" ? lastText(el) : firstText(el);
if (!text) { return el; }
const point: Point = side === "tail"
? { textNode: text, offset: trailingTokenStart(text.data, anyCjk) }
: { textNode: text, offset: leadingTokenEnd(text.data, anyCjk) };
const clone = splitTreeAfter(el, point.textNode, point.offset, pass);
if (!clone) { return el; } // 整元素即邊界詞元 → 綁整個 el。
return side === "tail" ? clone : el;
}
/**
* 把 el 之**指定切點**(精確字位)起至末尾剝入克隆並回傳(供 orphan:自實義字綁至塊末)。
* 不可切或切點在最起點 → 回 null(呼叫端綁整個直接子)。
*/
export function splitElementAt(
el: Element,
textNode: Text,
offset: number,
pass: string,
): Element | null
{
if (!canSplit(el)) { return null; }
return splitTreeAfter(el, textNode, offset, pass);
}
+15 -2
View File
@@ -12,6 +12,7 @@
import { bdClassOf, createJz, 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";
@@ -110,10 +111,22 @@ function processParent(parent: Node, finder: Finder, anyCjk: RegExp): void
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, anyCjk); }
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)。不可切之元素退化整綁。
const el = u as Element;
if (needLast) { isolateBoundaryToken(el, "tail", anyCjk, PASS); }
if (needFirst) { isolateBoundaryToken(el, "head", anyCjk, PASS); }
}
}
// 階段 2:重掃當前子節點,把禁斷相連之相鄰原子併成 jz-jinze。
+1
View File
@@ -158,6 +158,7 @@ export const jiyaAdjacencyPass: StandalonePass = {
if (prev) { squeeze(prev, el); }
prev = el; // jz-char 為原子,不下探(內含 jz-inner)
}
else if (finder.pillMatches(el)) { prev = null; } // pillcode/kbd):斷相鄰、不下探(其內容對排版不可見,不應被當透明行內穿越)
else if (finder.isAvoided(el)) { prev = null; } // avoid/skip 子樹:斷、不下探
else if (blockSet.has(nm))
{
+28 -10
View File
@@ -62,9 +62,20 @@ function uninstallLayout(root: Element, key: string): void
if (ro) { ro.disconnect(); m.delete(key); }
}
// 我們的行內包裹:跨越它們找相鄰內容(jz-char 可能被 jz-jinze 包住,其流
// 鄰居在群組外)。遇 block 邊界即止(回 null,視為行端)。
const INLINE_WRAP = new Set([ "JZ-JINZE", "JZ-CHAR", "JZ-INNER", "JZ-HWS", "SPAN", "STRONG", "EM", "A", "B", "I", "U", "MARK" ]);
// 行內包裹集:edgeRect 攀爬時須跨越「不中斷行內流」之元素找相鄰內容(jz-char 可能被
// jz-jinze 包住,其流鄰居在群組外)。遇 block 邊界即止(回 null,視為行端)。
// **由 options.finder.styleInlines 派生**+聚珍自身之 jz 包裹標籤——舊版硬編碼僅含
// SPAN/STRONG/EM/A/B/I/U/MARK 子集,遇未列之樣式行內(SINSDELSUBSUPSMALL
// ABBRCITEDFNQVARSAMPTIMEBDOBDIRUBY 等)會提前止步、誤判行端而錯加行端
// 半形(透明性不一致之同根隱患 T4)。
const JZ_WRAP_TAGS = [ "JZ-JINZE", "JZ-CHAR", "JZ-INNER", "JZ-HWS" ];
function buildWrapSet(options: ResolvedOptions): Set<string>
{
const s = new Set<string>(JZ_WRAP_TAGS);
for (const name of options.finder.styleInlines) { s.add(name); }
return s;
}
function rectOf(n: Node, side: "prev" | "next"): DOMRect | null
{
@@ -90,7 +101,12 @@ function rectOf(n: Node, side: "prev" | "next"): DOMRect | null
/** 取某節點側邊界字元之矩形(橫排:判定相鄰內容是否同行);跨我們的行內包裹
* 向上攀爬,遇 block 邊界回 null(視為行端)。 */
function edgeRect(start: Node | null, side: "prev" | "next", from: Node): DOMRect | null
function edgeRect(
start: Node | null,
side: "prev" | "next",
from: Node,
wrapSet: Set<string>,
): DOMRect | null
{
let n = start;
let parentRef: Node | null = from.parentNode;
@@ -104,7 +120,7 @@ function edgeRect(start: Node | null, side: "prev" | "next", from: Node): DOMRec
}
// 本層無內容:若父為行內包裹,攀出續找其鄰居。
if (!parentRef || parentRef.nodeType !== 1) { return null; }
if (!INLINE_WRAP.has((parentRef as Element).nodeName)) { return null; }
if (!wrapSet.has((parentRef as Element).nodeName)) { return null; }
n = side === "prev" ? parentRef.previousSibling : parentRef.nextSibling;
parentRef = parentRef.parentNode;
}
@@ -189,6 +205,7 @@ interface EdgeDecision { el: Element; edge: "head" | "tail"; mode: LineEndMode;
function relayout(root: Element, finder: Finder, options: ResolvedOptions): void
{
clearEdge(root);
const wrapSet = buildWrapSet(options);
const chars = Array.from(root.querySelectorAll("jz-char")).filter(
(c) => finder.inScope(c) && !finder.isAvoided(c),
@@ -219,12 +236,12 @@ function relayout(root: Element, finder: Finder, options: ResolvedOptions): void
if (isHead)
{
const prev = edgeRect(el.previousSibling, "prev", el);
const prev = edgeRect(el.previousSibling, "prev", el, wrapSet);
if (!prev || higherLine(r, prev)) { decisions.push({ el, edge: "head", mode }); }
}
else
{
const next = edgeRect(el.nextSibling, "next", el);
const next = edgeRect(el.nextSibling, "next", el, wrapSet);
if (!next || lowerLine(r, next)) { decisions.push({ el, edge: "tail", mode }); }
}
}
@@ -259,7 +276,7 @@ export const lineEdgePass: StandalonePass = {
const TRIM = "jz-hws-trim";
function trimGaps(root: Element, finder: Finder): void
function trimGaps(root: Element, finder: Finder, wrapSet: Set<string>): void
{
const gaps = Array.from(root.querySelectorAll("jz-hws")).filter(
(g) => finder.inScope(g) && !finder.isAvoided(g) && finder.levelAllows(g, "paragraph"),
@@ -285,7 +302,7 @@ function trimGaps(root: Element, finder: Finder): void
for (const g of gaps)
{
const r = lastRect(g);
const next = r ? edgeRect(g.nextSibling, "next", g) : null;
const next = r ? edgeRect(g.nextSibling, "next", g, wrapSet) : null;
const atLineEnd = !r || !next || next.top > r.top + EPS;
if (!atLineEnd) { g.classList.remove(TRIM); }
}
@@ -300,7 +317,8 @@ export const gapTrimPass: StandalonePass = {
render(ctx: RenderContext): void
{
if (!hasLayout()) { return; }
installLayout(ctx.root, "gapTrim", () => trimGaps(ctx.root, ctx.finder));
const wrapSet = buildWrapSet(ctx.options);
installLayout(ctx.root, "gapTrim", () => trimGaps(ctx.root, ctx.finder, wrapSet));
},
revert(ctx: RenderContext): void
{
+11 -2
View File
@@ -14,6 +14,7 @@
import { createJz, revertPass } from "../core/dom.js";
import type { Finder } from "../core/finder.js";
import { splitElementAt } from "../core/split.js";
import { classifyBiaodian } from "../core/unicode.js";
import type { RenderContext, Ruleset, StandalonePass } from "../types.js";
@@ -96,8 +97,9 @@ function processBlock(
const boundary = subst[subst.length - chars]!;
let startChild: Node = boundary.directChild;
// 邊界落於裸文本直接子 → 精確切分(最小綁定);否則綁定整個直接子(含其內行內元素,
// 不切割,跨 jz-jinze 等攀爬)。
// 邊界落於裸文本直接子 → 精確切分(最小綁定);落於行內元素內 → 克隆切分出
// [邊界字..元素末](避免整個長元素入 nowrap 之 jz-orphan 致溢出容器);不可切之
// 元素退化為綁定整個直接子(跨 jz-jinze 等攀爬,不切割)。
if (boundary.textNode && boundary.textNode === startChild)
{
if (boundary.offset > 0)
@@ -106,6 +108,13 @@ function processBlock(
}
// offset === 0:實義字即文本起點,整個文本節點入綁,無需切分。
}
else if (boundary.textNode && startChild.nodeType === 1)
{
const clone = splitElementAt(
startChild as Element, boundary.textNode, boundary.offset, PASS,
);
if (clone) { startChild = clone; }
}
// 把 startChild 及其後全部直接子搬入 jz-orphan。
const orphan = createJz("jz-orphan", PASS, "wrap");
+3
View File
@@ -251,6 +251,9 @@ function processPills(root: Element, finder: Finder, pillSelector: string, pillN
root.querySelectorAll(pillSelector).forEach((pill) =>
{
if (finder.isAvoided(pill) || !finder.inScope(pill)) { return; }
// per-node 功能閘(T6):data-juzhen-off="spacing" 等區域內之 pill 不補間隙
// (與 Pass A 逐 block 之 featureEnabledFor 一致;舊版漏判)。
if (!finder.featureEnabledFor(pill, PASS)) { return; }
if (!pill.parentNode) { return; }
const prev = finder.adjacentLogicalChar(pill, -1);