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();
+34
View File
@@ -23,6 +23,33 @@
<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>
@@ -60,6 +87,13 @@
<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 規則表
+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>
<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,7 +2250,7 @@ main > *:nth-child(n+6) { animation-delay: 0.46s; }
<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>
@@ -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>
<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>
<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>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>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>
</tbody>
</table>
@@ -2382,7 +2537,7 @@ obs.observe(document.body, { childList: true, subtree: true, characterData: true
<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>
@@ -2455,7 +2610,7 @@ autospace.apply(document.body);
* 處理。預設 '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
@@ -2463,6 +2618,10 @@ autospace.apply(document.body);
* 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 / 容許
@@ -2502,6 +2661,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 = "⺀-⻿⼀-⿟"
+ "぀-ゟ゠-ヺー-ヿ"
@@ -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 ----- */
function apply(root)
{
@@ -2903,6 +3143,9 @@ function createCjkAutospace(options)
/* Pass B */
processPills(root);
/* Pass Copt-in */
if (longWordCfg) { processLongWords(root, longWordCfg); }
}
return { apply: apply };
@@ -3528,156 +3771,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
@@ -4518,7 +4626,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();
+39 -2
View File
@@ -23,7 +23,7 @@
<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>
@@ -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>
<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>
<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>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>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>
</tbody>
</table>
@@ -273,7 +310,7 @@ obs.observe(document.body, { childList: true, subtree: true, characterData: true
<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>