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
+142
View File
@@ -128,6 +128,27 @@ test("spacingpillcode)兩側與 CJK 相鄰插入 jz-hws", () =>
assert.equal(countTag(document.querySelector("p"), "jz-hws"), 2);
});
// T6processPills 須過 per-node 功能閘——data-juzhen-off="spacing" 區域內 pill 不補間隙。
test("spacing T6data-juzhen-off=spacing 區域內 pill 邊界不補 jz-hws", () =>
{
const { document } = setupDom('<p data-juzhen-off="spacing">用<code>vim</code>編輯</p>');
createJuzhen({ jiya: false, longWord: false, jinze: false }).render(document.body);
assert.equal(countTag(document.querySelector("p"), "jz-hws"), 0, "spacing 停用區域內 pill 不補隙");
});
// T5jiyaAdjacency 須把 pill 當相鄰邊界(斷相鄰、不穿越)——pill 兩側之標點不應被當
// 連續標點對而擠壓(pill 內容對排版不可見)。空 pill 才能凸顯此路徑(含文本之 pill 由
// 其內文本天然斷相鄰)。
test("jiya T5pill 隔斷標點相鄰——,<code></code>「不互擠(pill 斷相鄰)", () =>
{
const { document } = setupDom('<p lang="zh-Hant">甲,<code></code>「乙」</p>');
createJuzhen({ spacing: false, longWord: false, jinze: false, lang: { style: "quanjiao" } })
.render(document.body);
const comma = document.querySelector("jz-char.bd-pause"); //
assert.ok(comma, ",已 charify");
assert.ok(!comma.classList.contains("jz-half"), ",與「之間隔 pill → 不視為相鄰、不擠壓");
});
// ----- 標點擠壓(jiya-----
test("jiya:標點 charify 為 jz-char>jz-inner,標 bd-* classtextContent 不變(copy-clean", () =>
@@ -394,6 +415,58 @@ test("jinze 避頭:孤立逗號亦綁前字(逗號不可置行首)", () =>
assert.equal(group.textContent, "文,", "前字 文 + , 綁定(中 在外可斷)");
});
// 行內樣式元素最小綁定(下游 Bug:整個 <strong> 入 nowrap inline-block 致行末整體掉行)。
// 邊界詞元經 core/split 克隆切分,只綁末/首詞元,餘下留原位(splitoff 克隆)。
test("jinze 元素最小綁定:<strong>長串</strong>,只綁末字+逗號(餘下留原位)", () =>
{
const { document } = setupDom('<p lang="zh-Hans">前文<strong>粗体文字</strong>,后续</p>');
createJuzhen({ spacing: false, longWord: false }).render(document.body);
const p = document.querySelector("p");
const group = p.querySelector("jz-jinze");
assert.ok(group, "應有綁定群組");
assert.equal(group.textContent, "字,", "只綁 strong 末字「字」+逗號(非整個 strong)");
const splitoff = p.querySelector('strong[data-jz-kind="splitoff"]');
assert.ok(splitoff && splitoff.textContent === "字", "切出之克隆持末字「字」");
const orig = Array.from(p.querySelectorAll("strong"))
.find((s) => !s.hasAttribute("data-jz-kind"));
assert.ok(orig && orig.textContent === "粗体文", "原 strong 餘「粗体文」留原位");
assert.equal(p.textContent, "前文粗体文字,后续", "textContent 不變");
});
test("jinze 元素最小綁定:「<strong>引文</strong> 只綁開引號+首字", () =>
{
const { document } = setupDom('<p lang="zh-Hant">他說「<strong>引文內容</strong>結束</p>');
createJuzhen({ spacing: false, longWord: false }).render(document.body);
const p = document.querySelector("p");
const group = p.querySelector("jz-jinze");
assert.ok(group, "應有綁定群組");
assert.equal(group.textContent, "「引", "開引號「綁 strong 首字「引」(非整個 strong");
assert.equal(p.textContent, "他說「引文內容結束", "textContent 不變");
});
test("jinze 元素最小綁定 revert:還原為單一 <strong>,文字回原樣", () =>
{
const { document } = setupDom('<p lang="zh-Hans">前文<strong>粗体文字</strong>,后续</p>');
const jz = createJuzhen({ spacing: false, longWord: false });
jz.render(document.body);
jz.revert(document.body);
const p = document.querySelector("p");
assert.equal(countTag(p, "jz-jinze"), 0, "無殘留 jz-jinze");
assert.equal(p.querySelectorAll("strong").length, 1, "兩個 strong 併回單一");
assert.equal(p.querySelector("strong").textContent, "粗体文字", "strong 內容還原");
assert.equal(p.textContent, "前文粗体文字,后续");
});
test("jinze 不可切元素退化整綁:<a> 連結整體綁定(不克隆出兩個錨點)", () =>
{
const { document } = setupDom('<p lang="zh-Hans">见<a href="/x">链接文字</a>,详情</p>');
createJuzhen({ spacing: false, longWord: false }).render(document.body);
const p = document.querySelector("p");
assert.equal(p.querySelectorAll("a").length, 1, "連結不被切分(仍單一 <a>");
const group = p.querySelector("jz-jinze");
assert.ok(group && group.querySelector("a"), "整個 <a> 入綁(退化但安全)");
});
test("jinzerevert 還原(無 jz-jinzejz-char,文字回原樣)", () =>
{
const { document } = setupDom('<p lang="zh-Hant">他說好。」嗎</p>');
@@ -473,6 +546,41 @@ test("1bCJK↔CJK 不插 jz-hws(僅剝空格,無留隙)", () =>
assert.equal(document.querySelector("p").textContent, "事件元件");
});
// pill 左鄰為標點(經 jinze 包入 jz-charjz-jinze):不應於標點前補間隙。
// 根因:adjacentLogicalChar 之 descend 舊版跳過不透明之 jz-char、越過標點取到更前之
// 漢字,誤包 jz-hws;修復=jz-charjz-inner 透明,descend 回傳標點(非 pillNeighbor)。
test("pill 左鄰標點:汉字。<code> 不於「字」「。」間補 jz-hwsjz-char 透明)", () =>
{
const { document } = setupDom("<p>汉字。<code>x</code></p>");
createJuzhen({ lang: { default: "zh-Hans", style: "quanjiao" } }).render(document.body);
const p = document.querySelector("p");
assert.equal(countTag(p, "jz-hws"), 0, "pill 左鄰為句號(非 pillNeighbor),不補間隙");
});
test("pill 左鄰漢字(無標點):汉字<code> 仍於「字」補 jz-hws", () =>
{
const { document } = setupDom("<p>汉字<code>x</code></p>");
createJuzhen({ lang: { default: "zh-Hans" } }).render(document.body);
const hws = document.querySelector("jz-hws");
assert.ok(hws && hws.textContent === "字", "pill 左鄰為漢字,正常補間隙(修復無回歸)");
});
test("pill 右鄰標點:<code>x</code>。汉 不於 pill 與句號間補 jz-hws", () =>
{
const { document } = setupDom("<p><code>x</code>。汉字</p>");
createJuzhen({ lang: { default: "zh-Hans", style: "quanjiao" } }).render(document.body);
const p = document.querySelector("p");
assert.equal(countTag(p, "jz-hws"), 0, "pill 右鄰為句號(非 pillNeighbor),不補間隙");
});
test("pill 左鄰句內點號:事项:<code> 不補 jz-hwsbd-pause 同理)", () =>
{
const { document } = setupDom("<p>事项:<code>x</code></p>");
createJuzhen({ lang: { default: "zh-Hans", style: "quanjiao" } }).render(document.body);
const p = document.querySelector("p");
assert.equal(countTag(p, "jz-hws"), 0, "pill 左鄰為冒號(非 pillNeighbor),不補間隙");
});
test("問題二:justifyAtoms:false → root 設 data-jz-atoms=inlinerevert 清除", () =>
{
const { document } = setupDom("<p lang='zh-Hans'>测试,(甲)。</p>");
@@ -783,6 +891,40 @@ test("垂懸字:段落級——data-jz-level=text 子樹跳過", () =>
assert.equal(countTag(document.querySelector("p"), "jz-orphan"), 0, "text-level 不跑垂懸字");
});
// 邊界實義字落於可切行內元素內 → 克隆切分出 [邊界字..元素末],避免整個長元素入
// nowrap 之 jz-orphan(可溢出容器)。
test("垂懸字 元素最小綁定:段末長 <strong> 只綁末 N 實義字(非整個元素)", () =>
{
const { document } = setupDom(
'<p lang="zh-Hant">這是比較長的開頭文字而結尾為<strong>一段很長的加粗文字</strong></p>',
);
createJuzhen().render(document.body);
const p = document.querySelector("p");
const orphan = p.querySelector("jz-orphan");
assert.ok(orphan, "應產生 jz-orphan");
assert.equal(orphan.textContent, "文字", "只綁 strong 末 2 實義字「文字」");
const splitoff = p.querySelector('strong[data-jz-kind="splitoff"]');
assert.ok(splitoff && splitoff.textContent === "文字", "切出克隆持末 2 字");
const orig = Array.from(p.querySelectorAll("strong"))
.find((s) => !s.hasAttribute("data-jz-kind"));
assert.ok(orig && orig.textContent === "一段很長的加粗", "原 strong 餘部留原位(未入 orphan");
assert.equal(p.textContent, "這是比較長的開頭文字而結尾為一段很長的加粗文字", "textContent 不變");
});
test("垂懸字 元素最小綁定 revert:兩個 <strong> 併回單一、文字還原", () =>
{
const { document } = setupDom(
'<p lang="zh-Hant">這是比較長的開頭文字而結尾為<strong>一段很長的加粗文字</strong></p>',
);
const jz = createJuzhen();
jz.render(document.body);
jz.revert(document.body);
const p = document.querySelector("p");
assert.equal(countTag(p, "jz-orphan"), 0, "無殘留 jz-orphan");
assert.equal(p.querySelectorAll("strong").length, 1, "克隆併回單一 strong");
assert.equal(p.querySelector("strong").textContent, "一段很長的加粗文字", "strong 還原");
});
// ----- 標點懸掛(§6.5.2,行端模式接線;版面凸出量於 headless Chrome 驗證)-----
test("懸掛:已全面停用——hanging 預設關,行端模式 squeeze", () =>