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:
+304
-145
@@ -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><wbr></code> 每 6 字元亂插斷詞機會,產生兩個觀感問題:(1)位置與音節邊界無關,斷在怪位;(2)<code><wbr></code> 為 zero-width,斷處不顯連字符,讀者無法辨認「上行末是被斷開的詞」。</p>
|
||||
|
||||
<p>Pass C 走另一條路:把 <code>[A-Za-z]{N,}</code>(預設 <code>N=6</code>)之長英文段包進 <code><span lang="en"></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><span lang></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 或作者標
|
||||
語的英文段都會被 catch;e.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 C(opt-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);
|
||||
}
|
||||
對應 CSS(runtime.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();
|
||||
|
||||
@@ -23,6 +23,33 @@
|
||||
|
||||
<main>
|
||||
|
||||
<h2>2026-05-26 — Pass C:長英文段 lang 包裝(預設啟用)</h2>
|
||||
|
||||
<p>解決 CJK 兩端對齊正文中行末長英文詞之斷行問題。原模板於 runtime 端用 <code><wbr></code> 每 6 字元亂插斷詞機會,產生兩個觀感問題:(1)位置與音節邊界無關,斷在怪位;(2)<code><wbr></code> 為 zero-width,斷處不顯連字符,讀者無法辨認「上行末是被斷開的詞」。</p>
|
||||
|
||||
<p>Pass C 走另一條路:把 <code>[A-Za-z]{N,}</code>(預設 <code>N=6</code>)之長英文段包進 <code><span lang="en"></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><span lang></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
@@ -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><span class="cjk-autospace" aria-hidden="true">(空白)</span></code>,靠 CSS <code>user-select: none</code> 讓選取與剪貼板跳過。textContent 不變 → Copy 與序列化都乾淨。</p>
|
||||
<p class="lede">Pangu.js v7.2.1 之規則 port,加上「跨樣式標籤透明 + 行內塊邊界」雙 pass 架構,再加 Pass C 之「長英文段 <code><span lang></code> 包裝」(給瀏覽器原生 <code>hyphens: auto</code> 用)。工廠函式設計,由消費端注入 pill 選擇器、樣式 / 段落 / skip 標籤集等配置。輸出 <code><span class="cjk-autospace" aria-hidden="true">(空白)</span></code>,靠 CSS <code>user-select: none</code> 讓選取與剪貼板跳過。textContent 不變 → Copy 與序列化都乾淨。</p>
|
||||
|
||||
<h2>Quick start</h2>
|
||||
|
||||
@@ -2250,6 +2368,42 @@ autospace.apply(document.body);
|
||||
|
||||
<p>結果:<code>命令<span class="cjk-autospace"> </span><code>git</code><span class="cjk-autospace"> </span>更新</code></p>
|
||||
|
||||
<h2>Pass C — long English word wrap(預設啟用)</h2>
|
||||
|
||||
<p>解 CJK 兩端對齊正文中「行末長英文詞」之斷行問題:原靠 <code><wbr></code> 每 N 字元亂插之路徑會產生「斷點位置與音節無關 + 斷處不顯連字符」兩個觀感問題。Pass C 改採 <code><span lang="en"></code> 包裝,把斷字決定權交給瀏覽器原生 <code>hyphens: auto</code> 用內建英文字典處理。</p>
|
||||
|
||||
<ol>
|
||||
<li>對 root 內所有 text node 走 TreeWalker(SHOW_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><span lang></code> → skip)</li>
|
||||
<li>對通過篩選之 node:用 <code>[A-Za-z]{minLength,}</code> 收所有匹配,把每個匹配位置之子串裹進 <code><span lang="en"></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 或作者標
|
||||
語的英文段都會被 catch;e.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 C(opt-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);
|
||||
}
|
||||
對應 CSS(runtime.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
@@ -23,7 +23,7 @@
|
||||
|
||||
<main>
|
||||
|
||||
<p class="lede">Pangu.js v7.2.1 之規則 port,加上「跨樣式標籤透明 + 行內塊邊界」雙 pass 架構。工廠函式設計,由消費端注入 pill 選擇器、樣式 / 段落 / skip 標籤集等配置。輸出 <code><span class="cjk-autospace" aria-hidden="true">(空白)</span></code>,靠 CSS <code>user-select: none</code> 讓選取與剪貼板跳過。textContent 不變 → Copy 與序列化都乾淨。</p>
|
||||
<p class="lede">Pangu.js v7.2.1 之規則 port,加上「跨樣式標籤透明 + 行內塊邊界」雙 pass 架構,再加 Pass C 之「長英文段 <code><span lang></code> 包裝」(給瀏覽器原生 <code>hyphens: auto</code> 用)。工廠函式設計,由消費端注入 pill 選擇器、樣式 / 段落 / skip 標籤集等配置。輸出 <code><span class="cjk-autospace" aria-hidden="true">(空白)</span></code>,靠 CSS <code>user-select: none</code> 讓選取與剪貼板跳過。textContent 不變 → Copy 與序列化都乾淨。</p>
|
||||
|
||||
<h2>Quick start</h2>
|
||||
|
||||
@@ -141,6 +141,42 @@ autospace.apply(document.body);
|
||||
|
||||
<p>結果:<code>命令<span class="cjk-autospace"> </span><code>git</code><span class="cjk-autospace"> </span>更新</code></p>
|
||||
|
||||
<h2>Pass C — long English word wrap(預設啟用)</h2>
|
||||
|
||||
<p>解 CJK 兩端對齊正文中「行末長英文詞」之斷行問題:原靠 <code><wbr></code> 每 N 字元亂插之路徑會產生「斷點位置與音節無關 + 斷處不顯連字符」兩個觀感問題。Pass C 改採 <code><span lang="en"></code> 包裝,把斷字決定權交給瀏覽器原生 <code>hyphens: auto</code> 用內建英文字典處理。</p>
|
||||
|
||||
<ol>
|
||||
<li>對 root 內所有 text node 走 TreeWalker(SHOW_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><span lang></code> → skip)</li>
|
||||
<li>對通過篩選之 node:用 <code>[A-Za-z]{minLength,}</code> 收所有匹配,把每個匹配位置之子串裹進 <code><span lang="en"></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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user