docs: 詳細 HTML 文檔(API、雙 pass、配置、整合範例)

This commit is contained in:
2026-05-22 22:44:49 +08:00
commit 3e5e45fb89
2 changed files with 4850 additions and 0 deletions
+4531
View File
File diff suppressed because it is too large Load Diff
+319
View File
@@ -0,0 +1,319 @@
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>cjk-autospace — 詳細文檔</title>
<meta name="generated-at" content="2026-05-22">
<meta name="source" content="cjk-autospace docs branch">
<!-- RUNTIME_CSS -->
</head>
<body>
<header class="page">
<div class="meta">
<h1>cjk-autospace</h1>
<p class="subtitle">CJK ↔ Latin 邊界自動間距 shim — 工廠模式、跨樣式透明、行內塊邊界、Pangu.js v7.2.1 規則 port</p>
<p class="timestamp"><time>2026-05-22</time> · <span>docs branch</span></p>
</div>
<div class="actions">
<!-- ACTIONS -->
</div>
</header>
<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>
<h2>Quick start</h2>
<pre><code>// 1) 載入 shim(以下任一)
import { createCjkAutospace } from "./cjk-autospace.js"; // ESM / TS
// 或 inline &lt;script&gt; / require / globalThis.createCjkAutospace
// 2) 配置(pill 選擇器是唯一需要消費端自訂的;其餘有合理預設)
const autospace = createCjkAutospace({
pillSelector: "code, kbd, .your-pill-class",
});
// 3) 套用
autospace.apply(document.body);
</code></pre>
<p>套用後 DOM 內每個 Pangu 認定之邊界都被加上 <code>.cjk-autospace</code> marker。在已套用之 DOM 上重跑為冪等。</p>
<h3>必備 CSS</h3>
<pre><code>.cjk-autospace {
-webkit-user-select: none;
user-select: none;
/* 視覺寬度由消費端決定,例如 0.25em */
}</code></pre>
<h2>標籤三分類</h2>
<table>
<thead>
<tr><th>類別</th><th>典型成員</th><th>shim 對待</th></tr>
</thead>
<tbody>
<tr>
<td><strong>STYLE_INLINES</strong></td>
<td><code>strong</code> / <code>em</code> / <code>a</code> / <code>b</code> / <code>i</code> / <code>u</code> / <code>s</code> / <code>mark</code> / <code>ins</code> / <code>del</code> / <code>sub</code> / <code>sup</code> / <code>small</code> / <code>abbr</code> / <code>cite</code> / <code>dfn</code> / <code>q</code> / <code>span</code> / <code>time</code> / <code>ruby</code> / <code>wbr</code> / …</td>
<td><strong>透明</strong>。shim 在 Pass A 走 DFS 時穿過去,把內外文本當成連續文本判斷 Pangu 邊界。</td>
</tr>
<tr>
<td><strong>PILL</strong></td>
<td><code>pillSelector</code> 指定,預設 <code>code, kbd</code></td>
<td><strong>opaque 行內塊</strong>。Pass A 視為 run 邊界;Pass B 處理其與鄰字之介面。</td>
</tr>
<tr>
<td><strong>BLOCK</strong></td>
<td><code>p</code> / <code>div</code> / <code>h1</code>-<code>h6</code> / <code>li</code> / <code>pre</code> / <code>table</code> / <code>tr</code> / <code>td</code> / <code>br</code> / <code>hr</code> / …</td>
<td><strong>段落級</strong>。Pass A 走到此即中斷 run;子 block 各自一次 <code>processBlockText</code> 呼叫。</td>
</tr>
</tbody>
</table>
<div class="callout info">
<span class="label">關鍵設計</span> 樣式標籤透明 → shim 看穿 <code>&lt;strong&gt;&lt;/strong&gt; Neovim</code> 這類跨元素邊界,把「是」(CJK)+ 空格 + 「N」(ANS)視為 Pangu 觸發位置;而非僅在單一 text-node 內找匹配。
</div>
<h2>Pass A — per-block Pangu on tag-stripped text</h2>
<p>對 root 之每個 BLOCK 後代執行一次。流程:</p>
<ol>
<li>DFS 走 block 內部子節點:
<ul>
<li>遇 text node:把每個 char 累積到 <code>curText</code>,同步把 <code>{ textNode, offset }</code> 推入 <code>curMap</code></li>
<li>遇 STYLE_INLINES:透明遞迴,curText 繼續累積</li>
<li>遇 PILL / 子 BLOCK / 既有 <code>.cjk-autospace</code><code>flushRun()</code>,把累積之 (text, map) 作為一個 run 推入 runs,重置</li>
</ul>
</li>
<li>對每個 run,套 <code>panguSpace(text)</code>,得到 <code>spaced</code> 字串(含 MARK PUA 字符 U+E007</li>
<li>平行掃描 <code>spaced</code><code>text</code>,把 MARK 對應到原文之插入位置:
<ul>
<li><code>text[origIdx]</code> 是 ASCII space → MARK 替換了該空格。動作:strip 該空格 + 在前一字後插 marker</li>
<li>否則 → MARK 純插入。動作:在前一字後插 marker</li>
</ul>
</li>
<li>actions 倒序執行:保證前面動作之 textNode offset 不被後面動作(splitText / data slicing)影響</li>
</ol>
<h3></h3>
<p><code>&lt;p&gt;本資料夾&lt;strong&gt;不是&lt;/strong&gt; Neovim 使用者目錄。&lt;/p&gt;</code></p>
<p>DFS 收集到的 <code>curText</code>(樣式標籤剝離後):<code>"本資料夾不是 Neovim 使用者目錄。"</code></p>
<p><code>panguSpace</code> 應用:<code>是 N</code> 命中 <code>STRIP_CJK_SPACE_ANS</code>CJK + 空格 + ANS)→ 「<code><sub>MARK</sub>N</code>」(空格被 MARK 取代);<code>m 使</code> 命中 <code>STRIP_ANS_SPACE_CJK</code> → 同理。</p>
<p>反映射:</p>
<ul>
<li>位置 1:原文「是」與「 」之間,「 」被 strip → marker 插在「是」後(DOM 上是 <code>&lt;strong&gt;</code> 之 nextSibling 處)</li>
<li>位置 2:原文「m」與「 」之間,「 」被 strip → marker 插在「m」後(同一 text node 內,splitText 後插入)</li>
</ul>
<p>最終 DOM(簡化):<code>本資料夾&lt;strong&gt;不是&lt;/strong&gt;&lt;span class="cjk-autospace"&gt; &lt;/span&gt;Neovim&lt;span class="cjk-autospace"&gt; &lt;/span&gt;使用者目錄。</code></p>
<h2>Pass B — pill boundary, style-inline aware</h2>
<p>對 root 內每個匹配 <code>pillSelector</code> 之元素執行:</p>
<ol>
<li><code>adjacentLogicalChar(pill, -1)</code>:走 prev sibling 鏈,遇 text 取末字元;遇 STYLE_INLINES 遞迴進入找其最末 text;遇 PILL / BLOCK / 既有 marker / skip ancestor → null</li>
<li>若回傳邏輯鄰字是 CJK / 字母數字 → 在 pill 前插 marker</li>
<li>若邏輯鄰字是 1 個 ASCII space 且該 space 另一側(同 text node 內)是內容字 → strip 該 space + 插 marker</li>
<li><code>+1</code> 方向(pill 後)做對稱處理</li>
</ol>
<h3></h3>
<p><code>命令 &lt;code&gt;git&lt;/code&gt; 更新</code></p>
<ul>
<li>Pass A run 因 pill 中斷:「命令 」(trailing space 為 run 末尾,不觸發 panguSpace 之 STRIP rule,因 rule 要求兩側都是內容字而 run 末尾沒有右側字)</li>
<li>Pass B 處理 pill 之 prevtext 末字元是 <code>' '</code>,前一字元是 <code>'令'</code>(CJK),命中 strip 規則 → 移除空格 + 在 pill 前插 marker</li>
<li>Pass B 處理 pill 之 nexttext 首字元是 <code>' '</code>,後一字元是 <code>'更'</code>CJK),同理 strip + 插 marker</li>
</ul>
<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>空白規則</h2>
<p>所有觸發點(Pangu 命中或 PILL 邊界),對「插入位置之原有空白」的處理一致:</p>
<table>
<thead>
<tr><th>原有空白</th><th>動作</th></tr>
</thead>
<tbody>
<tr><td>0 個</td><td>純插 marker</td></tr>
<tr><td>1 個 ASCII space</td><td>strip 該 space + 插 marker(取代)</td></tr>
<tr><td>2 個或更多 space</td><td>跳過(視為刻意 structural whitespace</td></tr>
<tr><td>tab / newline / 其他空白</td><td>跳過(panguSpace 之 regex 只匹配 single ASCII space</td></tr>
<tr><td>標點隔開</td><td>跳過(panguSpace 命中要求兩端是內容字)</td></tr>
<tr><td>既有 <code>.cjk-autospace</code> marker</td><td>跳過(避免雙重插入;冪等性)</td></tr>
</tbody>
</table>
<h2>配置選項</h2>
<table>
<thead>
<tr><th>選項</th><th>預設</th><th>說明</th></tr>
</thead>
<tbody>
<tr><td><code>pillSelector</code></td><td><code>"code, kbd"</code></td><td>CSS 選擇器;行內塊範圍。常見擴充:<code>code, kbd, .cjk-pill</code>(按 class opt-in)或 <code>code, kbd, mark, samp</code>(更廣)</td></tr>
<tr><td><code>styleInlines</code></td><td>樣式標籤預設集</td><td>tagName 大寫之陣列;自訂可改寫</td></tr>
<tr><td><code>blockTags</code></td><td>段落標籤預設集</td><td>tagName 大寫之陣列</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>skipAttribute</code></td><td><code>"data-md-key"</code></td><td>任一祖先帶此屬性 → 整 subtree skip</td></tr>
<tr><td><code>isSkipped</code></td><td>(無)</td><td>自訂 callback;先於內建判定執行;回傳 true 即 skip</td></tr>
</tbody>
</table>
<h2>Marker span 解剖</h2>
<pre><code>&lt;span class="cjk-autospace" aria-hidden="true"&gt; &lt;/span&gt;</code></pre>
<dl>
<dt><code>class="cjk-autospace"</code></dt>
<dd>由消費端之 CSS 添加 <code>user-select: none</code>(選取時跳過);視覺寬度也由消費端決定(典型 0.25em)。</dd>
<dt><code>aria-hidden="true"</code></dt>
<dd>讓 screen reader 忽略,避免「空格、空格、空格」朗讀干擾。</dd>
<dt>內含真實 ASCII space</dt>
<dd>不是 0 寬 span — 真實字符。layout 視為一般 whitespace:行末可 collapse、容許在此換行、justify 可拉伸該空白。<br>對比方案(inline-block + fixed em width)破壞 line-end 行為與 justify 之分配;不採用。</dd>
<dt>textContent 不變</dt>
<dd>從 DOM serialization 或選取與複製角度看,marker 是「不可見」之 spanuser-select: none 讓 selection range 跳過該 span;故剪貼板 / Copy MD 之輸出無空白污染。</dd>
</dl>
<h2>Pangu 規則簡表</h2>
<p>panguSpace 內部依序套 30+ 條 regex。簡列各觸發類別:</p>
<table>
<thead>
<tr><th>類別</th><th></th><th>動作</th></tr>
</thead>
<tbody>
<tr><td>Pre-pass strip</td><td><code>2023 年</code><code>2023年</code></td><td>strip CJK ↔ ANS 之間單 ASCII space,後續 pass 把它當 flush 寫法處理</td></tr>
<tr><td>CJK + Latin / 數字</td><td><code>使用 GPT</code></td><td>插 MARK</td></tr>
<tr><td>CJK + ASCII 標點</td><td><code>是,沒錯</code></td><td>標點後插 MARK</td></tr>
<tr><td>CJK + 引號 / 括號</td><td><code>是(實驗)</code></td><td>插 MARK</td></tr>
<tr><td>CJK + 運算子 + ANS</td><td><code>是+1</code></td><td>運算子兩側各插 MARK</td></tr>
<tr><td>compound word 保護</td><td><code>GPT-4</code> / <code>state-of-the-art</code></td><td>以 PUA 占位替換,避免運算子 pass wedge 入空白;bracket / CJK_ANS pass 前還原</td></tr>
</tbody>
</table>
<h2>整合範例</h2>
<h3>HTML build script 串接</h3>
<pre><code># build.sh 把 lib 串到 runtime.js 之前
{
printf '&lt;script&gt;\n'
cat "$LIB/cjk-autospace.js" # 工廠函式
printf '\n'
sed '$d' "$RUNTIME/runtime.js" # 應用程式 runtime
cat "$MODE_BEHAVIOR_JS"
printf '\n})();\n'
printf '&lt;/script&gt;\n'
}</code></pre>
<p>由 runtime.js 之 init 呼叫:<code>createCjkAutospace({}).apply();</code></p>
<h3>Lua 內嵌 JS 字串</h3>
<pre><code>-- local.lua build hook 讀 lib 內容,sprintf 注入 selector 配置
local lib = read_file("lua/user/lib/cjk-autospace/cjk-autospace.js")
local entry = string.format(
"createCjkAutospace({ pillSelector: %q }).apply(document.getElementById('peek-markdown-body'));",
"code, kbd, mark, samp"
)
local script = "&lt;script&gt;" .. lib .. "\n" .. entry .. "&lt;/script&gt;"
inject_into_html(script)</code></pre>
<h3>TypeScript / Vite</h3>
<pre><code>// cjkAutospace.ts
import { createCjkAutospace } from "./lib/cjk-autospace/cjk-autospace.js";
const autospace = createCjkAutospace({
pillSelector: "code, kbd, .cjk-pill",
});
let pending = false;
const obs = new MutationObserver(() => {
if (pending) return;
pending = true;
queueMicrotask(() => {
pending = false;
obs.disconnect();
try { autospace.apply(); }
finally { obs.observe(document.body, { childList: true, subtree: true, characterData: true }); }
});
});
autospace.apply();
obs.observe(document.body, { childList: true, subtree: true, characterData: true });</code></pre>
<h2>邊界情境與限制</h2>
<ul>
<li><strong>SVG namespace</strong>jdgement <code>p.namespaceURI === 'http://www.w3.org/2000/svg'</code> 整 subtree skip。SVG <code>&lt;text&gt;</code> 不會被處理。</li>
<li><strong>contentEditable</strong>:用戶輸入區整 subtree skip,避免影響游標 / 選取。</li>
<li><strong>已存在 marker</strong>Pass A 走 DFS 時,<code>.cjk-autospace</code> 中斷 runPass B <code>adjacentLogicalChar</code> 遇 marker 即停。故重複呼叫 <code>apply()</code> 不會雙倍插入。</li>
<li><strong>動態 DOM</strong>shim 自身不訂閱 mutation;增量更新由消費端用 <code>MutationObserver</code> 包裝(範例見上)。建議處理時暫斷 observer 避免自反饋。</li>
<li><strong>跨 BLOCK 邊界</strong>Pangu 規則不跨段落觸發。<code>&lt;p&gt;&lt;/p&gt;&lt;p&gt;Neovim&lt;/p&gt;</code> 不會插 marker(兩段獨立)。</li>
<li><strong>Compound word 保護限制</strong>:僅匹配 <code>[A-Za-z0-9]*[a-z][A-Za-z0-9]*-[A-Za-z0-9]+</code> 等模式;無 dash 之純大寫縮寫(<code>HTML</code><code>API</code>)不被特殊保護,但本來就沒問題(不會被運算子規則 wedge)。</li>
</ul>
<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>
</main>
<footer class="page">
<p>cjk-autospace · docs branch · 2026-05-22</p>
</footer>
<!-- RUNTIME_SPRITE -->
<script type="text/markdown" id="md-source">
# cjk-autospace
CJK Latin 邊界自動間距 shim工廠模式由消費端注入配置
## Quick start
```js
import { createCjkAutospace } from "./cjk-autospace.js";
const autospace = createCjkAutospace({
pillSelector: "code, kbd, .cjk-pill",
});
autospace.apply(document.body);
```
## 標籤三分類
- STYLE_INLINES 樣式標籤shim 透明走過
- PILL 行內塊邊界由 Pass B 處理
- BLOCK 段落級Pass A 把它們當作 run 邊界
## Pass A
對每個 BLOCK DFS 收集邏輯文本樣式透明PILL / BLOCK 中斷 run整段套 panguSpace反映射 MARK textNode / offset
## Pass B
對每個 PILL 透過樣式鏈遞迴找邏輯鄰字 0/1/+特殊規則處理0 個或 1 ASCII space marker其餘跳過
</script>
<!-- RUNTIME_JS -->
</body>
</html>