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:
+541
-54
@@ -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>Bug:pill 前標點誤加間隙</strong>(如 <code>漢字。<code></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><strong>很長一串</strong>,</code> 把整串粗體塞進 <code>nowrap</code> inline-block,行末放不下即整體掉行、上行留巨大空隙)。新增 <code>core/split.ts</code> 之克隆切分:沿祖先鏈把邊界詞元剝入淺克隆(保留標籤/class/style/lang、去 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>,惟 Chrome/Firefox 至今不支援(僅 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-char/jz-inner 標點原子)。穿越這些
|
||||
* 才能正確找到跨包裝之邏輯鄰字(如 jinze 已把 pill 納入 jz-jinze 後,pill 之左
|
||||
* 鄰字仍須可達;I7)。
|
||||
* ⚠ jz-char/jz-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 之 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 字
|
||||
包入 `<jz-orphan white-space:nowrap>` 拉下首字,杜絕「孤字成行」。判定僅計
|
||||
漢字實義字(標點/符號不納入)。方案 C 屬斷行層、無測量,結構性 nowrap 同
|
||||
禁則,故分頁安全(academic DEFER 後於定版頁上跑亦不擾 Paged.js)。三模式共用,
|
||||
lib 預設即開,此處顯式標記為刻意採用。 */
|
||||
orphan: true,
|
||||
jiya: {
|
||||
halfWidth: halfWidth,
|
||||
},
|
||||
|
||||
@@ -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>Bug:pill 前標點誤加間隙</strong>(如 <code>漢字。<code></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><strong>很長一串</strong>,</code> 把整串粗體塞進 <code>nowrap</code> inline-block,行末放不下即整體掉行、上行留巨大空隙)。新增 <code>core/split.ts</code> 之克隆切分:沿祖先鏈把邊界詞元剝入淺克隆(保留標籤/class/style/lang、去 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>,惟 Chrome/Firefox 至今不支援(僅 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
@@ -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> pill(pill 緊鄰標點時穿越 <code>jz-jinze</code> 仍補間隙)。贅餘作者空格一律正規化:CJK↔西文吸收為受管間隙、CJK↔CJK(含跨 inline 標籤)剝除不留隙。</td></tr>
|
||||
<tr><td>長英文詞 <code>longWord</code></td><td><code>[A-Za-z]{6,}</code> 包入 <code><span lang="en"></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-char/jz-inner 標點原子)。穿越這些
|
||||
* 才能正確找到跨包裝之邏輯鄰字(如 jinze 已把 pill 納入 jz-jinze 後,pill 之左
|
||||
* 鄰字仍須可達;I7)。
|
||||
* ⚠ jz-char/jz-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 之 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 字
|
||||
包入 `<jz-orphan white-space:nowrap>` 拉下首字,杜絕「孤字成行」。判定僅計
|
||||
漢字實義字(標點/符號不納入)。方案 C 屬斷行層、無測量,結構性 nowrap 同
|
||||
禁則,故分頁安全(academic DEFER 後於定版頁上跑亦不擾 Paged.js)。三模式共用,
|
||||
lib 預設即開,此處顯式標記為刻意採用。 */
|
||||
orphan: true,
|
||||
jiya: {
|
||||
halfWidth: halfWidth,
|
||||
},
|
||||
|
||||
+5
-5
@@ -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> pill(pill 緊鄰標點時穿越 <code>jz-jinze</code> 仍補間隙)。贅餘作者空格一律正規化:CJK↔西文吸收為受管間隙、CJK↔CJK(含跨 inline 標籤)剝除不留隙。</td></tr>
|
||||
<tr><td>長英文詞 <code>longWord</code></td><td><code>[A-Za-z]{6,}</code> 包入 <code><span lang="en"></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 原子)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user