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:
+15
-2
@@ -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。
|
||||
|
||||
@@ -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; } // pill(code/kbd):斷相鄰、不下探(其內容對排版不可見,不應被當透明行內穿越)
|
||||
else if (finder.isAvoided(el)) { prev = null; } // avoid/skip 子樹:斷、不下探
|
||||
else if (blockSet.has(nm))
|
||||
{
|
||||
|
||||
+28
-10
@@ -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 子集,遇未列之樣式行內(S/INS/DEL/SUB/SUP/SMALL/
|
||||
// ABBR/CITE/DFN/Q/VAR/SAMP/TIME/BDO/BDI/RUBY 等)會提前止步、誤判行端而錯加行端
|
||||
// 半形(透明性不一致之同根隱患 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
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user