feat: lineedge 行端模式(squeeze);標點懸掛實作後全面停用(§6.5.2)

行端 lineedge 升級為可配模式(squeeze|hang|none),抽出共用行端標點偵測;
squeeze(現有行端半形)保留並修正「行歸屬判定」bug:jz-char 為 inline-block,其行盒
top 與相鄰純文本字形 top 因基線差約 5px、遠小於行高卻超舊容差(EPS=4px),致同行誤判
為下一行;改判「垂直區間」(下一行=b.top≥a.bottom、上一行=b.bottom≤a.top,容 2px)。

標點懸掛 hang 模式經 headless + 真實瀏覽器實測**確認不可行、全面停用**(純負 margin):
1. 句號墨色偏框左下,安全凸出量(<1em)只推出空白半框、墨色仍在版心內(視覺不懸掛);
2. 凸出滿 1em 釋放一整字寬、把下一字拉上本行致重疊;
3. 與 inline-block 對齊原子互斥(justifyAtoms:true 下 margin 被 justify 吸收);
4. ResizeObserver 重排時兩階段標記錯亂。
真正的解為原生 hanging-punctuation,惟 Chrome 不支援、且與本庫原子結構衝突。

**三重防逃逸**(下游以任何方式皆無法啟用):① index 鎖 lineEnd:"squeeze"(忽略 hanging
選項);② lineedge.modeForChar 無條件降 squeeze;③ applyEdge 之 HANG_ENABLED=false 總開關。
hang 之 lineedge 程式碼、CSS(jz-hang-*)、LineEndMode 型別保留休眠,俟原生支援再議。
hanging 選項仍被接受(API 相容)但無效果。

單元測試 90 通過,含防逃逸覆蓋(hanging:true/data-juzhen/blocks 各路徑皆不產生 jz-hang-*)。
This commit is contained in:
2026-06-09 23:25:47 +08:00
parent 754b00da42
commit 04f2b25e4c
9 changed files with 357 additions and 43 deletions
+44
View File
@@ -782,3 +782,47 @@ test("垂懸字:段落級——data-jz-level=text 子樹跳過", () =>
createJuzhen().render(document.body);
assert.equal(countTag(document.querySelector("p"), "jz-orphan"), 0, "text-level 不跑垂懸字");
});
// ----- 標點懸掛(§6.5.2,行端模式接線;版面凸出量於 headless Chrome 驗證)-----
test("懸掛:已全面停用——hanging 預設關,行端模式 squeeze", () =>
{
const jz = createJuzhen();
assert.equal(jz.options.lineEnd, "squeeze", "行端模式恒 squeeze");
});
test("懸掛:防逃逸——即使 hanging:true,行端模式仍鎖死 squeeze(不啟用 hang", () =>
{
const jz = createJuzhen({ hanging: true });
// option 仍被接受(API 不破),但 lineEnd 鎖死 squeeze——hang 不可能激活(§6.5.6 停用)。
assert.equal(jz.options.lineEnd, "squeeze", "hanging:true 亦不轉 hang(無逃逸)");
});
test("懸掛:防逃逸——hanging:true render 後不產生任何 jz-hang-* 標記", () =>
{
const { document } = setupDom('<p lang="zh-Hans">春。夏。秋。冬。</p>');
createJuzhen({ hanging: true, justifyAtoms: false }).render(document.body);
assert.equal(countTag(document.body, "jz-char.jz-hang-r"), 0, "無 jz-hang-r");
assert.equal(countTag(document.body, "jz-char.jz-hang-l"), 0, "無 jz-hang-l");
});
test("懸掛:防逃逸——下游 data-juzhen=\"hanging\" 亦不啟用(lineEnd 不受 per-node 影響)", () =>
{
const { document } = setupDom('<p lang="zh-Hans" data-juzhen="spacing jiya jinze hanging">春。夏。秋。冬。</p>');
const jz = createJuzhen({ hanging: true, justifyAtoms: false });
jz.render(document.body);
assert.equal(jz.options.lineEnd, "squeeze", "lineEnd 恒 squeeze");
assert.equal(countTag(document.body, "jz-char.jz-hang-r"), 0, "data-juzhen 要求亦無 jz-hang-r");
assert.equal(countTag(document.body, "jz-char.jz-hang-l"), 0, "data-juzhen 要求亦無 jz-hang-l");
});
test("懸掛:防逃逸——blocks features:[\"hanging\"] 亦不啟用", () =>
{
const { document } = setupDom('<p class="q" lang="zh-Hans">春。夏。秋。冬。</p>');
createJuzhen({
hanging: true, justifyAtoms: false,
blocks: [ { selector: ".q", features: [ "spacing", "jiya", "hanging" ] } ],
}).render(document.body);
assert.equal(countTag(document.body, "jz-char.jz-hang-r"), 0, "blocks 要求亦無 jz-hang-r");
assert.equal(countTag(document.body, "jz-char.jz-hang-l"), 0, "blocks 要求亦無 jz-hang-l");
});