docs: changelog 2026-07-07 專案整理(階段一/二)+ index 三分類分層/avoid 七源/demo/19.7em

新增 changelog 2026-07-07「專案整理」條目(架構一致性:透明判定單一真相源+跨透明邊界避頭尾
+avoid 排除歸一;確認缺陷修復+清理;復驗後 lineedge 非缺陷/五處幂等不做之定性;文檔重整)
+md-twin 同步。

index 同步:三分類「共用同一判定」改分層表述(isTransparent 為邏輯流單一真相源+未知行內採
嚴格白名單邊界;canSplit/lineedge 為獨立層)、透明列標「未列=邊界」、avoid 改七源+isAvoided
組合入口+skipTags 不含 code/kbd;修 19.5em→19.7em(與 main 一致);目視驗證補 demo/resize-check
與 demo/showcase 引用。changelog/index HTML 經 build.sh 重建。
This commit is contained in:
2026-07-07 21:21:52 +08:00
parent f75382c9b5
commit 648605ac53
4 changed files with 370 additions and 106 deletions
+134 -51
View File
@@ -1,4 +1,4 @@
<!-- built with theme=dracula, mode=report, at=2026-06-13T07:41:09Z -->
<!-- built with theme=dracula, mode=report, at=2026-07-07T13:21:19Z -->
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
@@ -706,6 +706,14 @@ hr.line
}
hr.line::before { content: none; }
.lede
{
/* 首字下沉為 float,本段行數不足以填滿下沉字高度時,浮標會探出段底,
令次段首數行繞排、向右擠貼下沉字。將本段下沿托至下沉字下沿(高度=
4.2em × line-height 0.85 頂距 8px,與下方 ::first-letter 同步),
使次段恆起於下沉字之下。本段自然行高足夠時 min-height 不生效,無副作用。 */
min-height: calc(4.2em * 0.85 + 8px);
}
.lede::first-letter
{
font-family: var(--font-serif);
@@ -2323,9 +2331,10 @@ main > *:nth-child(n+6) { animation-delay: 0.46s; }
/* ---------- Mode: report — Print / PDF ----------
Dynamic single-page PDF: runtime.js setupDynamicPrintPage() measures
content height before print and injects @page sized to fit. Slides
mode uses per-slide pagination (page-break-after: always) instead. */
Dynamic single-page PDF: report/behavior.js setupDynamicPrintPage()
measures the printed height (off-screen iframe at the print column width)
and injects @page sized to fit. Slides mode uses per-slide pagination
(page-break-after: always) instead. */
/* ---------- Print ---------- */
/* ---------- Print / PDF export ----------
@@ -2357,9 +2366,9 @@ main > *:nth-child(n+6) { animation-delay: 0.46s; }
@page
{
/* A4 fallback. Real page size is set dynamically at runtime by
`setupDynamicPrintPage()` in runtime.js — it measures the rendered
height before print and injects a fresh `@page` rule sized to fit
content exactly.
`setupDynamicPrintPage()` in report/behavior.js — it measures the
printed height (off-screen iframe at the print column width) and
injects a fresh `@page` rule sized to fit content exactly.
Margin: 0 here. The real margin lives in `body` padding (see the
@media print rule below), which the user's print dialog cannot
@@ -2609,7 +2618,7 @@ createJuzhen({ lang: { default: "zh-Hant" } }).render(document.querySelector("ma
<tr><td><code>margin</code>(後備)</td><td><code>jiya: { halfWidth: "margin" }</code></td><td><code>width:0.5em</code> +字身依墨色偏側溢出定位(以 CSS 重現 halt)</td><td>字型<strong></strong> <code>halt</code>(多數系統字型、GBK 老字型如方正書宋 GBK、PingFang</td></tr>
</tbody>
</table>
<div class="callout warn"><span class="label">為何需要後備</span> <code>halt</code> 缺席時開明式擠壓「完全無效」(標點仍佔全形)。實測<strong>方正書宋 GBK 僅含 <code>vert</code>、無 <code>halt</code></strong> → halt 模式不擠;切 <code>margin</code> 後備後(開明式 zh-Hans),同句行寬 <code>24.0em → 19.5em</code>、單一句內點號 <code>1.0em → 0.5em</code>,半形生效。</div>
<div class="callout warn"><span class="label">為何需要後備</span> <code>halt</code> 缺席時開明式擠壓「完全無效」(標點仍佔全形)。實測<strong>方正書宋 GBK 僅含 <code>vert</code>、無 <code>halt</code></strong> → halt 模式不擠;切 <code>margin</code> 後備後(開明式 zh-Hans),同句行寬 <code>24.0em → 19.7em</code>、單一句內點號 <code>1.0em → 0.5em</code>,半形生效。</div>
<div class="callout"><span class="label">句末標點與連續挤压</span> 句末點號為 <code>。.!?</code>——含<strong>全角句點 <code></code></strong>U+FF0E,異於表意句號 <code></code> U+3002Noto CJK 有 <code>halt</code>);全角式與開明式句末皆<strong>保持全角 1em</strong>banjiao 挤半。<strong>連續標點</strong>對每一標點獨立判斷——若其有空白之側緊鄰另一標點即收半形:故 <code>,「</code> <strong>全角式與開明式皆 1em</strong><code>。」</code> <strong>全角式 1.5em/開明式 1em</strong>;並透明穿越 <code>strong</code><code>em</code> 等行內邊界。<strong>句界例外</strong>:句末點號接開類(<code>。「</code><code>?「</code>)為句界,保留句末點號右側空白、僅收開類左側 → 全角式與開明式皆 <strong>1.5em</strong>(對稱於 <code>。」</code>);例外僅限句末點號+開類,句內點號 <code>,「</code> 與雙括號 <code>」「</code><code>》《</code> 仍兩側皆收 1em。半角式句末點號由 baseline 已半形,故仍 1em(不回歸全寬)。</div>
<h3>激活與粒度</h3>
@@ -2664,16 +2673,16 @@ createJuzhen({ lang: { default: "zh-Hant" } }).render(document.querySelector("ma
</ol>
<h2>行內元素三分類:透明/隔離/整體</h2>
<p>行內元素對排版有且僅有三種口徑<strong>所有遍歷原語共用一判定</strong>(中西間隙、擠壓相鄰、禁則綁定、垂懸字、克隆切分皆一致);判定<strong>以元素為準、非標籤名</strong>——KaTeX 全樹皆 <code>&lt;span&gt;</code>,但 <code>.katex</code> 命中 <code>pillSelector</code> 即為整體邊界。</p>
<p>行內元素對排版有且僅有三種口徑<strong>邏輯文本流之遍歷原語共用一判定</strong> <code>isTransparent</code>(中西間隙、擠壓相鄰、禁則綁定、垂懸字);<strong>未在 <code>styleInlines</code> 之未知行內標籤(自訂元素等)採嚴格白名單、一律當邊界</strong>,透明須顯式登記。判定<strong>以元素為準、非標籤名</strong>——KaTeX 全樹皆 <code>&lt;span&gt;</code>,但 <code>.katex</code> 命中 <code>pillSelector</code> 即為整體邊界。結構克隆切分 <code>canSplit</code>、行端物理版面 <code>lineedge</code> 為正當之獨立層(邊界各異,見架構)。</p>
<table>
<thead><tr><th>分類</th><th>選擇器</th><th>邊界</th><th>內部</th><th>典型</th></tr></thead>
<tbody>
<tr><td><strong>透明</strong></td><td><code>styleInlines</code></td><td>穿越——間隙/相鄰/run 皆延續</td><td>照常處理;禁則可<strong>克隆切分</strong>(最小綁定)</td><td><code>strong</code><code>em</code><code>a</code><code>span</code></td></tr>
<tr><td><strong>透明</strong></td><td><code>styleInlines</code><strong>未列之行內標籤=邊界</strong></td><td>穿越——間隙/相鄰/run 皆延續</td><td>照常處理;禁則可<strong>克隆切分</strong>(最小綁定)</td><td><code>strong</code><code>em</code><code>a</code><code>span</code></td></tr>
<tr><td><strong>隔離</strong></td><td><code>isolateSelector</code>(預設 <code>"mark"</code><code>""</code> 停用)</td><td>斷 run/斷相鄰/不可切分;<strong>兩端</strong><code>jz-hws</code> 間隙</td><td><strong>照常處理</strong>(自成脈絡)</td><td>帶背景色之 <code>&lt;mark&gt;</code>、badge 類</td></tr>
<tr><td><strong>整體</strong></td><td><code>pillSelector</code>(預設 <code>"code, kbd"</code></td><td>同隔離</td><td><strong>完全不可見</strong>——不 charify、不插間隙、不切分、不下潛</td><td><code>code</code><code>kbd</code>、行內公式 <code>.katex</code>、引用標 <code>.cite-ref</code></td></tr>
</tbody>
</table>
<p>禁則遇 pill<code>…$公式$</code>):整個 pill 作為原子鄰字單位<strong>整體綁入</strong> <code>jz-jinze</code>(公式本不可斷,無額外代價);隔離元素同樣整綁(切分背景盒有可見接縫)。<code>scope.avoid</code><code>skipAttribute</code><code>data-jz-skip</code> <strong>自身</strong>判定命中元素自身即邊界、內外皆不處理</p>
<p>禁則遇 pill<code>…$公式$</code>):整個 pill 作為原子鄰字單位<strong>整體綁入</strong> <code>jz-jinze</code>(公式本不可斷,無額外代價);隔離元素同樣整綁(切分背景盒有可見接縫)。<strong>avoid 七源</strong><code>isSkipped</code><code>skipTags</code>SVG<code>contentEditable</code><code>data-jz-skip</code><code>skipAttribute</code><code>scope.avoid</code><strong>自身</strong>判定命中即邊界;一次性檢查用組合入口 <code>isAvoided</code>(自身或祖先)。<strong><code>skipTags</code> 預設不含 <code>code</code><code>kbd</code></strong>——二者由 <code>pillSelector</code> 以 pill 涵蓋(三分類互斥)</p>
<div class="callout"><span class="label">建議</span> 行內公式/夾注引用等獨立行內單位宜列為 pill:<code>pillSelector: "code, kbd, .katex, .cite-ref"</code>——兩側補隙、吸收作者空格、整體綁定,統一處理。</div>
<h2>分頁器(Paged.js 等)相容:<code>justifyAtoms</code></h2>
@@ -2699,7 +2708,7 @@ createJuzhen({ lang: { default: "zh-Hant" } }).render(document.querySelector("ma
<p>v1 相容層(<code>createCjkAutospace</code> 導出、IIFE 之全域別名、<code>autospaceClass</code> 選項)<strong>已隨最後一批 v1 消費端遷移完成而移除</strong>。請改用 <code>createJuzhen(options).render(root)</code> v2 APIv1 原始碼保存於 <code>v1-autospace</code> 分支。</p>
<h2>目視驗證</h2>
<p><code>demo/index.html</code> 載入 <code>dist/</code> 產物,簡繁並列、可切 <code>justify</code>、附 copy-clean 測試區。<code>demo/fallback.html</code> 以方正書宋 GBK(無 <code>halt</code>)對照 <code>halt</code> 失效 vs <code>margin</code> 後備生效。版面相關結論(justify 單份間距、擠壓視覺量、行端、<code>inline-block</code> 基線)須於 ChromeSafariFirefox 三引擎目視核對——jsdom 僅驗證 DOM 結構正確性。</p>
<p><code>demo/index.html</code> 載入 <code>dist/</code> 產物,簡繁並列、可切 <code>justify</code>、附 copy-clean 測試區。<code>demo/fallback.html</code> 以方正書宋 GBK(無 <code>halt</code>)對照 <code>halt</code> 失效 vs <code>margin</code> 後備生效。<code>demo/resize-check.html</code> 為可拖曳容器檢查頁(驗中西間隙在各寬度恆穩、行末去隙、pill 跨行右隙)。<code>demo/showcase.html</code> 以定寬文本框並列,逐場景演示功能分級與垂懸字。版面相關結論(justify 單份間距、擠壓視覺量、行端、<code>inline-block</code> 基線、間隙隨寬度之穩定性)須於 ChromeSafariFirefox 三引擎目視核對——jsdom 僅驗證 DOM 結構正確性。</p>
</main>
@@ -2774,7 +2783,7 @@ JS 只注入 `jz-*` 結構,所有視覺尺寸在 juzhen.css,缺一不可。
## 行內元素三分類
行內元素對排版三種口徑,所有遍歷原語共用一判定(以元素為準、非標籤名):透明(styleInlines——穿越、run 延續、可克隆切分);隔離(isolateSelector,預設 "mark"——邊界斷 run/斷相鄰/不可切分、兩端補隙,內部照常處理);整體(pillSelector——內部對一切 pass 不可見,禁則時作為原子鄰字單位整體綁入 jz-jinze)。avoidscope.avoidskipAttributedata-jz-skip)以自身判定命中即邊界。行內公式/夾注引用宜列為 pill`pillSelector: "code, kbd, .katex, .cite-ref"`。
行內元素對排版三種口徑,邏輯文本流之遍歷原語共用一判定 isTransparent(以元素為準、非標籤名);未在 styleInlines 之未知行內標籤採嚴格白名單、一律當邊界(透明須顯式登記):透明(styleInlines——穿越、run 延續、可克隆切分);隔離(isolateSelector,預設 "mark"——邊界斷 run/斷相鄰/不可切分、兩端補隙,內部照常處理);整體(pillSelector——內部對一切 pass 不可見,禁則時作為原子鄰字單位整體綁入 jz-jinze)。結構克隆切分 canSplit、行端物理版面 lineedge 為正當之獨立層。avoid 七源(isSkippedskipTagsSVGcontentEditabledata-jz-skipskipAttributescope.avoid)以自身判定命中即邊界(一次性用組合 isAvoided);skipTags 預設不含 code/kbd(由 pill 涵蓋、三分類互斥)。行內公式/夾注引用宜列為 pill`pillSelector: "code, kbd, .katex, .cite-ref"`。
## 分頁器相容
@@ -3652,9 +3661,12 @@ var Juzhen = (() => {
if (!finder.featureEnabledFor(L, PASS)) {
return;
}
const lSides = blankSides(bdClassOf(L));
const rSides = blankSides(bdClassOf(R));
if (lSides.right) {
const lClass = bdClassOf(L);
const rClass = bdClassOf(R);
const lSides = blankSides(lClass);
const rSides = blankSides(rClass);
const stopBeforeOpen = lClass === "bd-stop" && rClass === "bd-open";
if (lSides.right && !stopBeforeOpen) {
L.classList.add("jz-half");
}
if (rSides.left) {
@@ -5092,7 +5104,9 @@ window.__JUZHEN_PUNCT__="quanjiao";window.__JUZHEN_JUSTIFY_ATOMS__=true;window._
.lede.marginalia.references.appendix……)不命中、維持全功能。最近祖先勝,
故標題與 chrome 容器一旦命中,其後代(如 .slide-title 內的 .section-name
.slide-dot、.title-page 內的作者欄)一併降級。選擇器涵蓋三模式;某模式不存在之
class 自然不命中。 */
class 自然不命中。**切記只列「非段落 chrome 本身」,切勿納入包裹正文的容器**
(如 .deck-stage.deck.cols-2)——否則「最近祖先勝」會把整個子樹正文一併降級、
喪失禁則/垂懸/長詞(曾誤納 .deck-stage 致 slides 全 deck 正文無禁則、標點落行首)。 */
level: {
text: [
"h1, h2, h3, h4, h5, h6", // 全部標題(含 deck-section-slide-tp-toc-title
@@ -5100,7 +5114,7 @@ window.__JUZHEN_PUNCT__="quanjiao";window.__JUZHEN_JUSTIFY_ATOMS__=true;window._
".subtitle, .section-subtitle, .deck-subtitle", // 副標題
".meta, .timestamp, .actions, .page", // report chrome
".cover-block, .slide-foot, .toc-list", // slides 封面/頁尾/目錄條目
".deck-nav, .deck-counter, .deck-author, .deck-institute, .deck-date, .deck-actions, .deck-stage",
".deck-nav, .deck-counter, .deck-author, .deck-institute, .deck-date, .deck-actions",
".title-page, .toc-page, .simple-title", // academic 前置 chrome(含 tp-st- 欄位)
].join(", "),
},
@@ -5131,9 +5145,10 @@ window.__JUZHEN_PUNCT__="quanjiao";window.__JUZHEN_JUSTIFY_ATOMS__=true;window._
renderMath();
wrapFlexTitleText();
/* 一般模式於 init 立即排版。__JUZHEN_DEFER__academic 由 build.sh 設)令此處
跳過,改由該模式 behavior.js Paged.js preview() 完成後對成品頁呼叫
__juzhenRun__——關鍵:Paged.js chunker 對 inline-block 原子量測失準(分頁前
排版會致窄欄散架),故先以純文字分頁、再於定版頁上加排版原子。 */
跳過,改由該模式 behavior.js 在編號/目錄/正文標記完成後、Paged.js preview()
**之前**手動呼叫 __juzhenRun__——使 Paged.js 直接以聚珍後的最終幾何分頁(每頁
自然填滿、末行齊滿、無需分頁後斷縫修復)。整頁寬單欄下 chunker 正確量度聚珍
inline-block 原子,無散架。 */
if (!window.__JUZHEN_DEFER__) { runJuzhen(); }
/* ========== Feature: code-block — behavior =================================
@@ -6042,30 +6057,28 @@ if (!window.__copyDelegated__)
/* ---------- Dynamic print page sizing ----------
Measure the document's rendered height and inject an @page rule
sized to fit content exactly. Produces a single-page PDF on "Save
as PDF" with no trailing blank space.
Measure the document's true printed height and inject an @page rule
sized to fit it exactly a single-page PDF on "Save as PDF" with no
trailing blank space and no overflow onto a second page.
Page margin: 0. Real margins live in `body` padding (runtime.css
@media print). That keeps content width stable regardless of the
print dialog's Margins selector — and stable width is what makes
the screen-to-print height heuristic reliable.
Measurement: an off-screen iframe at the print page width (210mm) with
the document's own @media print rules un-gated reproduces the printed
layout; its content height is the answer (see measurePrintMm). This is
window-independent.
Page-height heuristic: with horizontal padding fixed (1cm each
side), print rendering height roughly matches screen rendering
height. Ratio = 1.0 (was 0.85 — too aggressive an under-estimate;
caused tail clipping). The 50mm buffer absorbs the residual
variance plus whatever vertical margin a dialog like Chrome's
Default might add on top.
Earlier this used `documentElement.scrollHeight` × a ratio. That broke
because report has NO max-width (content width = 100vw4em): on a wide
window the screen wraps less and measures short, but print always lays
out at the fixed 190mm column and wraps more, so the page came out too
short and content overflowed. The wider the window, the worse it got.
Vertical padding (body padding-top 1.5cm + padding-bottom 1cm =
25mm total) is reserved inside the page so the breathing room
above the title and below the footer is preserved.
Page margin: 0. Real margins live in `body` padding (the @media print
rule), so they survive the print dialog's "Margins: None" and stay
inside the measured height.
Escape hatch: set `window.disablePrintAutoSize = true` (in the
DevTools console or before page load) to disable. The injected
rule is cleared and printing falls back to the static A4 in
runtime.css (multi-page). */
Escape hatch: set `window.disablePrintAutoSize = true` (DevTools console
or before load) to clear the injected rule and fall back to the static
A4 @page in runtime.css (multi-page). */
function setupDynamicPrintPage()
{
let styleEl = null;
@@ -6079,25 +6092,95 @@ if (!window.__copyDelegated__)
}
return styleEl;
}
/* Collect this document's own `@media print` rules as unconditional
CSS, so an off-screen iframe can reproduce the printed-page layout. */
function collectPrintCss()
{
let css = "";
Array.from(document.styleSheets).forEach((sheet) =>
{
let rules;
try { rules = sheet.cssRules; }
catch (e) { return; } /* cross-origin sheet — unreadable */
Array.from(rules).forEach((rule) =>
{
if (rule.type === CSSRule.MEDIA_RULE && /print/i.test(rule.media.mediaText))
{
Array.from(rule.cssRules).forEach((inner) => { css += inner.cssText + "\n"; });
}
});
});
return css;
}
/* Measure the true printed height: clone the already-rendered DOM (KaTeX
+ juzhen baked in) into a hidden iframe sized to the print page width
(210mm) with the print rules un-gated, then read its content height.
innerHTML insertion never runs <script>, so the runtime IIFE / KaTeX /
juzhen do NOT re-execute. scrollHeight already includes the print
`body` padding (the un-gated @media print rule). Resolves to mm.
Why an iframe and not `documentElement.scrollHeight`: report mode has
NO max-width — on-screen content width = 100vw4em, so screen height
tracks the window and diverged from the fixed 190mm print column
(wide windows under-sized the page → overflow). The iframe pins the
measurement to the real print width, making it window-independent. */
function measurePrintMm()
{
return new Promise((resolve) =>
{
const iframe = document.createElement("iframe");
iframe.setAttribute("aria-hidden", "true");
iframe.style.cssText =
"position:fixed;left:-10000px;top:0;width:210mm;height:50px;"
+ "border:0;visibility:hidden;";
document.body.appendChild(iframe);
const idoc = iframe.contentDocument;
idoc.open();
idoc.write("<!DOCTYPE html><html><head></head><body></body></html>");
idoc.close();
idoc.documentElement.lang = document.documentElement.lang;
idoc.head.innerHTML = document.head.innerHTML;
idoc.body.innerHTML = document.body.innerHTML;
const ps = idoc.createElement("style");
ps.textContent = collectPrintCss();
idoc.head.appendChild(ps);
function done()
{
const h = Math.max(idoc.documentElement.scrollHeight, idoc.body.scrollHeight);
const mm = Math.ceil(h * 25.4 / 96);
iframe.remove();
resolve(mm);
}
/* Wait for the iframe's fonts (same @font-face, HTTP-cached from
the parent) so glyph metrics match; rAF lets layout settle. */
const fontsReady = (idoc.fonts && idoc.fonts.ready) || Promise.resolve();
fontsReady.then(() => requestAnimationFrame(() => requestAnimationFrame(done)));
});
}
function update()
{
const el = ensureStyle();
if (window.disablePrintAutoSize)
{
el.textContent = ""; /* fall back to runtime.css @page */
el.textContent = ""; /* fall back to runtime.css static @page */
return;
}
const PX_TO_MM = 25.4 / 96;
const RATIO = 1.0;
const PAD_V_MM = 25; /* body padding-top 15 + padding-bottom 10 */
const BUFFER_MM = 0; /* absorbs dialog margin + heuristic variance */
const screenH = document.documentElement.scrollHeight;
const printContentH = Math.ceil(screenH * PX_TO_MM * RATIO);
const pageH = printContentH + PAD_V_MM + BUFFER_MM;
el.textContent = "@page { size: 210mm " + pageH + "mm; margin: 0; }";
measurePrintMm().then((contentMm) =>
{
if (window.disablePrintAutoSize) { el.textContent = ""; return; }
const BUFFER_MM = 10; /* dialog margin + sub-mm rounding slack */
const pageH = contentMm + BUFFER_MM;
el.textContent = "@page { size: 210mm " + pageH + "mm; margin: 0; }";
});
}
update();
window.addEventListener("resize", update);
/* Print column width is fixed (190mm), so page height no longer depends
on the window — no resize re-measure. Re-measure before printing to
pick up dynamically expanded/collapsed content (async; the init rule
already covers static content for the current print). */
window.addEventListener("beforeprint", update);
}