docs: 行內透明性修復 + 標點懸掛移除(changelog 2026-06-11、index 功能表)

changelog 新增 2026-06-11 條目:兩個下游 Bug(pill 前標點誤隙、行內元素整體掉行)
+審計 T4-T6;標點懸掛由「停用」正式移除(寬度守恆論證、已移除清單、原生管線未來路徑)。
index 功能表:禁則/垂懸字行補克隆切分(splitoff)說明;標點懸掛行改「已移除」。
重建 changelog.html/index.html。
This commit is contained in:
2026-06-11 16:19:15 +08:00
parent bbda4cb3d5
commit 445716e34e
4 changed files with 1092 additions and 118 deletions
+541 -54
View File
@@ -1,4 +1,4 @@
<!-- built with theme=dracula, mode=report, at=2026-06-09T15:34:49Z -->
<!-- built with theme=dracula, mode=report, at=2026-06-11T08:19:00Z -->
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
@@ -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; }
<!-- BODY -->
<p class="lede">聚珍(Juzhen)/cjk-autospace 變更紀錄。<a href="./index.html">← 回文檔</a></p>
<h2>行內排版透明性修復 標點懸掛移除 <span class="badge accent">2026-06-11</span></h2>
<p>修復兩個下游 Bug(同源於「行內元素對排版透明」原則在各遍歷原語口徑不一)+三項同根隱患審計;並把上一輪「停用」之標點懸掛<strong>正式移除</strong>(負 margin 程式碼/CSS/型別一併刪)。單元測試 90→101 全綠。</p>
<h3>修復</h3>
<ul>
<li><strong>Bugpill 前標點誤加間隙</strong>(如 <code>漢字。&lt;code&gt;</code>)。根因:<code>finder.adjacentLogicalChar</code><code>descend</code> 遇不透明之 <code>jz-char</code> 既不進入亦不終止、而<strong>跳過</strong>它越過標點取到更前之漢字,誤補 <code>jz-hws</code>。修復=<code>isTransparentInline</code><code>JZ-CHAR</code><code>JZ-INNER</code><code>descend</code> 遂回傳標點(非 pill 鄰字、不補隙),與 <code>collectRuns</code> 之既有透明遞迴一致。</li>
<li><strong>Bug:行內樣式元素整體掉行</strong><code>&lt;strong&gt;很長一串&lt;/strong&gt;</code> 把整串粗體塞進 <code>nowrap</code> inline-block,行末放不下即整體掉行、上行留巨大空隙)。新增 <code>core/split.ts</code> 之克隆切分:沿祖先鏈把邊界詞元剝入淺克隆(保留標籤/class/stylelang、去 id;標 <code>data-jz-kind="splitoff"</code>),禁則/垂懸字只綁該克隆(最小綁定),原元素餘部留原位、渲染無縫。<code>revertPass</code> 增延後階段把 splitoff 併回原元素(先解包 wrap、後併 splitoff),<code>render→revert</code> 還原一致。純樣式標籤白名單可切,<code>a</code><code>abbr</code><code>ruby</code>/帶 id 退化整綁。</li>
<li><strong>審計(同根隱患)</strong>T4 <code>lineedge</code> 之行端攀爬集改由 <code>options.finder.styleInlines</code> <strong>派生</strong>(棄硬編碼子集,免遺漏 <code>ins</code><code>del</code><code>sub</code> 等致行端誤判);T5 <code>jiyaAdjacency</code> 遇 pill 斷相鄰、不穿越;T6 <code>spacing</code> processPills 補 per-node 功能閘(<code>data-juzhen-off="spacing"</code> 區域內不補隙)。</li>
</ul>
<h3>標點懸掛:實作後停用 → 正式移除</h3>
<p>純 CSS 手段(負 margin/相對位移/零寬盒)經實測為<strong>機制級死路</strong>,已移除(負 margin 之 <code>lineedge</code> 程式碼/<code>jz-hang-*</code> CSS<code>LineEndMode</code><code>lineEnd</code><code>features.hanging</code> 一併刪)。行端僅餘半形擠壓(<code>jz-half-le</code>)。</p>
<ul>
<li><strong>寬度守恆論證</strong>:行端標點盒若保持非零寬,則墨色出去多少、版心內就空出多少(行末實義字無法齊版心右緣);若收為零寬,則釋放一整字寬、斷行器把下一字拉上本行與標點重疊(且 <code>justifyAtoms</code> 預設之 inline-block 下負 margin 被 justify 吸收)。<strong>CSS hack 接觸不到斷行器,此恒等式無解。</strong></li>
<li><strong>唯一正解=原生</strong> <code>hanging-punctuation</code>,惟 ChromeFirefox 至今不支援(僅 Safari),且 <code>jz-char</code> 為 inline-block 原子非文本、原生懸掛不作用之。未來若做須是繞開 jz-char 的「原生管線」新功能(<code>text-spacing-trim</code> <code>hanging-punctuation</code>,按能力門控),與已移除碼無共享,故不保留休眠碼。</li>
<li><code>hanging</code> 公開選項保留為 <strong>@deprecated no-op</strong>(API 相容;測試覆蓋 option/<code>data-juzhen</code><code>blocks</code> 各路徑皆不產生 <code>jz-hang-*</code> 且輸出與不傳一致)。詳見 <code>ARCHITECTURE §6.5.6</code></li>
</ul>
<h2>功能分級 垂懸字(標點懸掛停用) <span class="badge accent">2026-06-09</span></h2>
<p>ARCHITECTURE §6.5:功能分級、垂懸字(方案 C)落地工作良好;原計劃之標點懸掛經實測<strong>確認不可行、全面停用</strong>。共 90 單元測試全綠(含懸掛防逃逸覆蓋)。</p>
<h3>新增</h3>
@@ -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-charjz-inner 標點原子)。穿越這些
* 才能正確找到跨包裝之邏輯鄰字(如 jinze 已把 pill 納入 jz-jinze 後,pill 之左
* 鄰字仍須可達;I7)。
* ⚠ jz-charjz-inner 必須透明:否則 adjacentLogicalChar 之 descend 遇標點原子既
* 不進入亦不終止、而是**跳過**它續找更前之兄弟(下游 Bug:`汉字。<code>` 經 jinze
* 綁定為 `汉<jz-jinze><jz-char></jz-char></jz-jinze><code>`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 之 CSSlineedge 程式碼保留(休眠),俟原生支援成熟再議。
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
包入 `<jz-orphan white-space:nowrap>` 拉下首字,杜絕「孤字成行」。判定僅計
漢字實義字(標點/符號不納入)。方案 C 屬斷行層、無測量,結構性 nowrap 同
禁則,故分頁安全(academic DEFER 後於定版頁上跑亦不擾 Paged.js)。三模式共用,
lib 預設即開,此處顯式標記為刻意採用。 */
orphan: true,
jiya: {
halfWidth: halfWidth,
},
+16
View File
@@ -25,6 +25,22 @@
<!-- BODY -->
<p class="lede">聚珍(Juzhen)/cjk-autospace 變更紀錄。<a href="./index.html">← 回文檔</a></p>
<h2>行內排版透明性修復 標點懸掛移除 <span class="badge accent">2026-06-11</span></h2>
<p>修復兩個下游 Bug(同源於「行內元素對排版透明」原則在各遍歷原語口徑不一)+三項同根隱患審計;並把上一輪「停用」之標點懸掛<strong>正式移除</strong>(負 margin 程式碼/CSS/型別一併刪)。單元測試 90→101 全綠。</p>
<h3>修復</h3>
<ul>
<li><strong>Bugpill 前標點誤加間隙</strong>(如 <code>漢字。&lt;code&gt;</code>)。根因:<code>finder.adjacentLogicalChar</code><code>descend</code> 遇不透明之 <code>jz-char</code> 既不進入亦不終止、而<strong>跳過</strong>它越過標點取到更前之漢字,誤補 <code>jz-hws</code>。修復=<code>isTransparentInline</code><code>JZ-CHAR</code><code>JZ-INNER</code><code>descend</code> 遂回傳標點(非 pill 鄰字、不補隙),與 <code>collectRuns</code> 之既有透明遞迴一致。</li>
<li><strong>Bug:行內樣式元素整體掉行</strong><code>&lt;strong&gt;很長一串&lt;/strong&gt;</code> 把整串粗體塞進 <code>nowrap</code> inline-block,行末放不下即整體掉行、上行留巨大空隙)。新增 <code>core/split.ts</code> 之克隆切分:沿祖先鏈把邊界詞元剝入淺克隆(保留標籤/class/stylelang、去 id;標 <code>data-jz-kind="splitoff"</code>),禁則/垂懸字只綁該克隆(最小綁定),原元素餘部留原位、渲染無縫。<code>revertPass</code> 增延後階段把 splitoff 併回原元素(先解包 wrap、後併 splitoff),<code>render→revert</code> 還原一致。純樣式標籤白名單可切,<code>a</code><code>abbr</code><code>ruby</code>/帶 id 退化整綁。</li>
<li><strong>審計(同根隱患)</strong>T4 <code>lineedge</code> 之行端攀爬集改由 <code>options.finder.styleInlines</code> <strong>派生</strong>(棄硬編碼子集,免遺漏 <code>ins</code><code>del</code><code>sub</code> 等致行端誤判);T5 <code>jiyaAdjacency</code> 遇 pill 斷相鄰、不穿越;T6 <code>spacing</code> processPills 補 per-node 功能閘(<code>data-juzhen-off="spacing"</code> 區域內不補隙)。</li>
</ul>
<h3>標點懸掛:實作後停用 → 正式移除</h3>
<p>純 CSS 手段(負 margin/相對位移/零寬盒)經實測為<strong>機制級死路</strong>,已移除(負 margin 之 <code>lineedge</code> 程式碼/<code>jz-hang-*</code> CSS<code>LineEndMode</code><code>lineEnd</code><code>features.hanging</code> 一併刪)。行端僅餘半形擠壓(<code>jz-half-le</code>)。</p>
<ul>
<li><strong>寬度守恆論證</strong>:行端標點盒若保持非零寬,則墨色出去多少、版心內就空出多少(行末實義字無法齊版心右緣);若收為零寬,則釋放一整字寬、斷行器把下一字拉上本行與標點重疊(且 <code>justifyAtoms</code> 預設之 inline-block 下負 margin 被 justify 吸收)。<strong>CSS hack 接觸不到斷行器,此恒等式無解。</strong></li>
<li><strong>唯一正解=原生</strong> <code>hanging-punctuation</code>,惟 ChromeFirefox 至今不支援(僅 Safari),且 <code>jz-char</code> 為 inline-block 原子非文本、原生懸掛不作用之。未來若做須是繞開 jz-char 的「原生管線」新功能(<code>text-spacing-trim</code> <code>hanging-punctuation</code>,按能力門控),與已移除碼無共享,故不保留休眠碼。</li>
<li><code>hanging</code> 公開選項保留為 <strong>@deprecated no-op</strong>(API 相容;測試覆蓋 option/<code>data-juzhen</code><code>blocks</code> 各路徑皆不產生 <code>jz-hang-*</code> 且輸出與不傳一致)。詳見 <code>ARCHITECTURE §6.5.6</code></li>
</ul>
<h2>功能分級 垂懸字(標點懸掛停用) <span class="badge accent">2026-06-09</span></h2>
<p>ARCHITECTURE §6.5:功能分級、垂懸字(方案 C)落地工作良好;原計劃之標點懸掛經實測<strong>確認不可行、全面停用</strong>。共 90 單元測試全綠(含懸掛防逃逸覆蓋)。</p>
<h3>新增</h3>
+530 -59
View File
@@ -1,4 +1,4 @@
<!-- built with theme=dracula, mode=report, at=2026-06-09T15:34:49Z -->
<!-- built with theme=dracula, mode=report, at=2026-06-11T08:19:00Z -->
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
@@ -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
<tr><td>中西間隙 <code>spacing</code></td><td>CJK↔西文邊界,<strong>margin 模型</strong>:包裹左側字、以 <code>margin-right</code> 留隙,不注入空白字元 → <code>textContent</code> 完全不變、複製緊湊、<code>justify</code> 不暴增、行首乾淨;行末由 <code>gapTrim</code> layout pass 去隙以齊右。含跨樣式邊界與 <code>code</code><code>kbd</code> pillpill 緊鄰標點時穿越 <code>jz-jinze</code> 仍補間隙)。贅餘作者空格一律正規化:CJK↔西文吸收為受管間隙、CJK↔CJK(含跨 inline 標籤)剝除不留隙。</td></tr>
<tr><td>長英文詞 <code>longWord</code></td><td><code>[A-Za-z]{6,}</code> 包入 <code>&lt;span lang="en"&gt;</code>,配合消費端 CSS <code>hyphens:auto</code> 原生斷詞(DOM 無連字符 → 複製不污染)。</td></tr>
<tr><td>標點擠壓 <code>jiya</code></td><td>標點 charify 為 <code>jz-char</code> <code>bd-*</code> class。三機制:baseline 政策(全角式不擠/開明式擠句內點號+括號引號/半角式全擠)+行內連續擠壓+行端擠壓。半形預設以字型 OpenType <code>halt</code> 渲染;缺 <code>halt</code> 字型可切 <code>margin</code> 後備模式(見下)。</td></tr>
<tr><td>禁則 <code>jinze</code></td><td>行首行末避頭尾:閉類/點號綁前一字(不置行首)、開類綁後一字(不置行末);只隔離邊界詞元,長引文中段仍可斷。亦為 <code>justify</code> 綁定載體。</td></tr>
<tr><td>垂懸字避免 <code>orphan</code></td><td>段末行 CJK 實義字 ≥ N(預設 2;標點不計)。方案 C(斷行層無測量):段末末 N 實義字+尾隨標點包入 <code>jz-orphan</code><code>white-space:nowrap</code>),整體不可斷 → 末行恒含之 ⇒ 末行 ≥ N。<strong>預設開</strong>;段落級。</td></tr>
<tr><td><s>標點懸掛 <code>hanging</code></s></td><td><strong>評估停用</strong>:純負 margin 機制不可行(墨色不出版心/滿凸拉升後字重疊/resize 錯亂),原生 <code>hanging-punctuation</code> 為正解但 Chrome 不支援。選項仍被接受但無效果、程式碼休眠。詳見 ARCHITECTURE §6.5.6。</td></tr>
<tr><td>禁則 <code>jinze</code></td><td>行首行末避頭尾:閉類/點號綁前一字(不置行首)、開類綁後一字(不置行末);只隔離邊界詞元,長引文中段仍可斷。邊界字落於行內樣式元素(<code>strong</code><code>em</code> 等)內時<strong>克隆切分</strong>只綁邊界詞元(避免整串入 <code>nowrap</code> 致行末整體掉行),克隆標 <code>data-jz-kind="splitoff"</code><code>revert</code> 時併回。亦為 <code>justify</code> 綁定載體。</td></tr>
<tr><td>垂懸字避免 <code>orphan</code></td><td>段末行 CJK 實義字 ≥ N(預設 2;標點不計)。方案 C(斷行層無測量):段末末 N 實義字+尾隨標點包入 <code>jz-orphan</code><code>white-space:nowrap</code>),整體不可斷 → 末行恒含之 ⇒ 末行 ≥ N。邊界字落於行內元素內時同樣<strong>克隆切分</strong>(避免長元素溢出容器)。<strong>預設開</strong>;段落級。</td></tr>
<tr><td><s>標點懸掛 <code>hanging</code></s></td><td><strong>移除</strong>:純 CSS 機制為機制級死路(寬度守恆矛盾——墨色出去多少版心內就空多少,CSS 接觸不到斷行器)。原生 <code>hanging-punctuation</code>唯一正解但僅 Safari 支援、且與 inline-block 原子互斥。<code>hanging</code> 選項保留為 no-op(無效果)。詳見 ARCHITECTURE §6.5.6。</td></tr>
<tr><td>功能分級 <code>level</code></td><td>文本級(間隙、擠壓——任意文本適用)vs 段落級(禁則、垂懸字、長詞——需行/段布局)。塊級預設全功能;<code>data-jz-level="text"</code> 子樹或 <code>level:{text:"選擇器"}</code>(標題/按鈕)僅跑文本級。</td></tr>
<tr><td>justify 單份間距</td><td>每個 <code>jz-char</code><code>inline-block</code> 原子,句末標點只貢獻一份對齊伸縮量。</td></tr>
</tbody>
@@ -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-charjz-inner 標點原子)。穿越這些
* 才能正確找到跨包裝之邏輯鄰字(如 jinze 已把 pill 納入 jz-jinze 後,pill 之左
* 鄰字仍須可達;I7)。
* ⚠ jz-charjz-inner 必須透明:否則 adjacentLogicalChar 之 descend 遇標點原子既
* 不進入亦不終止、而是**跳過**它續找更前之兄弟(下游 Bug:`汉字。<code>` 經 jinze
* 綁定為 `汉<jz-jinze><jz-char></jz-char></jz-jinze><code>`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 之 CSSlineedge 程式碼保留(休眠),俟原生支援成熟再議。
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
包入 `<jz-orphan white-space:nowrap>` 拉下首字,杜絕「孤字成行」。判定僅計
漢字實義字(標點/符號不納入)。方案 C 屬斷行層、無測量,結構性 nowrap 同
禁則,故分頁安全(academic DEFER 後於定版頁上跑亦不擾 Paged.js)。三模式共用,
lib 預設即開,此處顯式標記為刻意採用。 */
orphan: true,
jiya: {
halfWidth: halfWidth,
},
+5 -5
View File
@@ -56,9 +56,9 @@ createJuzhen({ lang: { default: "zh-Hant" } }).render(document.querySelector("ma
<tr><td>中西間隙 <code>spacing</code></td><td>CJK↔西文邊界,<strong>margin 模型</strong>:包裹左側字、以 <code>margin-right</code> 留隙,不注入空白字元 → <code>textContent</code> 完全不變、複製緊湊、<code>justify</code> 不暴增、行首乾淨;行末由 <code>gapTrim</code> layout pass 去隙以齊右。含跨樣式邊界與 <code>code</code><code>kbd</code> pillpill 緊鄰標點時穿越 <code>jz-jinze</code> 仍補間隙)。贅餘作者空格一律正規化:CJK↔西文吸收為受管間隙、CJK↔CJK(含跨 inline 標籤)剝除不留隙。</td></tr>
<tr><td>長英文詞 <code>longWord</code></td><td><code>[A-Za-z]{6,}</code> 包入 <code>&lt;span lang="en"&gt;</code>,配合消費端 CSS <code>hyphens:auto</code> 原生斷詞(DOM 無連字符 → 複製不污染)。</td></tr>
<tr><td>標點擠壓 <code>jiya</code></td><td>標點 charify 為 <code>jz-char</code> <code>bd-*</code> class。三機制:baseline 政策(全角式不擠/開明式擠句內點號+括號引號/半角式全擠)+行內連續擠壓+行端擠壓。半形預設以字型 OpenType <code>halt</code> 渲染;缺 <code>halt</code> 字型可切 <code>margin</code> 後備模式(見下)。</td></tr>
<tr><td>禁則 <code>jinze</code></td><td>行首行末避頭尾:閉類/點號綁前一字(不置行首)、開類綁後一字(不置行末);只隔離邊界詞元,長引文中段仍可斷。亦為 <code>justify</code> 綁定載體。</td></tr>
<tr><td>垂懸字避免 <code>orphan</code></td><td>段末行 CJK 實義字 ≥ N(預設 2;標點不計)。方案 C(斷行層無測量):段末末 N 實義字+尾隨標點包入 <code>jz-orphan</code><code>white-space:nowrap</code>),整體不可斷 → 末行恒含之 ⇒ 末行 ≥ N。<strong>預設開</strong>;段落級。</td></tr>
<tr><td><s>標點懸掛 <code>hanging</code></s></td><td><strong>評估停用</strong>:純負 margin 機制不可行(墨色不出版心/滿凸拉升後字重疊/resize 錯亂),原生 <code>hanging-punctuation</code> 為正解但 Chrome 不支援。選項仍被接受但無效果、程式碼休眠。詳見 ARCHITECTURE §6.5.6。</td></tr>
<tr><td>禁則 <code>jinze</code></td><td>行首行末避頭尾:閉類/點號綁前一字(不置行首)、開類綁後一字(不置行末);只隔離邊界詞元,長引文中段仍可斷。邊界字落於行內樣式元素(<code>strong</code><code>em</code> 等)內時<strong>克隆切分</strong>只綁邊界詞元(避免整串入 <code>nowrap</code> 致行末整體掉行),克隆標 <code>data-jz-kind="splitoff"</code><code>revert</code> 時併回。亦為 <code>justify</code> 綁定載體。</td></tr>
<tr><td>垂懸字避免 <code>orphan</code></td><td>段末行 CJK 實義字 ≥ N(預設 2;標點不計)。方案 C(斷行層無測量):段末末 N 實義字+尾隨標點包入 <code>jz-orphan</code><code>white-space:nowrap</code>),整體不可斷 → 末行恒含之 ⇒ 末行 ≥ N。邊界字落於行內元素內時同樣<strong>克隆切分</strong>(避免長元素溢出容器)。<strong>預設開</strong>;段落級。</td></tr>
<tr><td><s>標點懸掛 <code>hanging</code></s></td><td><strong>移除</strong>:純 CSS 機制為機制級死路(寬度守恆矛盾——墨色出去多少版心內就空多少,CSS 接觸不到斷行器)。原生 <code>hanging-punctuation</code>唯一正解但僅 Safari 支援、且與 inline-block 原子互斥。<code>hanging</code> 選項保留為 no-op(無效果)。詳見 ARCHITECTURE §6.5.6。</td></tr>
<tr><td>功能分級 <code>level</code></td><td>文本級(間隙、擠壓——任意文本適用)vs 段落級(禁則、垂懸字、長詞——需行/段布局)。塊級預設全功能;<code>data-jz-level="text"</code> 子樹或 <code>level:{text:"選擇器"}</code>(標題/按鈕)僅跑文本級。</td></tr>
<tr><td>justify 單份間距</td><td>每個 <code>jz-char</code><code>inline-block</code> 原子,句末標點只貢獻一份對齊伸縮量。</td></tr>
</tbody>
@@ -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 原子)