feat: Pass D 開明式標點擠壓(句內點號半形)
句內點號(,、;:)逐字包進 <span class="cjk-squeeze">,配合消費端 CSS `font-feature-settings: "halt"` 擠成半形;句末點號(。?!)保持全形—— 此即開明式。span 內含真標點字元,複製不污染原文。 clreq 之「連續標點擠壓」與「行端壓縮」屬 layout-aware 規則,交由原生 `text-spacing-trim` 處理(Chrome 123+ 預設生效,要求字型有 halt/chws), JS 不涉入以免重複擠壓。真·行尾懸掛(hanging-punctuation)僅 Safari 支援 且繁體橫排不宜,故不納入配方。 預設啟用,支援 `langWhitelist` 依 lang 前綴限定擠壓範圍、`marks` 與 `squeezeClass` 自訂。含 16 項 jsdom DOM 測試。
This commit is contained in:
+139
-5
@@ -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,10 +22,16 @@
|
||||
* 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}`
|
||||
* 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 / 容許
|
||||
@@ -80,6 +86,36 @@ function createCjkAutospace(options)
|
||||
};
|
||||
}());
|
||||
|
||||
/* 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 言繁體橫排不宜,故不納
|
||||
入配方。
|
||||
langWhitelist:BCP-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 = "⺀-⼀-"
|
||||
+ "-ゟ゠-ヺー-ヿ"
|
||||
@@ -527,6 +563,101 @@ function createCjkAutospace(options)
|
||||
}
|
||||
}
|
||||
|
||||
/* ----- 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)
|
||||
{
|
||||
@@ -548,8 +679,11 @@ function createCjkAutospace(options)
|
||||
/* Pass B */
|
||||
processPills(root);
|
||||
|
||||
/* Pass C(opt-in) */
|
||||
/* Pass C(預設啟用) */
|
||||
if (longWordCfg) { processLongWords(root, longWordCfg); }
|
||||
|
||||
/* Pass D(開明式標點擠壓,預設啟用) */
|
||||
if (squeezeCfg) { processPunctuationSqueeze(root, squeezeCfg); }
|
||||
}
|
||||
|
||||
return { apply: apply };
|
||||
|
||||
Reference in New Issue
Block a user