feat: Pass C —— 長英文段 lang 包裝(預設啟用)
把 [A-Za-z]{N,} 之長英文段包進 <span lang="en">,配合消費端 CSS
[lang|="en"] { hyphens: auto; ... } 用瀏覽器原生 en 字典斷詞 + 渲
染連字符。連字符純由 CSS 渲染,DOM 內不存在 → 選取 / 複製返回原文
無污染。冪等:parent 為 <span lang> 直接跳過。
skip 鏈共用 Pass A / B 之 isSkipped(CODE / KBD / PRE / SAMP / TT
/ VAR / SCRIPT / STYLE / TEXTAREA / INPUT / SVG / contentEditable
/ data-md-key)。
預設啟用以解 CJK 兩端對齊正文中行末長英文詞之斷行問題;消費端可傳
longWordWrap: false 顯式關閉,或 { minLength, lang } 自訂粒度與標
語。
README + .d.ts 同步說明,含字型陷阱(CJK serif 把 U+2010 設為全形)
與消費端 CSS 範本(含 hyphenate-character: "-")。
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# cjk-autospace
|
# cjk-autospace
|
||||||
|
|
||||||
CJK ↔ Latin / 數字 / 標點 / 引號 / 括號 / 運算子等邊界之自動間距 shim。Pangu.js v7.2.1 規則之 ES5 端口,加上「跨樣式標籤透明 + 行內塊邊界」兩 pass 架構。工廠模式,由消費端注入配置。
|
CJK ↔ Latin / 數字 / 標點 / 引號 / 括號 / 運算子等邊界之自動間距 shim。Pangu.js v7.2.1 規則之 ES5 端口,加上「跨樣式標籤透明 + 行內塊邊界」兩 pass 架構,再加 Pass C 長英文段 `<span lang>` 包裝(讓瀏覽器原生連字斷詞)。工廠模式,由消費端注入配置。
|
||||||
|
|
||||||
> **詳細文檔 + 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`。
|
> **詳細文檔 + 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`。
|
||||||
|
|
||||||
@@ -33,10 +33,21 @@ autospace.apply(document.body);
|
|||||||
- **PILL**(由 `pillSelector` 指定,預設 `code, kbd`)— 行內塊。邊界由 Pass B 處理,與內容鄰接(CJK / 字母數字)即插 marker。
|
- **PILL**(由 `pillSelector` 指定,預設 `code, kbd`)— 行內塊。邊界由 Pass B 處理,與內容鄰接(CJK / 字母數字)即插 marker。
|
||||||
- **BLOCK**(`<p>` / `<div>` / `<h1>`–`<h6>` / `<li>` / `<pre>` / `<table>` / `<br>` / `<hr>` / …)— 段落級。Pass A 把它們當作 run 邊界,子 block 各自一次。
|
- **BLOCK**(`<p>` / `<div>` / `<h1>`–`<h6>` / `<li>` / `<pre>` / `<table>` / `<br>` / `<hr>` / …)— 段落級。Pass A 把它們當作 run 邊界,子 block 各自一次。
|
||||||
|
|
||||||
## 兩 pass
|
## 三 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 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 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 變更但無視覺效果)。
|
||||||
|
|
||||||
## 配置選項
|
## 配置選項
|
||||||
|
|
||||||
@@ -49,6 +60,7 @@ autospace.apply(document.body);
|
|||||||
| `autospaceClass` | `"cjk-autospace"` | marker span class 名 |
|
| `autospaceClass` | `"cjk-autospace"` | marker span class 名 |
|
||||||
| `skipAttribute` | `"data-md-key"` | 任一祖先帶此屬性即整 subtree skip |
|
| `skipAttribute` | `"data-md-key"` | 任一祖先帶此屬性即整 subtree skip |
|
||||||
| `isSkipped` | (無) | 自訂 skip callback;先於內建判定檢查 |
|
| `isSkipped` | (無) | 自訂 skip callback;先於內建判定檢查 |
|
||||||
|
| `longWordWrap` | `true`(預設啟用) | Pass C 開關。傳 `false` 顯式關閉;`{minLength, lang}` 自訂;`true` / 省略等同 `{minLength: 6, lang: "en"}` |
|
||||||
|
|
||||||
## 模組形式
|
## 模組形式
|
||||||
|
|
||||||
|
|||||||
Vendored
+23
@@ -33,6 +33,29 @@ export interface CjkAutospaceOptions {
|
|||||||
|
|
||||||
/** 自訂 skip 判定(在內建判定前先檢查)。回傳 true 表示應跳過。 */
|
/** 自訂 skip 判定(在內建判定前先檢查)。回傳 true 表示應跳過。 */
|
||||||
isSkipped?: (node: Node) => boolean;
|
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;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CjkAutospaceInstance {
|
export interface CjkAutospaceInstance {
|
||||||
|
|||||||
+89
-1
@@ -14,7 +14,7 @@
|
|||||||
* 處理。預設 'code, kbd',可由消費端擴充。
|
* 處理。預設 'code, kbd',可由消費端擴充。
|
||||||
* - BLOCK — 段落級標籤,Pass A 把它們當作 run 邊界。
|
* - BLOCK — 段落級標籤,Pass A 把它們當作 run 邊界。
|
||||||
*
|
*
|
||||||
* 兩 pass:
|
* 三 pass:
|
||||||
* Pass A — 對每個 BLOCK 走 DFS 收集邏輯文本(STYLE 透明、PILL 與子
|
* Pass A — 對每個 BLOCK 走 DFS 收集邏輯文本(STYLE 透明、PILL 與子
|
||||||
* BLOCK 中斷 run),整段套 panguSpace,把 MARK 反映射回 textNode/
|
* BLOCK 中斷 run),整段套 panguSpace,把 MARK 反映射回 textNode/
|
||||||
* offset。MARK 對應原文若是單 ASCII space 則 strip 後插 marker,
|
* offset。MARK 對應原文若是單 ASCII space 則 strip 後插 marker,
|
||||||
@@ -22,6 +22,10 @@
|
|||||||
* Pass B — 對每個 PILL 透過 STYLE 鏈遞迴找邏輯鄰字(adjacentLogical-
|
* Pass B — 對每個 PILL 透過 STYLE 鏈遞迴找邏輯鄰字(adjacentLogical-
|
||||||
* Char),按 0/1/多+特殊規則處理(0 個或 1 個 ASCII space 插
|
* Char),按 0/1/多+特殊規則處理(0 個或 1 個 ASCII space 插
|
||||||
* marker;其餘跳過)。
|
* marker;其餘跳過)。
|
||||||
|
* Pass C — 可選(options.longWordWrap)。把 [A-Za-z]{N,} 之長英文段
|
||||||
|
* 包進 <span lang="en">;配合消費端 CSS `[lang|="en"]{hyphens:auto}`
|
||||||
|
* 讓瀏覽器原生連字斷詞。連字符純由 CSS 渲染(DOM 內不存在),複製
|
||||||
|
* 不污染原文。預設關閉以保持向後相容。
|
||||||
*
|
*
|
||||||
* Marker = <span class="cjk-autospace" aria-hidden="true">(空白)</span>。
|
* Marker = <span class="cjk-autospace" aria-hidden="true">(空白)</span>。
|
||||||
* 內含真實 ASCII 空白:layout 視為 whitespace(行末 collapse / 容許
|
* 內含真實 ASCII 空白:layout 視為 whitespace(行末 collapse / 容許
|
||||||
@@ -61,6 +65,21 @@ function createCjkAutospace(options)
|
|||||||
const skipAttr = options.skipAttribute || "data-md-key";
|
const skipAttr = options.skipAttribute || "data-md-key";
|
||||||
const userIsSkipped = typeof options.isSkipped === "function" ? options.isSkipped : null;
|
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",
|
||||||
|
};
|
||||||
|
}());
|
||||||
|
|
||||||
/* ----- Pangu 字符類與規則表 ----- */
|
/* ----- Pangu 字符類與規則表 ----- */
|
||||||
const PANGU_CJK = "⺀-⼀-"
|
const PANGU_CJK = "⺀-⼀-"
|
||||||
+ "-ゟ゠-ヺー-ヿ"
|
+ "-ゟ゠-ヺー-ヿ"
|
||||||
@@ -442,6 +461,72 @@ function createCjkAutospace(options)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ----- Pass C: long English word wrap ----- */
|
||||||
|
/* 冪等性:parent 為 <span lang="…"> 直接跳過。已被本 pass 或作者標
|
||||||
|
語的英文段都會被 catch;e.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ----- 對外 API ----- */
|
/* ----- 對外 API ----- */
|
||||||
function apply(root)
|
function apply(root)
|
||||||
{
|
{
|
||||||
@@ -462,6 +547,9 @@ function createCjkAutospace(options)
|
|||||||
|
|
||||||
/* Pass B */
|
/* Pass B */
|
||||||
processPills(root);
|
processPills(root);
|
||||||
|
|
||||||
|
/* Pass C(opt-in) */
|
||||||
|
if (longWordCfg) { processLongWords(root, longWordCfg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
return { apply: apply };
|
return { apply: apply };
|
||||||
|
|||||||
Reference in New Issue
Block a user