diff --git a/README.md b/README.md index 9e406e9..366afde 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # cjk-autospace -CJK ↔ Latin / 數字 / 標點 / 引號 / 括號 / 運算子等邊界之自動間距 shim。Pangu.js v7.2.1 規則之 ES5 端口,加上「跨樣式標籤透明 + 行內塊邊界」兩 pass 架構。工廠模式,由消費端注入配置。 +CJK ↔ Latin / 數字 / 標點 / 引號 / 括號 / 運算子等邊界之自動間距 shim。Pangu.js v7.2.1 規則之 ES5 端口,加上「跨樣式標籤透明 + 行內塊邊界」兩 pass 架構,再加 Pass C 長英文段 `` 包裝(讓瀏覽器原生連字斷詞)。工廠模式,由消費端注入配置。 > **詳細文檔 + 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。 - **BLOCK**(`

` / `

` / `

`–`

` / `
  • ` / `
    ` / `` / `
    ` / `
    ` / …)— 段落級。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 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`)之長英文段包進 ``。冪等:parent 已為 `` 直接跳過。**連字符不在 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 名 | | `skipAttribute` | `"data-md-key"` | 任一祖先帶此屬性即整 subtree skip | | `isSkipped` | (無) | 自訂 skip callback;先於內建判定檢查 | +| `longWordWrap` | `true`(預設啟用) | Pass C 開關。傳 `false` 顯式關閉;`{minLength, lang}` 自訂;`true` / 省略等同 `{minLength: 6, lang: "en"}` | ## 模組形式 diff --git a/cjk-autospace.d.ts b/cjk-autospace.d.ts index 7cdf025..461d134 100644 --- a/cjk-autospace.d.ts +++ b/cjk-autospace.d.ts @@ -33,6 +33,29 @@ export interface CjkAutospaceOptions { /** 自訂 skip 判定(在內建判定前先檢查)。回傳 true 表示應跳過。 */ isSkipped?: (node: Node) => boolean; + + /** + * Pass C(**預設啟用**):把 `[A-Za-z]{minLength,}` 之長英文段包進 + * ``,讓瀏覽器原生 `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 { diff --git a/cjk-autospace.js b/cjk-autospace.js index d7ba1d8..a2359a8 100644 --- a/cjk-autospace.js +++ b/cjk-autospace.js @@ -14,7 +14,7 @@ * 處理。預設 'code, kbd',可由消費端擴充。 * - BLOCK — 段落級標籤,Pass A 把它們當作 run 邊界。 * - * 兩 pass: + * 三 pass: * Pass A — 對每個 BLOCK 走 DFS 收集邏輯文本(STYLE 透明、PILL 與子 * BLOCK 中斷 run),整段套 panguSpace,把 MARK 反映射回 textNode/ * offset。MARK 對應原文若是單 ASCII space 則 strip 後插 marker, @@ -22,6 +22,10 @@ * Pass B — 對每個 PILL 透過 STYLE 鏈遞迴找邏輯鄰字(adjacentLogical- * Char),按 0/1/多+特殊規則處理(0 個或 1 個 ASCII space 插 * marker;其餘跳過)。 + * Pass C — 可選(options.longWordWrap)。把 [A-Za-z]{N,} 之長英文段 + * 包進 ;配合消費端 CSS `[lang|="en"]{hyphens:auto}` + * 讓瀏覽器原生連字斷詞。連字符純由 CSS 渲染(DOM 內不存在),複製 + * 不污染原文。預設關閉以保持向後相容。 * * Marker = 。 * 內含真實 ASCII 空白:layout 視為 whitespace(行末 collapse / 容許 @@ -61,6 +65,21 @@ function createCjkAutospace(options) 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", + }; + }()); + /* ----- Pangu 字符類與規則表 ----- */ const PANGU_CJK = "⺀-⻿⼀-⿟" + "぀-ゟ゠-ヺー-ヿ" @@ -442,6 +461,72 @@ function createCjkAutospace(options) }); } + /* ----- Pass C: long English word wrap ----- */ + /* 冪等性:parent 為 直接跳過。已被本 pass 或作者標 + 語的英文段都會被 catch;e.g. 雖然 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 ----- */ function apply(root) { @@ -462,6 +547,9 @@ function createCjkAutospace(options) /* Pass B */ processPills(root); + + /* Pass C(opt-in) */ + if (longWordCfg) { processLongWords(root, longWordCfg); } } return { apply: apply };