初始:cjk-autospace 工廠函式 + TS 宣告 + README
雙 pass 架構: - Pass A — 對每個 BLOCK 走 DFS 收集邏輯文本(樣式標籤透明、PILL 與子 BLOCK 中斷 run),套 panguSpace,把 MARK 反映射回 textNode / offset 並插入 .cjk-autospace marker span - Pass B — 對每個 PILL(pillSelector)透過樣式鏈遞迴找邏輯鄰字, 按 0 / 1 / 多 + 特殊規則處理空白 工廠 createCjkAutospace(options) 接受 pillSelector / styleInlines / blockTags / skipTags / autospaceClass / skipAttribute / isSkipped 等 配置。UMD-ish export 同時支援 ESM、CommonJS、browser global 與 inline script 形式。 規則表 port 自 Pangu.js v7.2.1(MIT),加上跨樣式邊界透明與行內塊 邊界兩 pass 為原創設計。
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
.DS_Store
|
||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# cjk-autospace
|
||||||
|
|
||||||
|
CJK ↔ Latin / 數字 / 標點 / 引號 / 括號 / 運算子等邊界之自動間距 shim。Pangu.js v7.2.1 規則之 ES5 端口,加上「跨樣式標籤透明 + 行內塊邊界」兩 pass 架構。工廠模式,由消費端注入配置。
|
||||||
|
|
||||||
|
## 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` 讓選取與剪貼板跳過 marker;marker 內含真實 ASCII space(layout 視為 whitespace:行末 collapse、容許換行、justify 可拉伸)。
|
||||||
|
|
||||||
|
## 標籤三分類
|
||||||
|
|
||||||
|
- **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)跳過。
|
||||||
|
|
||||||
|
## 配置選項
|
||||||
|
|
||||||
|
| 選項 | 預設 | 說明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `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;先於內建判定檢查 |
|
||||||
|
|
||||||
|
## 模組形式
|
||||||
|
|
||||||
|
UMD-ish:
|
||||||
|
|
||||||
|
- **ES module / TypeScript**:直接 `import { createCjkAutospace } from "./cjk-autospace.js"`(搭配 `cjk-autospace.d.ts`)
|
||||||
|
- **CommonJS / Node**:`module.exports.createCjkAutospace`
|
||||||
|
- **Browser global**:`globalThis.createCjkAutospace`
|
||||||
|
- **Inline `<script>`**:函式宣告即註冊於 enclosing scope
|
||||||
|
|
||||||
|
## 授權
|
||||||
|
|
||||||
|
基於 Pangu.js v7.2.1 的規則表 port([原始項目](https://github.com/vinta/pangu.js) MIT)。
|
||||||
Vendored
+50
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* cjk-autospace — CJK ↔ Latin 邊界自動間距 shim(Pangu.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
/* cjk-autospace — Pangu.js v7.2.1 風格 CJK ↔ Latin 邊界自動間距 shim。
|
||||||
|
*
|
||||||
|
* 工廠模式:`createCjkAutospace(options).apply(root)`。配置由消費端注入;
|
||||||
|
* 預設值對應 HTML 模板 runtime 之原狀(pill = code, kbd;標籤三分類;
|
||||||
|
* marker class 為 .cjk-autospace;skip 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;其餘跳過)。
|
||||||
|
*
|
||||||
|
* 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;
|
||||||
|
|
||||||
|
/* ----- 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-pass:strip 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 三類都中斷 run(pill 與 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- 對外 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { apply: apply };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- UMD-ish export ----- */
|
||||||
|
if (typeof module !== "undefined" && module.exports)
|
||||||
|
{
|
||||||
|
module.exports = { createCjkAutospace: createCjkAutospace };
|
||||||
|
}
|
||||||
|
if (typeof globalThis !== "undefined" && !globalThis.createCjkAutospace)
|
||||||
|
{
|
||||||
|
globalThis.createCjkAutospace = createCjkAutospace;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user