docs: Pass C —— 長英文段 lang 包裝

CHANGELOG 記新項:Pass C 預設啟用之動機(避開原模板 wbr 每 6 字元亂
插之兩個觀感問題:斷點位置與音節無關、斷處不顯連字符),實作要點,
與字型陷阱(CJK serif 把 U+2010 設計為全形 1 em,須消費端加
hyphenate-character: "-" 強制 U+002D)。

index 加 Pass C 完整章節(演算法、消費端 CSS、選取潔淨性、不在範圍
之識別符 / URL);lede 與 attribution 段同步更新雙 pass → 三 pass。
This commit is contained in:
2026-05-26 16:54:02 +08:00
parent bda7795c85
commit b5ac89b687
4 changed files with 686 additions and 294 deletions
+304 -145
View File
@@ -1,4 +1,4 @@
<!-- built with theme=dracula, mode=report, at=2026-05-22T15:25:04Z -->
<!-- built with theme=dracula, mode=report, at=2026-05-26T08:53:50Z -->
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
@@ -312,6 +312,28 @@
user-select: none;
}
/* Pass C 配套:cjk-autospace lib 把長英文段包進 <span lang="en">,此處
讓瀏覽器原生 hyphens: auto 用 en 字典做斷詞 + 渲染連字符。連字符純
視覺(DOM 不存在),複製返回原文無污染。
hyphenate-limit-chars: 6 3 3 — 詞長 ≥6、斷點前/後各 ≥3 字符,避免
把 "before" 斷成 "be-fore" 之類粒度過細之結果。Safari/Firefox 對此
屬性支援不全,未生效時退至各家瀏覽器之默認啟發。
hyphenate-character: "-" — 強制斷字符為 U+002D HYPHEN-MINUS(窄拉丁
寬度)而非預設之 U+2010 HYPHEN。Noto Serif CJK SC 把 U+2010 設計為
全形 CJK 標點(1 em,等於一個中文字寬度)以服務 CJK 範圍表達,但
用在英文斷字會視覺斷裂;U+002D 在同字型中為窄拉丁,與作者鍵入的 -
一致。仍為純視覺渲染,DOM 與剪貼簿不受影響。 */
[lang|="en"]
{
hyphens: auto;
-webkit-hyphens: auto;
hyphenate-limit-chars: 6 3 3;
hyphenate-character: "-";
-webkit-hyphenate-character: "-";
}
/* Default two-sided justified alignment for any multi-line prose / data
container in CJK lang. Auto-applies — no class required. Principle:
text that wraps to 2+ lines should justify regardless of tag (table
@@ -1880,6 +1902,102 @@ header.page .actions
main { padding-top: var(--gap-xl); padding-bottom: var(--gap-xl); }
/* ---------- Table of Contents (opt in via body[data-toc]) ----------
Runtime 由 behavior.js 之 setupToc 注入 <nav class="toc">,置於 <main>
首位。h2 顯主序、h3 縮排掛二級。預設 <details open>summary 可點收折。 */
main > nav.toc
{
margin: 0 0 var(--gap-xl) 0;
padding: var(--gap) var(--gap-lg);
border-left: 3px solid var(--accent);
background: var(--bg-elev);
font-size: 14px;
}
main > nav.toc summary
{
cursor: pointer;
list-style: none;
font-family: var(--font-sans);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
user-select: none;
}
main > nav.toc summary::-webkit-details-marker { display: none; }
main > nav.toc summary::before
{
content: "▸";
display: inline-block;
width: 1.2em;
transition: transform 0.15s;
}
main > nav.toc details[open] summary::before { transform: rotate(90deg); }
main > nav.toc details[open] summary
{
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid var(--rule);
}
main > nav.toc ol
{
list-style: none;
margin: 0;
padding: 0;
counter-reset: toc-section;
}
main > nav.toc li.toc-h2
{
counter-increment: toc-section;
margin: 6px 0;
line-height: 1.4;
}
main > nav.toc li.toc-h2::before
{
content: counter(toc-section, decimal-leading-zero);
display: inline-block;
width: 2.4em;
color: var(--accent);
font-family: var(--font-mono);
font-size: 12px;
font-feature-settings: "tnum";
letter-spacing: 0.04em;
}
main > nav.toc li.toc-h3
{
margin: 3px 0 3px 2.4em;
font-size: 13px;
line-height: 1.4;
color: var(--muted);
}
main > nav.toc li.toc-h3::before
{
content: "—";
display: inline-block;
width: 1.5em;
color: var(--rule);
}
main > nav.toc a
{
color: var(--fg);
text-decoration: none;
border-bottom: 1px solid transparent;
}
main > nav.toc a:hover
{
color: var(--accent);
border-bottom-color: var(--accent);
}
main > nav.toc li.toc-h3 a { color: var(--muted); }
main > nav.toc li.toc-h3 a:hover { color: var(--accent); }
html { scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce)
{
html { scroll-behavior: auto; }
}
footer.page
{
margin-top: 64px;
@@ -2132,6 +2250,33 @@ main > *:nth-child(n+6) { animation-delay: 0.46s; }
<main>
<h2>2026-05-26 — Pass C:長英文段 lang 包裝(預設啟用)</h2>
<p>解決 CJK 兩端對齊正文中行末長英文詞之斷行問題。原模板於 runtime 端用 <code>&lt;wbr&gt;</code> 每 6 字元亂插斷詞機會,產生兩個觀感問題:(1)位置與音節邊界無關,斷在怪位;(2)<code>&lt;wbr&gt;</code> 為 zero-width,斷處不顯連字符,讀者無法辨認「上行末是被斷開的詞」。</p>
<p>Pass C 走另一條路:把 <code>[A-Za-z]{N,}</code>(預設 <code>N=6</code>)之長英文段包進 <code>&lt;span lang="en"&gt;</code>,配合消費端 CSS</p>
<pre><code>[lang|="en"] {
hyphens: auto;
-webkit-hyphens: auto;
hyphenate-limit-chars: 6 3 3;
hyphenate-character: "-"; /* 強制 U+002D 而非預設 U+2010 */
-webkit-hyphenate-character: "-";
}</code></pre>
<ul>
<li>瀏覽器原生 <code>hyphens: auto</code> 用 en 字典做斷詞 + 渲染連字符;連字符純由 CSS 渲染,<strong>DOM 內不存在</strong> → 選取 / 複製返回原文無污染(符合「斷字符僅供渲染」之核心約束)</li>
<li>冪等:parent 為 <code>&lt;span lang&gt;</code> 直接跳過,重跑或 MutationObserver 觸發不會重複包裝</li>
<li>skip 鏈與 Pass A / B 共用 <code>isSkipped</code>CODE / KBD / PRE / SAMP / TT / VAR / SCRIPT / STYLE / TEXTAREA / INPUT / SVG / contentEditable / <code>data-md-key</code> 皆跳過</li>
<li><strong>預設啟用</strong><code>longWordWrap: false</code> 顯式關閉;<code>{ minLength, lang }</code> 自訂粒度與標語</li>
</ul>
<h3>字型陷阱:U+2010 之全形 CJK 設計</h3>
<p>瀏覽器 <code>hyphens: auto</code> 預設在斷處插 U+2010 HYPHEN。Noto Serif CJK SC 等 CJK serif 字型把 U+2010 設計為 <strong>全形 1 em</strong>(與一個漢字同寬),以服務 CJK 範圍表達(<code>1月—3月</code>);而 U+002D HYPHEN-MINUS 在同字型內為窄拉丁(約 0.35 em)。直接吃預設會看到「全形連字符」突兀地接在英文詞末。</p>
<p>解法是消費端 CSS 加 <code>hyphenate-character: "-"</code>,強制使用 U+002D;仍為純視覺渲染,DOM 與剪貼簿不受影響。lib 不涉入字型決策,僅在文檔中提示。</p>
<h2>2026-05-22 — 初始發布</h2>
<ul>
@@ -2185,6 +2330,13 @@ main > *:nth-child(n+6) { animation-delay: 0.46s; }
<script type="text/markdown" id="md-source">
# cjk-autospace Changelog
## 2026-05-26 Pass C長英文段 lang 包裝預設啟用
- [A-Za-z]{N,} 之長英文段包進 `<span lang="en">`配消費端 CSS `[lang|="en"] { hyphens: auto; ... }` 用瀏覽器原生 en 字典斷詞 + 渲染連字符
- 連字符純由 CSS 渲染**DOM 內不存在**選取 / 複製返回原文無污染
- 預設啟用`longWordWrap: false` 顯式關閉
- 字型陷阱CJK serif 字型把 U+2010 設計為全形1 em須加 `hyphenate-character: "-"` 強制 U+002D 才不會看到全形連字符
## 2026-05-22 初始發布
- 工廠函式 createCjkAutospace(options)Port Pangu.js v7.2.1 規則表
@@ -2214,7 +2366,7 @@ main > *:nth-child(n+6) { animation-delay: 0.46s; }
* 處理。預設 'code, kbd',可由消費端擴充。
* - BLOCK — 段落級標籤,Pass A 把它們當作 run 邊界。
*
* pass
* pass
* Pass A — 對每個 BLOCK 走 DFS 收集邏輯文本(STYLE 透明、PILL 與子
* BLOCK 中斷 run),整段套 panguSpace,把 MARK 反映射回 textNode/
* offset。MARK 對應原文若是單 ASCII space 則 strip 後插 marker
@@ -2222,6 +2374,10 @@ main > *:nth-child(n+6) { animation-delay: 0.46s; }
* Pass B — 對每個 PILL 透過 STYLE 鏈遞迴找邏輯鄰字(adjacentLogical-
* Char),按 0/1/多+特殊規則處理(0 個或 1 個 ASCII space 插
* marker;其餘跳過)。
* Pass C — 可選(options.longWordWrap)。把 [A-Za-z]{N,} 之長英文段
* 包進 <span lang="en">;配合消費端 CSS `[lang|="en"]{hyphens:auto}`
* 讓瀏覽器原生連字斷詞。連字符純由 CSS 渲染(DOM 內不存在),複製
* 不污染原文。預設關閉以保持向後相容。
*
* Marker = <span class="cjk-autospace" aria-hidden="true">(空白)</span>。
* 內含真實 ASCII 空白:layout 視為 whitespace(行末 collapse / 容許
@@ -2261,6 +2417,21 @@ function createCjkAutospace(options)
const skipAttr = options.skipAttribute || "data-md-key";
const userIsSkipped = typeof options.isSkipped === "function" ? options.isSkipped : null;
/* Pass C 設定。預設啟用(minLength=6 / lang="en");顯式傳 false 才
關閉。可給 { minLength, lang } 局部自訂。
注意:斷點前後最少字符數由消費端 CSS `hyphenate-limit-chars` 控
制(建議 `6 3 3`),lib 不涉入。 */
const longWordCfg = (function ()
{
const v = options.longWordWrap;
if (v === false) { return null; }
if (v === undefined || v === true || v === null) { return { minLength: 6, lang: "en" }; }
return {
minLength: typeof v.minLength === "number" && v.minLength >= 2 ? v.minLength : 6,
lang: typeof v.lang === "string" && v.lang ? v.lang : "en",
};
}());
/* ----- Pangu 字符類與規則表 ----- */
const PANGU_CJK = "⺀-⻿⼀-⿟"
+ "぀-ゟ゠-ヺー-ヿ"
@@ -2642,6 +2813,72 @@ function createCjkAutospace(options)
});
}
/* ----- Pass C: long English word wrap ----- */
/* 冪等性:parent 為 <span lang="…"> 直接跳過。已被本 pass 或作者標
語的英文段都會被 catche.g. <span lang="en-US">…</span> 雖然 lang
不等於 "en",但 caller 已意圖標語,重複包裝無實益。 */
function isInLangSpan(node)
{
const p = node.parentNode;
if (!p || p.nodeType !== 1) { return false; }
if (p.nodeName !== "SPAN") { return false; }
return p.hasAttribute && p.hasAttribute("lang");
}
function wrapLongWordsIn(textNode, cfg)
{
const re = new RegExp("[A-Za-z]{" + cfg.minLength + ",}", "g");
const data = textNode.data;
const matches = [];
let m;
while ((m = re.exec(data)) !== null)
{
matches.push({ start: m.index, end: m.index + m[0].length });
}
if (matches.length === 0) { return; }
const frag = document.createDocumentFragment();
let cursor = 0;
for (let i = 0; i < matches.length; i += 1)
{
const mm = matches[i];
if (mm.start > cursor)
{
frag.appendChild(document.createTextNode(data.slice(cursor, mm.start)));
}
const span = document.createElement("span");
span.setAttribute("lang", cfg.lang);
span.textContent = data.slice(mm.start, mm.end);
frag.appendChild(span);
cursor = mm.end;
}
if (cursor < data.length)
{
frag.appendChild(document.createTextNode(data.slice(cursor)));
}
textNode.parentNode.replaceChild(frag, textNode);
}
function processLongWords(root, cfg)
{
const reTest = new RegExp("[A-Za-z]{" + cfg.minLength + ",}");
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
const targets = [];
let n = walker.nextNode();
while (n)
{
if (!isSkipped(n) && !isInLangSpan(n) && reTest.test(n.data))
{
targets.push(n);
}
n = walker.nextNode();
}
for (let i = 0; i < targets.length; i += 1)
{
wrapLongWordsIn(targets[i], cfg);
}
}
/* ----- 對外 API ----- */
function apply(root)
{
@@ -2662,6 +2899,9 @@ function createCjkAutospace(options)
/* Pass B */
processPills(root);
/* Pass Copt-in */
if (longWordCfg) { processLongWords(root, longWordCfg); }
}
return { apply: apply };
@@ -3287,156 +3527,21 @@ if (typeof globalThis !== "undefined" && !globalThis.createCjkAutospace)
});
/* ---------- CJK typography shims ----------
Two DOM-walking passes that complement (or supplement) the CSS
Text 4 rules in runtime.css. Both honor the same skip list so they
never touch monospaced regions, the Markdown twin, editable areas,
or any container the author has explicitly opted out via the class
`.no-cjk-typography`. */
Pass A / B(跨樣式 Pangu autospace + pill 邊界)+ Pass C(長英文
段包 `<span lang="en">` 給瀏覽器原生 `hyphens: auto` 用)皆由
cjk-autospace lib 提供(build.sh 將 lib 之 cjk-autospace.js 串接
至此 IIFE 之前)。Pass C 預設啟用;不需要時於 createCjkAutospace
options 加 `longWordWrap: false` 顯式關閉。
const CJK_TYPO_SKIP_TAGS = new Set([
"CODE", "KBD", "PRE", "SAMP", "TT", "VAR",
"SCRIPT", "STYLE", "TEXTAREA", "INPUT",
]);
function cjkTypoIsSkipped(node)
{
let p = node.parentNode;
while (p && p.nodeType === 1)
{
if (CJK_TYPO_SKIP_TAGS.has(p.nodeName)) { return true; }
if (p.namespaceURI === "http://www.w3.org/2000/svg") { return true; }
if (p.isContentEditable) { return true; }
if (p.hasAttribute && p.hasAttribute("data-md-key")) { return true; }
p = p.parentNode;
}
return false;
}
function cjkTypoWalk(root)
{
const walker = document.createTreeWalker(
root,
NodeFilter.SHOW_TEXT,
{ acceptNode(n) { return cjkTypoIsSkipped(n) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT; } }
);
const nodes = [];
let cur = walker.nextNode();
while (cur) { nodes.push(cur); cur = walker.nextNode(); }
return nodes;
}
/* Cross-text Pangu + pill autospace is now provided by the
cjk-autospace lib (loaded ahead of this IIFE by build.sh; see
`runtime/lib/cjk-autospace/cjk-autospace.js`). Init is at the
bottom of this file via `createCjkAutospace({}).apply()`. */
/* Long-word break opportunities: inject <wbr> into English words
≥10 chars throughout the document body. Without this, a long word
at line end gets pushed entire-word to the next line, and justify
(now auto-applied to all CJK-lang prose) over-stretches the
previous line's character spacing.
<wbr> is zero-width and only acts as a break opportunity, so even
in components opted out of justify it's harmless (browser won't
use the hint unless overflow demands it). textContent is
unchanged so the Markdown twin and Copy-MD output stay clean.
Hint positions: CamelCase boundaries first (so OpenAIWhisperAPI
becomes Open|AI|Whisper|API), then any remaining run >9 chars is
sliced every 6 chars as fallback. */
function injectWbrInLongWords()
{
const MIN_WORD = 10;
const INTERVAL = 6;
const WORD = /[A-Za-z][A-Za-z0-9]*/g;
function splitWithHints(word)
{
const parts = [];
let cur = "";
for (let i = 0; i < word.length; i += 1)
{
const c = word[i];
const isUpper = c >= "A" && c <= "Z";
const prevIsLower = cur.length > 0
&& cur[cur.length - 1] >= "a"
&& cur[cur.length - 1] <= "z";
if (isUpper && prevIsLower) { parts.push(cur); cur = c; }
else { cur += c; }
}
if (cur) { parts.push(cur); }
const result = [];
for (const part of parts)
{
if (part.length <= INTERVAL + 3) { result.push(part); }
else
{
for (let i = 0; i < part.length; i += INTERVAL)
{
result.push(part.slice(i, i + INTERVAL));
}
}
}
return result;
}
function processTextNode(node)
{
const text = node.data;
WORD.lastIndex = 0;
let m;
let hasLong = false;
while ((m = WORD.exec(text)) !== null)
{
if (m[0].length >= MIN_WORD) { hasLong = true; break; }
}
if (!hasLong) { return; }
const parent = node.parentNode;
const frag = document.createDocumentFragment();
let lastIdx = 0;
WORD.lastIndex = 0;
while ((m = WORD.exec(text)) !== null)
{
if (m.index > lastIdx)
{
frag.appendChild(document.createTextNode(text.slice(lastIdx, m.index)));
}
if (m[0].length >= MIN_WORD)
{
const parts = splitWithHints(m[0]);
for (let i = 0; i < parts.length; i += 1)
{
frag.appendChild(document.createTextNode(parts[i]));
if (i < parts.length - 1)
{
frag.appendChild(document.createElement("wbr"));
}
}
}
else
{
frag.appendChild(document.createTextNode(m[0]));
}
lastIdx = m.index + m[0].length;
}
if (lastIdx < text.length)
{
frag.appendChild(document.createTextNode(text.slice(lastIdx)));
}
parent.replaceChild(frag, node);
}
cjkTypoWalk(document.body).forEach(processTextNode);
}
對應 CSSruntime.css):
[lang|="en"] { hyphens: auto; -webkit-hyphens: auto;
hyphenate-limit-chars: 6 3 3; } */
/* ---------- Init ---------- */
ensureLiveRegion();
setupDiagrams();
createCjkAutospace({}).apply();
injectWbrInLongWords();
/* ---------- Mode: report — Behavior ----------
Concatenated INTO the same IIFE as runtime.js by build.sh, so
@@ -4277,7 +4382,61 @@ if (typeof globalThis !== "undefined" && !globalThis.createCjkAutospace)
window.addEventListener("beforeprint", update);
}
/* ---------- Table of Contents (opt in via body[data-toc]) ----------
長文 report 可選掛載 TOC。掃 <main> 內所有 h2 / h3,缺 id 者補
toc-N,組成 <nav class="toc"><details open><summary>…<ol>…。
lang 取自 <html lang>,控 summary 之語系預設文字;作者可
data-toc="自訂標題" 覆蓋。少於兩個 heading 則不掛(無實益)。 */
function tocLabel()
{
const lang = (document.documentElement.lang || "").toLowerCase();
if (lang.startsWith("zh-hant") || lang.startsWith("zh-tw") || lang.startsWith("zh-hk")) { return "目錄"; }
if (lang.startsWith("zh")) { return "目录"; }
if (lang.startsWith("ja")) { return "目次"; }
if (lang.startsWith("ko")) { return "목차"; }
return "Contents";
}
function setupToc()
{
const attr = document.body.getAttribute("data-toc");
if (attr === null) { return; }
const main = document.querySelector("main");
if (!main) { return; }
const headings = Array.from(main.querySelectorAll("h2, h3"));
if (headings.length < 2) { return; }
const customLabel = (attr && attr !== "1" && attr !== "") ? attr : null;
const nav = document.createElement("nav");
nav.className = "toc";
nav.setAttribute("aria-label", customLabel || tocLabel());
const details = document.createElement("details");
details.open = true;
const summary = document.createElement("summary");
summary.textContent = customLabel || tocLabel();
details.appendChild(summary);
const ol = document.createElement("ol");
headings.forEach((h, i) =>
{
if (!h.id) { h.id = "toc-" + (i + 1); }
const li = document.createElement("li");
li.className = "toc-" + h.tagName.toLowerCase();
const a = document.createElement("a");
a.href = "#" + h.id;
a.textContent = h.textContent.trim();
li.appendChild(a);
ol.appendChild(li);
});
details.appendChild(ol);
nav.appendChild(details);
main.insertBefore(nav, main.firstElementChild);
}
/* ---------- Init (mode: report) ---------- */
setupToc();
setupCardsets();
setupSortables();
setupDynamicPrintPage();