cjk-autospace

CJK ↔ Latin 邊界自動間距 shim — 工廠模式、跨樣式透明、行內塊邊界、Pangu.js v7.2.1 規則 port

· docs branch

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 呼叫。
關鍵設計 樣式標籤透明 → shim 看穿 <strong>是</strong> Neovim 這類跨元素邊界,把「是」(CJK)+ 空格 + 「N」(ANS)視為 Pangu 觸發位置;而非僅在單一 text-node 內找匹配。

Pass A — per-block Pangu on tag-stripped text

對 root 之每個 BLOCK 後代執行一次。流程:

  1. DFS 走 block 內部子節點:
    • 遇 text node:把每個 char 累積到 curText,同步把 { textNode, offset } 推入 curMap
    • 遇 STYLE_INLINES:透明遞迴,curText 繼續累積
    • 遇 PILL / 子 BLOCK / 既有 .cjk-autospaceflushRun(),把累積之 (text, map) 作為一個 run 推入 runs,重置
  2. 對每個 run,套 panguSpace(text),得到 spaced 字串(含 MARK PUA 字符 U+E007)
  3. 平行掃描 spacedtext,把 MARK 對應到原文之插入位置:
    • text[origIdx] 是 ASCII space → MARK 替換了該空格。動作:strip 該空格 + 在前一字後插 marker
    • 否則 → MARK 純插入。動作:在前一字後插 marker
  4. 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 → 同理。

反映射:

最終 DOM(簡化):本資料夾<strong>不是</strong><span class="cjk-autospace"> </span>Neovim<span class="cjk-autospace"> </span>使用者目錄。

Pass B — pill boundary, style-inline aware

對 root 內每個匹配 pillSelector 之元素執行:

  1. adjacentLogicalChar(pill, -1):走 prev sibling 鏈,遇 text 取末字元;遇 STYLE_INLINES 遞迴進入找其最末 text;遇 PILL / BLOCK / 既有 marker / skip ancestor → null
  2. 若回傳邏輯鄰字是 CJK / 字母數字 → 在 pill 前插 marker
  3. 若邏輯鄰字是 1 個 ASCII space 且該 space 另一側(同 text node 內)是內容字 → strip 該 space + 插 marker
  4. +1 方向(pill 後)做對稱處理

命令 <code>git</code> 更新

結果:命令<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 用內建英文字典處理。

  1. 對 root 內所有 text node 走 TreeWalker(SHOW_TEXT)
  2. 對每個 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)
  3. 對通過篩選之 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: "-";
}
字型陷阱 Noto Serif CJK SC 等 CJK serif 字型把 U+2010 HYPHEN 設計為全形 1 em(與漢字同寬),以服務 CJK 範圍表達;而 hyphens: auto 預設正是插 U+2010。直接用會看到全形連字符突兀地接在英文詞末。hyphenate-character: "-" 強制改用 U+002D(同字型內為窄拉丁,與作者鍵入的 - 一致),仍為純視覺渲染、DOM 內不存在。

選取 / 複製潔淨性

連字符純由 CSS 渲染(不存在於 DOM),Selection.toString()Cmd+C 返回原文無污染。包裝 span 本身是 inline,textContent 視為原樣,序列化 / 複製不受影響。

不在範圍

空白規則

所有觸發點(Pangu 命中或 PILL 邊界),對「插入位置之原有空白」的處理一致:

原有空白動作
0 個純插 marker
1 個 ASCII spacestrip 該 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
longWordWraptrue(預設啟用)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 strip2023 年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 });

邊界情境與限制

授權與來源

規則表 port 自 Pangu.js v7.2.1(MIT 授權)。本 lib 之三 pass 架構(樣式透明 + 行內塊邊界 + 長英文 lang 包裝)為原創設計,前兩 pass 解決 Pangu.js 原版「text-node walker 看不到跨元素邊界」之問題;Pass C 把 CJK 兩端對齊正文中之斷英文詞責任交給瀏覽器原生 hyphens: auto