Pangu.js v7.2.1 之規則 port,加上「跨樣式標籤透明 + 行內塊邊界」雙 pass 架構,再加 Pass C 之「長英文段 <span lang> 包裝」(給瀏覽器原生 hyphens: auto 用)。工廠函式設計,由消費端注入 pill 選擇器、樣式 / 段落 / skip 標籤集等配置。輸出 <span class="cjk-autospace" aria-hidden="true">(空白)</span>,靠 CSS user-select: none 讓選取與剪貼板跳過。textContent 不變 → Copy 與序列化都乾淨。
Quick start
// 1) 載入 shim(以下任一)
import { createCjkAutospace } from "./cjk-autospace.js"; // ESM / TS
// 或 inline <script> / require / globalThis.createCjkAutospace
// 2) 配置(pill 選擇器是唯一需要消費端自訂的;其餘有合理預設)
const autospace = createCjkAutospace({
pillSelector: "code, kbd, .your-pill-class",
});
// 3) 套用
autospace.apply(document.body);
套用後 DOM 內每個 Pangu 認定之邊界都被加上 .cjk-autospace marker。在已套用之 DOM 上重跑為冪等。
必備 CSS
.cjk-autospace {
-webkit-user-select: none;
user-select: none;
/* 視覺寬度由消費端決定,例如 0.25em */
}
標籤三分類
| 類別 | 典型成員 | shim 對待 |
|---|---|---|
| STYLE_INLINES | strong / em / a / b / i / u / s / mark / ins / del / sub / sup / small / abbr / cite / dfn / q / span / time / ruby / wbr / … |
透明。shim 在 Pass A 走 DFS 時穿過去,把內外文本當成連續文本判斷 Pangu 邊界。 |
| PILL | 由 pillSelector 指定,預設 code, kbd |
opaque 行內塊。Pass A 視為 run 邊界;Pass B 處理其與鄰字之介面。 |
| BLOCK | p / div / h1-h6 / li / pre / table / tr / td / br / hr / … |
段落級。Pass A 走到此即中斷 run;子 block 各自一次 processBlockText 呼叫。 |
<strong>是</strong> Neovim 這類跨元素邊界,把「是」(CJK)+ 空格 + 「N」(ANS)視為 Pangu 觸發位置;而非僅在單一 text-node 內找匹配。
Pass A — per-block Pangu on tag-stripped text
對 root 之每個 BLOCK 後代執行一次。流程:
- DFS 走 block 內部子節點:
- 遇 text node:把每個 char 累積到
curText,同步把{ textNode, offset }推入curMap - 遇 STYLE_INLINES:透明遞迴,curText 繼續累積
- 遇 PILL / 子 BLOCK / 既有
.cjk-autospace:flushRun(),把累積之 (text, map) 作為一個 run 推入 runs,重置
- 遇 text node:把每個 char 累積到
- 對每個 run,套
panguSpace(text),得到spaced字串(含 MARK PUA 字符 U+E007) - 平行掃描
spaced與text,把 MARK 對應到原文之插入位置:- 若
text[origIdx]是 ASCII space → MARK 替換了該空格。動作:strip 該空格 + 在前一字後插 marker - 否則 → MARK 純插入。動作:在前一字後插 marker
- 若
- actions 倒序執行:保證前面動作之 textNode offset 不被後面動作(splitText / data slicing)影響
例
<p>本資料夾<strong>不是</strong> Neovim 使用者目錄。</p>
DFS 收集到的 curText(樣式標籤剝離後):"本資料夾不是 Neovim 使用者目錄。"
panguSpace 應用:是 N 命中 STRIP_CJK_SPACE_ANS(CJK + 空格 + ANS)→ 「是MARKN」(空格被 MARK 取代);m 使 命中 STRIP_ANS_SPACE_CJK → 同理。
反映射:
- 位置 1:原文「是」與「 」之間,「 」被 strip → marker 插在「是」後(DOM 上是
<strong>之 nextSibling 處) - 位置 2:原文「m」與「 」之間,「 」被 strip → marker 插在「m」後(同一 text node 內,splitText 後插入)
最終 DOM(簡化):本資料夾<strong>不是</strong><span class="cjk-autospace"> </span>Neovim<span class="cjk-autospace"> </span>使用者目錄。
Pass B — pill boundary, style-inline aware
對 root 內每個匹配 pillSelector 之元素執行:
adjacentLogicalChar(pill, -1):走 prev sibling 鏈,遇 text 取末字元;遇 STYLE_INLINES 遞迴進入找其最末 text;遇 PILL / BLOCK / 既有 marker / skip ancestor → null- 若回傳邏輯鄰字是 CJK / 字母數字 → 在 pill 前插 marker
- 若邏輯鄰字是 1 個 ASCII space 且該 space 另一側(同 text node 內)是內容字 → strip 該 space + 插 marker
- 對
+1方向(pill 後)做對稱處理
例
命令 <code>git</code> 更新
- Pass A run 因 pill 中斷:「命令 」(trailing space 為 run 末尾,不觸發 panguSpace 之 STRIP rule,因 rule 要求兩側都是內容字而 run 末尾沒有右側字)
- Pass B 處理 pill 之 prev:text 末字元是
' ',前一字元是'令'(CJK),命中 strip 規則 → 移除空格 + 在 pill 前插 marker - Pass B 處理 pill 之 next:text 首字元是
' ',後一字元是'更'(CJK),同理 strip + 插 marker
結果:命令<span class="cjk-autospace"> </span><code>git</code><span class="cjk-autospace"> </span>更新
Pass C — long English word wrap(預設啟用)
解 CJK 兩端對齊正文中「行末長英文詞」之斷行問題:原靠 <wbr> 每 N 字元亂插之路徑會產生「斷點位置與音節無關 + 斷處不顯連字符」兩個觀感問題。Pass C 改採 <span lang="en"> 包裝,把斷字決定權交給瀏覽器原生 hyphens: auto 用內建英文字典處理。
- 對 root 內所有 text node 走 TreeWalker(SHOW_TEXT)
- 對每個 node:先
isSkipped(共用 Pass A / B 之 skip 鏈:CODE / KBD / PRE / SAMP / TT / VAR / SCRIPT / STYLE / TEXTAREA / INPUT / SVG / contentEditable /data-md-key)+ 冪等檢查(parent 為<span lang>→ skip) - 對通過篩選之 node:用
[A-Za-z]{minLength,}收所有匹配,把每個匹配位置之子串裹進<span lang="en">(其餘原文保留為純 text node),整個用 fragment 一次性 replaceChild
消費端 CSS(與 lib 解耦但必須配套)
[lang|="en"] {
hyphens: auto;
-webkit-hyphens: auto;
hyphenate-limit-chars: 6 3 3; /* 詞長 / 前 / 後最少字符 */
hyphenate-character: "-"; /* 強制 U+002D,避免 CJK 字型把 U+2010 渲成全形 */
-webkit-hyphenate-character: "-";
}
hyphens: auto 預設正是插 U+2010。直接用會看到全形連字符突兀地接在英文詞末。hyphenate-character: "-" 強制改用 U+002D(同字型內為窄拉丁,與作者鍵入的 - 一致),仍為純視覺渲染、DOM 內不存在。
選取 / 複製潔淨性
連字符純由 CSS 渲染(不存在於 DOM),Selection.toString() 與 Cmd+C 返回原文無污染。包裝 span 本身是 inline,textContent 視為原樣,序列化 / 複製不受影響。
不在範圍
- CamelCase / 標識符(
getUserByEmailAddress、OpenAIWhisperAPI):不在英文字典 → 瀏覽器不斷詞;本實作未開overflow-wrap: anywherefallback(會破壞「連字符必須存在」之原則),故識別符 overflow 時整塊保留 - URL / 純數字串:同上,
https://example.com/very/long/path此類超長 token 不被處理 - 非英文之 ASCII 拉丁變體(café、naïve):正則
[A-Za-z]不匹配重音字符;如需擴充可自訂longWordWrap.minLength之外再 patch lib
空白規則
所有觸發點(Pangu 命中或 PILL 邊界),對「插入位置之原有空白」的處理一致:
| 原有空白 | 動作 |
|---|---|
| 0 個 | 純插 marker |
| 1 個 ASCII space | strip 該 space + 插 marker(取代) |
| 2 個或更多 space | 跳過(視為刻意 structural whitespace) |
| tab / newline / 其他空白 | 跳過(panguSpace 之 regex 只匹配 single ASCII space) |
| 標點隔開 | 跳過(panguSpace 命中要求兩端是內容字) |
既有 .cjk-autospace marker | 跳過(避免雙重插入;冪等性) |
配置選項
| 選項 | 預設 | 說明 |
|---|---|---|
pillSelector | "code, kbd" | CSS 選擇器;行內塊範圍。常見擴充:code, kbd, .cjk-pill(按 class opt-in)或 code, kbd, mark, samp(更廣) |
styleInlines | 樣式標籤預設集 | tagName 大寫之陣列;自訂可改寫 |
blockTags | 段落標籤預設集 | tagName 大寫之陣列 |
skipTags | ["CODE", "KBD", "PRE", "SAMP", "TT", "VAR", "SCRIPT", "STYLE", "TEXTAREA", "INPUT"] | 整 subtree skip,連祖先 chain 一併判斷 |
autospaceClass | "cjk-autospace" | marker span 之 class 名 |
skipAttribute | "data-md-key" | 任一祖先帶此屬性 → 整 subtree skip |
longWordWrap | true(預設啟用) | Pass C 開關。傳 false 顯式關閉;{ minLength, lang } 自訂粒度與標語;true / 省略等同 { minLength: 6, lang: "en" } |
isSkipped | (無) | 自訂 callback;先於內建判定執行;回傳 true 即 skip |
Marker span 解剖
<span class="cjk-autospace" aria-hidden="true"> </span>
class="cjk-autospace"- 由消費端之 CSS 添加
user-select: none(選取時跳過);視覺寬度也由消費端決定(典型 0.25em)。 aria-hidden="true"- 讓 screen reader 忽略,避免「空格、空格、空格」朗讀干擾。
- 內含真實 ASCII space
- 不是 0 寬 span — 真實字符。layout 視為一般 whitespace:行末可 collapse、容許在此換行、justify 可拉伸該空白。
對比方案(inline-block + fixed em width)破壞 line-end 行為與 justify 之分配;不採用。 - textContent 不變
- 從 DOM serialization 或選取與複製角度看,marker 是「不可見」之 span;user-select: none 讓 selection range 跳過該 span;故剪貼板 / Copy MD 之輸出無空白污染。
Pangu 規則簡表
panguSpace 內部依序套 30+ 條 regex。簡列各觸發類別:
| 類別 | 例 | 動作 |
|---|---|---|
| Pre-pass strip | 2023 年 → 2023年 | strip CJK ↔ ANS 之間單 ASCII space,後續 pass 把它當 flush 寫法處理 |
| CJK + Latin / 數字 | 使用 GPT | 插 MARK |
| CJK + ASCII 標點 | 是,沒錯 | 標點後插 MARK |
| CJK + 引號 / 括號 | 是(實驗) | 插 MARK |
| CJK + 運算子 + ANS | 是+1 | 運算子兩側各插 MARK |
| compound word 保護 | GPT-4 / state-of-the-art | 以 PUA 占位替換,避免運算子 pass wedge 入空白;bracket / CJK_ANS pass 前還原 |
整合範例
HTML build script 串接
# build.sh 把 lib 串到 runtime.js 之前
{
printf '<script>\n'
cat "$LIB/cjk-autospace.js" # 工廠函式
printf '\n'
sed '$d' "$RUNTIME/runtime.js" # 應用程式 runtime
cat "$MODE_BEHAVIOR_JS"
printf '\n})();\n'
printf '</script>\n'
}
由 runtime.js 之 init 呼叫:createCjkAutospace({}).apply();。
Lua 內嵌 JS 字串
-- 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 = "<script>" .. lib .. "\n" .. entry .. "</script>"
inject_into_html(script)
TypeScript / Vite
// 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 });
邊界情境與限制
- SVG namespace:jdgement
p.namespaceURI === 'http://www.w3.org/2000/svg'整 subtree skip。SVG<text>不會被處理。 - contentEditable:用戶輸入區整 subtree skip,避免影響游標 / 選取。
- 已存在 marker:Pass A 走 DFS 時,
.cjk-autospace中斷 run;Pass BadjacentLogicalChar遇 marker 即停。故重複呼叫apply()不會雙倍插入。 - 動態 DOM:shim 自身不訂閱 mutation;增量更新由消費端用
MutationObserver包裝(範例見上)。建議處理時暫斷 observer 避免自反饋。 - 跨 BLOCK 邊界:Pangu 規則不跨段落觸發。
<p>是</p><p>Neovim</p>不會插 marker(兩段獨立)。 - Compound word 保護限制:僅匹配
[A-Za-z0-9]*[a-z][A-Za-z0-9]*-[A-Za-z0-9]+等模式;無 dash 之純大寫縮寫(HTML、API)不被特殊保護,但本來就沒問題(不會被運算子規則 wedge)。
授權與來源
規則表 port 自 Pangu.js v7.2.1(MIT 授權)。本 lib 之三 pass 架構(樣式透明 + 行內塊邊界 + 長英文 lang 包裝)為原創設計,前兩 pass 解決 Pangu.js 原版「text-node walker 看不到跨元素邊界」之問題;Pass C 把 CJK 兩端對齊正文中之斷英文詞責任交給瀏覽器原生 hyphens: auto。