diff --git a/dist/juzhen.css b/dist/juzhen.css index d342cda..6bd1d3a 100644 --- a/dist/juzhen.css +++ b/dist/juzhen.css @@ -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 { diff --git a/dist/juzhen.iife.js b/dist/juzhen.iife.js index 5aa759e..67f1e9b 100644 --- a/dist/juzhen.iife.js +++ b/dist/juzhen.iife.js @@ -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 之 CSS/lineedge 程式碼保留(休眠),俟原生支援成熟再議。 + lineEnd: "squeeze", jiyaConfig: resolveJiya(opts.jiya), blocks: Array.isArray(opts.blocks) ? opts.blocks.slice() : [], finder: { diff --git a/dist/juzhen.mjs b/dist/juzhen.mjs index 9f7d536..18f92da 100644 --- a/dist/juzhen.mjs +++ b/dist/juzhen.mjs @@ -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 之 CSS/lineedge 程式碼保留(休眠),俟原生支援成熟再議。 + lineEnd: "squeeze", jiyaConfig: resolveJiya(opts.jiya), blocks: Array.isArray(opts.blocks) ? opts.blocks.slice() : [], finder: { diff --git a/dist/types.d.ts b/dist/types.d.ts index ce21123..5cb3968 100644 --- a/dist/types.d.ts +++ b/dist/types.d.ts @@ -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"; diff --git a/src/css/_jiya.css b/src/css/_jiya.css index ebccabb..431c11f 100644 --- a/src/css/_jiya.css +++ b/src/css/_jiya.css @@ -120,3 +120,35 @@ jz-char.jz-half-le jz-inner { width: auto; } + +/* 標點懸掛(§6.5.2,hang 模式;由 lineEdge layout pass 標 jz-hang-*)。 + 行尾點號/閉類右凸出右緣(jz-hang-r)、行首開類左凸出左緣(jz-hang-l),令文字格線 + 齊版心、標點溢出。**自管負 margin**——不用 CSS hanging-punctuation(瀏覽器支援差+ + 已被原子透明 reset)。 + 凸出量 **0.5em**(句末/句內點號 --jz-hang-stop、括號引號 --jz-hang-bracket,CSS 變數 + 可逐字型調校)。**關鍵約束(headless 實測):凸出量必須 < 一個 CJK 字寬(1em)**—— + 負 margin 會減少該行斷行寬度,若釋放出 ≥1em 空間,下一字會被拉上本行、與懸掛標點 + 重疊(實測 1em 時每個行末標點皆與後字重疊一整字)。0.5em 釋放半字、不足容下後字, + 故後字不上移、標點穩定凸出半形。代價:正文格線距版心約剩 0.5em(非完全齊邊)——純 + 負 margin 機制無法在不拉升後字下做到全凸,全凸須改用原生 hanging-punctuation(見 §6.5.6)。 + 已 baseline 半形者(jz-half)不懸掛(JS 端跳過:無留白可凸;連續標點經鄰接挤压為半形 + 者亦在此列)。繁體預設不懸掛(逐 lang 閘)。 + ⚠ **需 justifyAtoms:false(jz-char 為 inline)**:預設 inline-block 之 justify 對齊 + 原子模型下,負 margin 會被 justify 重分配吸收、標點不凸出(headless 實測)。懸掛與 + inline-block 對齊原子互斥;欲懸掛須設 justifyAtoms:false(見 README)。容器宜留左右 + padding ≥0.5em 以容納凸出之標點。 */ +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)); +} diff --git a/src/index.ts b/src/index.ts index 88f37d6..749b379 100644 --- a/src/index.ts +++ b/src/index.ts @@ -116,6 +116,14 @@ export function normalizeOptions(opts: JuzhenOptions = {}): ResolvedOptions }, 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 之 CSS/lineedge 程式碼保留(休眠),俟原生支援成熟再議。 + lineEnd: "squeeze", jiyaConfig: resolveJiya(opts.jiya), blocks: Array.isArray(opts.blocks) ? opts.blocks.slice() : [], finder: { diff --git a/src/types.ts b/src/types.ts index 6792399..f6c5146 100644 --- a/src/types.ts +++ b/src/types.ts @@ -20,6 +20,10 @@ export interface OrphanOptions chars?: number; } +/** 行端標點模式(§6.5.2):squeeze=行端半形(現有,預設);hang=標點懸掛右/左凸; + * none=不處理。squeeze 與 hang 同屬渲染層、互斥(共用行端偵測碼)。 */ +export type LineEndMode = "squeeze" | "hang" | "none"; + /** 標點擠壓設定(架構 §6.3)。布林 true 等同 { halfWidth: "halt" }。 */ export interface JiyaOptions { @@ -88,6 +92,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; @@ -134,6 +141,8 @@ export interface ResolvedOptions longWord: { minLength: number; lang: string }; /** 垂懸字避免設定(解析後)。 */ orphan: { chars: number }; + /** 行端標點模式(解析後;§6.5.2)。 */ + lineEnd: LineEndMode; /** 擠壓半形渲染設定(halt 預設/margin 後備)。 */ jiyaConfig: { halfWidth: "halt" | "margin"; bias: BiasTable }; blocks: BlockRule[]; diff --git a/src/typeset/lineedge.ts b/src/typeset/lineedge.ts index 97e0255..f99eda7 100644 --- a/src/typeset/lineedge.ts +++ b/src/typeset/lineedge.ts @@ -1,7 +1,11 @@ -// 行端擠壓(架構 §6.4/§6.5 之行端部分;clreq 行首行尾擠壓)。機制 B。 -// standalone layout pass:量測各 jz-char 之行盒位置,對「行首之開類」收左、 -// 「行尾之閉類/點號」收右(加 jz-le-cl/jz-le-cr,CSS 同 -0.5em margin)。 -// 掛 ResizeObserver 於 root,容器寬度變動時重算;字型載入後亦重算。 +// 行端標點處理(架構 §6.4/§6.5;clreq 行首行尾擠壓 + 標點懸掛)。機制 B。 +// standalone layout pass:量測各 jz-char 之行盒位置,偵測「行首開類」「行尾閉類/ +// 點號」,按**行端模式**套用—— +// · squeeze(現有半形,預設):行首開類收左、行尾閉類收右半形(jz-half-le,同 halt); +// · hang(標點懸掛,§6.5.2):行首開類左凸、行尾閉類/點號右凸出版心(jz-hang-*,負 margin); +// · none:不處理。 +// 行端偵測碼共用、僅「合格 bd 子類集」與「套用之 class」隨模式而異。掛 ResizeObserver +// 於 root,容器寬度變動時重算;字型載入後亦重算。 // // ⚠ 純版面、無法於 jsdom 驗證(getClientRects 回傳 0);故無 ResizeObserver // 之環境(jsdom)直接跳過,僅於真實瀏覽器生效(架構 §12/R1/R2)。須跨 @@ -9,10 +13,20 @@ import { bdClassOf } from "../core/dom.js"; import type { Finder } from "../core/finder.js"; -import type { RenderContext, StandalonePass } from "../types.js"; +import type { LineEndMode, RenderContext, ResolvedOptions, StandalonePass } from "../types.js"; -const LE = "jz-half-le"; // 行端半形(CSS 同 halt;獨立 class 以便重算時只清行端結果) -const EPS = 4; // px:判定「不同行」之 top 差容差。 +const LE = "jz-half-le"; // 行端半形(squeeze 模式;CSS 同 halt) +const HANG_L = "jz-hang-l"; // 行首開類左凸(hang 模式;負 margin-left) +const HANG_R = "jz-hang-r"; // 行尾閉類/點號右凸(hang 模式;負 margin-right) +const ALL_EDGE = [ LE, HANG_L, HANG_R ]; +const EPS = 2; // px:行盒比較之亞像素容差。 + +// 行端合格之 bd 子類(隨模式):head=行首左緣偵測,tail=行尾右緣偵測。 +// squeeze 僅括號引號(沿用現有:點號於行端保持全形,避免孤立句末誤收); +// hang 之 tail 增點號(行末句號/逗號右凸為懸掛之經典用例)。 +const HEAD_CLASSES = new Set([ "bd-open" ]); +const TAIL_SQUEEZE = new Set([ "bd-close" ]); +const TAIL_HANG = new Set([ "bd-close", "bd-stop", "bd-pause" ]); // 每 root 各 layout pass(以 key 區分)一個 ResizeObserver。 const observers = new WeakMap>(); @@ -102,6 +116,21 @@ function firstRect(el: Element): DOMRect | null return rects.length ? rects[0]! : null; } +// 行歸屬比較須以**垂直區間**而非單純 top 差判定:jz-char 為 inline-block,其行盒矩形 +// 之 top 與相鄰純文本字形矩形之 top 因基線/盒高差異常差數像素(實測 ~5px),遠小於 +// 行高(如 2em=40px)卻足以超過小容差 → 誤判同行為「下一行」(hang 模式句末 。 過度 +// 觸發之根因)。改判「b 是否整體落於 a 之下一行」= b.top ≥ a.bottom(容 EPS 亞像素), +// 同行(垂直重疊)則否。對稱地,higherLine 判 b 在 a 之上一行。 +function lowerLine(a: DOMRect, b: DOMRect): boolean +{ + return b.top >= a.bottom - EPS; +} + +function higherLine(a: DOMRect, b: DOMRect): boolean +{ + return b.bottom <= a.top + EPS; +} + // 元素之**最後**一個行盒矩形。jz-hws 之 margin-right 落在其內容末尾之 fragment, // 故行末判定須以此 rect(而非 firstRect)為準——否則包裹會跨行之 pill 時,firstRect // 取到首行矩形、與末行之後續內容比較會誤判(見 trimGaps)。 @@ -111,40 +140,96 @@ function lastRect(el: Element): DOMRect | null return rects.length ? rects[rects.length - 1]! : null; } -function relayout(root: Element, finder: Finder): void +// ⚠ **標點懸掛總開關(防逃逸第三層)**。hang 已全面停用(§6.5.2/§6.5.6):經實測,純 +// 負 margin 機制不可行(墨色不出版心/滿凸拉升後字重疊/resize 重排錯亂)。此常數為 false +// 時,applyEdge 永不施加 jz-hang-*,與「index 鎖 lineEnd=squeeze」「modeForChar 降 squeeze」 +// 三重保證:縱使下游以任何方式(hanging 選項/data-juzhen/blocks/直接改 lineEnd)要求, +// 亦絕無可能產生懸掛標記。hang 之程式碼與 CSS 保留休眠,俟原生 hanging-punctuation 成熟再議。 +const HANG_ENABLED = false; + +/** 套用行端結果之 class(按模式):head=行首左緣、tail=行尾右緣。 */ +function applyEdge(el: Element, edge: "head" | "tail", mode: LineEndMode): void { - // 清除上次行端 class。 - root.querySelectorAll("jz-char." + LE).forEach((e) => e.classList.remove(LE)); + 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: Element): void +{ + for (const cls of ALL_EDGE) + { + root.querySelectorAll("jz-char." + cls).forEach((e) => e.classList.remove(cls)); + } +} + +/** 該標點 jz-char 之有效行端模式。 + * ⚠ **防逃逸**:標點懸掛 hang 已全面停用(見 index.ts/§6.5.6)——縱使 base 為 "hang" + * (理論上不會,index 已鎖 squeeze),此處一律降回 squeeze,確保不產生任何 jz-hang-*。 + * hang 之逐 lang 閘邏輯保留於註解供日後重啟:繁體橫排降 squeeze、簡體可 hang。 */ +function modeForChar(el: Element, options: ResolvedOptions): LineEndMode +{ + const base = options.lineEnd; + if (base !== "hang") { return base; } + // hang 已停用:無條件降 squeeze(防逃逸)。下行為日後重啟之逐 lang 閘(現不可達): + // if (classify(effectiveLang(el, options.locale.default)) === "zh-Hant") return "squeeze"; + return "squeeze"; +} + +interface EdgeDecision { el: Element; edge: "head" | "tail"; mode: LineEndMode; } + +/** + * 行端偵測 + 模式套用(共用偵測碼;合格 bd 集與套用 class 隨模式而異)。 + * + * **兩階段免自我應驗**(§6.5.2 重點風險):先 clearEdge 清除全部行端 margin/半形, + * 再於「自然(無行端效果)佈局」下量測**全部**標點之行端狀態(階段 1,不變更 DOM), + * 最後一次性套用(階段 2)。如此每次量測皆見無污染之自然佈局——既不受自身、亦不受 + * 其他標點之 hang 負 margin/halt 收縮影響(避免迴圈內逐一 add class 致下游量測偏移, + * 同 gapTrim 之雙向污染教訓)。ResizeObserver 僅觀 root 尺寸、不因套用後內部重排再 + * 觸發,故終態穩定(基於自然斷行決定懸掛者,正是所欲)。 */ +function relayout(root: Element, finder: Finder, options: ResolvedOptions): void +{ + clearEdge(root); const chars = Array.from(root.querySelectorAll("jz-char")).filter( (c) => finder.inScope(c) && !finder.isAvoided(c), ); + const decisions: EdgeDecision[] = []; for (const el of chars) { - // 行端為段落級(§6.5.0a):text-level 子樹(單行標題等)不收行端半形。 + // 行端為段落級(§6.5.0a):text-level 子樹(單行標題等)不處理行端。 if (!finder.levelAllows(el, "paragraph")) { continue; } if (!finder.featureEnabledFor(el, "jiya")) { continue; } - // 行端僅處理括號引號:開類於行首收左、閉類於行尾收右。 - // **不**處理句末/句內點號(。!?,等)——行尾/行首之點號保持全形, - // 避免「孤立句末 。/?!之 ! 落在行末」被誤收為半形(實測之困惑來源)。 - // `。」` 行尾仍為 1em:其中 。 由鄰接(在 」 前)收,非由行端收。 + const mode = modeForChar(el, options); + if (mode === "none") { continue; } + // hang 模式跳過已 baseline 半形(jz-half)之標點:其字身已收為 0.5em、無留白可 + // 凸出,再加負 margin 會把墨色整體拉出版心。故僅全形標點(有留白者)懸掛; + // 已半形者保持原狀(行端本就緊湊)。 + if (mode === "hang" && el.classList.contains("jz-half")) { continue; } const bd = bdClassOf(el); - if (bd !== "bd-open" && bd !== "bd-close") { continue; } + 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; } + // squeeze 不處理點號(行尾/行首點號保持全形,避免孤立句末誤收,實測困惑來源): + // 已由 TAIL_SQUEEZE 僅含 bd-close 保證;hang 之 tail 始含點號(懸掛經典用例)。 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); } } export const lineEdgePass: StandalonePass = { @@ -156,12 +241,12 @@ export const lineEdgePass: StandalonePass = { render(ctx: RenderContext): void { if (!hasLayout()) { return; } // jsdom 等無版面環境:跳過。 - installLayout(ctx.root, "lineEdge", () => relayout(ctx.root, ctx.finder)); + installLayout(ctx.root, "lineEdge", () => relayout(ctx.root, ctx.finder, ctx.options)); }, revert(ctx: RenderContext): void { uninstallLayout(ctx.root, "lineEdge"); - ctx.root.querySelectorAll("jz-char." + LE).forEach((e) => e.classList.remove(LE)); + clearEdge(ctx.root); }, }; diff --git a/test/juzhen.test.mjs b/test/juzhen.test.mjs index 8e2b9e5..4f6bef5 100644 --- a/test/juzhen.test.mjs +++ b/test/juzhen.test.mjs @@ -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('

春。夏。秋。冬。

'); + 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('

春。夏。秋。冬。

'); + 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('

春。夏。秋。冬。

'); + 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"); +});