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
+10
View File
@@ -85,6 +85,16 @@ jz-char.jz-half-le jz-inner {
[data-jz-halfwidth=halt] jz-char.jz-half-le {
width: auto;
}
jz-char.bd-stop.jz-hang-r,
jz-char.bd-pause.jz-hang-r {
margin-right: calc(-1 * var(--jz-hang-stop, 0.5em));
}
jz-char.bd-close.jz-hang-r {
margin-right: calc(-1 * var(--jz-hang-bracket, 0.5em));
}
jz-char.bd-open.jz-hang-l {
margin-left: calc(-1 * var(--jz-hang-bracket, 0.5em));
}
/* src/css/_jinze.css */
jz-jinze {
+70 -11
View File
@@ -963,7 +963,13 @@ var Juzhen = (() => {
// src/typeset/lineedge.ts
var LE = "jz-half-le";
var EPS = 4;
var HANG_L = "jz-hang-l";
var HANG_R = "jz-hang-r";
var ALL_EDGE = [LE, HANG_L, HANG_R];
var EPS = 2;
var HEAD_CLASSES = /* @__PURE__ */ new Set(["bd-open"]);
var TAIL_SQUEEZE = /* @__PURE__ */ new Set(["bd-close"]);
var TAIL_HANG = /* @__PURE__ */ new Set(["bd-close", "bd-stop", "bd-pause"]);
var observers = /* @__PURE__ */ new WeakMap();
function hasLayout() {
return typeof ResizeObserver !== "undefined";
@@ -1047,15 +1053,44 @@ var Juzhen = (() => {
const rects = el.getClientRects();
return rects.length ? rects[0] : null;
}
function lowerLine(a, b) {
return b.top >= a.bottom - EPS;
}
function higherLine(a, b) {
return b.bottom <= a.top + EPS;
}
function lastRect(el) {
const rects = el.getClientRects();
return rects.length ? rects[rects.length - 1] : null;
}
function relayout(root, finder) {
root.querySelectorAll("jz-char." + LE).forEach((e) => e.classList.remove(LE));
var HANG_ENABLED = false;
function applyEdge(el, edge, mode) {
if (mode === "squeeze") {
el.classList.add(LE);
return;
}
if (mode === "hang" && HANG_ENABLED) {
el.classList.add(edge === "head" ? HANG_L : HANG_R);
}
}
function clearEdge(root) {
for (const cls of ALL_EDGE) {
root.querySelectorAll("jz-char." + cls).forEach((e) => e.classList.remove(cls));
}
}
function modeForChar(el, options) {
const base = options.lineEnd;
if (base !== "hang") {
return base;
}
return "squeeze";
}
function relayout(root, finder, options) {
clearEdge(root);
const chars = Array.from(root.querySelectorAll("jz-char")).filter(
(c) => finder.inScope(c) && !finder.isAvoided(c)
);
const decisions = [];
for (const el of chars) {
if (!finder.levelAllows(el, "paragraph")) {
continue;
@@ -1063,26 +1098,42 @@ var Juzhen = (() => {
if (!finder.featureEnabledFor(el, "jiya")) {
continue;
}
const mode = modeForChar(el, options);
if (mode === "none") {
continue;
}
if (mode === "hang" && el.classList.contains("jz-half")) {
continue;
}
const bd = bdClassOf(el);
if (bd !== "bd-open" && bd !== "bd-close") {
if (!bd) {
continue;
}
const tailSet = mode === "hang" ? TAIL_HANG : TAIL_SQUEEZE;
const isHead = HEAD_CLASSES.has(bd);
const isTail = tailSet.has(bd);
if (!isHead && !isTail) {
continue;
}
const r = firstRect(el);
if (!r) {
continue;
}
if (bd === "bd-open") {
if (isHead) {
const prev = edgeRect(el.previousSibling, "prev", el);
if (!prev || prev.top < r.top - EPS) {
el.classList.add(LE);
if (!prev || higherLine(r, prev)) {
decisions.push({ el, edge: "head", mode });
}
} else {
const next = edgeRect(el.nextSibling, "next", el);
if (!next || next.top > r.top + EPS) {
el.classList.add(LE);
if (!next || lowerLine(r, next)) {
decisions.push({ el, edge: "tail", mode });
}
}
}
for (const d of decisions) {
applyEdge(d.el, d.edge, d.mode);
}
}
var lineEdgePass = {
name: "jiyaLineEdge",
@@ -1094,11 +1145,11 @@ var Juzhen = (() => {
if (!hasLayout()) {
return;
}
installLayout(ctx.root, "lineEdge", () => relayout(ctx.root, ctx.finder));
installLayout(ctx.root, "lineEdge", () => relayout(ctx.root, ctx.finder, ctx.options));
},
revert(ctx) {
uninstallLayout(ctx.root, "lineEdge");
ctx.root.querySelectorAll("jz-char." + LE).forEach((e) => e.classList.remove(LE));
clearEdge(ctx.root);
}
};
var TRIM = "jz-hws-trim";
@@ -1739,6 +1790,14 @@ var Juzhen = (() => {
},
longWord: resolveLongWord(opts.longWord),
orphan: resolveOrphan(opts.orphan),
// 行端模式恒為 squeeze(行端半形)。**標點懸掛 hang 已全面停用**(§6.5.2/§6.5.6):
// 經 headless 真實瀏覽器實測,純負 margin 機制不可行——句號墨色偏框左下角,安全
// 凸出量(<1em)只把空白半框推出版心、墨色仍在內(視覺不懸掛);凸出滿 1em 又會釋放
// 一字寬、把下一字拉上本行致重疊;且 ResizeObserver 重排時兩階段標記錯亂。原生
// hanging-punctuation 為正解但 Chrome 不支援、且與本庫 inline-block 原子衝突。
// 故此處**唯一關卡**鎖死為 squeeze——即使 opts.hanging 為 true 亦不啟用 hang(無逃逸);
// hang 之 CSSlineedge 程式碼保留(休眠),俟原生支援成熟再議。
lineEnd: "squeeze",
jiyaConfig: resolveJiya(opts.jiya),
blocks: Array.isArray(opts.blocks) ? opts.blocks.slice() : [],
finder: {
+70 -11
View File
@@ -935,7 +935,13 @@ var jinzePass = {
// src/typeset/lineedge.ts
var LE = "jz-half-le";
var EPS = 4;
var HANG_L = "jz-hang-l";
var HANG_R = "jz-hang-r";
var ALL_EDGE = [LE, HANG_L, HANG_R];
var EPS = 2;
var HEAD_CLASSES = /* @__PURE__ */ new Set(["bd-open"]);
var TAIL_SQUEEZE = /* @__PURE__ */ new Set(["bd-close"]);
var TAIL_HANG = /* @__PURE__ */ new Set(["bd-close", "bd-stop", "bd-pause"]);
var observers = /* @__PURE__ */ new WeakMap();
function hasLayout() {
return typeof ResizeObserver !== "undefined";
@@ -1019,15 +1025,44 @@ function firstRect(el) {
const rects = el.getClientRects();
return rects.length ? rects[0] : null;
}
function lowerLine(a, b) {
return b.top >= a.bottom - EPS;
}
function higherLine(a, b) {
return b.bottom <= a.top + EPS;
}
function lastRect(el) {
const rects = el.getClientRects();
return rects.length ? rects[rects.length - 1] : null;
}
function relayout(root, finder) {
root.querySelectorAll("jz-char." + LE).forEach((e) => e.classList.remove(LE));
var HANG_ENABLED = false;
function applyEdge(el, edge, mode) {
if (mode === "squeeze") {
el.classList.add(LE);
return;
}
if (mode === "hang" && HANG_ENABLED) {
el.classList.add(edge === "head" ? HANG_L : HANG_R);
}
}
function clearEdge(root) {
for (const cls of ALL_EDGE) {
root.querySelectorAll("jz-char." + cls).forEach((e) => e.classList.remove(cls));
}
}
function modeForChar(el, options) {
const base = options.lineEnd;
if (base !== "hang") {
return base;
}
return "squeeze";
}
function relayout(root, finder, options) {
clearEdge(root);
const chars = Array.from(root.querySelectorAll("jz-char")).filter(
(c) => finder.inScope(c) && !finder.isAvoided(c)
);
const decisions = [];
for (const el of chars) {
if (!finder.levelAllows(el, "paragraph")) {
continue;
@@ -1035,26 +1070,42 @@ function relayout(root, finder) {
if (!finder.featureEnabledFor(el, "jiya")) {
continue;
}
const mode = modeForChar(el, options);
if (mode === "none") {
continue;
}
if (mode === "hang" && el.classList.contains("jz-half")) {
continue;
}
const bd = bdClassOf(el);
if (bd !== "bd-open" && bd !== "bd-close") {
if (!bd) {
continue;
}
const tailSet = mode === "hang" ? TAIL_HANG : TAIL_SQUEEZE;
const isHead = HEAD_CLASSES.has(bd);
const isTail = tailSet.has(bd);
if (!isHead && !isTail) {
continue;
}
const r = firstRect(el);
if (!r) {
continue;
}
if (bd === "bd-open") {
if (isHead) {
const prev = edgeRect(el.previousSibling, "prev", el);
if (!prev || prev.top < r.top - EPS) {
el.classList.add(LE);
if (!prev || higherLine(r, prev)) {
decisions.push({ el, edge: "head", mode });
}
} else {
const next = edgeRect(el.nextSibling, "next", el);
if (!next || next.top > r.top + EPS) {
el.classList.add(LE);
if (!next || lowerLine(r, next)) {
decisions.push({ el, edge: "tail", mode });
}
}
}
for (const d of decisions) {
applyEdge(d.el, d.edge, d.mode);
}
}
var lineEdgePass = {
name: "jiyaLineEdge",
@@ -1066,11 +1117,11 @@ var lineEdgePass = {
if (!hasLayout()) {
return;
}
installLayout(ctx.root, "lineEdge", () => relayout(ctx.root, ctx.finder));
installLayout(ctx.root, "lineEdge", () => relayout(ctx.root, ctx.finder, ctx.options));
},
revert(ctx) {
uninstallLayout(ctx.root, "lineEdge");
ctx.root.querySelectorAll("jz-char." + LE).forEach((e) => e.classList.remove(LE));
clearEdge(ctx.root);
}
};
var TRIM = "jz-hws-trim";
@@ -1711,6 +1762,14 @@ function normalizeOptions(opts = {}) {
},
longWord: resolveLongWord(opts.longWord),
orphan: resolveOrphan(opts.orphan),
// 行端模式恒為 squeeze(行端半形)。**標點懸掛 hang 已全面停用**(§6.5.2/§6.5.6):
// 經 headless 真實瀏覽器實測,純負 margin 機制不可行——句號墨色偏框左下角,安全
// 凸出量(<1em)只把空白半框推出版心、墨色仍在內(視覺不懸掛);凸出滿 1em 又會釋放
// 一字寬、把下一字拉上本行致重疊;且 ResizeObserver 重排時兩階段標記錯亂。原生
// hanging-punctuation 為正解但 Chrome 不支援、且與本庫 inline-block 原子衝突。
// 故此處**唯一關卡**鎖死為 squeeze——即使 opts.hanging 為 true 亦不啟用 hang(無逃逸);
// hang 之 CSSlineedge 程式碼保留(休眠),俟原生支援成熟再議。
lineEnd: "squeeze",
jiyaConfig: resolveJiya(opts.jiya),
blocks: Array.isArray(opts.blocks) ? opts.blocks.slice() : [],
finder: {
+8
View File
@@ -12,6 +12,9 @@ export interface OrphanOptions {
/** 段末行至少保留之 CJK 實義字數(標點/符號不計;預設 2)。 */
chars?: number;
}
/** 行端標點模式(§6.5.2):squeeze=行端半形(現有,預設);hang=標點懸掛右/左凸;
* none=不處理。squeeze 與 hang 同屬渲染層、互斥(共用行端偵測碼)。 */
export type LineEndMode = "squeeze" | "hang" | "none";
/** 標點擠壓設定(架構 §6.3)。布林 true 等同 { halfWidth: "halt" }。 */
export interface JiyaOptions {
/** 半形渲染機制:
@@ -68,6 +71,9 @@ export interface JuzhenOptions {
jiya?: boolean | JiyaOptions;
jinze?: boolean;
orphan?: boolean | OrphanOptions;
/** @deprecated 標點懸掛**已全面停用**(§6.5.6):純負 margin 機制不可行(墨色不出版心/
* 滿凸拉升後字重疊/resize 重排錯亂),原生 hanging-punctuation 為正解但 Chrome 不支援。
* 此選項仍被接受以保 API 相容,但**無任何效果**(行端恒 squeeze);俟原生支援成熟再議。 */
hanging?: boolean;
biaodian?: boolean;
emphasis?: boolean;
@@ -114,6 +120,8 @@ export interface ResolvedOptions {
orphan: {
chars: number;
};
/** 行端標點模式(解析後;§6.5.2)。 */
lineEnd: LineEndMode;
/** 擠壓半形渲染設定(halt 預設/margin 後備)。 */
jiyaConfig: {
halfWidth: "halt" | "margin";