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> <!DOCTYPE html>
<html lang="zh-Hant"> <html lang="zh-Hant">
<head> <head>
@@ -312,6 +312,28 @@
user-select: none; 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 /* Default two-sided justified alignment for any multi-line prose / data
container in CJK lang. Auto-applies — no class required. Principle: container in CJK lang. Auto-applies — no class required. Principle:
text that wraps to 2+ lines should justify regardless of tag (table 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); } 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 footer.page
{ {
margin-top: 64px; margin-top: 64px;
@@ -2132,6 +2250,33 @@ main > *:nth-child(n+6) { animation-delay: 0.46s; }
<main> <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> <h2>2026-05-22 — 初始發布</h2>
<ul> <ul>
@@ -2185,6 +2330,13 @@ main > *:nth-child(n+6) { animation-delay: 0.46s; }
<script type="text/markdown" id="md-source"> <script type="text/markdown" id="md-source">
# cjk-autospace Changelog # 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 初始發布 ## 2026-05-22 初始發布
- 工廠函式 createCjkAutospace(options)Port Pangu.js v7.2.1 規則表 - 工廠函式 createCjkAutospace(options)Port Pangu.js v7.2.1 規則表
@@ -2214,7 +2366,7 @@ main > *:nth-child(n+6) { animation-delay: 0.46s; }
* 處理。預設 'code, kbd',可由消費端擴充。 * 處理。預設 'code, kbd',可由消費端擴充。
* - BLOCK — 段落級標籤,Pass A 把它們當作 run 邊界。 * - BLOCK — 段落級標籤,Pass A 把它們當作 run 邊界。
* *
* pass * pass
* Pass A — 對每個 BLOCK 走 DFS 收集邏輯文本(STYLE 透明、PILL 與子 * Pass A — 對每個 BLOCK 走 DFS 收集邏輯文本(STYLE 透明、PILL 與子
* BLOCK 中斷 run),整段套 panguSpace,把 MARK 反映射回 textNode/ * BLOCK 中斷 run),整段套 panguSpace,把 MARK 反映射回 textNode/
* offset。MARK 對應原文若是單 ASCII space 則 strip 後插 marker * offset。MARK 對應原文若是單 ASCII space 則 strip 後插 marker
@@ -2222,6 +2374,10 @@ main > *:nth-child(n+6) { animation-delay: 0.46s; }
* Pass B — 對每個 PILL 透過 STYLE 鏈遞迴找邏輯鄰字(adjacentLogical- * Pass B — 對每個 PILL 透過 STYLE 鏈遞迴找邏輯鄰字(adjacentLogical-
* Char),按 0/1/多+特殊規則處理(0 個或 1 個 ASCII space 插 * Char),按 0/1/多+特殊規則處理(0 個或 1 個 ASCII space 插
* marker;其餘跳過)。 * 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>。 * Marker = <span class="cjk-autospace" aria-hidden="true">(空白)</span>。
* 內含真實 ASCII 空白:layout 視為 whitespace(行末 collapse / 容許 * 內含真實 ASCII 空白:layout 視為 whitespace(行末 collapse / 容許
@@ -2261,6 +2417,21 @@ function createCjkAutospace(options)
const skipAttr = options.skipAttribute || "data-md-key"; const skipAttr = options.skipAttribute || "data-md-key";
const userIsSkipped = typeof options.isSkipped === "function" ? options.isSkipped : null; 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 字符類與規則表 ----- */ /* ----- Pangu 字符類與規則表 ----- */
const PANGU_CJK = "⺀-⻿⼀-⿟" 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 ----- */ /* ----- 對外 API ----- */
function apply(root) function apply(root)
{ {
@@ -2662,6 +2899,9 @@ function createCjkAutospace(options)
/* Pass B */ /* Pass B */
processPills(root); processPills(root);
/* Pass Copt-in */
if (longWordCfg) { processLongWords(root, longWordCfg); }
} }
return { apply: apply }; return { apply: apply };
@@ -3287,156 +3527,21 @@ if (typeof globalThis !== "undefined" && !globalThis.createCjkAutospace)
}); });
/* ---------- CJK typography shims ---------- /* ---------- CJK typography shims ----------
Two DOM-walking passes that complement (or supplement) the CSS Pass A / B(跨樣式 Pangu autospace + pill 邊界)+ Pass C(長英文
Text 4 rules in runtime.css. Both honor the same skip list so they 段包 `<span lang="en">` 給瀏覽器原生 `hyphens: auto` 用)皆由
never touch monospaced regions, the Markdown twin, editable areas, cjk-autospace lib 提供(build.sh 將 lib 之 cjk-autospace.js 串接
or any container the author has explicitly opted out via the class 至此 IIFE 之前)。Pass C 預設啟用;不需要時於 createCjkAutospace
`.no-cjk-typography`. */ options 加 `longWordWrap: false` 顯式關閉。
const CJK_TYPO_SKIP_TAGS = new Set([ 對應 CSSruntime.css):
"CODE", "KBD", "PRE", "SAMP", "TT", "VAR", [lang|="en"] { hyphens: auto; -webkit-hyphens: auto;
"SCRIPT", "STYLE", "TEXTAREA", "INPUT", hyphenate-limit-chars: 6 3 3; } */
]);
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);
}
/* ---------- Init ---------- */ /* ---------- Init ---------- */
ensureLiveRegion(); ensureLiveRegion();
setupDiagrams(); setupDiagrams();
createCjkAutospace({}).apply(); createCjkAutospace({}).apply();
injectWbrInLongWords();
/* ---------- Mode: report — Behavior ---------- /* ---------- Mode: report — Behavior ----------
Concatenated INTO the same IIFE as runtime.js by build.sh, so 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); 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) ---------- */ /* ---------- Init (mode: report) ---------- */
setupToc();
setupCardsets(); setupCardsets();
setupSortables(); setupSortables();
setupDynamicPrintPage(); setupDynamicPrintPage();
+34
View File
@@ -23,6 +23,33 @@
<main> <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> <h2>2026-05-22 — 初始發布</h2>
<ul> <ul>
@@ -60,6 +87,13 @@
<script type="text/markdown" id="md-source"> <script type="text/markdown" id="md-source">
# cjk-autospace Changelog # 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 初始發布 ## 2026-05-22 初始發布
- 工廠函式 createCjkAutospace(options)Port Pangu.js v7.2.1 規則表 - 工廠函式 createCjkAutospace(options)Port Pangu.js v7.2.1 規則表
+309 -147
View File
@@ -1,4 +1,4 @@
<!-- built with theme=dracula, mode=report, at=2026-05-22T14:44:29Z --> <!-- built with theme=dracula, mode=report, at=2026-05-26T08:53:50Z -->
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-Hant"> <html lang="zh-Hant">
<head> <head>
@@ -312,6 +312,28 @@
user-select: none; 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 /* Default two-sided justified alignment for any multi-line prose / data
container in CJK lang. Auto-applies — no class required. Principle: container in CJK lang. Auto-applies — no class required. Principle:
text that wraps to 2+ lines should justify regardless of tag (table 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); } 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 footer.page
{ {
margin-top: 64px; margin-top: 64px;
@@ -2132,7 +2250,7 @@ main > *:nth-child(n+6) { animation-delay: 0.46s; }
<main> <main>
<p class="lede">Pangu.js v7.2.1 之規則 port,加上「跨樣式標籤透明 + 行內塊邊界」雙 pass 架構。工廠函式設計,由消費端注入 pill 選擇器、樣式 / 段落 / skip 標籤集等配置。輸出 <code>&lt;span class="cjk-autospace" aria-hidden="true"&gt;(空白)&lt;/span&gt;</code>,靠 CSS <code>user-select: none</code> 讓選取與剪貼板跳過。textContent 不變 → Copy 與序列化都乾淨。</p> <p class="lede">Pangu.js v7.2.1 之規則 port,加上「跨樣式標籤透明 + 行內塊邊界」雙 pass 架構,再加 Pass C 之「長英文段 <code>&lt;span lang&gt;</code> 包裝」(給瀏覽器原生 <code>hyphens: auto</code> 用)。工廠函式設計,由消費端注入 pill 選擇器、樣式 / 段落 / skip 標籤集等配置。輸出 <code>&lt;span class="cjk-autospace" aria-hidden="true"&gt;(空白)&lt;/span&gt;</code>,靠 CSS <code>user-select: none</code> 讓選取與剪貼板跳過。textContent 不變 → Copy 與序列化都乾淨。</p>
<h2>Quick start</h2> <h2>Quick start</h2>
@@ -2250,6 +2368,42 @@ autospace.apply(document.body);
<p>結果:<code>命令&lt;span class="cjk-autospace"&gt; &lt;/span&gt;&lt;code&gt;git&lt;/code&gt;&lt;span class="cjk-autospace"&gt; &lt;/span&gt;更新</code></p> <p>結果:<code>命令&lt;span class="cjk-autospace"&gt; &lt;/span&gt;&lt;code&gt;git&lt;/code&gt;&lt;span class="cjk-autospace"&gt; &lt;/span&gt;更新</code></p>
<h2>Pass C — long English word wrap(預設啟用)</h2>
<p>解 CJK 兩端對齊正文中「行末長英文詞」之斷行問題:原靠 <code>&lt;wbr&gt;</code> 每 N 字元亂插之路徑會產生「斷點位置與音節無關 + 斷處不顯連字符」兩個觀感問題。Pass C 改採 <code>&lt;span lang="en"&gt;</code> 包裝,把斷字決定權交給瀏覽器原生 <code>hyphens: auto</code> 用內建英文字典處理。</p>
<ol>
<li>對 root 內所有 text node 走 TreeWalkerSHOW_TEXT</li>
<li>對每個 node:先 <code>isSkipped</code>(共用 Pass A / B 之 skip 鏈:CODE / KBD / PRE / SAMP / TT / VAR / SCRIPT / STYLE / TEXTAREA / INPUT / SVG / contentEditable / <code>data-md-key</code>+ 冪等檢查(parent 為 <code>&lt;span lang&gt;</code> → skip</li>
<li>對通過篩選之 node:用 <code>[A-Za-z]{minLength,}</code> 收所有匹配,把每個匹配位置之子串裹進 <code>&lt;span lang="en"&gt;</code>(其餘原文保留為純 text node),整個用 fragment 一次性 replaceChild</li>
</ol>
<h3>消費端 CSS(與 lib 解耦但必須配套)</h3>
<pre><code>[lang|="en"] {
hyphens: auto;
-webkit-hyphens: auto;
hyphenate-limit-chars: 6 3 3; /* 詞長 / 前 / 後最少字符 */
hyphenate-character: "-"; /* 強制 U+002D,避免 CJK 字型把 U+2010 渲成全形 */
-webkit-hyphenate-character: "-";
}</code></pre>
<div class="callout warn">
<span class="label">字型陷阱</span> Noto Serif CJK SC 等 CJK serif 字型把 U+2010 HYPHEN 設計為全形 1 em(與漢字同寬),以服務 CJK 範圍表達;而 <code>hyphens: auto</code> 預設正是插 U+2010。直接用會看到全形連字符突兀地接在英文詞末。<code>hyphenate-character: "-"</code> 強制改用 U+002D(同字型內為窄拉丁,與作者鍵入的 <code>-</code> 一致),仍為純視覺渲染、DOM 內不存在。
</div>
<h3>選取 / 複製潔淨性</h3>
<p>連字符純由 CSS 渲染(不存在於 DOM),<code>Selection.toString()</code><code>Cmd+C</code> 返回原文無污染。包裝 span 本身是 inline<code>textContent</code> 視為原樣,序列化 / 複製不受影響。</p>
<h3>不在範圍</h3>
<ul>
<li><strong>CamelCase / 標識符</strong><code>getUserByEmailAddress</code><code>OpenAIWhisperAPI</code>):不在英文字典 → 瀏覽器不斷詞;本實作未開 <code>overflow-wrap: anywhere</code> fallback(會破壞「連字符必須存在」之原則),故識別符 overflow 時整塊保留</li>
<li><strong>URL / 純數字串</strong>:同上,<code>https://example.com/very/long/path</code> 此類超長 token 不被處理</li>
<li><strong>非英文之 ASCII 拉丁變體</strong>café、naïve):正則 <code>[A-Za-z]</code> 不匹配重音字符;如需擴充可自訂 <code>longWordWrap.minLength</code> 之外再 patch lib</li>
</ul>
<h2>空白規則</h2> <h2>空白規則</h2>
<p>所有觸發點(Pangu 命中或 PILL 邊界),對「插入位置之原有空白」的處理一致:</p> <p>所有觸發點(Pangu 命中或 PILL 邊界),對「插入位置之原有空白」的處理一致:</p>
@@ -2281,6 +2435,7 @@ autospace.apply(document.body);
<tr><td><code>skipTags</code></td><td><code>["CODE", "KBD", "PRE", "SAMP", "TT", "VAR", "SCRIPT", "STYLE", "TEXTAREA", "INPUT"]</code></td><td>整 subtree skip,連祖先 chain 一併判斷</td></tr> <tr><td><code>skipTags</code></td><td><code>["CODE", "KBD", "PRE", "SAMP", "TT", "VAR", "SCRIPT", "STYLE", "TEXTAREA", "INPUT"]</code></td><td>整 subtree skip,連祖先 chain 一併判斷</td></tr>
<tr><td><code>autospaceClass</code></td><td><code>"cjk-autospace"</code></td><td>marker span 之 class 名</td></tr> <tr><td><code>autospaceClass</code></td><td><code>"cjk-autospace"</code></td><td>marker span 之 class 名</td></tr>
<tr><td><code>skipAttribute</code></td><td><code>"data-md-key"</code></td><td>任一祖先帶此屬性 → 整 subtree skip</td></tr> <tr><td><code>skipAttribute</code></td><td><code>"data-md-key"</code></td><td>任一祖先帶此屬性 → 整 subtree skip</td></tr>
<tr><td><code>longWordWrap</code></td><td><code>true</code>(預設啟用)</td><td>Pass C 開關。傳 <code>false</code> 顯式關閉;<code>{ minLength, lang }</code> 自訂粒度與標語;<code>true</code> / 省略等同 <code>{ minLength: 6, lang: "en" }</code></td></tr>
<tr><td><code>isSkipped</code></td><td>(無)</td><td>自訂 callback;先於內建判定執行;回傳 true 即 skip</td></tr> <tr><td><code>isSkipped</code></td><td>(無)</td><td>自訂 callback;先於內建判定執行;回傳 true 即 skip</td></tr>
</tbody> </tbody>
</table> </table>
@@ -2382,7 +2537,7 @@ obs.observe(document.body, { childList: true, subtree: true, characterData: true
<h2>授權與來源</h2> <h2>授權與來源</h2>
<p>規則表 port 自 <a href="https://github.com/vinta/pangu.js">Pangu.js v7.2.1</a>MIT 授權)。本 lib 之 pass 架構(樣式透明 + 行內塊邊界)為原創設計,解決 Pangu.js 原版「text-node walker 看不到跨元素邊界」之問題。</p> <p>規則表 port 自 <a href="https://github.com/vinta/pangu.js">Pangu.js v7.2.1</a>MIT 授權)。本 lib 之 pass 架構(樣式透明 + 行內塊邊界 + 長英文 lang 包裝)為原創設計,前兩 pass 解決 Pangu.js 原版「text-node walker 看不到跨元素邊界」之問題;Pass C 把 CJK 兩端對齊正文中之斷英文詞責任交給瀏覽器原生 <code>hyphens: auto</code></p>
</main> </main>
@@ -2455,7 +2610,7 @@ autospace.apply(document.body);
* 處理。預設 'code, kbd',可由消費端擴充。 * 處理。預設 'code, kbd',可由消費端擴充。
* - BLOCK — 段落級標籤,Pass A 把它們當作 run 邊界。 * - BLOCK — 段落級標籤,Pass A 把它們當作 run 邊界。
* *
* pass * pass
* Pass A — 對每個 BLOCK 走 DFS 收集邏輯文本(STYLE 透明、PILL 與子 * Pass A — 對每個 BLOCK 走 DFS 收集邏輯文本(STYLE 透明、PILL 與子
* BLOCK 中斷 run),整段套 panguSpace,把 MARK 反映射回 textNode/ * BLOCK 中斷 run),整段套 panguSpace,把 MARK 反映射回 textNode/
* offset。MARK 對應原文若是單 ASCII space 則 strip 後插 marker * offset。MARK 對應原文若是單 ASCII space 則 strip 後插 marker
@@ -2463,6 +2618,10 @@ autospace.apply(document.body);
* Pass B — 對每個 PILL 透過 STYLE 鏈遞迴找邏輯鄰字(adjacentLogical- * Pass B — 對每個 PILL 透過 STYLE 鏈遞迴找邏輯鄰字(adjacentLogical-
* Char),按 0/1/多+特殊規則處理(0 個或 1 個 ASCII space 插 * Char),按 0/1/多+特殊規則處理(0 個或 1 個 ASCII space 插
* marker;其餘跳過)。 * 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>。 * Marker = <span class="cjk-autospace" aria-hidden="true">(空白)</span>。
* 內含真實 ASCII 空白:layout 視為 whitespace(行末 collapse / 容許 * 內含真實 ASCII 空白:layout 視為 whitespace(行末 collapse / 容許
@@ -2502,6 +2661,21 @@ function createCjkAutospace(options)
const skipAttr = options.skipAttribute || "data-md-key"; const skipAttr = options.skipAttribute || "data-md-key";
const userIsSkipped = typeof options.isSkipped === "function" ? options.isSkipped : null; 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 字符類與規則表 ----- */ /* ----- Pangu 字符類與規則表 ----- */
const PANGU_CJK = "⺀-⻿⼀-⿟" const PANGU_CJK = "⺀-⻿⼀-⿟"
+ "぀-ゟ゠-ヺー-ヿ" + "぀-ゟ゠-ヺー-ヿ"
@@ -2883,6 +3057,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 ----- */ /* ----- 對外 API ----- */
function apply(root) function apply(root)
{ {
@@ -2903,6 +3143,9 @@ function createCjkAutospace(options)
/* Pass B */ /* Pass B */
processPills(root); processPills(root);
/* Pass Copt-in */
if (longWordCfg) { processLongWords(root, longWordCfg); }
} }
return { apply: apply }; return { apply: apply };
@@ -3528,156 +3771,21 @@ if (typeof globalThis !== "undefined" && !globalThis.createCjkAutospace)
}); });
/* ---------- CJK typography shims ---------- /* ---------- CJK typography shims ----------
Two DOM-walking passes that complement (or supplement) the CSS Pass A / B(跨樣式 Pangu autospace + pill 邊界)+ Pass C(長英文
Text 4 rules in runtime.css. Both honor the same skip list so they 段包 `<span lang="en">` 給瀏覽器原生 `hyphens: auto` 用)皆由
never touch monospaced regions, the Markdown twin, editable areas, cjk-autospace lib 提供(build.sh 將 lib 之 cjk-autospace.js 串接
or any container the author has explicitly opted out via the class 至此 IIFE 之前)。Pass C 預設啟用;不需要時於 createCjkAutospace
`.no-cjk-typography`. */ options 加 `longWordWrap: false` 顯式關閉。
const CJK_TYPO_SKIP_TAGS = new Set([ 對應 CSSruntime.css):
"CODE", "KBD", "PRE", "SAMP", "TT", "VAR", [lang|="en"] { hyphens: auto; -webkit-hyphens: auto;
"SCRIPT", "STYLE", "TEXTAREA", "INPUT", hyphenate-limit-chars: 6 3 3; } */
]);
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);
}
/* ---------- Init ---------- */ /* ---------- Init ---------- */
ensureLiveRegion(); ensureLiveRegion();
setupDiagrams(); setupDiagrams();
createCjkAutospace({}).apply(); createCjkAutospace({}).apply();
injectWbrInLongWords();
/* ---------- Mode: report — Behavior ---------- /* ---------- Mode: report — Behavior ----------
Concatenated INTO the same IIFE as runtime.js by build.sh, so Concatenated INTO the same IIFE as runtime.js by build.sh, so
@@ -4518,7 +4626,61 @@ if (typeof globalThis !== "undefined" && !globalThis.createCjkAutospace)
window.addEventListener("beforeprint", update); 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) ---------- */ /* ---------- Init (mode: report) ---------- */
setupToc();
setupCardsets(); setupCardsets();
setupSortables(); setupSortables();
setupDynamicPrintPage(); setupDynamicPrintPage();
+39 -2
View File
@@ -23,7 +23,7 @@
<main> <main>
<p class="lede">Pangu.js v7.2.1 之規則 port,加上「跨樣式標籤透明 + 行內塊邊界」雙 pass 架構。工廠函式設計,由消費端注入 pill 選擇器、樣式 / 段落 / skip 標籤集等配置。輸出 <code>&lt;span class="cjk-autospace" aria-hidden="true"&gt;(空白)&lt;/span&gt;</code>,靠 CSS <code>user-select: none</code> 讓選取與剪貼板跳過。textContent 不變 → Copy 與序列化都乾淨。</p> <p class="lede">Pangu.js v7.2.1 之規則 port,加上「跨樣式標籤透明 + 行內塊邊界」雙 pass 架構,再加 Pass C 之「長英文段 <code>&lt;span lang&gt;</code> 包裝」(給瀏覽器原生 <code>hyphens: auto</code> 用)。工廠函式設計,由消費端注入 pill 選擇器、樣式 / 段落 / skip 標籤集等配置。輸出 <code>&lt;span class="cjk-autospace" aria-hidden="true"&gt;(空白)&lt;/span&gt;</code>,靠 CSS <code>user-select: none</code> 讓選取與剪貼板跳過。textContent 不變 → Copy 與序列化都乾淨。</p>
<h2>Quick start</h2> <h2>Quick start</h2>
@@ -141,6 +141,42 @@ autospace.apply(document.body);
<p>結果:<code>命令&lt;span class="cjk-autospace"&gt; &lt;/span&gt;&lt;code&gt;git&lt;/code&gt;&lt;span class="cjk-autospace"&gt; &lt;/span&gt;更新</code></p> <p>結果:<code>命令&lt;span class="cjk-autospace"&gt; &lt;/span&gt;&lt;code&gt;git&lt;/code&gt;&lt;span class="cjk-autospace"&gt; &lt;/span&gt;更新</code></p>
<h2>Pass C — long English word wrap(預設啟用)</h2>
<p>解 CJK 兩端對齊正文中「行末長英文詞」之斷行問題:原靠 <code>&lt;wbr&gt;</code> 每 N 字元亂插之路徑會產生「斷點位置與音節無關 + 斷處不顯連字符」兩個觀感問題。Pass C 改採 <code>&lt;span lang="en"&gt;</code> 包裝,把斷字決定權交給瀏覽器原生 <code>hyphens: auto</code> 用內建英文字典處理。</p>
<ol>
<li>對 root 內所有 text node 走 TreeWalkerSHOW_TEXT</li>
<li>對每個 node:先 <code>isSkipped</code>(共用 Pass A / B 之 skip 鏈:CODE / KBD / PRE / SAMP / TT / VAR / SCRIPT / STYLE / TEXTAREA / INPUT / SVG / contentEditable / <code>data-md-key</code>+ 冪等檢查(parent 為 <code>&lt;span lang&gt;</code> → skip</li>
<li>對通過篩選之 node:用 <code>[A-Za-z]{minLength,}</code> 收所有匹配,把每個匹配位置之子串裹進 <code>&lt;span lang="en"&gt;</code>(其餘原文保留為純 text node),整個用 fragment 一次性 replaceChild</li>
</ol>
<h3>消費端 CSS(與 lib 解耦但必須配套)</h3>
<pre><code>[lang|="en"] {
hyphens: auto;
-webkit-hyphens: auto;
hyphenate-limit-chars: 6 3 3; /* 詞長 / 前 / 後最少字符 */
hyphenate-character: "-"; /* 強制 U+002D,避免 CJK 字型把 U+2010 渲成全形 */
-webkit-hyphenate-character: "-";
}</code></pre>
<div class="callout warn">
<span class="label">字型陷阱</span> Noto Serif CJK SC 等 CJK serif 字型把 U+2010 HYPHEN 設計為全形 1 em(與漢字同寬),以服務 CJK 範圍表達;而 <code>hyphens: auto</code> 預設正是插 U+2010。直接用會看到全形連字符突兀地接在英文詞末。<code>hyphenate-character: "-"</code> 強制改用 U+002D(同字型內為窄拉丁,與作者鍵入的 <code>-</code> 一致),仍為純視覺渲染、DOM 內不存在。
</div>
<h3>選取 / 複製潔淨性</h3>
<p>連字符純由 CSS 渲染(不存在於 DOM),<code>Selection.toString()</code><code>Cmd+C</code> 返回原文無污染。包裝 span 本身是 inline<code>textContent</code> 視為原樣,序列化 / 複製不受影響。</p>
<h3>不在範圍</h3>
<ul>
<li><strong>CamelCase / 標識符</strong><code>getUserByEmailAddress</code><code>OpenAIWhisperAPI</code>):不在英文字典 → 瀏覽器不斷詞;本實作未開 <code>overflow-wrap: anywhere</code> fallback(會破壞「連字符必須存在」之原則),故識別符 overflow 時整塊保留</li>
<li><strong>URL / 純數字串</strong>:同上,<code>https://example.com/very/long/path</code> 此類超長 token 不被處理</li>
<li><strong>非英文之 ASCII 拉丁變體</strong>café、naïve):正則 <code>[A-Za-z]</code> 不匹配重音字符;如需擴充可自訂 <code>longWordWrap.minLength</code> 之外再 patch lib</li>
</ul>
<h2>空白規則</h2> <h2>空白規則</h2>
<p>所有觸發點(Pangu 命中或 PILL 邊界),對「插入位置之原有空白」的處理一致:</p> <p>所有觸發點(Pangu 命中或 PILL 邊界),對「插入位置之原有空白」的處理一致:</p>
@@ -172,6 +208,7 @@ autospace.apply(document.body);
<tr><td><code>skipTags</code></td><td><code>["CODE", "KBD", "PRE", "SAMP", "TT", "VAR", "SCRIPT", "STYLE", "TEXTAREA", "INPUT"]</code></td><td>整 subtree skip,連祖先 chain 一併判斷</td></tr> <tr><td><code>skipTags</code></td><td><code>["CODE", "KBD", "PRE", "SAMP", "TT", "VAR", "SCRIPT", "STYLE", "TEXTAREA", "INPUT"]</code></td><td>整 subtree skip,連祖先 chain 一併判斷</td></tr>
<tr><td><code>autospaceClass</code></td><td><code>"cjk-autospace"</code></td><td>marker span 之 class 名</td></tr> <tr><td><code>autospaceClass</code></td><td><code>"cjk-autospace"</code></td><td>marker span 之 class 名</td></tr>
<tr><td><code>skipAttribute</code></td><td><code>"data-md-key"</code></td><td>任一祖先帶此屬性 → 整 subtree skip</td></tr> <tr><td><code>skipAttribute</code></td><td><code>"data-md-key"</code></td><td>任一祖先帶此屬性 → 整 subtree skip</td></tr>
<tr><td><code>longWordWrap</code></td><td><code>true</code>(預設啟用)</td><td>Pass C 開關。傳 <code>false</code> 顯式關閉;<code>{ minLength, lang }</code> 自訂粒度與標語;<code>true</code> / 省略等同 <code>{ minLength: 6, lang: "en" }</code></td></tr>
<tr><td><code>isSkipped</code></td><td>(無)</td><td>自訂 callback;先於內建判定執行;回傳 true 即 skip</td></tr> <tr><td><code>isSkipped</code></td><td>(無)</td><td>自訂 callback;先於內建判定執行;回傳 true 即 skip</td></tr>
</tbody> </tbody>
</table> </table>
@@ -273,7 +310,7 @@ obs.observe(document.body, { childList: true, subtree: true, characterData: true
<h2>授權與來源</h2> <h2>授權與來源</h2>
<p>規則表 port 自 <a href="https://github.com/vinta/pangu.js">Pangu.js v7.2.1</a>MIT 授權)。本 lib 之 pass 架構(樣式透明 + 行內塊邊界)為原創設計,解決 Pangu.js 原版「text-node walker 看不到跨元素邊界」之問題。</p> <p>規則表 port 自 <a href="https://github.com/vinta/pangu.js">Pangu.js v7.2.1</a>MIT 授權)。本 lib 之 pass 架構(樣式透明 + 行內塊邊界 + 長英文 lang 包裝)為原創設計,前兩 pass 解決 Pangu.js 原版「text-node walker 看不到跨元素邊界」之問題;Pass C 把 CJK 兩端對齊正文中之斷英文詞責任交給瀏覽器原生 <code>hyphens: auto</code></p>
</main> </main>