From 445716e34e470652459a0eb0e1e8782e374e3048 Mon Sep 17 00:00:00 2001 From: commilitia Date: Thu, 11 Jun 2026 16:19:15 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E8=A1=8C=E5=85=A7=E9=80=8F=E6=98=8E?= =?UTF-8?q?=E6=80=A7=E4=BF=AE=E5=BE=A9=20=EF=BC=8B=20=E6=A8=99=E9=BB=9E?= =?UTF-8?q?=E6=87=B8=E6=8E=9B=E7=A7=BB=E9=99=A4=EF=BC=88changelog=202026-0?= =?UTF-8?q?6-11=E3=80=81index=20=E5=8A=9F=E8=83=BD=E8=A1=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit changelog 新增 2026-06-11 條目:兩個下游 Bug(pill 前標點誤隙、行內元素整體掉行) +審計 T4-T6;標點懸掛由「停用」正式移除(寬度守恆論證、已移除清單、原生管線未來路徑)。 index 功能表:禁則/垂懸字行補克隆切分(splitoff)說明;標點懸掛行改「已移除」。 重建 changelog.html/index.html。 --- docs/changelog.html | 595 ++++++++++++++++++++++++++++++++++++---- docs/changelog.src.html | 16 ++ docs/index.html | 589 +++++++++++++++++++++++++++++++++++---- docs/index.src.html | 10 +- 4 files changed, 1092 insertions(+), 118 deletions(-) diff --git a/docs/changelog.html b/docs/changelog.html index bcafbf1..2be9753 100644 --- a/docs/changelog.html +++ b/docs/changelog.html @@ -1,4 +1,4 @@ - + @@ -963,6 +963,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 { @@ -974,6 +984,11 @@ jz-jinze { display: inline; } +/* src/css/_orphan.css */ +jz-orphan { + white-space: nowrap; +} + /* src/css/juzhen.css */ /* ========== Feature: code-block ========================================== @@ -2531,6 +2546,22 @@ main > *:nth-child(n+6) { animation-delay: 0.46s; }

聚珍(Juzhen)/cjk-autospace 變更紀錄。← 回文檔

+

行內排版透明性修復 + 標點懸掛移除 2026-06-11

+

修復兩個下游 Bug(同源於「行內元素對排版透明」原則在各遍歷原語口徑不一)+三項同根隱患審計;並把上一輪「停用」之標點懸掛正式移除(負 margin 程式碼/CSS/型別一併刪)。單元測試 90→101 全綠。

+

修復

+ +

標點懸掛:實作後停用 → 正式移除

+

純 CSS 手段(負 margin/相對位移/零寬盒)經實測為機制級死路,已移除(負 margin 之 lineedge 程式碼/jz-hang-* CSS/LineEndModelineEndfeatures.hanging 一併刪)。行端僅餘半形擠壓(jz-half-le)。

+ +

功能分級 + 垂懸字(標點懸掛停用) 2026-06-09

ARCHITECTURE §6.5:功能分級、垂懸字(方案 C)落地工作良好;原計劃之標點懸掛經實測確認不可行、全面停用。共 90 單元測試全綠(含懸掛防逃逸覆蓋)。

新增

@@ -2765,19 +2796,29 @@ var Juzhen = (() => { this.pillSelector = opts.finder.pillSelector; this.skipAttr = opts.finder.skipAttribute; this.userIsSkipped = opts.finder.isSkipped; + this.levelTextSelector = opts.levelText; this.avoidSelector = opts.scope.avoid; this.includeSelector = opts.scope.include; this.featureCache = /* @__PURE__ */ new WeakMap(); + this.levelCache = /* @__PURE__ */ new WeakMap(); } /** 該元素是否匹配 pill 選擇器(行內塊,如 code/kbd)。 */ pillMatches(el) { return !!(el.matches && this.pillSelector && el.matches(this.pillSelector)); } /** 邏輯文本應**透明穿越**之行內元素:樣式行內標籤,以及聚珍自身之 wrap 類 - * jz-*(jz-hws 間隙、jz-jinze 禁則群組)。穿越這些才能正確找到跨包裝之邏輯 - * 鄰字(如 jinze 已把 pill 納入 jz-jinze 後,pill 之左鄰字仍須可達;I7)。 */ + * jz-*(jz-hws 間隙、jz-jinze 禁則群組、jz-char/jz-inner 標點原子)。穿越這些 + * 才能正確找到跨包裝之邏輯鄰字(如 jinze 已把 pill 納入 jz-jinze 後,pill 之左 + * 鄰字仍須可達;I7)。 + * ⚠ jz-char/jz-inner 必須透明:否則 adjacentLogicalChar 之 descend 遇標點原子既 + * 不進入亦不終止、而是**跳過**它續找更前之兄弟(下游 Bug:`汉字。` 經 jinze + * 綁定為 `汉`,pill 之左鄰字應 + * 為「。」、不補間隙;舊邏輯卻越過 jz-char 取到「字」→ 誤包 jz-hws 於「字。」之間)。 + * jz-char 僅含標點(jiya 只 charify biaodian),標點非 pillNeighbor,故透明後 descend + * 回傳標點 → 不補隙,與 collectRuns 之既有透明遞迴一致。jz-inner 亦須列入,否則 + * descend 進入 jz-char 後因 jz-inner 不透明而跳過其內標點。 */ isTransparentInline(name) { - return this.styleSet.has(name) || name === "JZ-HWS" || name === "JZ-JINZE"; + return this.styleSet.has(name) || name === "JZ-HWS" || name === "JZ-JINZE" || name === "JZ-CHAR" || name === "JZ-INNER"; } /** 節點是否處於 avoid 子樹(內建 skip 名單 / SVG / contentEditable / * data-jz-skip / 使用者 avoid 選擇器 / skipAttribute / isSkipped)。 */ @@ -2987,6 +3028,48 @@ var Juzhen = (() => { p = pe.parentNode; } } + /** + * 節點之功能級別(§6.5.0a)。自 node 向上找**最近**之顯式信號:元素屬性 + * `data-jz-level="text|paragraph"`(最優先),或命中 `level.text` 選擇器(→ text); + * 皆無則預設 paragraph(全功能)。結果以解析後之元素為鍵快取。 + */ + levelFor(node) { + const start = node.nodeType === 1 ? node : node.parentElement; + if (!start) { + return "paragraph"; + } + const cached = this.levelCache.get(start); + if (cached) { + return cached; + } + let el = start; + let result = "paragraph"; + while (el) { + const attr = el.getAttribute ? el.getAttribute("data-jz-level") : null; + if (attr === "text" || attr === "paragraph") { + result = attr; + break; + } + if (this.levelTextSelector && el.matches && el.matches(this.levelTextSelector)) { + result = "text"; + break; + } + el = el.parentElement; + } + this.levelCache.set(start, result); + return result; + } + /** + * 某級別之 pass 是否可作用於該節點(§6.5.0a gate)。文本級 pass 不受限(恆 true); + * 段落級 pass 僅作用於段落級元素(text-level 子樹跳過)。與 featureEnabledFor 疊加: + * level 為粗粒度語義組(先判),blocks/屬性為細粒度逐功能(後調)。 + */ + levelAllows(node, passLevel) { + if (passLevel === "text") { + return true; + } + return this.levelFor(node) === "paragraph"; + } /** * 解析節點對某功能之有效啟用狀態(§3.6,per-node 閘)。向上找最近帶 * 功能指令之祖先(data-juzhen 白名單 / data-juzhen-off 黑名單 / 命中 @@ -3059,6 +3142,9 @@ var Juzhen = (() => { ctx.finder.eachTextNode(ctx.root, (node) => { let requests = []; for (const p of passes) { + if (!ctx.finder.levelAllows(node, p.level)) { + continue; + } const got = p.collect(node, ctx); if (got.length > 0) { requests = requests.concat(got); @@ -3152,8 +3238,11 @@ var Juzhen = (() => { if (!parent) { continue; } - touched.add(parent); const kind = el.getAttribute(KIND_ATTR); + if (kind === "splitoff") { + continue; + } + touched.add(parent); if (kind === "marker") { parent.removeChild(el); } else { @@ -3163,6 +3252,30 @@ var Juzhen = (() => { parent.removeChild(el); } } + const splitoffs = Array.from( + root.querySelectorAll( + "[" + PASS_ATTR + '="' + pass + '"][' + KIND_ATTR + '="splitoff"]' + ) + ); + for (const so of splitoffs) { + const parent = so.parentNode; + if (!parent) { + continue; + } + touched.add(parent); + const prev = so.previousSibling; + if (prev && prev.nodeType === 1 && prev.nodeName === so.nodeName) { + while (so.firstChild) { + prev.appendChild(so.firstChild); + } + parent.removeChild(so); + } else { + while (so.firstChild) { + parent.insertBefore(so.firstChild, so); + } + parent.removeChild(so); + } + } for (const p of touched) { if (p.normalize) { p.normalize(); @@ -3393,6 +3506,7 @@ var Juzhen = (() => { name: PASS, kind: "charify", order: 20, + level: "text", enabled: (o) => o.features.jiya, collect(node, ctx) { if (inJzChar(node)) { @@ -3446,6 +3560,7 @@ var Juzhen = (() => { name: "jiyaAdjacency", kind: "standalone", order: 25, + level: "text", enabled: (o) => o.features.jiya, render(ctx) { const { finder, options } = ctx; @@ -3482,6 +3597,8 @@ var Juzhen = (() => { squeeze(prev, el); } prev = el; + } else if (finder.pillMatches(el)) { + prev = null; } else if (finder.isAvoided(el)) { prev = null; } else if (blockSet.has(nm)) { @@ -3499,27 +3616,60 @@ var Juzhen = (() => { } }; - // src/typeset/jinze.ts - var PASS2 = "jinze"; - var AVOID_HEAD = /* @__PURE__ */ new Set(["bd-close", "bd-pause", "bd-stop", "bd-middle", "bd-liga"]); - var AVOID_TAIL = /* @__PURE__ */ new Set(["bd-open"]); - function isCharEl(n) { - return !!n && n.nodeType === 1 && n.nodeName === "JZ-CHAR"; + // src/core/split.ts + var PASS_ATTR2 = "data-jz"; + var KIND_ATTR2 = "data-jz-kind"; + var SPLITTABLE = /* @__PURE__ */ new Set([ + "STRONG", + "EM", + "B", + "I", + "U", + "S", + "SPAN", + "MARK", + "SMALL", + "INS", + "DEL", + "SUB", + "SUP" + ]); + function canSplit(el) { + return SPLITTABLE.has(el.nodeName) && !el.hasAttribute("id"); } - function forbiddenBreak(a, b, finder) { - if (isCharEl(b)) { - const c = bdClassOf(b); - if (c && AVOID_HEAD.has(c) && finder.featureEnabledFor(b, PASS2)) { - return true; + function firstText(el) { + for (let c = el.firstChild; c; c = c.nextSibling) { + if (c.nodeType === 3 && c.data.length > 0) { + return c; + } + if (c.nodeType === 1) { + if (!canSplit(c)) { + return null; + } + const r = firstText(c); + if (r) { + return r; + } } } - if (isCharEl(a)) { - const c = bdClassOf(a); - if (c && AVOID_TAIL.has(c) && finder.featureEnabledFor(a, PASS2)) { - return true; + return null; + } + function lastText(el) { + for (let c = el.lastChild; c; c = c.previousSibling) { + if (c.nodeType === 3 && c.data.length > 0) { + return c; + } + if (c.nodeType === 1) { + if (!canSplit(c)) { + return null; + } + const r = lastText(c); + if (r) { + return r; + } } } - return false; + return null; } function isTokenChar(ch, anyCjk) { return !anyCjk.test(ch) && !/\s/.test(ch); @@ -3545,16 +3695,135 @@ var Juzhen = (() => { } return s; } + function splitTreeAfter(top, textNode, offset, pass) { + if (offset <= 0 && firstText(top) === textNode) { + return null; + } + let rightAtLevel; + if (offset >= textNode.data.length) { + rightAtLevel = textNode.nextSibling; + } else if (offset <= 0) { + rightAtLevel = textNode; + } else { + rightAtLevel = textNode.splitText(offset); + } + let ancestor = textNode.parentNode; + let topClone = null; + while (ancestor && ancestor.nodeType === 1) { + const anc = ancestor; + let clone = null; + if (rightAtLevel) { + clone = anc.cloneNode(false); + clone.removeAttribute("id"); + clone.setAttribute(PASS_ATTR2, pass); + clone.setAttribute(KIND_ATTR2, "splitoff"); + let n = rightAtLevel; + while (n) { + const next = n.nextSibling; + clone.appendChild(n); + n = next; + } + } + if (anc === top) { + if (!clone) { + return null; + } + if (top.parentNode) { + top.parentNode.insertBefore(clone, top.nextSibling); + } + topClone = clone; + break; + } + if (clone) { + if (anc.parentNode) { + anc.parentNode.insertBefore(clone, anc.nextSibling); + } + rightAtLevel = clone; + } else { + rightAtLevel = anc.nextSibling; + } + ancestor = anc.parentNode; + } + return topClone; + } + function isolateBoundaryToken(el, side, anyCjk, pass) { + if (!canSplit(el)) { + return el; + } + const text = side === "tail" ? lastText(el) : firstText(el); + if (!text) { + return el; + } + const point = side === "tail" ? { textNode: text, offset: trailingTokenStart(text.data, anyCjk) } : { textNode: text, offset: leadingTokenEnd(text.data, anyCjk) }; + const clone = splitTreeAfter(el, point.textNode, point.offset, pass); + if (!clone) { + return el; + } + return side === "tail" ? clone : el; + } + function splitElementAt(el, textNode, offset, pass) { + if (!canSplit(el)) { + return null; + } + return splitTreeAfter(el, textNode, offset, pass); + } + + // src/typeset/jinze.ts + var PASS2 = "jinze"; + var AVOID_HEAD = /* @__PURE__ */ new Set(["bd-close", "bd-pause", "bd-stop", "bd-middle", "bd-liga"]); + var AVOID_TAIL = /* @__PURE__ */ new Set(["bd-open"]); + function isCharEl(n) { + return !!n && n.nodeType === 1 && n.nodeName === "JZ-CHAR"; + } + function forbiddenBreak(a, b, finder) { + if (isCharEl(b)) { + const c = bdClassOf(b); + if (c && AVOID_HEAD.has(c) && finder.featureEnabledFor(b, PASS2)) { + return true; + } + } + if (isCharEl(a)) { + const c = bdClassOf(a); + if (c && AVOID_TAIL.has(c) && finder.featureEnabledFor(a, PASS2)) { + return true; + } + } + return false; + } + function isTokenChar2(ch, anyCjk) { + return !anyCjk.test(ch) && !/\s/.test(ch); + } + function leadingTokenEnd2(d, anyCjk) { + if (anyCjk.test(d.charAt(0))) { + return 1; + } + let e = 1; + while (e < d.length && isTokenChar2(d.charAt(e), anyCjk)) { + e += 1; + } + return e; + } + function trailingTokenStart2(d, anyCjk) { + const last = d.length - 1; + if (anyCjk.test(d.charAt(last))) { + return last; + } + let s = last; + while (s > 0 && isTokenChar2(d.charAt(s - 1), anyCjk)) { + s -= 1; + } + return s; + } function splitBoundaries(t, needFirst, needLast, anyCjk) { let node = t; if (needFirst) { - const e = leadingTokenEnd(node.data, anyCjk); + const e = leadingTokenEnd2(node.data, anyCjk); if (e < node.data.length) { node = node.splitText(e); } } if (needLast) { - const s = trailingTokenStart(node.data, anyCjk); + const s = trailingTokenStart2(node.data, anyCjk); if (s > 0) { node.splitText(s); } @@ -3584,13 +3853,21 @@ var Juzhen = (() => { } for (let i2 = 0; i2 < n; i2 += 1) { const u = units[i2]; - if (u.nodeType !== 3) { - continue; - } const needFirst = i2 > 0 && forbidden[i2 - 1]; const needLast = i2 < n - 1 && forbidden[i2]; - if (needFirst || needLast) { + if (!needFirst && !needLast) { + continue; + } + if (u.nodeType === 3) { splitBoundaries(u, needFirst, needLast, anyCjk); + } else if (u.nodeType === 1) { + const el = u; + if (needLast) { + isolateBoundaryToken(el, "tail", anyCjk, PASS2); + } + if (needFirst) { + isolateBoundaryToken(el, "head", anyCjk, PASS2); + } } } const kids = []; @@ -3618,11 +3895,15 @@ var Juzhen = (() => { name: PASS2, kind: "standalone", order: 40, + level: "paragraph", enabled: (o) => o.features.jinze, render(ctx) { const anyCjk = new RegExp("[" + ctx.options.ruleset.cjk + "]"); const parents = /* @__PURE__ */ new Set(); ctx.root.querySelectorAll("jz-char").forEach((c) => { + if (!ctx.finder.levelAllows(c, "paragraph")) { + return; + } if (c.parentNode) { parents.add(c.parentNode); } @@ -3638,7 +3919,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"; @@ -3673,7 +3960,14 @@ var Juzhen = (() => { m.delete(key); } } - var INLINE_WRAP = /* @__PURE__ */ new Set(["JZ-JINZE", "JZ-CHAR", "JZ-INNER", "JZ-HWS", "SPAN", "STRONG", "EM", "A", "B", "I", "U", "MARK"]); + var JZ_WRAP_TAGS = ["JZ-JINZE", "JZ-CHAR", "JZ-INNER", "JZ-HWS"]; + function buildWrapSet(options) { + const s = new Set(JZ_WRAP_TAGS); + for (const name of options.finder.styleInlines) { + s.add(name); + } + return s; + } function rectOf(n, side) { if (n.nodeType === 3 && n.data.length > 0) { const t = n; @@ -3697,7 +3991,7 @@ var Juzhen = (() => { } return null; } - function edgeRect(start, side, from) { + function edgeRect(start, side, from, wrapSet) { let n = start; let parentRef = from.parentNode; for (; ; ) { @@ -3711,7 +4005,7 @@ var Juzhen = (() => { if (!parentRef || parentRef.nodeType !== 1) { return null; } - if (!INLINE_WRAP.has(parentRef.nodeName)) { + if (!wrapSet.has(parentRef.nodeName)) { return null; } n = side === "prev" ? parentRef.previousSibling : parentRef.nextSibling; @@ -3722,60 +4016,110 @@ 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 wrapSet = buildWrapSet(options); 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; + } 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") { - const prev = edgeRect(el.previousSibling, "prev", el); - if (!prev || prev.top < r.top - EPS) { - el.classList.add(LE); + if (isHead) { + const prev = edgeRect(el.previousSibling, "prev", el, wrapSet); + 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); + const next = edgeRect(el.nextSibling, "next", el, wrapSet); + 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", kind: "standalone", order: 90, + level: "paragraph", enabled: (o) => o.features.jiya, render(ctx) { 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"; - function trimGaps(root, finder) { + function trimGaps(root, finder, wrapSet) { const gaps = Array.from(root.querySelectorAll("jz-hws")).filter( - (g) => finder.inScope(g) && !finder.isAvoided(g) + (g) => finder.inScope(g) && !finder.isAvoided(g) && finder.levelAllows(g, "paragraph") ); if (!gaps.length) { return; @@ -3783,7 +4127,7 @@ var Juzhen = (() => { gaps.forEach((g) => g.classList.add(TRIM)); for (const g of gaps) { const r = lastRect(g); - const next = r ? edgeRect(g.nextSibling, "next", g) : null; + const next = r ? edgeRect(g.nextSibling, "next", g, wrapSet) : null; const atLineEnd = !r || !next || next.top > r.top + EPS; if (!atLineEnd) { g.classList.remove(TRIM); @@ -3794,12 +4138,14 @@ var Juzhen = (() => { name: "spacingGapTrim", kind: "standalone", order: 92, + level: "paragraph", enabled: (o) => o.features.spacing, render(ctx) { if (!hasLayout()) { return; } - installLayout(ctx.root, "gapTrim", () => trimGaps(ctx.root, ctx.finder)); + const wrapSet = buildWrapSet(ctx.options); + installLayout(ctx.root, "gapTrim", () => trimGaps(ctx.root, ctx.finder, wrapSet)); }, revert(ctx) { uninstallLayout(ctx.root, "gapTrim"); @@ -3824,6 +4170,7 @@ var Juzhen = (() => { name: PASS3, kind: "charify", order: 60, + level: "paragraph", enabled: (o) => o.features.longWord, collect(node, ctx) { if (inLangSpan(node)) { @@ -3861,8 +4208,116 @@ var Juzhen = (() => { } }; + // src/typeset/orphan.ts + var PASS4 = "orphan"; + function hasOrphan(block) { + for (let c = block.firstChild; c; c = c.nextSibling) { + if (c.nodeType === 1 && c.nodeName === "JZ-ORPHAN") { + return true; + } + } + return false; + } + function collectSubst(block, finder, blockSet, anyCjk, biaodian) { + const out = []; + const visit = (node, directChild) => { + if (node.nodeType === 3) { + const t = node; + const d = t.data; + for (let i = 0; i < d.length; i += 1) { + const ch = d.charAt(i); + if (anyCjk.test(ch) && !classifyBiaodian(ch, biaodian)) { + out.push({ directChild, textNode: t, offset: i }); + } + } + return; + } + if (node.nodeType !== 1) { + return; + } + const el = node; + if (finder.isAvoided(el)) { + return; + } + if (blockSet.has(el.nodeName) || finder.pillMatches(el)) { + return; + } + for (let c = el.firstChild; c; c = c.nextSibling) { + visit(c, directChild); + } + }; + for (let dc = block.firstChild; dc; dc = dc.nextSibling) { + visit(dc, dc); + } + return out; + } + function processBlock(block, finder, blockSet, anyCjk, biaodian, chars) { + if (hasOrphan(block)) { + return; + } + const subst = collectSubst(block, finder, blockSet, anyCjk, biaodian); + if (subst.length < chars) { + return; + } + const boundary = subst[subst.length - chars]; + let startChild = boundary.directChild; + if (boundary.textNode && boundary.textNode === startChild) { + if (boundary.offset > 0) { + startChild = boundary.textNode.splitText(boundary.offset); + } + } else if (boundary.textNode && startChild.nodeType === 1) { + const clone = splitElementAt( + startChild, + boundary.textNode, + boundary.offset, + PASS4 + ); + if (clone) { + startChild = clone; + } + } + const orphan = createJz("jz-orphan", PASS4, "wrap"); + block.insertBefore(orphan, startChild); + let n = startChild; + while (n) { + const next = n.nextSibling; + orphan.appendChild(n); + n = next; + } + } + var orphanPass = { + name: PASS4, + kind: "standalone", + order: 70, + level: "paragraph", + enabled: (o) => o.features.orphan, + render(ctx) { + const { finder, options } = ctx; + const blockSet = options.finder.blockTags; + const anyCjk = new RegExp("[" + options.ruleset.cjk + "]"); + const biaodian = options.ruleset.biaodian; + const chars = options.orphan.chars; + finder.eachBlock(ctx.root, (block) => { + if (!finder.levelAllows(block, "paragraph")) { + return; + } + if (!finder.featureEnabledFor(block, PASS4)) { + return; + } + const sel = Array.from(blockSet).map((t) => t.toLowerCase()).join(","); + if (sel && block.querySelector(sel)) { + return; + } + processBlock(block, finder, blockSet, anyCjk, biaodian, chars); + }); + }, + revert(ctx) { + revertPass(PASS4, ctx.root); + } + }; + // src/typeset/spacing.ts - var PASS4 = "spacing"; + var PASS5 = "spacing"; var MARK = "\uE000"; var PH_OPEN = "\uE001"; var PH_CLOSE = "\uE002"; @@ -3963,7 +4418,7 @@ var Juzhen = (() => { if (!parent || parent.nodeName === "JZ-HWS") { return; } - const gap = createJz("jz-hws", PASS4, "wrap"); + const gap = createJz("jz-hws", PASS5, "wrap"); parent.insertBefore(gap, node); gap.appendChild(node); } @@ -3980,7 +4435,7 @@ var Juzhen = (() => { } wrapNodeInGap(tn); } - function processBlock(block, finder, R) { + function processBlock2(block, finder, R) { const runs = finder.collectRuns(block); for (const run of runs) { const { text, map } = run; @@ -4030,6 +4485,9 @@ var Juzhen = (() => { if (finder.isAvoided(pill) || !finder.inScope(pill)) { return; } + if (!finder.featureEnabledFor(pill, PASS5)) { + return; + } if (!pill.parentNode) { return; } @@ -4072,23 +4530,27 @@ var Juzhen = (() => { } } var spacingPass = { - name: PASS4, + name: PASS5, kind: "standalone", order: 50, + level: "text", enabled: (o) => o.features.spacing, render(ctx) { const { finder, options } = ctx; const R = makeRules(options.ruleset); ctx.finder.eachBlock(ctx.root, (block) => { - if (!finder.featureEnabledFor(block, PASS4)) { + if (!finder.featureEnabledFor(block, PASS5)) { return; } - processBlock(block, finder, R); + processBlock2(block, finder, R); }); + if (!options.finder.blockTags.has(ctx.root.nodeName) && finder.featureEnabledFor(ctx.root, PASS5) && finder.inScope(ctx.root) && !finder.isAvoided(ctx.root)) { + processBlock2(ctx.root, finder, R); + } processPills(ctx.root, finder, options.finder.pillSelector, R.pillNeighbor); }, revert(ctx) { - revertPass(PASS4, ctx.root); + revertPass(PASS5, ctx.root); } }; @@ -4241,6 +4703,7 @@ var Juzhen = (() => { longWordPass, spacingPass, jinzePass, + orphanPass, lineEdgePass, gapTrimPass ]; @@ -4258,6 +4721,13 @@ var Juzhen = (() => { const lang = typeof v.lang === "string" && v.lang ? v.lang : "en"; return { minLength, lang }; } + function resolveOrphan(v) { + if (v && typeof v === "object") { + const chars = typeof v.chars === "number" && v.chars >= 1 ? Math.floor(v.chars) : 2; + return { chars }; + } + return { chars: 2 }; + } function resolveJiya(v) { if (v && typeof v === "object") { return { @@ -4279,6 +4749,7 @@ var Juzhen = (() => { ruleset: resolveRuleset(opts.charClass), autospaceClass: opts.autospaceClass || "", justifyAtoms: opts.justifyAtoms === false ? false : true, + levelText: opts.level && opts.level.text || null, scope: { root: scope.root || null, include: scope.include || null, @@ -4289,12 +4760,22 @@ var Juzhen = (() => { longWord: opts.longWord === false ? false : feature(opts.longWord, true), 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 之 CSS/lineedge 程式碼保留(休眠),俟原生支援成熟再議。 + lineEnd: "squeeze", jiyaConfig: resolveJiya(opts.jiya), blocks: Array.isArray(opts.blocks) ? opts.blocks.slice() : [], finder: { @@ -4582,6 +5063,12 @@ window.__JUZHEN_PUNCT__="quanjiao";window.__JUZHEN_JUSTIFY_ATOMS__=true;window._ spacing: true, jinze: true, longWord: true, + /* orphan(垂懸字避免,聚珍 §6.5.1):段末行 CJK 實義字 < 2 時,把末 2 字 + 包入 `` 拉下首字,杜絕「孤字成行」。判定僅計 + 漢字實義字(標點/符號不納入)。方案 C 屬斷行層、無測量,結構性 nowrap 同 + 禁則,故分頁安全(academic DEFER 後於定版頁上跑亦不擾 Paged.js)。三模式共用, + lib 預設即開,此處顯式標記為刻意採用。 */ + orphan: true, jiya: { halfWidth: halfWidth, }, diff --git a/docs/changelog.src.html b/docs/changelog.src.html index 397861b..1bc338d 100644 --- a/docs/changelog.src.html +++ b/docs/changelog.src.html @@ -25,6 +25,22 @@

聚珍(Juzhen)/cjk-autospace 變更紀錄。← 回文檔

+

行內排版透明性修復 + 標點懸掛移除 2026-06-11

+

修復兩個下游 Bug(同源於「行內元素對排版透明」原則在各遍歷原語口徑不一)+三項同根隱患審計;並把上一輪「停用」之標點懸掛正式移除(負 margin 程式碼/CSS/型別一併刪)。單元測試 90→101 全綠。

+

修復

+
    +
  • Bug:pill 前標點誤加間隙(如 漢字。<code>)。根因:finder.adjacentLogicalChardescend 遇不透明之 jz-char 既不進入亦不終止、而跳過它越過標點取到更前之漢字,誤補 jz-hws。修復=isTransparentInlineJZ-CHARJZ-INNERdescend 遂回傳標點(非 pill 鄰字、不補隙),與 collectRuns 之既有透明遞迴一致。
  • +
  • Bug:行內樣式元素整體掉行<strong>很長一串</strong>, 把整串粗體塞進 nowrap inline-block,行末放不下即整體掉行、上行留巨大空隙)。新增 core/split.ts 之克隆切分:沿祖先鏈把邊界詞元剝入淺克隆(保留標籤/class/style/lang、去 id;標 data-jz-kind="splitoff"),禁則/垂懸字只綁該克隆(最小綁定),原元素餘部留原位、渲染無縫。revertPass 增延後階段把 splitoff 併回原元素(先解包 wrap、後併 splitoff),render→revert 還原一致。純樣式標籤白名單可切,aabbrruby/帶 id 退化整綁。
  • +
  • 審計(同根隱患):T4 lineedge 之行端攀爬集改由 options.finder.styleInlines 派生(棄硬編碼子集,免遺漏 insdelsub 等致行端誤判);T5 jiyaAdjacency 遇 pill 斷相鄰、不穿越;T6 spacing processPills 補 per-node 功能閘(data-juzhen-off="spacing" 區域內不補隙)。
  • +
+

標點懸掛:實作後停用 → 正式移除

+

純 CSS 手段(負 margin/相對位移/零寬盒)經實測為機制級死路,已移除(負 margin 之 lineedge 程式碼/jz-hang-* CSS/LineEndModelineEndfeatures.hanging 一併刪)。行端僅餘半形擠壓(jz-half-le)。

+
    +
  • 寬度守恆論證:行端標點盒若保持非零寬,則墨色出去多少、版心內就空出多少(行末實義字無法齊版心右緣);若收為零寬,則釋放一整字寬、斷行器把下一字拉上本行與標點重疊(且 justifyAtoms 預設之 inline-block 下負 margin 被 justify 吸收)。CSS hack 接觸不到斷行器,此恒等式無解。
  • +
  • 唯一正解=原生 hanging-punctuation,惟 Chrome/Firefox 至今不支援(僅 Safari),且 jz-char 為 inline-block 原子非文本、原生懸掛不作用之。未來若做須是繞開 jz-char 的「原生管線」新功能(text-spacing-trimhanging-punctuation,按能力門控),與已移除碼無共享,故不保留休眠碼。
  • +
  • hanging 公開選項保留為 @deprecated no-op(API 相容;測試覆蓋 option/data-juzhenblocks 各路徑皆不產生 jz-hang-* 且輸出與不傳一致)。詳見 ARCHITECTURE §6.5.6
  • +
+

功能分級 + 垂懸字(標點懸掛停用) 2026-06-09

ARCHITECTURE §6.5:功能分級、垂懸字(方案 C)落地工作良好;原計劃之標點懸掛經實測確認不可行、全面停用。共 90 單元測試全綠(含懸掛防逃逸覆蓋)。

新增

diff --git a/docs/index.html b/docs/index.html index 010cb3c..2b7be4c 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1,4 +1,4 @@ - + @@ -963,6 +963,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 { @@ -974,6 +984,11 @@ jz-jinze { display: inline; } +/* src/css/_orphan.css */ +jz-orphan { + white-space: nowrap; +} + /* src/css/juzhen.css */ /* ========== Feature: code-block ========================================== @@ -2562,9 +2577,9 @@ createJuzhen({ lang: { default: "zh-Hant" } }).render(document.querySelector("ma 中西間隙 spacingCJK↔西文邊界,margin 模型:包裹左側字、以 margin-right 留隙,不注入空白字元 → textContent 完全不變、複製緊湊、justify 不暴增、行首乾淨;行末由 gapTrim layout pass 去隙以齊右。含跨樣式邊界與 codekbd pill(pill 緊鄰標點時穿越 jz-jinze 仍補間隙)。贅餘作者空格一律正規化:CJK↔西文吸收為受管間隙、CJK↔CJK(含跨 inline 標籤)剝除不留隙。 長英文詞 longWord[A-Za-z]{6,} 包入 <span lang="en">,配合消費端 CSS hyphens:auto 原生斷詞(DOM 無連字符 → 複製不污染)。 標點擠壓 jiya標點 charify 為 jz-charbd-* class。三機制:baseline 政策(全角式不擠/開明式擠句內點號+括號引號/半角式全擠)+行內連續擠壓+行端擠壓。半形預設以字型 OpenType halt 渲染;缺 halt 字型可切 margin 後備模式(見下)。 -禁則 jinze行首行末避頭尾:閉類/點號綁前一字(不置行首)、開類綁後一字(不置行末);只隔離邊界詞元,長引文中段仍可斷。亦為 justify 綁定載體。 -垂懸字避免 orphan段末行 CJK 實義字 ≥ N(預設 2;標點不計)。方案 C(斷行層無測量):段末末 N 實義字+尾隨標點包入 jz-orphanwhite-space:nowrap),整體不可斷 → 末行恒含之 ⇒ 末行 ≥ N。預設開;段落級。 -標點懸掛 hanging已評估停用:純負 margin 機制不可行(墨色不出版心/滿凸拉升後字重疊/resize 錯亂),原生 hanging-punctuation 為正解但 Chrome 不支援。選項仍被接受但無效果、程式碼休眠。詳見 ARCHITECTURE §6.5.6。 +禁則 jinze行首行末避頭尾:閉類/點號綁前一字(不置行首)、開類綁後一字(不置行末);只隔離邊界詞元,長引文中段仍可斷。邊界字落於行內樣式元素(strongem 等)內時克隆切分只綁邊界詞元(避免整串入 nowrap 致行末整體掉行),克隆標 data-jz-kind="splitoff"revert 時併回。亦為 justify 綁定載體。 +垂懸字避免 orphan段末行 CJK 實義字 ≥ N(預設 2;標點不計)。方案 C(斷行層無測量):段末末 N 實義字+尾隨標點包入 jz-orphanwhite-space:nowrap),整體不可斷 → 末行恒含之 ⇒ 末行 ≥ N。邊界字落於行內元素內時同樣克隆切分(避免長元素溢出容器)。預設開;段落級。 +標點懸掛 hanging已移除:純 CSS 機制為機制級死路(寬度守恆矛盾——墨色出去多少版心內就空多少,CSS 接觸不到斷行器)。原生 hanging-punctuation 為唯一正解但僅 Safari 支援、且與 inline-block 原子互斥。hanging 選項保留為 no-op(無效果)。詳見 ARCHITECTURE §6.5.6。 功能分級 level文本級(間隙、擠壓——任意文本適用)vs 段落級(禁則、垂懸字、長詞——需行/段布局)。塊級預設全功能;data-jz-level="text" 子樹或 level:{text:"選擇器"}(標題/按鈕)僅跑文本級。 justify 單份間距每個 jz-charinline-block 原子,句末標點只貢獻一份對齊伸縮量。 @@ -2584,7 +2599,7 @@ createJuzhen({ lang: { default: "zh-Hant" } }).render(document.querySelector("ma jiya: true, // 或物件:{ halfWidth: "halt"|"margin", bias } jinze: true, orphan: true, // 垂懸字避免(預設開);或 { chars: 2 };false 關 - // hanging:已停用、無效果(純負 margin 機制不可行,詳見 §6.5.6) + // hanging:已移除、無效果(純 CSS 機制級死路,詳見 §6.5.6) level: { text: "h1,h2,h3,button,.ui" }, // 此選擇器子樹僅跑文本級功能 justifyAtoms: true, // 分頁器下設 false 改 inline(見下) blocks: [ { selector: ".code-comment", features: [ "spacing" ] } ], @@ -2733,7 +2748,7 @@ JS 只注入 `jz-*` 結構,所有視覺尺寸在 juzhen.css,缺一不可。 - jiya 標點擠壓(全角/開明/半角;半形 halt 預設、margin 後備)【文本級】 - jinze 禁則(避頭尾、justify 綁定)【段落級】 - orphan 垂懸字避免(方案 C,末行 CJK 實義字 ≥ N;預設開 chars=2)【段落級】 -- hanging 標點懸掛:已評估停用(純負 margin 機制不可行,原生 hanging-punctuation 待 Chrome 支援;選項無效果、程式碼休眠;詳見 §6.5.6) +- hanging 標點懸掛:已移除(純 CSS 機制級死路:寬度守恆矛盾,CSS 接觸不到斷行器;原生 hanging-punctuation 為唯一正解但僅 Safari 支援、且與 inline-block 原子互斥;選項保留為 no-op;詳見 §6.5.6) - level 功能分級(文本級 vs 段落級;data-jz-level="text"/level:{text:選擇器} 標記標題等僅跑文本級) - justify 句末標點單份間距(jz-char inline-block 原子) @@ -2811,19 +2826,29 @@ var Juzhen = (() => { this.pillSelector = opts.finder.pillSelector; this.skipAttr = opts.finder.skipAttribute; this.userIsSkipped = opts.finder.isSkipped; + this.levelTextSelector = opts.levelText; this.avoidSelector = opts.scope.avoid; this.includeSelector = opts.scope.include; this.featureCache = /* @__PURE__ */ new WeakMap(); + this.levelCache = /* @__PURE__ */ new WeakMap(); } /** 該元素是否匹配 pill 選擇器(行內塊,如 code/kbd)。 */ pillMatches(el) { return !!(el.matches && this.pillSelector && el.matches(this.pillSelector)); } /** 邏輯文本應**透明穿越**之行內元素:樣式行內標籤,以及聚珍自身之 wrap 類 - * jz-*(jz-hws 間隙、jz-jinze 禁則群組)。穿越這些才能正確找到跨包裝之邏輯 - * 鄰字(如 jinze 已把 pill 納入 jz-jinze 後,pill 之左鄰字仍須可達;I7)。 */ + * jz-*(jz-hws 間隙、jz-jinze 禁則群組、jz-char/jz-inner 標點原子)。穿越這些 + * 才能正確找到跨包裝之邏輯鄰字(如 jinze 已把 pill 納入 jz-jinze 後,pill 之左 + * 鄰字仍須可達;I7)。 + * ⚠ jz-char/jz-inner 必須透明:否則 adjacentLogicalChar 之 descend 遇標點原子既 + * 不進入亦不終止、而是**跳過**它續找更前之兄弟(下游 Bug:`汉字。` 經 jinze + * 綁定為 `汉`,pill 之左鄰字應 + * 為「。」、不補間隙;舊邏輯卻越過 jz-char 取到「字」→ 誤包 jz-hws 於「字。」之間)。 + * jz-char 僅含標點(jiya 只 charify biaodian),標點非 pillNeighbor,故透明後 descend + * 回傳標點 → 不補隙,與 collectRuns 之既有透明遞迴一致。jz-inner 亦須列入,否則 + * descend 進入 jz-char 後因 jz-inner 不透明而跳過其內標點。 */ isTransparentInline(name) { - return this.styleSet.has(name) || name === "JZ-HWS" || name === "JZ-JINZE"; + return this.styleSet.has(name) || name === "JZ-HWS" || name === "JZ-JINZE" || name === "JZ-CHAR" || name === "JZ-INNER"; } /** 節點是否處於 avoid 子樹(內建 skip 名單 / SVG / contentEditable / * data-jz-skip / 使用者 avoid 選擇器 / skipAttribute / isSkipped)。 */ @@ -3033,6 +3058,48 @@ var Juzhen = (() => { p = pe.parentNode; } } + /** + * 節點之功能級別(§6.5.0a)。自 node 向上找**最近**之顯式信號:元素屬性 + * `data-jz-level="text|paragraph"`(最優先),或命中 `level.text` 選擇器(→ text); + * 皆無則預設 paragraph(全功能)。結果以解析後之元素為鍵快取。 + */ + levelFor(node) { + const start = node.nodeType === 1 ? node : node.parentElement; + if (!start) { + return "paragraph"; + } + const cached = this.levelCache.get(start); + if (cached) { + return cached; + } + let el = start; + let result = "paragraph"; + while (el) { + const attr = el.getAttribute ? el.getAttribute("data-jz-level") : null; + if (attr === "text" || attr === "paragraph") { + result = attr; + break; + } + if (this.levelTextSelector && el.matches && el.matches(this.levelTextSelector)) { + result = "text"; + break; + } + el = el.parentElement; + } + this.levelCache.set(start, result); + return result; + } + /** + * 某級別之 pass 是否可作用於該節點(§6.5.0a gate)。文本級 pass 不受限(恆 true); + * 段落級 pass 僅作用於段落級元素(text-level 子樹跳過)。與 featureEnabledFor 疊加: + * level 為粗粒度語義組(先判),blocks/屬性為細粒度逐功能(後調)。 + */ + levelAllows(node, passLevel) { + if (passLevel === "text") { + return true; + } + return this.levelFor(node) === "paragraph"; + } /** * 解析節點對某功能之有效啟用狀態(§3.6,per-node 閘)。向上找最近帶 * 功能指令之祖先(data-juzhen 白名單 / data-juzhen-off 黑名單 / 命中 @@ -3105,6 +3172,9 @@ var Juzhen = (() => { ctx.finder.eachTextNode(ctx.root, (node) => { let requests = []; for (const p of passes) { + if (!ctx.finder.levelAllows(node, p.level)) { + continue; + } const got = p.collect(node, ctx); if (got.length > 0) { requests = requests.concat(got); @@ -3198,8 +3268,11 @@ var Juzhen = (() => { if (!parent) { continue; } - touched.add(parent); const kind = el.getAttribute(KIND_ATTR); + if (kind === "splitoff") { + continue; + } + touched.add(parent); if (kind === "marker") { parent.removeChild(el); } else { @@ -3209,6 +3282,30 @@ var Juzhen = (() => { parent.removeChild(el); } } + const splitoffs = Array.from( + root.querySelectorAll( + "[" + PASS_ATTR + '="' + pass + '"][' + KIND_ATTR + '="splitoff"]' + ) + ); + for (const so of splitoffs) { + const parent = so.parentNode; + if (!parent) { + continue; + } + touched.add(parent); + const prev = so.previousSibling; + if (prev && prev.nodeType === 1 && prev.nodeName === so.nodeName) { + while (so.firstChild) { + prev.appendChild(so.firstChild); + } + parent.removeChild(so); + } else { + while (so.firstChild) { + parent.insertBefore(so.firstChild, so); + } + parent.removeChild(so); + } + } for (const p of touched) { if (p.normalize) { p.normalize(); @@ -3439,6 +3536,7 @@ var Juzhen = (() => { name: PASS, kind: "charify", order: 20, + level: "text", enabled: (o) => o.features.jiya, collect(node, ctx) { if (inJzChar(node)) { @@ -3492,6 +3590,7 @@ var Juzhen = (() => { name: "jiyaAdjacency", kind: "standalone", order: 25, + level: "text", enabled: (o) => o.features.jiya, render(ctx) { const { finder, options } = ctx; @@ -3528,6 +3627,8 @@ var Juzhen = (() => { squeeze(prev, el); } prev = el; + } else if (finder.pillMatches(el)) { + prev = null; } else if (finder.isAvoided(el)) { prev = null; } else if (blockSet.has(nm)) { @@ -3545,27 +3646,60 @@ var Juzhen = (() => { } }; - // src/typeset/jinze.ts - var PASS2 = "jinze"; - var AVOID_HEAD = /* @__PURE__ */ new Set(["bd-close", "bd-pause", "bd-stop", "bd-middle", "bd-liga"]); - var AVOID_TAIL = /* @__PURE__ */ new Set(["bd-open"]); - function isCharEl(n) { - return !!n && n.nodeType === 1 && n.nodeName === "JZ-CHAR"; + // src/core/split.ts + var PASS_ATTR2 = "data-jz"; + var KIND_ATTR2 = "data-jz-kind"; + var SPLITTABLE = /* @__PURE__ */ new Set([ + "STRONG", + "EM", + "B", + "I", + "U", + "S", + "SPAN", + "MARK", + "SMALL", + "INS", + "DEL", + "SUB", + "SUP" + ]); + function canSplit(el) { + return SPLITTABLE.has(el.nodeName) && !el.hasAttribute("id"); } - function forbiddenBreak(a, b, finder) { - if (isCharEl(b)) { - const c = bdClassOf(b); - if (c && AVOID_HEAD.has(c) && finder.featureEnabledFor(b, PASS2)) { - return true; + function firstText(el) { + for (let c = el.firstChild; c; c = c.nextSibling) { + if (c.nodeType === 3 && c.data.length > 0) { + return c; + } + if (c.nodeType === 1) { + if (!canSplit(c)) { + return null; + } + const r = firstText(c); + if (r) { + return r; + } } } - if (isCharEl(a)) { - const c = bdClassOf(a); - if (c && AVOID_TAIL.has(c) && finder.featureEnabledFor(a, PASS2)) { - return true; + return null; + } + function lastText(el) { + for (let c = el.lastChild; c; c = c.previousSibling) { + if (c.nodeType === 3 && c.data.length > 0) { + return c; + } + if (c.nodeType === 1) { + if (!canSplit(c)) { + return null; + } + const r = lastText(c); + if (r) { + return r; + } } } - return false; + return null; } function isTokenChar(ch, anyCjk) { return !anyCjk.test(ch) && !/\s/.test(ch); @@ -3591,16 +3725,135 @@ var Juzhen = (() => { } return s; } + function splitTreeAfter(top, textNode, offset, pass) { + if (offset <= 0 && firstText(top) === textNode) { + return null; + } + let rightAtLevel; + if (offset >= textNode.data.length) { + rightAtLevel = textNode.nextSibling; + } else if (offset <= 0) { + rightAtLevel = textNode; + } else { + rightAtLevel = textNode.splitText(offset); + } + let ancestor = textNode.parentNode; + let topClone = null; + while (ancestor && ancestor.nodeType === 1) { + const anc = ancestor; + let clone = null; + if (rightAtLevel) { + clone = anc.cloneNode(false); + clone.removeAttribute("id"); + clone.setAttribute(PASS_ATTR2, pass); + clone.setAttribute(KIND_ATTR2, "splitoff"); + let n = rightAtLevel; + while (n) { + const next = n.nextSibling; + clone.appendChild(n); + n = next; + } + } + if (anc === top) { + if (!clone) { + return null; + } + if (top.parentNode) { + top.parentNode.insertBefore(clone, top.nextSibling); + } + topClone = clone; + break; + } + if (clone) { + if (anc.parentNode) { + anc.parentNode.insertBefore(clone, anc.nextSibling); + } + rightAtLevel = clone; + } else { + rightAtLevel = anc.nextSibling; + } + ancestor = anc.parentNode; + } + return topClone; + } + function isolateBoundaryToken(el, side, anyCjk, pass) { + if (!canSplit(el)) { + return el; + } + const text = side === "tail" ? lastText(el) : firstText(el); + if (!text) { + return el; + } + const point = side === "tail" ? { textNode: text, offset: trailingTokenStart(text.data, anyCjk) } : { textNode: text, offset: leadingTokenEnd(text.data, anyCjk) }; + const clone = splitTreeAfter(el, point.textNode, point.offset, pass); + if (!clone) { + return el; + } + return side === "tail" ? clone : el; + } + function splitElementAt(el, textNode, offset, pass) { + if (!canSplit(el)) { + return null; + } + return splitTreeAfter(el, textNode, offset, pass); + } + + // src/typeset/jinze.ts + var PASS2 = "jinze"; + var AVOID_HEAD = /* @__PURE__ */ new Set(["bd-close", "bd-pause", "bd-stop", "bd-middle", "bd-liga"]); + var AVOID_TAIL = /* @__PURE__ */ new Set(["bd-open"]); + function isCharEl(n) { + return !!n && n.nodeType === 1 && n.nodeName === "JZ-CHAR"; + } + function forbiddenBreak(a, b, finder) { + if (isCharEl(b)) { + const c = bdClassOf(b); + if (c && AVOID_HEAD.has(c) && finder.featureEnabledFor(b, PASS2)) { + return true; + } + } + if (isCharEl(a)) { + const c = bdClassOf(a); + if (c && AVOID_TAIL.has(c) && finder.featureEnabledFor(a, PASS2)) { + return true; + } + } + return false; + } + function isTokenChar2(ch, anyCjk) { + return !anyCjk.test(ch) && !/\s/.test(ch); + } + function leadingTokenEnd2(d, anyCjk) { + if (anyCjk.test(d.charAt(0))) { + return 1; + } + let e = 1; + while (e < d.length && isTokenChar2(d.charAt(e), anyCjk)) { + e += 1; + } + return e; + } + function trailingTokenStart2(d, anyCjk) { + const last = d.length - 1; + if (anyCjk.test(d.charAt(last))) { + return last; + } + let s = last; + while (s > 0 && isTokenChar2(d.charAt(s - 1), anyCjk)) { + s -= 1; + } + return s; + } function splitBoundaries(t, needFirst, needLast, anyCjk) { let node = t; if (needFirst) { - const e = leadingTokenEnd(node.data, anyCjk); + const e = leadingTokenEnd2(node.data, anyCjk); if (e < node.data.length) { node = node.splitText(e); } } if (needLast) { - const s = trailingTokenStart(node.data, anyCjk); + const s = trailingTokenStart2(node.data, anyCjk); if (s > 0) { node.splitText(s); } @@ -3630,13 +3883,21 @@ var Juzhen = (() => { } for (let i2 = 0; i2 < n; i2 += 1) { const u = units[i2]; - if (u.nodeType !== 3) { - continue; - } const needFirst = i2 > 0 && forbidden[i2 - 1]; const needLast = i2 < n - 1 && forbidden[i2]; - if (needFirst || needLast) { + if (!needFirst && !needLast) { + continue; + } + if (u.nodeType === 3) { splitBoundaries(u, needFirst, needLast, anyCjk); + } else if (u.nodeType === 1) { + const el = u; + if (needLast) { + isolateBoundaryToken(el, "tail", anyCjk, PASS2); + } + if (needFirst) { + isolateBoundaryToken(el, "head", anyCjk, PASS2); + } } } const kids = []; @@ -3664,11 +3925,15 @@ var Juzhen = (() => { name: PASS2, kind: "standalone", order: 40, + level: "paragraph", enabled: (o) => o.features.jinze, render(ctx) { const anyCjk = new RegExp("[" + ctx.options.ruleset.cjk + "]"); const parents = /* @__PURE__ */ new Set(); ctx.root.querySelectorAll("jz-char").forEach((c) => { + if (!ctx.finder.levelAllows(c, "paragraph")) { + return; + } if (c.parentNode) { parents.add(c.parentNode); } @@ -3684,7 +3949,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"; @@ -3719,7 +3990,14 @@ var Juzhen = (() => { m.delete(key); } } - var INLINE_WRAP = /* @__PURE__ */ new Set(["JZ-JINZE", "JZ-CHAR", "JZ-INNER", "JZ-HWS", "SPAN", "STRONG", "EM", "A", "B", "I", "U", "MARK"]); + var JZ_WRAP_TAGS = ["JZ-JINZE", "JZ-CHAR", "JZ-INNER", "JZ-HWS"]; + function buildWrapSet(options) { + const s = new Set(JZ_WRAP_TAGS); + for (const name of options.finder.styleInlines) { + s.add(name); + } + return s; + } function rectOf(n, side) { if (n.nodeType === 3 && n.data.length > 0) { const t = n; @@ -3743,7 +4021,7 @@ var Juzhen = (() => { } return null; } - function edgeRect(start, side, from) { + function edgeRect(start, side, from, wrapSet) { let n = start; let parentRef = from.parentNode; for (; ; ) { @@ -3757,7 +4035,7 @@ var Juzhen = (() => { if (!parentRef || parentRef.nodeType !== 1) { return null; } - if (!INLINE_WRAP.has(parentRef.nodeName)) { + if (!wrapSet.has(parentRef.nodeName)) { return null; } n = side === "prev" ? parentRef.previousSibling : parentRef.nextSibling; @@ -3768,60 +4046,110 @@ 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 wrapSet = buildWrapSet(options); 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; + } 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") { - const prev = edgeRect(el.previousSibling, "prev", el); - if (!prev || prev.top < r.top - EPS) { - el.classList.add(LE); + if (isHead) { + const prev = edgeRect(el.previousSibling, "prev", el, wrapSet); + 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); + const next = edgeRect(el.nextSibling, "next", el, wrapSet); + 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", kind: "standalone", order: 90, + level: "paragraph", enabled: (o) => o.features.jiya, render(ctx) { 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"; - function trimGaps(root, finder) { + function trimGaps(root, finder, wrapSet) { const gaps = Array.from(root.querySelectorAll("jz-hws")).filter( - (g) => finder.inScope(g) && !finder.isAvoided(g) + (g) => finder.inScope(g) && !finder.isAvoided(g) && finder.levelAllows(g, "paragraph") ); if (!gaps.length) { return; @@ -3829,7 +4157,7 @@ var Juzhen = (() => { gaps.forEach((g) => g.classList.add(TRIM)); for (const g of gaps) { const r = lastRect(g); - const next = r ? edgeRect(g.nextSibling, "next", g) : null; + const next = r ? edgeRect(g.nextSibling, "next", g, wrapSet) : null; const atLineEnd = !r || !next || next.top > r.top + EPS; if (!atLineEnd) { g.classList.remove(TRIM); @@ -3840,12 +4168,14 @@ var Juzhen = (() => { name: "spacingGapTrim", kind: "standalone", order: 92, + level: "paragraph", enabled: (o) => o.features.spacing, render(ctx) { if (!hasLayout()) { return; } - installLayout(ctx.root, "gapTrim", () => trimGaps(ctx.root, ctx.finder)); + const wrapSet = buildWrapSet(ctx.options); + installLayout(ctx.root, "gapTrim", () => trimGaps(ctx.root, ctx.finder, wrapSet)); }, revert(ctx) { uninstallLayout(ctx.root, "gapTrim"); @@ -3870,6 +4200,7 @@ var Juzhen = (() => { name: PASS3, kind: "charify", order: 60, + level: "paragraph", enabled: (o) => o.features.longWord, collect(node, ctx) { if (inLangSpan(node)) { @@ -3907,8 +4238,116 @@ var Juzhen = (() => { } }; + // src/typeset/orphan.ts + var PASS4 = "orphan"; + function hasOrphan(block) { + for (let c = block.firstChild; c; c = c.nextSibling) { + if (c.nodeType === 1 && c.nodeName === "JZ-ORPHAN") { + return true; + } + } + return false; + } + function collectSubst(block, finder, blockSet, anyCjk, biaodian) { + const out = []; + const visit = (node, directChild) => { + if (node.nodeType === 3) { + const t = node; + const d = t.data; + for (let i = 0; i < d.length; i += 1) { + const ch = d.charAt(i); + if (anyCjk.test(ch) && !classifyBiaodian(ch, biaodian)) { + out.push({ directChild, textNode: t, offset: i }); + } + } + return; + } + if (node.nodeType !== 1) { + return; + } + const el = node; + if (finder.isAvoided(el)) { + return; + } + if (blockSet.has(el.nodeName) || finder.pillMatches(el)) { + return; + } + for (let c = el.firstChild; c; c = c.nextSibling) { + visit(c, directChild); + } + }; + for (let dc = block.firstChild; dc; dc = dc.nextSibling) { + visit(dc, dc); + } + return out; + } + function processBlock(block, finder, blockSet, anyCjk, biaodian, chars) { + if (hasOrphan(block)) { + return; + } + const subst = collectSubst(block, finder, blockSet, anyCjk, biaodian); + if (subst.length < chars) { + return; + } + const boundary = subst[subst.length - chars]; + let startChild = boundary.directChild; + if (boundary.textNode && boundary.textNode === startChild) { + if (boundary.offset > 0) { + startChild = boundary.textNode.splitText(boundary.offset); + } + } else if (boundary.textNode && startChild.nodeType === 1) { + const clone = splitElementAt( + startChild, + boundary.textNode, + boundary.offset, + PASS4 + ); + if (clone) { + startChild = clone; + } + } + const orphan = createJz("jz-orphan", PASS4, "wrap"); + block.insertBefore(orphan, startChild); + let n = startChild; + while (n) { + const next = n.nextSibling; + orphan.appendChild(n); + n = next; + } + } + var orphanPass = { + name: PASS4, + kind: "standalone", + order: 70, + level: "paragraph", + enabled: (o) => o.features.orphan, + render(ctx) { + const { finder, options } = ctx; + const blockSet = options.finder.blockTags; + const anyCjk = new RegExp("[" + options.ruleset.cjk + "]"); + const biaodian = options.ruleset.biaodian; + const chars = options.orphan.chars; + finder.eachBlock(ctx.root, (block) => { + if (!finder.levelAllows(block, "paragraph")) { + return; + } + if (!finder.featureEnabledFor(block, PASS4)) { + return; + } + const sel = Array.from(blockSet).map((t) => t.toLowerCase()).join(","); + if (sel && block.querySelector(sel)) { + return; + } + processBlock(block, finder, blockSet, anyCjk, biaodian, chars); + }); + }, + revert(ctx) { + revertPass(PASS4, ctx.root); + } + }; + // src/typeset/spacing.ts - var PASS4 = "spacing"; + var PASS5 = "spacing"; var MARK = "\uE000"; var PH_OPEN = "\uE001"; var PH_CLOSE = "\uE002"; @@ -4009,7 +4448,7 @@ var Juzhen = (() => { if (!parent || parent.nodeName === "JZ-HWS") { return; } - const gap = createJz("jz-hws", PASS4, "wrap"); + const gap = createJz("jz-hws", PASS5, "wrap"); parent.insertBefore(gap, node); gap.appendChild(node); } @@ -4026,7 +4465,7 @@ var Juzhen = (() => { } wrapNodeInGap(tn); } - function processBlock(block, finder, R) { + function processBlock2(block, finder, R) { const runs = finder.collectRuns(block); for (const run of runs) { const { text, map } = run; @@ -4076,6 +4515,9 @@ var Juzhen = (() => { if (finder.isAvoided(pill) || !finder.inScope(pill)) { return; } + if (!finder.featureEnabledFor(pill, PASS5)) { + return; + } if (!pill.parentNode) { return; } @@ -4118,23 +4560,27 @@ var Juzhen = (() => { } } var spacingPass = { - name: PASS4, + name: PASS5, kind: "standalone", order: 50, + level: "text", enabled: (o) => o.features.spacing, render(ctx) { const { finder, options } = ctx; const R = makeRules(options.ruleset); ctx.finder.eachBlock(ctx.root, (block) => { - if (!finder.featureEnabledFor(block, PASS4)) { + if (!finder.featureEnabledFor(block, PASS5)) { return; } - processBlock(block, finder, R); + processBlock2(block, finder, R); }); + if (!options.finder.blockTags.has(ctx.root.nodeName) && finder.featureEnabledFor(ctx.root, PASS5) && finder.inScope(ctx.root) && !finder.isAvoided(ctx.root)) { + processBlock2(ctx.root, finder, R); + } processPills(ctx.root, finder, options.finder.pillSelector, R.pillNeighbor); }, revert(ctx) { - revertPass(PASS4, ctx.root); + revertPass(PASS5, ctx.root); } }; @@ -4287,6 +4733,7 @@ var Juzhen = (() => { longWordPass, spacingPass, jinzePass, + orphanPass, lineEdgePass, gapTrimPass ]; @@ -4304,6 +4751,13 @@ var Juzhen = (() => { const lang = typeof v.lang === "string" && v.lang ? v.lang : "en"; return { minLength, lang }; } + function resolveOrphan(v) { + if (v && typeof v === "object") { + const chars = typeof v.chars === "number" && v.chars >= 1 ? Math.floor(v.chars) : 2; + return { chars }; + } + return { chars: 2 }; + } function resolveJiya(v) { if (v && typeof v === "object") { return { @@ -4325,6 +4779,7 @@ var Juzhen = (() => { ruleset: resolveRuleset(opts.charClass), autospaceClass: opts.autospaceClass || "", justifyAtoms: opts.justifyAtoms === false ? false : true, + levelText: opts.level && opts.level.text || null, scope: { root: scope.root || null, include: scope.include || null, @@ -4335,12 +4790,22 @@ var Juzhen = (() => { longWord: opts.longWord === false ? false : feature(opts.longWord, true), 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 之 CSS/lineedge 程式碼保留(休眠),俟原生支援成熟再議。 + lineEnd: "squeeze", jiyaConfig: resolveJiya(opts.jiya), blocks: Array.isArray(opts.blocks) ? opts.blocks.slice() : [], finder: { @@ -4628,6 +5093,12 @@ window.__JUZHEN_PUNCT__="quanjiao";window.__JUZHEN_JUSTIFY_ATOMS__=true;window._ spacing: true, jinze: true, longWord: true, + /* orphan(垂懸字避免,聚珍 §6.5.1):段末行 CJK 實義字 < 2 時,把末 2 字 + 包入 `` 拉下首字,杜絕「孤字成行」。判定僅計 + 漢字實義字(標點/符號不納入)。方案 C 屬斷行層、無測量,結構性 nowrap 同 + 禁則,故分頁安全(academic DEFER 後於定版頁上跑亦不擾 Paged.js)。三模式共用, + lib 預設即開,此處顯式標記為刻意採用。 */ + orphan: true, jiya: { halfWidth: halfWidth, }, diff --git a/docs/index.src.html b/docs/index.src.html index 30523af..1d59c23 100644 --- a/docs/index.src.html +++ b/docs/index.src.html @@ -56,9 +56,9 @@ createJuzhen({ lang: { default: "zh-Hant" } }).render(document.querySelector("ma 中西間隙 spacingCJK↔西文邊界,margin 模型:包裹左側字、以 margin-right 留隙,不注入空白字元 → textContent 完全不變、複製緊湊、justify 不暴增、行首乾淨;行末由 gapTrim layout pass 去隙以齊右。含跨樣式邊界與 codekbd pill(pill 緊鄰標點時穿越 jz-jinze 仍補間隙)。贅餘作者空格一律正規化:CJK↔西文吸收為受管間隙、CJK↔CJK(含跨 inline 標籤)剝除不留隙。 長英文詞 longWord[A-Za-z]{6,} 包入 <span lang="en">,配合消費端 CSS hyphens:auto 原生斷詞(DOM 無連字符 → 複製不污染)。 標點擠壓 jiya標點 charify 為 jz-charbd-* class。三機制:baseline 政策(全角式不擠/開明式擠句內點號+括號引號/半角式全擠)+行內連續擠壓+行端擠壓。半形預設以字型 OpenType halt 渲染;缺 halt 字型可切 margin 後備模式(見下)。 -禁則 jinze行首行末避頭尾:閉類/點號綁前一字(不置行首)、開類綁後一字(不置行末);只隔離邊界詞元,長引文中段仍可斷。亦為 justify 綁定載體。 -垂懸字避免 orphan段末行 CJK 實義字 ≥ N(預設 2;標點不計)。方案 C(斷行層無測量):段末末 N 實義字+尾隨標點包入 jz-orphanwhite-space:nowrap),整體不可斷 → 末行恒含之 ⇒ 末行 ≥ N。預設開;段落級。 -標點懸掛 hanging已評估停用:純負 margin 機制不可行(墨色不出版心/滿凸拉升後字重疊/resize 錯亂),原生 hanging-punctuation 為正解但 Chrome 不支援。選項仍被接受但無效果、程式碼休眠。詳見 ARCHITECTURE §6.5.6。 +禁則 jinze行首行末避頭尾:閉類/點號綁前一字(不置行首)、開類綁後一字(不置行末);只隔離邊界詞元,長引文中段仍可斷。邊界字落於行內樣式元素(strongem 等)內時克隆切分只綁邊界詞元(避免整串入 nowrap 致行末整體掉行),克隆標 data-jz-kind="splitoff"revert 時併回。亦為 justify 綁定載體。 +垂懸字避免 orphan段末行 CJK 實義字 ≥ N(預設 2;標點不計)。方案 C(斷行層無測量):段末末 N 實義字+尾隨標點包入 jz-orphanwhite-space:nowrap),整體不可斷 → 末行恒含之 ⇒ 末行 ≥ N。邊界字落於行內元素內時同樣克隆切分(避免長元素溢出容器)。預設開;段落級。 +標點懸掛 hanging已移除:純 CSS 機制為機制級死路(寬度守恆矛盾——墨色出去多少版心內就空多少,CSS 接觸不到斷行器)。原生 hanging-punctuation 為唯一正解但僅 Safari 支援、且與 inline-block 原子互斥。hanging 選項保留為 no-op(無效果)。詳見 ARCHITECTURE §6.5.6。 功能分級 level文本級(間隙、擠壓——任意文本適用)vs 段落級(禁則、垂懸字、長詞——需行/段布局)。塊級預設全功能;data-jz-level="text" 子樹或 level:{text:"選擇器"}(標題/按鈕)僅跑文本級。 justify 單份間距每個 jz-charinline-block 原子,句末標點只貢獻一份對齊伸縮量。 @@ -78,7 +78,7 @@ createJuzhen({ lang: { default: "zh-Hant" } }).render(document.querySelector("ma jiya: true, // 或物件:{ halfWidth: "halt"|"margin", bias } jinze: true, orphan: true, // 垂懸字避免(預設開);或 { chars: 2 };false 關 - // hanging:已停用、無效果(純負 margin 機制不可行,詳見 §6.5.6) + // hanging:已移除、無效果(純 CSS 機制級死路,詳見 §6.5.6) level: { text: "h1,h2,h3,button,.ui" }, // 此選擇器子樹僅跑文本級功能 justifyAtoms: true, // 分頁器下設 false 改 inline(見下) blocks: [ { selector: ".code-comment", features: [ "spacing" ] } ], @@ -211,7 +211,7 @@ JS 只注入 `jz-*` 結構,所有視覺尺寸在 juzhen.css,缺一不可。 - jiya 標點擠壓(全角/開明/半角;半形 halt 預設、margin 後備)【文本級】 - jinze 禁則(避頭尾、justify 綁定)【段落級】 - orphan 垂懸字避免(方案 C,末行 CJK 實義字 ≥ N;預設開 chars=2)【段落級】 -- hanging 標點懸掛:已評估停用(純負 margin 機制不可行,原生 hanging-punctuation 待 Chrome 支援;選項無效果、程式碼休眠;詳見 §6.5.6) +- hanging 標點懸掛:已移除(純 CSS 機制級死路:寬度守恆矛盾,CSS 接觸不到斷行器;原生 hanging-punctuation 為唯一正解但僅 Safari 支援、且與 inline-block 原子互斥;選項保留為 no-op;詳見 §6.5.6) - level 功能分級(文本級 vs 段落級;data-jz-level="text"/level:{text:選擇器} 標記標題等僅跑文本級) - justify 句末標點單份間距(jz-char inline-block 原子)