refactor: 移除標點懸掛(機制級死路)——負 margin 程式碼/CSS/型別一併刪

標點懸掛經實測為**機制級死路**(純 CSS 之寬度守恆矛盾):行端標點盒保持非零寬則墨色
出去多少版心內就空多少(行末實義字無法齊邊);收為零寬則釋放一整字寬、斷行器把下一字
拉上本行與標點重疊(且 justifyAtoms 預設之 inline-block 下負 margin 被 justify 吸收)。
CSS hack 接觸不到斷行器,此恒等式無解。唯一正解為原生 hanging-punctuation(僅 Safari
支援、且 jz-char 為 inline-block 原子非文本,原生懸掛不作用),屬遠期「原生管線」新功能、
與此處無共享,故不保留休眠碼。

移除:
  lineedge.ts — jz-hang-* class、HANG_* 常量、applyEdge hang 分支、modeForChar、
    HANG_ENABLED 總開關;行端僅餘 squeeze(半形)。
  _jiya.css — jz-hang-* 三條規則(margin 半形 fallback 完整保留)。
  types.ts — LineEndMode 型別、ResolvedOptions.lineEnd、features.hanging。
  index.ts — lineEnd 鎖。
  hanging 公開選項保留為 @deprecated no-op(API 相容)。

文檔:README「標點懸掛」節、ARCHITECTURE §6.5(開頭/§6.5.2/§6.5.4/§6.5.5/§6.5.6)、
demo(showcase §三/resize-check)全部由「停用/休眠」改為「已移除」+寬度守恆論證。

測試:防逃逸測試改寫為「hanging 為 no-op、輸出與不傳一致、各路徑不產生 jz-hang-*」。
全綠 101。dist 已無 jz-hang/lineEnd/HANG 殘留。
This commit is contained in:
2026-06-11 16:16:01 +08:00
parent 2890549f44
commit a3c94ddee5
12 changed files with 156 additions and 336 deletions
-10
View File
@@ -85,16 +85,6 @@ 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 {
+9 -43
View File
@@ -1160,13 +1160,10 @@ var Juzhen = (() => {
// src/typeset/lineedge.ts
var LE = "jz-half-le";
var HANG_L = "jz-hang-l";
var HANG_R = "jz-hang-r";
var ALL_EDGE = [LE, HANG_L, HANG_R];
var ALL_EDGE = [LE];
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 TAIL_CLASSES = /* @__PURE__ */ new Set(["bd-close"]);
var observers = /* @__PURE__ */ new WeakMap();
function hasLayout() {
return typeof ResizeObserver !== "undefined";
@@ -1267,28 +1264,11 @@ var Juzhen = (() => {
const rects = el.getClientRects();
return rects.length ? rects[rects.length - 1] : null;
}
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 wrapSet = buildWrapSet(options);
@@ -1303,20 +1283,12 @@ 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) {
continue;
}
const tailSet = mode === "hang" ? TAIL_HANG : TAIL_SQUEEZE;
const isHead = HEAD_CLASSES.has(bd);
const isTail = tailSet.has(bd);
const isTail = TAIL_CLASSES.has(bd);
if (!isHead && !isTail) {
continue;
}
@@ -1327,17 +1299,17 @@ var Juzhen = (() => {
if (isHead) {
const prev = edgeRect(el.previousSibling, "prev", el, wrapSet);
if (!prev || higherLine(r, prev)) {
decisions.push({ el, edge: "head", mode });
decisions.push({ el });
}
} else {
const next = edgeRect(el.nextSibling, "next", el, wrapSet);
if (!next || lowerLine(r, next)) {
decisions.push({ el, edge: "tail", mode });
decisions.push({ el });
}
}
}
for (const d of decisions) {
applyEdge(d.el, d.edge, d.mode);
d.el.classList.add(LE);
}
}
var lineEdgePass = {
@@ -2002,21 +1974,15 @@ var Juzhen = (() => {
jiya: feature(opts.jiya, true),
jinze: feature(opts.jinze, true),
orphan: opts.orphan === false ? false : feature(opts.orphan, true),
hanging: feature(opts.hanging, false),
biaodian: feature(opts.biaodian, false),
emphasis: feature(opts.emphasis, false),
ruby: feature(opts.ruby, false)
},
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",
// 行端僅做半形擠壓(lineedge:行首開類收左、行尾閉類收右)。**標點懸掛已移除**
// (§6.5.6):純負 margin 機制為機制級死路(寬度守恆矛盾,CSS 接觸不到斷行器);
// opts.hanging 仍被接受以保 API 相容,但無任何效果。
jiyaConfig: resolveJiya(opts.jiya),
blocks: Array.isArray(opts.blocks) ? opts.blocks.slice() : [],
finder: {
+9 -43
View File
@@ -1132,13 +1132,10 @@ var jinzePass = {
// src/typeset/lineedge.ts
var LE = "jz-half-le";
var HANG_L = "jz-hang-l";
var HANG_R = "jz-hang-r";
var ALL_EDGE = [LE, HANG_L, HANG_R];
var ALL_EDGE = [LE];
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 TAIL_CLASSES = /* @__PURE__ */ new Set(["bd-close"]);
var observers = /* @__PURE__ */ new WeakMap();
function hasLayout() {
return typeof ResizeObserver !== "undefined";
@@ -1239,28 +1236,11 @@ function lastRect(el) {
const rects = el.getClientRects();
return rects.length ? rects[rects.length - 1] : null;
}
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 wrapSet = buildWrapSet(options);
@@ -1275,20 +1255,12 @@ function relayout(root, finder, options) {
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) {
continue;
}
const tailSet = mode === "hang" ? TAIL_HANG : TAIL_SQUEEZE;
const isHead = HEAD_CLASSES.has(bd);
const isTail = tailSet.has(bd);
const isTail = TAIL_CLASSES.has(bd);
if (!isHead && !isTail) {
continue;
}
@@ -1299,17 +1271,17 @@ function relayout(root, finder, options) {
if (isHead) {
const prev = edgeRect(el.previousSibling, "prev", el, wrapSet);
if (!prev || higherLine(r, prev)) {
decisions.push({ el, edge: "head", mode });
decisions.push({ el });
}
} else {
const next = edgeRect(el.nextSibling, "next", el, wrapSet);
if (!next || lowerLine(r, next)) {
decisions.push({ el, edge: "tail", mode });
decisions.push({ el });
}
}
}
for (const d of decisions) {
applyEdge(d.el, d.edge, d.mode);
d.el.classList.add(LE);
}
}
var lineEdgePass = {
@@ -1974,21 +1946,15 @@ function normalizeOptions(opts = {}) {
jiya: feature(opts.jiya, true),
jinze: feature(opts.jinze, true),
orphan: opts.orphan === false ? false : feature(opts.orphan, true),
hanging: feature(opts.hanging, false),
biaodian: feature(opts.biaodian, false),
emphasis: feature(opts.emphasis, false),
ruby: feature(opts.ruby, false)
},
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",
// 行端僅做半形擠壓(lineedge:行首開類收左、行尾閉類收右)。**標點懸掛已移除**
// (§6.5.6):純負 margin 機制為機制級死路(寬度守恆矛盾,CSS 接觸不到斷行器);
// opts.hanging 仍被接受以保 API 相容,但無任何效果。
jiyaConfig: resolveJiya(opts.jiya),
blocks: Array.isArray(opts.blocks) ? opts.blocks.slice() : [],
finder: {