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 變更紀錄。← 回文檔
+修復兩個下游 Bug(同源於「行內元素對排版透明」原則在各遍歷原語口徑不一)+三項同根隱患審計;並把上一輪「停用」之標點懸掛正式移除(負 margin 程式碼/CSS/型別一併刪)。單元測試 90→101 全綠。
+漢字。<code>)。根因:finder.adjacentLogicalChar 之 descend 遇不透明之 jz-char 既不進入亦不終止、而跳過它越過標點取到更前之漢字,誤補 jz-hws。修復=isTransparentInline 補 JZ-CHAR/JZ-INNER,descend 遂回傳標點(非 pill 鄰字、不補隙),與 collectRuns 之既有透明遞迴一致。<strong>很長一串</strong>, 把整串粗體塞進 nowrap inline-block,行末放不下即整體掉行、上行留巨大空隙)。新增 core/split.ts 之克隆切分:沿祖先鏈把邊界詞元剝入淺克隆(保留標籤/class/style/lang、去 id;標 data-jz-kind="splitoff"),禁則/垂懸字只綁該克隆(最小綁定),原元素餘部留原位、渲染無縫。revertPass 增延後階段把 splitoff 併回原元素(先解包 wrap、後併 splitoff),render→revert 還原一致。純樣式標籤白名單可切,a/abbr/ruby/帶 id 退化整綁。lineedge 之行端攀爬集改由 options.finder.styleInlines 派生(棄硬編碼子集,免遺漏 ins/del/sub 等致行端誤判);T5 jiyaAdjacency 遇 pill 斷相鄰、不穿越;T6 spacing processPills 補 per-node 功能閘(data-juzhen-off="spacing" 區域內不補隙)。純 CSS 手段(負 margin/相對位移/零寬盒)經實測為機制級死路,已移除(負 margin 之 lineedge 程式碼/jz-hang-* CSS/LineEndMode/lineEnd/features.hanging 一併刪)。行端僅餘半形擠壓(jz-half-le)。
justifyAtoms 預設之 inline-block 下負 margin 被 justify 吸收)。CSS hack 接觸不到斷行器,此恒等式無解。hanging-punctuation,惟 Chrome/Firefox 至今不支援(僅 Safari),且 jz-char 為 inline-block 原子非文本、原生懸掛不作用之。未來若做須是繞開 jz-char 的「原生管線」新功能(text-spacing-trim + hanging-punctuation,按能力門控),與已移除碼無共享,故不保留休眠碼。hanging 公開選項保留為 @deprecated no-op(API 相容;測試覆蓋 option/data-juzhen/blocks 各路徑皆不產生 jz-hang-* 且輸出與不傳一致)。詳見 ARCHITECTURE §6.5.6。ARCHITECTURE §6.5:功能分級、垂懸字(方案 C)落地工作良好;原計劃之標點懸掛經實測確認不可行、全面停用。共 90 單元測試全綠(含懸掛防逃逸覆蓋)。
` 經 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.adjacentLogicalChar 之 descend 遇不透明之 jz-char 既不進入亦不終止、而跳過它越過標點取到更前之漢字,誤補 jz-hws。修復=isTransparentInline 補 JZ-CHAR/JZ-INNER,descend 遂回傳標點(非 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 還原一致。純樣式標籤白名單可切,a/abbr/ruby/帶 id 退化整綁。
+- 審計(同根隱患):T4
lineedge 之行端攀爬集改由 options.finder.styleInlines 派生(棄硬編碼子集,免遺漏 ins/del/sub 等致行端誤判);T5 jiyaAdjacency 遇 pill 斷相鄰、不穿越;T6 spacing processPills 補 per-node 功能閘(data-juzhen-off="spacing" 區域內不補隙)。
+
+標點懸掛:實作後停用 → 正式移除
+純 CSS 手段(負 margin/相對位移/零寬盒)經實測為機制級死路,已移除(負 margin 之 lineedge 程式碼/jz-hang-* CSS/LineEndMode/lineEnd/features.hanging 一併刪)。行端僅餘半形擠壓(jz-half-le)。
+
+- 寬度守恆論證:行端標點盒若保持非零寬,則墨色出去多少、版心內就空出多少(行末實義字無法齊版心右緣);若收為零寬,則釋放一整字寬、斷行器把下一字拉上本行與標點重疊(且
justifyAtoms 預設之 inline-block 下負 margin 被 justify 吸收)。CSS hack 接觸不到斷行器,此恒等式無解。
+- 唯一正解=原生
hanging-punctuation,惟 Chrome/Firefox 至今不支援(僅 Safari),且 jz-char 為 inline-block 原子非文本、原生懸掛不作用之。未來若做須是繞開 jz-char 的「原生管線」新功能(text-spacing-trim + hanging-punctuation,按能力門控),與已移除碼無共享,故不保留休眠碼。
+hanging 公開選項保留為 @deprecated no-op(API 相容;測試覆蓋 option/data-juzhen/blocks 各路徑皆不產生 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
中西間隙 spacing CJK↔西文邊界,margin 模型:包裹左側字、以 margin-right 留隙,不注入空白字元 → textContent 完全不變、複製緊湊、justify 不暴增、行首乾淨;行末由 gapTrim layout pass 去隙以齊右。含跨樣式邊界與 code/kbd pill(pill 緊鄰標點時穿越 jz-jinze 仍補間隙)。贅餘作者空格一律正規化:CJK↔西文吸收為受管間隙、CJK↔CJK(含跨 inline 標籤)剝除不留隙。
長英文詞 longWord [A-Za-z]{6,} 包入 <span lang="en">,配合消費端 CSS hyphens:auto 原生斷詞(DOM 無連字符 → 複製不污染)。
標點擠壓 jiya 標點 charify 為 jz-char + bd-* class。三機制:baseline 政策(全角式不擠/開明式擠句內點號+括號引號/半角式全擠)+行內連續擠壓+行端擠壓。半形預設以字型 OpenType halt 渲染;缺 halt 字型可切 margin 後備模式(見下)。
-禁則 jinze 行首行末避頭尾:閉類/點號綁前一字(不置行首)、開類綁後一字(不置行末);只隔離邊界詞元,長引文中段仍可斷。亦為 justify 綁定載體。
-垂懸字避免 orphan 段末行 CJK 實義字 ≥ N(預設 2;標點不計)。方案 C(斷行層無測量):段末末 N 實義字+尾隨標點包入 jz-orphan(white-space:nowrap),整體不可斷 → 末行恒含之 ⇒ 末行 ≥ N。預設開;段落級。
-標點懸掛 hanging已評估停用:純負 margin 機制不可行(墨色不出版心/滿凸拉升後字重疊/resize 錯亂),原生 hanging-punctuation 為正解但 Chrome 不支援。選項仍被接受但無效果、程式碼休眠。詳見 ARCHITECTURE §6.5.6。
+禁則 jinze 行首行末避頭尾:閉類/點號綁前一字(不置行首)、開類綁後一字(不置行末);只隔離邊界詞元,長引文中段仍可斷。邊界字落於行內樣式元素(strong/em 等)內時克隆切分只綁邊界詞元(避免整串入 nowrap 致行末整體掉行),克隆標 data-jz-kind="splitoff"、revert 時併回。亦為 justify 綁定載體。
+垂懸字避免 orphan 段末行 CJK 實義字 ≥ N(預設 2;標點不計)。方案 C(斷行層無測量):段末末 N 實義字+尾隨標點包入 jz-orphan(white-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-char 為 inline-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
中西間隙 spacing CJK↔西文邊界,margin 模型:包裹左側字、以 margin-right 留隙,不注入空白字元 → textContent 完全不變、複製緊湊、justify 不暴增、行首乾淨;行末由 gapTrim layout pass 去隙以齊右。含跨樣式邊界與 code/kbd pill(pill 緊鄰標點時穿越 jz-jinze 仍補間隙)。贅餘作者空格一律正規化:CJK↔西文吸收為受管間隙、CJK↔CJK(含跨 inline 標籤)剝除不留隙。
長英文詞 longWord [A-Za-z]{6,} 包入 <span lang="en">,配合消費端 CSS hyphens:auto 原生斷詞(DOM 無連字符 → 複製不污染)。
標點擠壓 jiya 標點 charify 為 jz-char + bd-* class。三機制:baseline 政策(全角式不擠/開明式擠句內點號+括號引號/半角式全擠)+行內連續擠壓+行端擠壓。半形預設以字型 OpenType halt 渲染;缺 halt 字型可切 margin 後備模式(見下)。
-禁則 jinze 行首行末避頭尾:閉類/點號綁前一字(不置行首)、開類綁後一字(不置行末);只隔離邊界詞元,長引文中段仍可斷。亦為 justify 綁定載體。
-垂懸字避免 orphan 段末行 CJK 實義字 ≥ N(預設 2;標點不計)。方案 C(斷行層無測量):段末末 N 實義字+尾隨標點包入 jz-orphan(white-space:nowrap),整體不可斷 → 末行恒含之 ⇒ 末行 ≥ N。預設開;段落級。
-標點懸掛 hanging已評估停用:純負 margin 機制不可行(墨色不出版心/滿凸拉升後字重疊/resize 錯亂),原生 hanging-punctuation 為正解但 Chrome 不支援。選項仍被接受但無效果、程式碼休眠。詳見 ARCHITECTURE §6.5.6。
+禁則 jinze 行首行末避頭尾:閉類/點號綁前一字(不置行首)、開類綁後一字(不置行末);只隔離邊界詞元,長引文中段仍可斷。邊界字落於行內樣式元素(strong/em 等)內時克隆切分只綁邊界詞元(避免整串入 nowrap 致行末整體掉行),克隆標 data-jz-kind="splitoff"、revert 時併回。亦為 justify 綁定載體。
+垂懸字避免 orphan 段末行 CJK 實義字 ≥ N(預設 2;標點不計)。方案 C(斷行層無測量):段末末 N 實義字+尾隨標點包入 jz-orphan(white-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-char 為 inline-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 原子)