4 Commits

Author SHA1 Message Date
admin 69f34413c9 CHANGELOG:補 ESM export
對應 main 之 a5faec5。檔尾加 `export { createCjkAutospace }` 讓
Vite / Rollup / TS 之 ESM 消費端可 named import;IIFE 串接端
sed 剝最後一行即可。
2026-05-26 17:26:30 +08:00
admin b5ac89b687 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。
2026-05-26 16:54:02 +08:00
admin bda7795c85 docs: 起步 Changelog 2026-05-22 23:25:04 +08:00
admin 3e5e45fb89 docs: 詳細 HTML 文檔(API、雙 pass、配置、整合範例) 2026-05-22 22:44:49 +08:00
8 changed files with 9647 additions and 937 deletions
-3
View File
@@ -1,3 +0,0 @@
.DS_Store
node_modules/
*.log
+4475
View File
File diff suppressed because it is too large Load Diff
+123
View File
@@ -0,0 +1,123 @@
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>cjk-autospace — Changelog</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 — Changelog</h1>
<p class="subtitle">時間倒序。架構細節見同分支 <code>index.html</code></p>
<p class="timestamp"><time>2026-05-22</time> · <span>docs branch</span></p>
</div>
<div class="actions">
<!-- ACTIONS -->
</div>
</header>
<main>
<h2>2026-05-26 — 補 ESM <code>export</code>(四種消費形式並存)</h2>
<p>下游 Vite / Rollup / TypeScript 之靜態 ESM 解析需要真正之 <code>export</code> 關鍵字;原 UMD-ish<code>module.exports</code> + <code>globalThis</code>)對 ESM 消費端仍須走「副作用導入 + 從 global 取 factory」之 workaround。檔尾在既有 CJS / globalThis 兩塊後加 <code>export { createCjkAutospace }</code>,順序刻意如此 —— 讓 IIFE 串接端(Claude 模板 <code>build.sh</code> / Nvim peek build hook)只需以 <code>sed '/^export {/d'</code> 剝最後一行。</p>
<p>四種消費形式並存:ESM <code>import</code> / CommonJS <code>require</code> / browser <code>globalThis</code> / IIFE 串接(後者需剝 <code>export</code> 行)。</p>
<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>
<li>工廠函式 <code>createCjkAutospace(options)</code>,回傳 <code>{ apply(root?) }</code>。Port 自 Pangu.js v7.2.1 規則表(MIT 授權),覆寫架構為雙 pass:
<ul>
<li><strong>Pass A — per-block Pangu on tag-stripped text</strong>:對每個 BLOCK 走 DFS 收集邏輯文本(樣式標籤透明、PILL 與子 BLOCK 中斷 run),整段套 <code>panguSpace</code>,把 MARK 反映射回 textNode/offset 並插入 <code>.cjk-autospace</code> marker span。</li>
<li><strong>Pass B — pill boundary, style-inline aware</strong>:對每個 PILL 透過樣式鏈遞迴找邏輯鄰字(<code>adjacentLogicalChar</code>),按 0/1/多+特殊規則處理空白。</li>
</ul>
</li>
<li>標籤三分類:STYLE_INLINES / PILL / BLOCK,皆可由消費端配置。</li>
<li>UMD-ish export:同時支援 ESM <code>import</code>、CommonJS <code>require</code>、browser global <code>globalThis.createCjkAutospace</code> 與 inline <code>&lt;script&gt;</code> 函式宣告形式。</li>
<li>TS 宣告:<code>cjk-autospace.d.ts</code> 提供完整 <code>CjkAutospaceOptions</code><code>CjkAutospaceInstance</code> 型別。</li>
<li>初始消費者:
<ul>
<li>Claude HTML 模板(<code>Base/templates/html/runtime/lib/cjk-autospace/</code>pillSelector <code>"code, kbd"</code></li>
<li>Peek.nvim Markdown 預覽 shim<code>Nvim/lua/user/lib/cjk-autospace/</code>pillSelector <code>"code, kbd, mark, samp"</code></li>
<li>cdrop<code>cdrop/web/src/lib/cjk-autospace/</code>pillSelector <code>"code, kbd, .cjk-pill"</code></li>
</ul>
</li>
<li>docs 分支:<code>index.html</code>(架構詳細文檔)+ <code>CHANGELOG.html</code>(本檔)。</li>
</ul>
<div class="callout info">
<span class="label">設計來源</span> 雙 pass 架構是原創設計,解決 Pangu.js 原版「text-node walker 看不到跨元素邊界」之問題(典型癥狀:<code>&lt;strong&gt;&lt;/strong&gt; Neovim</code> 此類邊界無法被識別)。
</div>
</main>
<footer class="page">
<p>cjk-autospace · docs branch · CHANGELOG</p>
</footer>
<!-- RUNTIME_SPRITE -->
<script type="text/markdown" id="md-source">
# cjk-autospace Changelog
## 2026-05-26 ESM `export`四種消費形式並存
- 檔尾加 `export { createCjkAutospace }`順序在 CJS / globalThis 兩塊之後 IIFE 串接端只需 `sed '/^export {/d'` 剝最後一行
- 四種消費形式並存ESM `import` / CommonJS `require` / browser `globalThis` / IIFE 串接
## 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 規則表
- pass 架構
- Pass A per-block Pangu on tag-stripped text
- Pass B pill boundary, style-inline aware
- 標籤三分類STYLE_INLINES / PILL / BLOCK皆可配置
- UMD-ish export + TS 宣告
- 初始消費者Claude HTML 模板Peek.nvimcdrop
- docs 分支index.html架構+ CHANGELOG.html本檔
</script>
<!-- RUNTIME_JS -->
</body>
</html>
-109
View File
@@ -1,109 +0,0 @@
# cjk-autospace
CJK ↔ Latin / 數字 / 標點 / 引號 / 括號 / 運算子等邊界之自動間距 shim。Pangu.js v7.2.1 規則之 ES5 端口,加上「跨樣式標籤透明 + 行內塊邊界」兩 pass 架構,再加 Pass C 長英文段 `<span lang>` 包裝(讓瀏覽器原生連字斷詞)、Pass D 開明式標點擠壓(句內點號半形)。工廠模式,由消費端注入配置。
> **詳細文檔 + Changelog 位於 `docs` 分支**:架構解析、雙 pass 流程、整合範例見 [`index.html`](https://git.commilitia.net/admin/cjk-autospace/src/branch/docs/index.html);變更記錄見 [`CHANGELOG.html`](https://git.commilitia.net/admin/cjk-autospace/src/branch/docs/CHANGELOG.html)。本機並列檢出:`git worktree add ../cjk-autospace-docs docs`。
## API
```js
const autospace = createCjkAutospace({
pillSelector: "code, kbd, .your-pill-class",
});
autospace.apply(document.body);
```
回傳 `{ apply(root?) }`。冪等:在已注入 marker 之 DOM 上重跑會跳過既有 `.cjk-autospace`。完整選項見 `cjk-autospace.d.ts`
## CSS 需求
```css
.cjk-autospace {
-webkit-user-select: none;
user-select: none;
/* 視覺寬度由消費端決定,例如 0.25em via inline-block + width */
}
```
`user-select: none` 讓選取與剪貼板跳過 markermarker 內含真實 ASCII spacelayout 視為 whitespace:行末 collapse、容許換行、justify 可拉伸)。
開明式標點擠壓(Pass D)另需:
```css
/* 句內點號半形——CSS 表達不了「只擠句內、不擠句末」,故由 lib 逐字包裝,
此處只負責把被包裝者擠成半形。需字型具 haltNoto Sans/Serif CJK 皆有)。 */
.cjk-squeeze {
font-feature-settings: "halt" 1;
}
/* 連續標點擠壓 + 行端壓縮(clreq)——原生 layout-aware,套在正文容器上。
Chrome 123+ 預設即 normal,明確標注以防被 reset;同樣要求字型具 halt/chws。 */
.article {
text-spacing-trim: normal;
}
```
不加 `.cjk-squeeze` 規則則 span 惰性(DOM 變更但無視覺效果)。`text-spacing-trim` 於 Chromium 不支援時優雅降級(句內半角仍由 `halt` 生效)。
## 標籤三分類
- **STYLE_INLINES**`<strong>` / `<em>` / `<a>` / `<b>` / `<i>` / `<u>` / `<s>` / `<mark>` / `<ins>` / `<del>` / `<sub>` / `<sup>` / `<small>` / `<abbr>` / `<cite>` / `<dfn>` / `<q>` / `<span>` / `<var>` / `<samp>` / `<time>` / `<bdo>` / `<bdi>` / `<ruby>` / `<rb>` / `<rt>` / `<rp>` / `<wbr>`)— shim 透明走過。跨樣式之 Pangu 邊界(如 `<strong>是</strong> Neovim`)由 Pass A 在剝離後識別。
- **PILL**(由 `pillSelector` 指定,預設 `code, kbd`)— 行內塊。邊界由 Pass B 處理,與內容鄰接(CJK / 字母數字)即插 marker。
- **BLOCK**`<p>` / `<div>` / `<h1>``<h6>` / `<li>` / `<pre>` / `<table>` / `<br>` / `<hr>` / …)— 段落級。Pass A 把它們當作 run 邊界,子 block 各自一次。
## 四 pass
- **Pass A — per-block Pangu on tag-stripped text**:對每個 BLOCK 走 DFS 收集邏輯文本(STYLE 透明、PILL 與子 BLOCK 中斷 run、既有 marker 也中斷),整段套 `panguSpace`,把 MARK 反映射回 textNode/offset。MARK 對應原文若是單 ASCII space 則 strip 後插 marker,否則純插。actions 倒序執行確保前面偏移不被後面動作影響。
- **Pass B — pill boundary**:對每個 PILL 透過 STYLE 鏈遞迴找邏輯鄰字(`adjacentLogicalChar`),鄰字若是 CJK / 字母數字直接插 marker;若是 1 個 ASCII space 且另一側為內容字則 strip 後插 marker;其餘(標點 / 多空格 / 換行 / 既有 marker)跳過。
- **Pass C — long English word wrap****預設啟用**`longWordWrap: false` 顯式關閉):將文本節點內所有 `[A-Za-z]{N,}`(預設 `N=6`)之長英文段包進 `<span lang="en">`。冪等:parent 已為 `<span lang>` 直接跳過。**連字符不在 DOM**,由瀏覽器 `hyphens: auto` 在斷行時純視覺渲染 → 選取 / 複製返回原文無污染。需消費端加 CSS:
```css
[lang|="en"] {
hyphens: auto;
-webkit-hyphens: auto;
hyphenate-limit-chars: 6 3 3; /* 詞長 / 斷點前 / 斷點後最少字符 */
}
```
不加 CSS 則 span 為惰性(DOM 變更但無視覺效果)。
- **Pass D — 開明式標點擠壓**(**預設啟用**,`punctuationSqueeze: false` 顯式關閉):把句內點號(`,、;:`)逐字包進 `<span class="cjk-squeeze">`,配合上節 CSS 之 `font-feature-settings: "halt"` 擠成半形;句末點號(`。?!`)保持全形——此即「開明式」。span 內含**真標點字元本身**(非注入空白),故 Copy / Paste / 序列化返回原文不變,無需 `user-select` 特殊處理。冪等:parent 已為 `.cjk-squeeze` 直接跳過。可傳 `{ squeezeClass, marks, langWhitelist }` 自訂。
**職責分工(不重複擠壓)**:開明式「只擠句內、不擠句末」CSS 無法表達(`halt` 套整個元素會把句末也擠成半形 → 變半角式),故由 lib 逐字包裝。clreq 之「連續標點擠壓」「行端壓縮」屬 layout-aware(相鄰對之側別、斷行位置只有 layout 時才知),交由原生 `text-spacing-trim`(其 collapse 規則與 clreq 逐條對應)。兩層字符集/職責不相交。
**`langWhitelist`**:BCP-47 前綴陣列。非空時僅擠壓最近祖先 `lang` 前綴命中之文本(`"zh"` 命中 `"zh-Hant"`;無 `lang` 祖先視為不命中);空則不限。開明式於簡體字型下字面位於左下角、擠壓最自然,繁體點號居中視覺略異——欲限定簡體可傳 `{ langWhitelist: ["zh-Hans"] }`。
**`hanging-punctuation`(真·行尾懸掛)不納入配方**:Chromium 至今未實作(無 flag 可開),僅 Safari 支援;且 clreq 言繁體中文橫排不宜行尾懸掛。clreq 的「行尾結束括號縮減末側半形」屬壓縮,已由 `text-spacing-trim` 涵蓋。
## 配置選項
| 選項 | 預設 | 說明 |
|---|---|---|
| `pillSelector` | `"code, kbd"` | 行內塊 CSS 選擇器 |
| `styleInlines` | 預設樣式標籤集 | tagName 大寫之陣列 |
| `blockTags` | 預設段落標籤集 | tagName 大寫之陣列 |
| `skipTags` | mono / form / script / style | tagName 大寫之陣列;整 subtree skip |
| `autospaceClass` | `"cjk-autospace"` | marker span class 名 |
| `skipAttribute` | `"data-md-key"` | 任一祖先帶此屬性即整 subtree skip |
| `isSkipped` | (無) | 自訂 skip callback;先於內建判定檢查 |
| `longWordWrap` | `true`(預設啟用) | Pass C 開關。傳 `false` 顯式關閉;`{minLength, lang}` 自訂;`true` / 省略等同 `{minLength: 6, lang: "en"}` |
| `punctuationSqueeze` | `true`(預設啟用) | Pass D 開明式擠壓開關。傳 `false` 顯式關閉;`{squeezeClass, marks, langWhitelist}` 自訂;`true` / 省略等同句內點號 `["","、","",""]` / class `"cjk-squeeze"` / 不限語言 |
## 模組形式
四種消費場景並存:
- **ES module / TypeScript**`import { createCjkAutospace } from "./cjk-autospace.js"`(檔尾 `export { createCjkAutospace }`,配 `cjk-autospace.d.ts`
- **CommonJS / Node**`require("./cjk-autospace.js").createCjkAutospace`(檔尾 `module.exports`
- **Browser global**:載入後可 `globalThis.createCjkAutospace`
- **IIFE 串接**:把本檔內容 `cat` 進單一閉包(如 Claude 模板 `build.sh` / Nvim peek build hook)— `export {…}` 為 ESM module-level 語法,串入函式體內會語法錯誤,串接端須剝掉那行:
```sh
sed '/^export[[:space:]]*{/d' cjk-autospace.js
```
其餘 `module.exports` / `globalThis` 兩塊在閉包內安全(`typeof` 檢查不會 throw、globalThis 賦值無副作用)。
## 授權
基於 Pangu.js v7.2.1 的規則表 port[原始項目](https://github.com/vinta/pangu.js) MIT)。
-109
View File
@@ -1,109 +0,0 @@
/**
* cjk-autospace — CJK ↔ Latin 邊界自動間距 shimPangu.js v7.2.1 風格)。
*
* 用法:
* ```ts
* import { createCjkAutospace } from "./cjk-autospace.js";
*
* const autospace = createCjkAutospace({
* pillSelector: "code, kbd, .cjk-pill",
* });
* autospace.apply(document.body);
* ```
*/
export interface CjkAutospaceOptions {
/** 行內塊(PILL)選擇器。預設 `"code, kbd"`。 */
pillSelector?: string;
/** 樣式標籤集合(shim 透明走過)。tagName 用大寫。 */
styleInlines?: readonly string[];
/** 段落級標籤集合(Pass A run 邊界)。tagName 用大寫。 */
blockTags?: readonly string[];
/** Skip-ancestor 標籤集合(subtree 整個跳過)。tagName 用大寫。 */
skipTags?: readonly string[];
/** Marker span 之 class 名。預設 `"cjk-autospace"`。 */
autospaceClass?: string;
/** Skip-ancestor 額外屬性檢查(任一祖先帶此屬性則 skip)。預設 `"data-md-key"`。 */
skipAttribute?: string;
/** 自訂 skip 判定(在內建判定前先檢查)。回傳 true 表示應跳過。 */
isSkipped?: (node: Node) => boolean;
/**
* Pass C**預設啟用**):把 `[A-Za-z]{minLength,}` 之長英文段包進
* `<span lang="…">`,讓瀏覽器原生 `hyphens: auto` 可斷詞並渲染連字
* 符(連字符純由 CSS 渲染,DOM 內不存在 → 複製不污染原文)。需消費
* 端 CSS 配:
*
* ```css
* [lang|="en"] {
* hyphens: auto;
* -webkit-hyphens: auto;
* hyphenate-limit-chars: 6 3 3; // 詞長 / 斷點前 / 斷點後最少字符
* }
* ```
*
* 傳 `false` 顯式關閉;`true` 或省略等同 `{ minLength: 6, lang: "en" }`。
*/
longWordWrap?: boolean | {
/** 觸發包裝之最短字母串長度。預設 6。 */
minLength?: number;
/** 包裝 span 之 `lang` 屬性值。預設 `"en"`。 */
lang?: string;
};
/**
* Pass D(**預設啟用**):開明式標點擠壓。把句內點號(`,、;:`)包進
* `<span class="…">`,配合消費端 CSS `font-feature-settings: "halt"` 擠
* 成半形;句末點號(`。?!`)保持全形——此即「開明式」。span 內含真
* 標點字元本身,故 Copy / Paste 返回原文不變。
*
* `font-feature-settings: "halt"` 套整個元素只會把所有標點變半形(半角
* 式),故必須逐字選擇性包裝——這是 CSS 表達不了、JS 不可替代的一層。
* clreq 之「連續標點擠壓」與「行端壓縮」交由原生 CSS(layout-aware):
*
* ```css
* .cjk-squeeze { font-feature-settings: "halt" 1; } // 句內點號半形
* .article { text-spacing-trim: normal; } // 連續標點 + 行端壓縮
* ```
*
* `text-spacing-trim` 於 Chrome 123+ 預設生效(要求字型有 `halt`/`chws`
* Noto CJK 滿足);`hanging-punctuation`(真·懸掛)僅 Safari 支援且繁體
* 橫排不宜,故不納入配方。
*
* 傳 `false` 顯式關閉;`true` 或省略等同預設(marks `["","、","",""]`、
* class `"cjk-squeeze"`、不限語言)。
*/
punctuationSqueeze?: boolean | {
/** 包裝 span 之 class 名。預設 `"cjk-squeeze"`。 */
squeezeClass?: string;
/** 要擠壓之標點字元(單字元)陣列。預設句內點號 `["", "、", "", ""]`。 */
marks?: readonly string[];
/**
* BCP-47 語言前綴白名單。非空時僅擠壓最近祖先 `lang` 前綴命中之文本
* `"zh"` 命中 `"zh-Hant"`;無 `lang` 祖先視為不命中)。空陣列或省略
* 則不限語言。例:`["zh-Hans"]` 限定簡體(開明式於簡體字型下字面位
* 於左下角,擠壓最自然;繁體點號居中,視覺略異)。
*/
langWhitelist?: readonly string[];
};
}
export interface CjkAutospaceInstance {
/**
* 對指定 root(預設 `document.body`)套用 Pass A + Pass B。
* 在已注入 marker 之 DOM 上重跑為冪等(會跳過既有 `.cjk-autospace`)。
*/
apply(root?: ParentNode | Document): void;
}
export function createCjkAutospace(options?: CjkAutospaceOptions): CjkAutospaceInstance;
declare global {
var createCjkAutospace: typeof createCjkAutospace;
}
-716
View File
@@ -1,716 +0,0 @@
"use strict";
/* cjk-autospace — Pangu.js v7.2.1 風格 CJK ↔ Latin 邊界自動間距 shim。
*
* 工廠模式:`createCjkAutospace(options).apply(root)`。配置由消費端注入;
* 預設值對應 HTML 模板 runtime 之原狀(pill = code, kbd;標籤三分類;
* marker class 為 .cjk-autospaceskip ancestor 為 mono / SVG /
* contentEditable / [data-md-key])。
*
* 標籤三分類:
* - STYLE_INLINES — 樣式標籤,shim 透明走過。跨樣式邊界(如
* <strong>是</strong> Neovim)由 Pass A 在剝離樣式後識別。
* - PILL — 行內塊(有獨立背景,如 <code>),邊界由 Pass B
* 處理。預設 'code, kbd',可由消費端擴充。
* - BLOCK — 段落級標籤,Pass A 把它們當作 run 邊界。
*
* 四 pass
* Pass A — 對每個 BLOCK 走 DFS 收集邏輯文本(STYLE 透明、PILL 與子
* BLOCK 中斷 run),整段套 panguSpace,把 MARK 反映射回 textNode/
* offset。MARK 對應原文若是單 ASCII space 則 strip 後插 marker
* 否則純插。actions 倒序執行確保前面偏移不被後面動作影響。
* 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 內不存在),複製
* 不污染原文。
* Pass D — 預設啟用(options.punctuationSqueeze)。開明式標點擠壓:把
* 句內點號(,、;:)包進 <span class="cjk-squeeze">,配合消費端 CSS
* `font-feature-settings:"halt"` 擠成半形;句末點號(。?!)保持全
* 形。span 內含真標點字元本身,複製不污染原文。clreq 之「連續標點擠
* 壓」與「行端壓縮」屬 layout-aware 規則,交由原生 CSS
* `text-spacing-trim` 處理(見 README),JS 不涉入以免重複擠壓。
*
* Marker = <span class="cjk-autospace" aria-hidden="true">(空白)</span>。
* 內含真實 ASCII 空白:layout 視為 whitespace(行末 collapse / 容許
* 換行 / justify 可拉伸);配 CSS user-select: none 讓選取與剪貼板跳
* 過該 span。textContent 不變(無視 span 內容)→ Copy / Paste / 序列
* 化都乾淨。
*/
function createCjkAutospace(options)
{
options = options || {};
/* ----- 預設配置 ----- */
const DEFAULT_STYLE_INLINES = [
"STRONG", "EM", "A", "B", "I", "U", "S", "MARK", "INS", "DEL",
"SUB", "SUP", "SMALL", "ABBR", "CITE", "DFN", "Q", "SPAN",
"VAR", "SAMP", "TIME", "BDO", "BDI", "RUBY", "RB", "RT", "RP", "WBR",
];
const DEFAULT_BLOCK_TAGS = [
"BODY", "P", "DIV", "H1", "H2", "H3", "H4", "H5", "H6", "LI",
"DT", "DD", "BLOCKQUOTE", "FIGURE", "FIGCAPTION", "TD", "TH",
"CAPTION", "SUMMARY", "DETAILS", "HEADER", "FOOTER", "SECTION",
"ARTICLE", "ASIDE", "NAV", "MAIN", "ADDRESS", "PRE", "UL", "OL",
"DL", "TABLE", "TR", "THEAD", "TBODY", "TFOOT", "FORM",
"FIELDSET", "LEGEND", "LABEL", "BR", "HR",
];
const DEFAULT_SKIP_TAGS = [
"CODE", "KBD", "PRE", "SAMP", "TT", "VAR",
"SCRIPT", "STYLE", "TEXTAREA", "INPUT",
];
const STYLE_SET = new Set(options.styleInlines || DEFAULT_STYLE_INLINES);
const BLOCK_SET = new Set(options.blockTags || DEFAULT_BLOCK_TAGS);
const SKIP_SET = new Set(options.skipTags || DEFAULT_SKIP_TAGS);
const pillSelector = options.pillSelector || "code, kbd";
const autospaceCls = options.autospaceClass || "cjk-autospace";
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",
};
}());
/* Pass D 設定——開明式標點擠壓。預設啟用。
開明式之定義:句內點號(,、;:)擠成半形,句末點號(。?!)保持
全形。`font-feature-settings: "halt"` 套在整個元素只會把所有標點變半
形(即半角式),故必須逐字選擇性包裝——這正是 JS 不可替代、CSS 無從
表達的那一層。包裝 span 內含真標點字元本身(非注入空白),故 Copy /
Paste 返回原文不變,無需 user-select 特殊處理。
clreq 之「連續標點擠壓」與「行端壓縮」屬 layout-aware 規則(相鄰對之
側別選擇、行端位置只有 layout 時才知),交由原生 CSS
`text-spacing-trim` 處理(Chrome 123+ 預設生效,要求字型有 halt/chws;
見 README)——JS 不涉入,避免與 CSS 重複擠壓。真·行尾懸掛
hanging-punctuation)僅 Safari 支援,且 clreq 言繁體橫排不宜,故不納
入配方。
langWhitelistBCP-47 前綴陣列。非空時僅擠壓最近祖先 lang 前綴命中之
文本;空陣列或省略則不限語言。 */
const squeezeCfg = (function ()
{
const v = options.punctuationSqueeze;
if (v === false) { return null; }
const DEFAULT_MARKS = ["", "、", "", ""]; // ,、;:
if (v === undefined || v === true || v === null)
{
return { cls: "cjk-squeeze", marks: DEFAULT_MARKS, langWhitelist: [] };
}
return {
cls: typeof v.squeezeClass === "string" && v.squeezeClass ? v.squeezeClass : "cjk-squeeze",
marks: Array.isArray(v.marks) && v.marks.length ? v.marks.slice() : DEFAULT_MARKS,
langWhitelist: Array.isArray(v.langWhitelist) ? v.langWhitelist.slice() : [],
};
}());
/* ----- Pangu 字符類與規則表 ----- */
const PANGU_CJK = "⺀-⻿⼀-⿟"
+ "぀-ゟ゠-ヺー-ヿ"
+ "㄀-ㄯ㈀-㋿"
+ "㐀-䶿一-鿿豈-﫿";
const AN_CHARS = "A-Za-z0-9";
const A_CHARS = "A-Za-z";
const OPS_HYPHEN = "\\+\\*=&\\-";
const OPS_GRADE = "\\+\\-\\*";
const QUOTES_CLS = "`\"״";
const LBR_BASIC = "\\(\\[\\{";
const RBR_BASIC = "\\)\\]\\}";
const LBR_EXT = "\\(\\[\\{<>";
const RBR_EXT = "\\)\\]\\}<>";
const ANS_CJK_AFTER = A_CHARS + "Ͱ-Ͽ0-9@\\$%\\^&\\*\\-\\+\\\\=¡-ÿ⅐-↏✀-➿";
const ANS_BEFORE_CJK = A_CHARS + "Ͱ-Ͽ0-9\\$%\\^&\\*\\-\\+\\\\=¡-ÿ⅐-↏✀-➿";
const ANY_CJK = new RegExp("[" + PANGU_CJK + "]");
/* PUA marker chars. 不會撞到正常 prose 中之 glyph。 */
const MARK = "";
const PH_OPEN = "";
const PH_CLOSE = "";
const STRIP_CJK_SPACE_ANS = new RegExp("([" + PANGU_CJK + "]) ([" + ANS_CJK_AFTER + "])", "g");
const STRIP_ANS_SPACE_CJK = new RegExp("([" + ANS_BEFORE_CJK + "]) ([" + PANGU_CJK + "])", "g");
const DOTS_CJK = new RegExp("([\\.]{2,}|…)([" + PANGU_CJK + "])", "g");
const CJK_PUNCTUATION = new RegExp("([" + PANGU_CJK + "])([!;,\\?:]+)(?=[" + PANGU_CJK + AN_CHARS + "])", "g");
const AN_PUNCTUATION_CJK = new RegExp("([" + AN_CHARS + "])([!;,\\?]+)([" + PANGU_CJK + "])", "g");
const CJK_TILDE = new RegExp("([" + PANGU_CJK + "])(~+)(?!=)(?=[" + PANGU_CJK + AN_CHARS + "])", "g");
const CJK_TILDE_EQUALS = new RegExp("([" + PANGU_CJK + "])(~=)", "g");
const CJK_PERIOD = new RegExp("([" + PANGU_CJK + "])(\\.)(?![" + AN_CHARS + "\\./])(?=[" + PANGU_CJK + AN_CHARS + "])", "g");
const AN_PERIOD_CJK = new RegExp("([" + AN_CHARS + "])(\\.)([" + PANGU_CJK + "])", "g");
const AN_COLON_CJK = new RegExp("([" + AN_CHARS + "])(:)([" + PANGU_CJK + "])", "g");
const CJK_QUOTE = new RegExp("([" + PANGU_CJK + "])([" + QUOTES_CLS + "])", "g");
const QUOTE_CJK = new RegExp("([" + QUOTES_CLS + "])([" + PANGU_CJK + "])", "g");
const QUOTE_AN = new RegExp("([”])([" + AN_CHARS + "])", "g");
const CJK_QUOTE_AN = new RegExp("([" + PANGU_CJK + "])(\")([" + AN_CHARS + "])", "g");
const CJK_HASH = new RegExp("([" + PANGU_CJK + "])(#([^ ]))", "g");
const HASH_CJK = new RegExp("(([^ ])#)([" + PANGU_CJK + "])", "g");
const SINGLE_LETTER_GRADE = new RegExp("\\b([" + A_CHARS + "])([" + OPS_GRADE + "])([" + PANGU_CJK + "])", "g");
const CJK_OPERATOR_ANS = new RegExp("([" + PANGU_CJK + "])([" + OPS_HYPHEN + "])([" + AN_CHARS + "])", "g");
const ANS_OPERATOR_CJK = new RegExp("([" + AN_CHARS + "])([" + OPS_HYPHEN + "])([" + PANGU_CJK + "])", "g");
const CJK_LESS_THAN = new RegExp("([" + PANGU_CJK + "])(<)([" + AN_CHARS + "])", "g");
const LESS_THAN_CJK = new RegExp("([" + AN_CHARS + "])(<)([" + PANGU_CJK + "])", "g");
const CJK_GREATER_THAN = new RegExp("([" + PANGU_CJK + "])(>)([" + AN_CHARS + "])", "g");
const GREATER_THAN_CJK = new RegExp("([" + AN_CHARS + "])(>)([" + PANGU_CJK + "])", "g");
const CJK_LEFT_BRACKET = new RegExp("([" + PANGU_CJK + "])([" + LBR_EXT + "])", "g");
const RIGHT_BRACKET_CJK = new RegExp("([" + RBR_EXT + "])([" + PANGU_CJK + "])", "g");
const AN_LEFT_BRACKET = new RegExp("([" + AN_CHARS + "])(?<!\\.[" + AN_CHARS + "]*)([" + LBR_BASIC + "])", "g");
const RIGHT_BRACKET_AN = new RegExp("([" + RBR_BASIC + "])([" + AN_CHARS + "])", "g");
const CJK_ANS = new RegExp("([" + PANGU_CJK + "])([" + ANS_CJK_AFTER + "])", "g");
const ANS_CJK = new RegExp("([" + ANS_BEFORE_CJK + "])([" + PANGU_CJK + "])", "g");
const S_A = new RegExp("(%)([" + A_CHARS + "])", "g");
/* Compound words like GPT-4 / state-of-the-art mustn't get operator
spaces wedged into them. Mask before operator passes, restore
before bracket / CJK_ANS / ANS_CJK passes (so the boundary
between 是 and GPT-4 still gets spaced). */
const COMPOUND_WORD = /\b(?:[A-Za-z0-9]*[a-z][A-Za-z0-9]*-[A-Za-z0-9]+|[A-Za-z0-9]+-[A-Za-z0-9]*[a-z][A-Za-z0-9]*|[A-Za-z]+-[0-9]+|[A-Za-z]+[0-9]+-[A-Za-z0-9]+)(?:-[A-Za-z0-9]+)*\b/g;
const PH_PATTERN = new RegExp(PH_OPEN + "(\\d+)" + PH_CLOSE, "g");
function panguSpace(text)
{
if (text.length <= 1 || !ANY_CJK.test(text)) { return text; }
let s = text;
/* Pre-passstrip authored single spaces at CJK↔ANS boundaries so
the rest of the pipeline can treat the text as if it had been
written flush. */
s = s.replace(STRIP_CJK_SPACE_ANS, "$1$2");
s = s.replace(STRIP_ANS_SPACE_CJK, "$1$2");
s = s.replace(DOTS_CJK, "$1" + MARK + "$2");
s = s.replace(CJK_PUNCTUATION, "$1$2" + MARK);
s = s.replace(AN_PUNCTUATION_CJK, "$1$2" + MARK + "$3");
s = s.replace(CJK_TILDE, "$1$2" + MARK);
s = s.replace(CJK_TILDE_EQUALS, "$1" + MARK + "$2" + MARK);
s = s.replace(CJK_PERIOD, "$1$2" + MARK);
s = s.replace(AN_PERIOD_CJK, "$1$2" + MARK + "$3");
s = s.replace(AN_COLON_CJK, "$1$2" + MARK + "$3");
s = s.replace(CJK_QUOTE, "$1" + MARK + "$2");
s = s.replace(QUOTE_CJK, "$1" + MARK + "$2");
s = s.replace(QUOTE_AN, "$1" + MARK + "$2");
s = s.replace(CJK_QUOTE_AN, "$1$2" + MARK + "$3");
s = s.replace(CJK_HASH, "$1" + MARK + "$2");
s = s.replace(HASH_CJK, "$1" + MARK + "$3");
const compounds = [];
s = s.replace(COMPOUND_WORD, function (m)
{
compounds.push(m);
return PH_OPEN + (compounds.length - 1) + PH_CLOSE;
});
s = s.replace(SINGLE_LETTER_GRADE, "$1$2" + MARK + "$3");
s = s.replace(CJK_OPERATOR_ANS, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(ANS_OPERATOR_CJK, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(CJK_LESS_THAN, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(LESS_THAN_CJK, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(CJK_GREATER_THAN, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(GREATER_THAN_CJK, "$1" + MARK + "$2" + MARK + "$3");
/* Restore compound words BEFORE bracket / CJK_ANS / ANS_CJK
passes — otherwise their PUA placeholders look like neither
CJK nor AN and the boundaries silently skip. */
s = s.replace(PH_PATTERN, function (_m, idx) { return compounds[+idx] || ""; });
s = s.replace(CJK_LEFT_BRACKET, "$1" + MARK + "$2");
s = s.replace(RIGHT_BRACKET_CJK, "$1" + MARK + "$2");
s = s.replace(AN_LEFT_BRACKET, "$1" + MARK + "$2");
s = s.replace(RIGHT_BRACKET_AN, "$1" + MARK + "$2");
s = s.replace(CJK_ANS, "$1" + MARK + "$2");
s = s.replace(ANS_CJK, "$1" + MARK + "$2");
s = s.replace(S_A, "$1" + MARK + "$2");
return s;
}
/* ----- skip ancestor 判定 ----- */
function isSkipped(node)
{
if (userIsSkipped && userIsSkipped(node)) { return true; }
let p = node.parentNode;
while (p && p.nodeType === 1)
{
if (SKIP_SET.has(p.nodeName)) { return true; }
if (p.namespaceURI === "http://www.w3.org/2000/svg") { return true; }
if (p.isContentEditable) { return true; }
if (skipAttr && p.hasAttribute && p.hasAttribute(skipAttr)) { return true; }
p = p.parentNode;
}
return false;
}
/* ----- helpers ----- */
function insertSpanAt(parent, refNode)
{
const span = document.createElement("span");
span.className = autospaceCls;
span.setAttribute("aria-hidden", "true");
span.textContent = " ";
parent.insertBefore(span, refNode);
}
function insertMarkerAfterPosition(pos)
{
const tn = pos.textNode;
const afterIdx = pos.offset + 1;
if (afterIdx >= tn.data.length)
{
insertSpanAt(tn.parentNode, tn.nextSibling);
}
else
{
const split = tn.splitText(afterIdx);
insertSpanAt(split.parentNode, split);
}
}
function isAutospaceSpan(node)
{
return node && node.nodeType === 1
&& node.classList && node.classList.contains(autospaceCls);
}
/* ----- Pass A: per-block Pangu on tag-stripped text ----- */
function processBlockText(block)
{
const runs = [];
let curText = "";
let curMap = [];
function flushRun()
{
if (curText.length >= 2) { runs.push({ text: curText, map: curMap }); }
curText = "";
curMap = [];
}
function visit(node)
{
if (!node) { return; }
if (node.nodeType === 3)
{
if (isSkipped(node)) { return; }
const data = node.data;
for (let i = 0; i < data.length; i += 1)
{
curText += data.charAt(i);
curMap.push({ textNode: node, offset: i });
}
return;
}
if (node.nodeType !== 1) { return; }
if (isSkipped(node)) { return; }
if (isAutospaceSpan(node)) { flushRun(); return; }
const tag = node.nodeName;
/* PILL / BLOCK / SKIP_SET 三類都中斷 runpill 與 block 邊界
另由 Pass B / 各自子 block 處理)。 */
if (SKIP_SET.has(tag) || BLOCK_SET.has(tag))
{
flushRun();
return;
}
if (node.matches && node.matches(pillSelector))
{
flushRun();
return;
}
/* 樣式標籤或未知元素:透明遞迴。 */
let c = node.firstChild;
while (c) { visit(c); c = c.nextSibling; }
}
let c = block.firstChild;
while (c) { visit(c); c = c.nextSibling; }
flushRun();
for (let r = 0; r < runs.length; r += 1)
{
const text = runs[r].text;
const map = runs[r].map;
const spaced = panguSpace(text);
if (spaced === text) { continue; }
/* Walk spaced + text in parallel. 每個 MARK 在 spaced 中要麼
(a) 取代了原文同位之 single space,要麼 (b) 是純插入。 */
const actions = [];
let origIdx = 0;
let spacedIdx = 0;
while (spacedIdx < spaced.length)
{
const sc = spaced.charAt(spacedIdx);
if (sc === MARK)
{
const oc = origIdx < text.length ? text.charAt(origIdx) : null;
if (oc === " ")
{
actions.push({ before: map[origIdx - 1], space: map[origIdx] });
origIdx += 1;
}
else
{
actions.push({ before: map[origIdx - 1], space: null });
}
spacedIdx += 1;
continue;
}
origIdx += 1;
spacedIdx += 1;
}
/* 倒序執行,避免 text-node 修改影響前面動作之 offset。 */
for (let i = actions.length - 1; i >= 0; i -= 1)
{
const act = actions[i];
if (!act.before) { continue; }
if (act.space)
{
const tn = act.space.textNode;
tn.data = tn.data.slice(0, act.space.offset) + tn.data.slice(act.space.offset + 1);
}
insertMarkerAfterPosition(act.before);
}
}
}
/* ----- Pass B: pill boundary, style-inline aware ----- */
function adjacentLogicalChar(el, direction)
{
function pickEnd(tn)
{
if (direction === -1)
{
return { textNode: tn, offset: tn.data.length - 1, ch: tn.data.charAt(tn.data.length - 1) };
}
return { textNode: tn, offset: 0, ch: tn.data.charAt(0) };
}
function descend(into)
{
let n = direction === -1 ? into.lastChild : into.firstChild;
while (n)
{
if (n.nodeType === 3 && n.data.length > 0) { return pickEnd(n); }
if (n.nodeType === 1 && STYLE_SET.has(n.nodeName) && !isAutospaceSpan(n))
{
const r = descend(n);
if (r) { return r; }
}
n = direction === -1 ? n.previousSibling : n.nextSibling;
}
return null;
}
let n = direction === -1 ? el.previousSibling : el.nextSibling;
let p = el.parentNode;
while (true)
{
while (n)
{
if (n.nodeType === 3)
{
if (n.data.length > 0) { return pickEnd(n); }
n = direction === -1 ? n.previousSibling : n.nextSibling;
continue;
}
if (n.nodeType === 1)
{
if (isSkipped(n)) { return null; }
if (isAutospaceSpan(n)) { return null; }
if (STYLE_SET.has(n.nodeName))
{
const r = descend(n);
if (r) { return r; }
n = direction === -1 ? n.previousSibling : n.nextSibling;
continue;
}
/* Pill 或 block — 停。 */
return null;
}
n = direction === -1 ? n.previousSibling : n.nextSibling;
}
if (!p || !STYLE_SET.has(p.nodeName)) { return null; }
n = direction === -1 ? p.previousSibling : p.nextSibling;
p = p.parentNode;
}
}
const PILL_NEIGHBOR = new RegExp("[A-Za-z0-9" + PANGU_CJK + "]");
function processPills(root)
{
root.querySelectorAll(pillSelector).forEach(function (pill)
{
if (isSkipped(pill)) { return; }
const parent = pill.parentNode;
if (!parent) { return; }
const prev = adjacentLogicalChar(pill, -1);
if (prev)
{
if (PILL_NEIGHBOR.test(prev.ch))
{
insertSpanAt(parent, pill);
}
else if (prev.ch === " " && prev.offset >= 1)
{
const tn = prev.textNode;
const bc = tn.data.charAt(prev.offset - 1);
if (bc !== " " && PILL_NEIGHBOR.test(bc))
{
tn.data = tn.data.slice(0, prev.offset) + tn.data.slice(prev.offset + 1);
insertSpanAt(parent, pill);
}
}
}
const next = adjacentLogicalChar(pill, +1);
if (next)
{
if (PILL_NEIGHBOR.test(next.ch))
{
insertSpanAt(parent, pill.nextSibling);
}
else if (next.ch === " " && next.textNode.data.length >= 2)
{
const ac = next.textNode.data.charAt(1);
if (ac !== " " && PILL_NEIGHBOR.test(ac))
{
next.textNode.data = next.textNode.data.slice(1);
insertSpanAt(parent, pill.nextSibling);
}
}
}
});
}
/* ----- 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);
}
}
/* ----- Pass D: 開明式標點擠壓(句內點號半形) ----- */
/* 冪等性:parent 已是 cfg.cls 之 <span> 直接跳過。 */
function isInSqueezeSpan(node, cls)
{
const p = node.parentNode;
return !!(p && p.nodeType === 1 && p.nodeName === "SPAN"
&& p.classList && p.classList.contains(cls));
}
/* 由 node 向上找最近帶 lang 屬性之祖先,回傳小寫值(無則 null)。 */
function nearestLang(node)
{
let p = node.parentNode;
while (p && p.nodeType === 1)
{
if (p.hasAttribute && p.hasAttribute("lang"))
{
const v = p.getAttribute("lang");
if (v) { return v.toLowerCase(); }
}
p = p.parentNode;
}
return null;
}
/* 空白名單 → 不限;非空 → 僅最近 lang 前綴命中者擠壓(無 lang 祖先視
為不命中)。前綴以 BCP-47 子標籤邊界比對:'zh' 命中 'zh-hant',但不
命中 'zhx'。 */
function langAllowed(node, whitelist)
{
if (!whitelist || whitelist.length === 0) { return true; }
const lang = nearestLang(node);
if (!lang) { return false; }
for (let i = 0; i < whitelist.length; i += 1)
{
const w = whitelist[i].toLowerCase();
if (lang === w || lang.indexOf(w + "-") === 0) { return true; }
}
return false;
}
function wrapSqueezeMarksIn(textNode, cfg, re)
{
const data = textNode.data;
re.lastIndex = 0;
const frag = document.createDocumentFragment();
let cursor = 0;
let m;
while ((m = re.exec(data)) !== null)
{
const idx = m.index;
if (idx > cursor)
{
frag.appendChild(document.createTextNode(data.slice(cursor, idx)));
}
const span = document.createElement("span");
span.className = cfg.cls;
span.textContent = m[0];
frag.appendChild(span);
cursor = idx + m[0].length;
}
if (cursor < data.length)
{
frag.appendChild(document.createTextNode(data.slice(cursor)));
}
textNode.parentNode.replaceChild(frag, textNode);
}
function processPunctuationSqueeze(root, cfg)
{
const escaped = cfg.marks.map(function (c)
{
return c.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}).join("");
if (!escaped) { return; }
const reTest = new RegExp("[" + escaped + "]");
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
const targets = [];
let n = walker.nextNode();
while (n)
{
if (!isSkipped(n) && !isInSqueezeSpan(n, cfg.cls)
&& reTest.test(n.data) && langAllowed(n, cfg.langWhitelist))
{
targets.push(n);
}
n = walker.nextNode();
}
const reAll = new RegExp("[" + escaped + "]", "g");
for (let i = 0; i < targets.length; i += 1)
{
wrapSqueezeMarksIn(targets[i], cfg, reAll);
}
}
/* ----- 對外 API ----- */
function apply(root)
{
root = root || (typeof document !== "undefined" ? document.body : null);
if (!root) { return; }
/* Pass A:對每個 block 逐個處理。把 BLOCK_TAGS 轉成 selector
並對 root 之 descendants 查詢;若 root 本身是 block 也處理。 */
const blockSel = Array.from(BLOCK_SET).map(function (t) { return t.toLowerCase(); }).join(",");
if (blockSel)
{
root.querySelectorAll(blockSel).forEach(processBlockText);
}
if (root.nodeType === 1 && BLOCK_SET.has(root.nodeName))
{
processBlockText(root);
}
/* Pass B */
processPills(root);
/* Pass C(預設啟用) */
if (longWordCfg) { processLongWords(root, longWordCfg); }
/* Pass D(開明式標點擠壓,預設啟用) */
if (squeezeCfg) { processPunctuationSqueeze(root, squeezeCfg); }
}
return { apply: apply };
}
/* ----- 多形 export -----
*
* 四種消費場景並存:
* ESMVite / Rollup / TypeScript):使用 `export { createCjkAutospace }`
* 可 `import { createCjkAutospace } from "./cjk-autospace.js"`
* CommonJSNode `require`):使用 `module.exports`
* Browser global / `<script>` 標籤:使用 `globalThis` 賦值;
* IIFE 串接(build.sh / Nvim peek hook 把本檔內容 cat 進單個閉包):
* `export` 語句屬 ESM 之 module-level 語法,串入函式體內會語法錯誤,
* 故串接端須剝掉 `export {` 起始之那行(單行 `sed '/^export {/d'`
* 即可)。其餘 module.exports / globalThis 兩塊在閉包內安全(typeof
* 檢查不會 throw、globalThis 賦值無副作用)。
*
* 順序:先 CJS / globalThis(不影響語法解析),最後 ESM `export`
* 讓串接端只需剝最後一塊。
*/
if (typeof module !== "undefined" && module.exports)
{
module.exports = { createCjkAutospace: createCjkAutospace };
}
if (typeof globalThis !== "undefined" && !globalThis.createCjkAutospace)
{
globalThis.createCjkAutospace = createCjkAutospace;
}
export { createCjkAutospace };
+4693
View File
File diff suppressed because it is too large Load Diff
+356
View File
@@ -0,0 +1,356 @@
<!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 架構,再加 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>
<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>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>
<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>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>
<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 架構(樣式透明 + 行內塊邊界 + 長英文 lang 包裝)為原創設計,前兩 pass 解決 Pangu.js 原版「text-node walker 看不到跨元素邊界」之問題;Pass C 把 CJK 兩端對齊正文中之斷英文詞責任交給瀏覽器原生 <code>hyphens: auto</code></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>