fix: 中西間隙下游回饋批次(B 省略號/C+E gapTrim 自我應驗/D 西文集/標點稽核)
實 Chromium 定位並修復下游回饋(Peek.nvim,commit 96f5ac3 基線)之全部間隙問題,
另完成現代漢語標點之誤隙稽核。jsdom 不可信(TreeWalker/offset 失真),結論均以
真實 Chromium 量測。
B 全角省略號誤隙:DOTS_CJK 之 alternation `|…` 誤把全角省略號 U+2026 納入,致
`……` 後接 CJK 冒出中西隙。移除 `|…`,僅留 ASCII `...`。
C+E gapTrim 自我應驗誤判(容器寬相關「最後一個間隙消失」、pill 跨行右隙消失):
間隙之 margin-right 參與斷行;依「後字是否在更低行」判行末,而後字落點又被
margin 推擠 → 間隙自身 margin 把後字擠到次行 → 判行末 → trim 去 margin → 後字
回同行,自相矛盾、卡死於「已 trim、後字同行、間隙消失」。污染雙向:下游間隙之
margin 亦令夾在中間之內容放不下其行。修法:trimGaps 改「起始全部 trim、由左至右
逐一定案」——量測時前方已定案、自身與其後皆 trim,後字落點不受任何 margin 污染。
以 lastRect 為準兼顧跨行 pill。實測 8–22em(ragged/justify)偽 trim 歸零、真行末
仍 trim(justify 齊右不變)。
D 西文字集完整化(列舉法,2016/Windows 10 相容、\uXXXX 無需 flag):補入貨幣
U+20A0–20BF(€ 等)、拉丁擴充 A/B(拼音聲調 ā ǎ ē)、西里爾、數學運算符
U+2200–22FF;並把希臘由原 U+0370–037D 補全至整塊(原漏 α-ω 本身)。€ 鄰接 CJK
之手工空格遂正規化、邊界補隙。
標點稽核:實 Chromium 全部常用中文標點 × 簡繁共 104 例,CJK 間皆 0 誤隙。修正
唯一偏例——收尾彎引號 `”`(pangu QUOTE_AN 令其後接拉丁/數字補隙,為唯一會補隙
之收尾標點,與 」』)》】〕 不一致;clreq 下全角收尾標點字身已含右側留白)。移除
QUOTE_AN。
- src/core/unicode.ts:WEST_SHARED 列舉補全(\uXXXX、區塊註記)。
- src/typeset/spacing.ts:DOTS_CJK 去 `|…`;移除 QUOTE_AN。
- src/typeset/lineedge.ts:trimGaps 起始全 trim、左→右逐一定案;新增 lastRect。
- test/juzhen.test.mjs:B/D/標點稽核/彎引號 回歸(56 測試通過)。
- demo/resize-check.html:可拖曳容器檢查頁(各寬度間隙穩定性、標點無誤隙)。
- README.md 目視驗證、ARCHITECTURE.md §6.1/§6.1a 自我應驗/§6.1b 標點稽核。
- dist/ 重建(juzhen.css 不變,僅 JS 邏輯)。
This commit is contained in:
@@ -338,6 +338,19 @@ class 約定:`bd`(標點)、`bd-open/close/cop/stop/sep/liga`、`cjk`、`j
|
||||
`DEL` 哨符 1:1 替換空格保持對齊,`processBlock` 據此刪空格而不插 `jz-hws`)。
|
||||
與 CJK↔Latin 之剝除一致——凡 inline 邊界之贅餘作者空格皆統一處理。全形空格
|
||||
U+3000 不動。
|
||||
- **西文字集之完整化(下游回饋 D;列舉法)**:CJK↔西文之邊界由 `unicode.ts` 之
|
||||
`ANS_AFTER_CJK`/`ANS_BEFORE_CJK`(共用片段 `WEST_SHARED`)界定。原僅涵蓋拉丁基本
|
||||
+拉丁-1+部分希臘,致**貨幣符號**(`€` U+20AC 等整塊 U+20A0–20BF)、**拼音聲調**
|
||||
(`ā ǎ ē` 等拉丁擴充 A/B U+0100–024F)、**西里爾**、**數學運算符**(U+2200–22FF)
|
||||
不被視為西文 → 鄰接 CJK 不補隙、手工空格亦不剝除。改採**逐區塊列舉**補全(見
|
||||
`WEST_SHARED` 註記),並把希臘由原 U+0370–037D 補全至整塊 U+0370–03FF(原漏
|
||||
`Α-Ω α-ω` 本身)。**取捨**:選列舉(whitelist)而非 `\p{…}` 類別法,因類別法之
|
||||
集合減法須 `v` flag(ES2024),會破壞 2016/Windows 10 時代相容性下限;列舉法所有
|
||||
範圍皆 BMP、以 `\uXXXX` 表示於字元類內無需任何 flag。代價:新區塊需手補。
|
||||
- **全角省略號不誤判(下游回饋 B)**:`DOTS_CJK` 原以 alternation `([\.]{2,}|…)`
|
||||
匹配「省略號後接 CJK 補隙」,但 `|…` 誤把**全角省略號 U+2026** 納入;全角 `…/……`
|
||||
屬 CJK 標點(已由 jiya charify 為 `bd-liga`),後接 CJK 不應有中西間隙。移除 `|…`,
|
||||
僅留 ASCII `...`。
|
||||
|
||||
### 6.1a 行末去隙(gapTrim,justify 修正)— 切片 1
|
||||
|
||||
@@ -346,6 +359,30 @@ class 約定:`bd`(標點)、`bd-open/close/cop/stop/sep/liga`、`cjk`、`j
|
||||
更低行)」者加 `jz-hws-trim`(CSS `margin-right:0`),該行遂齊右、無內縮。掛
|
||||
`ResizeObserver`+字型載入後重算。純版面、瀏覽器專用(jsdom 跳過)。與 `lineedge`
|
||||
共用量測工具與 layout 安裝器。
|
||||
- **自我應驗誤判(下游回饋 C/容器寬相關之「最後一個間隙消失」)**:間隙之 margin-right
|
||||
會**參與斷行**。若依「後字是否在更低行」判行末,而後字落點又被 margin 推擠,便自相矛盾
|
||||
——間隙 margin 把後字擠到次行 → 判行末 → trim 去 margin → 後字回同行,振盪停在「已
|
||||
trim、後字同行、間隙消失」之錯誤終態(`ResizeObserver` 只觀 root 尺寸、不因內部 margin
|
||||
變化重觸發,故卡死;resize 容器則該處重判、間隙「恢復」或轉移)。**污染雙向**:不僅當前
|
||||
間隙自身 margin,**下游間隙之 margin** 亦會令夾在中間之內容(如 `或<gap>$1299<gap>`)
|
||||
放不下其行、致後字假性換行。
|
||||
- **修法**:`trimGaps` 改為**起始全部 trim**(margin 全 0),再**由左至右逐一定案**。量測
|
||||
某間隙時其前皆已定案、其自身與其後全部仍 trim,故後字落點不受任何 margin 污染,純粹反映
|
||||
「無本間隙 margin 時後字是否仍被擠到次行」:仍在更低行(或無後字)→ 真行末、維持 trim;
|
||||
在同行 → 行內容得下、非真行末 → 還原間隙。逐一必要(斷行左→右因果);以末行 rect
|
||||
(`lastRect`,margin 實際落點)為準,兼顧跨行 pill(行尾 `code` 折行亦曾致右側間隙誤消)。
|
||||
實 Chromium 多寬度(8–22em、ragged/justify)掃描確認:偽 trim 由「某些寬度存在」歸零;
|
||||
真行末仍 trim(justify 齊右不變)。
|
||||
|
||||
### 6.1b 標點稽核:CJK 標點不生誤隙(下游回饋)
|
||||
|
||||
- **不變量**:任何現代漢語標點介於 CJK 之間,皆**不**補中西間隙。實 Chromium 稽核全部
|
||||
常用標點(。,、;:?!…… —— ·~ 簡繁引號 “”‘’「」『』、各式括號 ()【】《》〈〉
|
||||
[]{}〔〕〖〗 等)× 簡繁兩政策共 104 例,jz-hws 皆 0、textContent 不變。
|
||||
- **修正之偏例**:①全角省略號 `……`(§6.1 之 B:`DOTS_CJK` 之 `|…` 誤匹配,已移除);
|
||||
②收尾彎引號 `”`(原 pangu `QUOTE_AN` 規則令 `”` 後接拉丁/數字補隙,使其成為唯一會補隙
|
||||
之收尾標點,與 `」』)》】〕` 皆不補不一致;clreq 下全角收尾標點字身已內含右側留白,後接
|
||||
拉丁不需額外隙——已移除 `QUOTE_AN` 以求一致、消除誤隙)。
|
||||
|
||||
### 6.2 長英文詞斷詞包裝 — 切片 1
|
||||
|
||||
|
||||
@@ -261,8 +261,11 @@ IIFE 構建另掛 `globalThis.createCjkAutospace` 別名。**與 v1 之差異**
|
||||
|
||||
`demo/index.html` 載入 `dist/` 產物,簡繁並列、可切 `justify`、附 copy-clean 測試區。
|
||||
`demo/fallback.html` 以**方正書宋 GBK**(無 `halt`)對照 `halt` 模式失效 vs `margin`
|
||||
後備生效。版面相關結論(justify 單份間距、擠壓視覺量、行端、`inline-block` 基線)須於
|
||||
Chrome/Safari/Firefox 三引擎目視核對——jsdom 僅驗證 DOM 結構正確性。
|
||||
後備生效。`demo/resize-check.html` 為**可拖曳容器**檢查頁:拖動方框寬度,驗證中西間隙在
|
||||
各寬度下恆穩(含行末去隙之自我應驗、pill 跨行右隙、貨幣/拼音/西里爾外文補隙、全角省略號
|
||||
與各式中文標點無誤隙),可切 `justify` 與「顯示間隙」描邊。版面相關結論(justify 單份間距、
|
||||
擠壓視覺量、行端、`inline-block` 基線、間隙隨寬度之穩定性)須於 Chrome/Safari/Firefox
|
||||
三引擎目視核對——jsdom 僅驗證 DOM 結構正確性。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-Hant">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>聚珍(Juzhen)— 中西間隙/標點 拖曳檢查頁</title>
|
||||
<link rel="stylesheet" href="../dist/juzhen.css">
|
||||
<style>
|
||||
body { font: 16px/1.9 serif; max-width: 60em; margin: 2rem auto; padding: 0 1rem; color: #1a1a1a; }
|
||||
h1 { font-size: 1.4rem; }
|
||||
h2 { font-size: 1.05rem; margin: 1.6rem 0 .4rem; color: #036; }
|
||||
.hint { color: #666; font-size: .85rem; margin: .2rem 0 .6rem; }
|
||||
.toolbar { position: sticky; top: 0; background: #fff; border-bottom: 1px solid #ddd; padding: .5rem 0; margin-bottom: 1rem; font-size: .9rem; }
|
||||
.toolbar label { margin-right: 1.2rem; cursor: pointer; }
|
||||
|
||||
/* 可拖曳容器:右下角原生把手;拖動寬度即重排,觀察間隙是否恆穩。 */
|
||||
.box {
|
||||
resize: horizontal; overflow: hidden;
|
||||
min-width: 5em; max-width: 100%; width: 20em;
|
||||
border: 1px solid #bbb; border-radius: 4px; padding: .5rem .7rem;
|
||||
background: #fafafa; margin: .3rem 0;
|
||||
}
|
||||
.box p { margin: 0; }
|
||||
|
||||
/* 兩端對齊切換(檢查行末去隙是否齊右、不內縮)。 */
|
||||
body.justify .juzhen p { text-align: justify; }
|
||||
|
||||
/* 顯示間隙:描出 jz-hws(其右側 0.25em margin 即視覺間隙)與 pill 邊界。 */
|
||||
body.show-gaps jz-hws { outline: 1px solid rgba(220,30,30,.45); background: rgba(220,30,30,.06); }
|
||||
body.show-gaps jz-hws.jz-hws-trim { outline-color: rgba(30,120,220,.5); background: rgba(30,120,220,.06); }
|
||||
body.show-gaps code { background: rgba(0,0,0,.06); }
|
||||
code { font-family: ui-monospace, Menlo, Consolas, monospace; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>聚珍 — 中西間隙/標點 拖曳檢查頁</h1>
|
||||
<p class="hint">拖動每個方框右下角的把手調整寬度,觀察間隙在各寬度下是否恆穩、標點是否無誤隙。政策:quanjiao(全角式)。</p>
|
||||
|
||||
<div class="toolbar">
|
||||
<label><input type="checkbox" id="t-justify"> 兩端對齊 justify</label>
|
||||
<label><input type="checkbox" id="t-gaps"> 顯示間隙(紅=有隙 jz-hws,藍=已去隙 trim)</label>
|
||||
</div>
|
||||
|
||||
<h2>E 中西混排(重點:拖動時「最後一個間隙」不應消失、標點不應緊貼)</h2>
|
||||
<p class="hint">慢慢拖動寬度,每個 ¥9800/$1299/€1099 與中文間之隙都應始終存在;換行點落於任何邊界皆不致左字與後字緊貼。</p>
|
||||
<div class="box juzhen" lang="zh-Hant"><p>價格 ¥9800 或 $1299 或 €1099 都可以接受呢,歡迎洽詢 hello@x.com 謝謝惠顧再聯絡。</p></div>
|
||||
|
||||
<h2>C 行內 pill 跨行(重點:code 折行後,其右側與中文之間隙不應消失)</h2>
|
||||
<p class="hint">把方框拖窄使 <code>code</code> 內容折行,pill 末字與「之後」間應仍有隙。</p>
|
||||
<div class="box juzhen" lang="zh-Hant"><p>前綴 <code>git rebase --interactive --autosquash HEAD~10 feature</code> 之後接中文,再補一段較長敘述以利觀察換行行為。</p></div>
|
||||
|
||||
<h2>B 全角省略號(重點:…… 與後接中文之間不應有隙)</h2>
|
||||
<div class="box juzhen" lang="zh-Hant"><p>他竟然就這樣走了……留下一地,唉……真教人無言以對。</p></div>
|
||||
|
||||
<h2>D 貨幣/拼音/西里爾/希臘/數學(重點:這些外文字元與中文間應有隙)</h2>
|
||||
<div class="box juzhen" lang="zh-Hant"><p>售價 €1099 或 $50 都行;拼音 Pīnyīn 含 ā ē ǐ ǒ ǔ;俄文 Привет 世界;希臘 α β γ 常見;數學 設 x≥0 且 ∑aᵢ≠0 皆需間隙。</p></div>
|
||||
|
||||
<h2>標點稽核(重點:以下各式中文標點介於中文間,皆不應冒出西文間隙)</h2>
|
||||
<div class="box juzhen" lang="zh-Hant" style="width:30em"><p>句號。逗號,頓號、分號;冒號:問號?嘆號!引號「引述」『書名』(圓括)【方頭】《書名》〈單名〉省略……破折——間隔·波浪~皆不應有中西間隙;收尾彎引號“例”後接 ABC 亦不補隙。</p></div>
|
||||
|
||||
<h2>對照:簡體內容(zh-Hans 字形與標點)</h2>
|
||||
<div class="box juzhen" lang="zh-Hans" style="width:24em"><p>这是简体测试,包含 Python 与 €99;引号“示例”和标点,;:。混排间隙与标点均应正确。</p></div>
|
||||
|
||||
<script src="../dist/juzhen.iife.js"></script>
|
||||
<script>
|
||||
// 全頁一次渲染(quanjiao 全角式,與下游回饋之政策一致);各框由其 lang 屬性
|
||||
// 驅動簡/繁字形。
|
||||
Juzhen.createJuzhen({ scope: { include: ".juzhen" }, lang: { style: "quanjiao" } })
|
||||
.render(document.body);
|
||||
|
||||
document.getElementById("t-justify").addEventListener("change", function (e)
|
||||
{
|
||||
document.body.classList.toggle("justify", e.target.checked);
|
||||
});
|
||||
document.getElementById("t-gaps").addEventListener("change", function (e)
|
||||
{
|
||||
document.body.classList.toggle("show-gaps", e.target.checked);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Vendored
+2
-2
@@ -7,9 +7,9 @@ export declare const ALNUM = "A-Za-z0-9";
|
||||
export declare const ALPHA = "A-Za-z";
|
||||
/** 任一 CJK 字元測試。 */
|
||||
export declare const ANY_CJK: RegExp;
|
||||
/** CJK 之後可接間隙之西文集(含希臘字母、運算符、拉丁補充、箭號、裝飾符)。 */
|
||||
/** CJK 之後可接間隙之西文集(含 @)。 */
|
||||
export declare const ANS_AFTER_CJK: string;
|
||||
/** CJK 之前可接間隙之西文集(同上,去 @)。 */
|
||||
/** CJK 之前可接間隙之西文集(去 @)。 */
|
||||
export declare const ANS_BEFORE_CJK: string;
|
||||
/** 句末點號:。!?(開明式與全角式皆保持全形,不擠壓)。 */
|
||||
export declare const BD_STOP = "\u3002\uFF01\uFF1F";
|
||||
|
||||
Vendored
+17
-13
@@ -512,8 +512,9 @@ var Juzhen = (() => {
|
||||
var ALNUM = "A-Za-z0-9";
|
||||
var ALPHA = "A-Za-z";
|
||||
var ANY_CJK = new RegExp("[" + CJK + "]");
|
||||
var ANS_AFTER_CJK = ALPHA + "\u0370-\u03FF0-9@\\$%\\^&\\*\\-\\+\\\\=\xA1-\xB6\xB8-\xFF\u2150-\u218F\u2700-\u27BF";
|
||||
var ANS_BEFORE_CJK = ALPHA + "\u0370-\u03FF0-9\\$%\\^&\\*\\-\\+\\\\=\xA1-\xB6\xB8-\xFF\u2150-\u218F\u2700-\u27BF";
|
||||
var WEST_SHARED = "0-9\\$%\\^&\\*\\-\\+\\\\=\\u0370-\\u03ff\\u0100-\\u024f\\u0400-\\u04ff\\u00a1-\\u00b6\\u00b8-\\u00ff\\u20a0-\\u20bf\\u2150-\\u21ff\\u2200-\\u22ff\\u2700-\\u27bf";
|
||||
var ANS_AFTER_CJK = ALPHA + "@" + WEST_SHARED;
|
||||
var ANS_BEFORE_CJK = ALPHA + WEST_SHARED;
|
||||
var BD_STOP = "\u3002\uFF01\uFF1F";
|
||||
var BD_PAUSE = "\uFF0C\u3001\uFF1B\uFF1A";
|
||||
var BD_OPEN = "\u300C\u300E\uFF08\u300A\u3008\u3010\u3016\u3014\uFF3B\uFF5B\u201C\u2018";
|
||||
@@ -936,6 +937,10 @@ var Juzhen = (() => {
|
||||
const rects = el.getClientRects();
|
||||
return rects.length ? rects[0] : null;
|
||||
}
|
||||
function lastRect(el) {
|
||||
const rects = el.getClientRects();
|
||||
return rects.length ? rects[rects.length - 1] : null;
|
||||
}
|
||||
function relayout(root, finder) {
|
||||
root.querySelectorAll("jz-char." + LE).forEach((e) => e.classList.remove(LE));
|
||||
const chars = Array.from(root.querySelectorAll("jz-char")).filter(
|
||||
@@ -984,18 +989,19 @@ var Juzhen = (() => {
|
||||
};
|
||||
var TRIM = "jz-hws-trim";
|
||||
function trimGaps(root, finder) {
|
||||
root.querySelectorAll("jz-hws." + TRIM).forEach((e) => e.classList.remove(TRIM));
|
||||
const gaps = Array.from(root.querySelectorAll("jz-hws")).filter(
|
||||
(g) => finder.inScope(g) && !finder.isAvoided(g)
|
||||
);
|
||||
if (!gaps.length) {
|
||||
return;
|
||||
}
|
||||
gaps.forEach((g) => g.classList.add(TRIM));
|
||||
for (const g of gaps) {
|
||||
const r = firstRect(g);
|
||||
if (!r) {
|
||||
continue;
|
||||
}
|
||||
const next = edgeRect(g.nextSibling, "next", g);
|
||||
if (!next || next.top > r.top + EPS) {
|
||||
g.classList.add(TRIM);
|
||||
const r = lastRect(g);
|
||||
const next = r ? edgeRect(g.nextSibling, "next", g) : null;
|
||||
const atLineEnd = !r || !next || next.top > r.top + EPS;
|
||||
if (!atLineEnd) {
|
||||
g.classList.remove(TRIM);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1086,7 +1092,7 @@ var Juzhen = (() => {
|
||||
var STRIP_CJK_SPACE_ANS = new RegExp("([" + CJK + "]) ([" + ANS_AFTER_CJK + "])", "g");
|
||||
var STRIP_ANS_SPACE_CJK = new RegExp("([" + ANS_BEFORE_CJK + "]) ([" + CJK + "])", "g");
|
||||
var STRIP_CJK_SPACE_CJK = new RegExp("([" + CJK + "]) (?=[" + CJK + "])", "g");
|
||||
var DOTS_CJK = new RegExp("([\\.]{2,}|\u2026)([" + CJK + "])", "g");
|
||||
var DOTS_CJK = new RegExp("([\\.]{2,})([" + CJK + "])", "g");
|
||||
var CJK_PUNCTUATION = new RegExp("([" + CJK + "])([!;,\\?:]+)(?=[" + CJK + ALNUM + "])", "g");
|
||||
var AN_PUNCTUATION_CJK = new RegExp("([" + ALNUM + "])([!;,\\?]+)([" + CJK + "])", "g");
|
||||
var CJK_TILDE = new RegExp("([" + CJK + "])(~+)(?!=)(?=[" + CJK + ALNUM + "])", "g");
|
||||
@@ -1096,7 +1102,6 @@ var Juzhen = (() => {
|
||||
var AN_COLON_CJK = new RegExp("([" + ALNUM + "])(:)([" + CJK + "])", "g");
|
||||
var CJK_QUOTE = new RegExp("([" + CJK + "])([" + QUOTES_CLS + "])", "g");
|
||||
var QUOTE_CJK = new RegExp("([" + QUOTES_CLS + "])([" + CJK + "])", "g");
|
||||
var QUOTE_AN = new RegExp("([\u201D])([" + ALNUM + "])", "g");
|
||||
var CJK_QUOTE_AN = new RegExp("([" + CJK + '])(")([' + ALNUM + "])", "g");
|
||||
var CJK_HASH = new RegExp("([" + CJK + "])(#([^ ]))", "g");
|
||||
var HASH_CJK = new RegExp("(([^ ])#)([" + CJK + "])", "g");
|
||||
@@ -1134,7 +1139,6 @@ var Juzhen = (() => {
|
||||
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");
|
||||
|
||||
Vendored
+17
-13
@@ -484,8 +484,9 @@ var CJK = "\u2E80-\u2EFF\u2F00-\u2FDF\u3040-\u309F\u30A0-\u30FA\u30FC-\u30FF\u31
|
||||
var ALNUM = "A-Za-z0-9";
|
||||
var ALPHA = "A-Za-z";
|
||||
var ANY_CJK = new RegExp("[" + CJK + "]");
|
||||
var ANS_AFTER_CJK = ALPHA + "\u0370-\u03FF0-9@\\$%\\^&\\*\\-\\+\\\\=\xA1-\xB6\xB8-\xFF\u2150-\u218F\u2700-\u27BF";
|
||||
var ANS_BEFORE_CJK = ALPHA + "\u0370-\u03FF0-9\\$%\\^&\\*\\-\\+\\\\=\xA1-\xB6\xB8-\xFF\u2150-\u218F\u2700-\u27BF";
|
||||
var WEST_SHARED = "0-9\\$%\\^&\\*\\-\\+\\\\=\\u0370-\\u03ff\\u0100-\\u024f\\u0400-\\u04ff\\u00a1-\\u00b6\\u00b8-\\u00ff\\u20a0-\\u20bf\\u2150-\\u21ff\\u2200-\\u22ff\\u2700-\\u27bf";
|
||||
var ANS_AFTER_CJK = ALPHA + "@" + WEST_SHARED;
|
||||
var ANS_BEFORE_CJK = ALPHA + WEST_SHARED;
|
||||
var BD_STOP = "\u3002\uFF01\uFF1F";
|
||||
var BD_PAUSE = "\uFF0C\u3001\uFF1B\uFF1A";
|
||||
var BD_OPEN = "\u300C\u300E\uFF08\u300A\u3008\u3010\u3016\u3014\uFF3B\uFF5B\u201C\u2018";
|
||||
@@ -908,6 +909,10 @@ function firstRect(el) {
|
||||
const rects = el.getClientRects();
|
||||
return rects.length ? rects[0] : null;
|
||||
}
|
||||
function lastRect(el) {
|
||||
const rects = el.getClientRects();
|
||||
return rects.length ? rects[rects.length - 1] : null;
|
||||
}
|
||||
function relayout(root, finder) {
|
||||
root.querySelectorAll("jz-char." + LE).forEach((e) => e.classList.remove(LE));
|
||||
const chars = Array.from(root.querySelectorAll("jz-char")).filter(
|
||||
@@ -956,18 +961,19 @@ var lineEdgePass = {
|
||||
};
|
||||
var TRIM = "jz-hws-trim";
|
||||
function trimGaps(root, finder) {
|
||||
root.querySelectorAll("jz-hws." + TRIM).forEach((e) => e.classList.remove(TRIM));
|
||||
const gaps = Array.from(root.querySelectorAll("jz-hws")).filter(
|
||||
(g) => finder.inScope(g) && !finder.isAvoided(g)
|
||||
);
|
||||
if (!gaps.length) {
|
||||
return;
|
||||
}
|
||||
gaps.forEach((g) => g.classList.add(TRIM));
|
||||
for (const g of gaps) {
|
||||
const r = firstRect(g);
|
||||
if (!r) {
|
||||
continue;
|
||||
}
|
||||
const next = edgeRect(g.nextSibling, "next", g);
|
||||
if (!next || next.top > r.top + EPS) {
|
||||
g.classList.add(TRIM);
|
||||
const r = lastRect(g);
|
||||
const next = r ? edgeRect(g.nextSibling, "next", g) : null;
|
||||
const atLineEnd = !r || !next || next.top > r.top + EPS;
|
||||
if (!atLineEnd) {
|
||||
g.classList.remove(TRIM);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1058,7 +1064,7 @@ var DEL = String.fromCharCode(57347);
|
||||
var STRIP_CJK_SPACE_ANS = new RegExp("([" + CJK + "]) ([" + ANS_AFTER_CJK + "])", "g");
|
||||
var STRIP_ANS_SPACE_CJK = new RegExp("([" + ANS_BEFORE_CJK + "]) ([" + CJK + "])", "g");
|
||||
var STRIP_CJK_SPACE_CJK = new RegExp("([" + CJK + "]) (?=[" + CJK + "])", "g");
|
||||
var DOTS_CJK = new RegExp("([\\.]{2,}|\u2026)([" + CJK + "])", "g");
|
||||
var DOTS_CJK = new RegExp("([\\.]{2,})([" + CJK + "])", "g");
|
||||
var CJK_PUNCTUATION = new RegExp("([" + CJK + "])([!;,\\?:]+)(?=[" + CJK + ALNUM + "])", "g");
|
||||
var AN_PUNCTUATION_CJK = new RegExp("([" + ALNUM + "])([!;,\\?]+)([" + CJK + "])", "g");
|
||||
var CJK_TILDE = new RegExp("([" + CJK + "])(~+)(?!=)(?=[" + CJK + ALNUM + "])", "g");
|
||||
@@ -1068,7 +1074,6 @@ var AN_PERIOD_CJK = new RegExp("([" + ALNUM + "])(\\.)([" + CJK + "])", "g");
|
||||
var AN_COLON_CJK = new RegExp("([" + ALNUM + "])(:)([" + CJK + "])", "g");
|
||||
var CJK_QUOTE = new RegExp("([" + CJK + "])([" + QUOTES_CLS + "])", "g");
|
||||
var QUOTE_CJK = new RegExp("([" + QUOTES_CLS + "])([" + CJK + "])", "g");
|
||||
var QUOTE_AN = new RegExp("([\u201D])([" + ALNUM + "])", "g");
|
||||
var CJK_QUOTE_AN = new RegExp("([" + CJK + '])(")([' + ALNUM + "])", "g");
|
||||
var CJK_HASH = new RegExp("([" + CJK + "])(#([^ ]))", "g");
|
||||
var HASH_CJK = new RegExp("(([^ ])#)([" + CJK + "])", "g");
|
||||
@@ -1106,7 +1111,6 @@ function panguSpace(text) {
|
||||
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");
|
||||
|
||||
+24
-10
@@ -28,18 +28,32 @@ export const ANY_CJK = new RegExp("[" + CJK + "]");
|
||||
|
||||
// ----- Pangu 中西邊界用之擴充西文字集(沿用 v1)-----
|
||||
|
||||
// 拉丁補充以 ¡-¶¸-ÿ 表達(U+00A1–00B6, 00B8–00FF),刻意跳過 U+00B7 中間點
|
||||
// `·`——它是 CJK 間隔號(姓名分隔,如 卡尔·马克思),屬 CJK 標點而非西文,
|
||||
// 不應觸發中西間隙(否則圓點側會多一個空白)。
|
||||
/** CJK 之後可接間隙之西文集(含希臘字母、運算符、拉丁補充、箭號、裝飾符)。 */
|
||||
export const ANS_AFTER_CJK = (
|
||||
ALPHA + "Ͱ-Ͽ0-9@\\$%\\^&\\*\\-\\+\\\\=¡-¶¸-ÿ⅐-✀-➿"
|
||||
// 西文(外文)字集——CJK 與其相鄰時補中西間隙。以 regex 字元類片段表示,供
|
||||
// STRIP_*/CJK_ANS/ANS_CJK 等規則拼接。**列舉法(whitelist)**:逐區塊以
|
||||
// \uXXXX 明列並附名稱;未來新區塊於此補入即可。範圍皆 BMP(≤U+FFFF),\uXXXX
|
||||
// 於 regex 字元類內**無需 u flag** 即生效,故相容舊環境(2016/Windows 10 時代)。
|
||||
// 相對 v1 補入:拉丁擴充 A/B(拼音聲調)、西里爾、貨幣符號(下游回報之 €)、數學
|
||||
// 運算符;並把希臘由原 U+0370–037D 補全至整塊(原漏 Α-Ω α-ω 本身,致 `中文α`
|
||||
// 不補隙)。刻意跳過 U+00B7 中間點 `·`(CJK 間隔號,如 卡爾·馬克思,屬 CJK 標點,
|
||||
// 不觸發中西間隙)。
|
||||
const WEST_SHARED = (
|
||||
"0-9"
|
||||
+ "\\$%\\^&\\*\\-\\+\\\\=" // 常用運算符(字面:$ % ^ & * - + \ =)
|
||||
+ "\\u0370-\\u03ff" // 希臘+科普特(含 Α-Ω α-ω)
|
||||
+ "\\u0100-\\u024f" // 拉丁擴充 A/B(拼音聲調 ā ǎ ē ǐ ō ǔ)
|
||||
+ "\\u0400-\\u04ff" // 西里爾(俄文等)
|
||||
+ "\\u00a1-\\u00b6\\u00b8-\\u00ff" // 拉丁-1 補充(跳過 U+00B7 間隔號 ·)
|
||||
+ "\\u20a0-\\u20bf" // 貨幣符號(€ ₩ ₹ ₿ 等)
|
||||
+ "\\u2150-\\u21ff" // 數字形式+箭號
|
||||
+ "\\u2200-\\u22ff" // 數學運算符(∑ ∫ ≠ ≈ ± × ÷ 等)
|
||||
+ "\\u2700-\\u27bf" // dingbats 裝飾符
|
||||
);
|
||||
|
||||
/** CJK 之前可接間隙之西文集(同上,去 @)。 */
|
||||
export const ANS_BEFORE_CJK = (
|
||||
ALPHA + "Ͱ-Ͽ0-9\\$%\\^&\\*\\-\\+\\\\=¡-¶¸-ÿ⅐-✀-➿"
|
||||
);
|
||||
/** CJK 之後可接間隙之西文集(含 @)。 */
|
||||
export const ANS_AFTER_CJK = ALPHA + "@" + WEST_SHARED;
|
||||
|
||||
/** CJK 之前可接間隙之西文集(去 @)。 */
|
||||
export const ANS_BEFORE_CJK = ALPHA + WEST_SHARED;
|
||||
|
||||
// ----- 標點(biaodian)子類。對應 jz-char 之 bd-* class -----
|
||||
//
|
||||
|
||||
+31
-6
@@ -102,6 +102,15 @@ function firstRect(el: Element): DOMRect | null
|
||||
return rects.length ? rects[0]! : null;
|
||||
}
|
||||
|
||||
// 元素之**最後**一個行盒矩形。jz-hws 之 margin-right 落在其內容末尾之 fragment,
|
||||
// 故行末判定須以此 rect(而非 firstRect)為準——否則包裹會跨行之 pill 時,firstRect
|
||||
// 取到首行矩形、與末行之後續內容比較會誤判(見 trimGaps)。
|
||||
function lastRect(el: Element): DOMRect | null
|
||||
{
|
||||
const rects = el.getClientRects();
|
||||
return rects.length ? rects[rects.length - 1]! : null;
|
||||
}
|
||||
|
||||
function relayout(root: Element, finder: Finder): void
|
||||
{
|
||||
// 清除上次行端 class。
|
||||
@@ -164,17 +173,33 @@ const TRIM = "jz-hws-trim";
|
||||
|
||||
function trimGaps(root: Element, finder: Finder): void
|
||||
{
|
||||
root.querySelectorAll("jz-hws." + TRIM).forEach((e) => e.classList.remove(TRIM));
|
||||
const gaps = Array.from(root.querySelectorAll("jz-hws")).filter(
|
||||
(g) => finder.inScope(g) && !finder.isAvoided(g),
|
||||
);
|
||||
if (!gaps.length) { return; }
|
||||
|
||||
// 自我應驗陷阱(下游回饋 C/容器寬相關之「最後一個間隙消失」):間隙以 margin-right
|
||||
// 承載、會參與斷行。若依「後字是否在更低行」判行末,而後字落點又被 margin 推擠,便
|
||||
// 自相矛盾——間隙 margin 把後字擠到次行 → 判行末 → trim 去 margin → 後字回同行,振盪
|
||||
// 停在「已 trim、後字同行、間隙消失」之錯誤終態(ResizeObserver 只觀 root 尺寸、不因
|
||||
// 內部 margin 變化重觸發,故卡死)。**污染是雙向的**:不僅當前間隙自身 margin,**下游
|
||||
// 間隙之 margin** 亦會令夾在中間之內容(如 `或<gap>$1299<gap>`)放不下該行、致後字
|
||||
// 假性換行。
|
||||
//
|
||||
// 解法:**起始全部 trim**(margin 全 0),再由左至右逐一定案。量測某間隙時,其前皆已
|
||||
// 定案(最終 margin 狀態)、其自身與**其後全部**仍 trim(margin 0);故後字落點既不受
|
||||
// 自身、亦不受任何下游 margin 污染,純粹反映「無本間隙 margin 時後字是否仍被擠到次
|
||||
// 行」。後字仍在更低行(或無後字)→ 真行末、維持 trim;後字在同行 → 行內容得下、非真
|
||||
// 行末 → 還原間隙。逐一(非一次量全部)必要:斷行為左→右因果,當前間隙之行起點取決
|
||||
// 於其前已定案內容;讀取 getClientRects 觸發同步重排,故每次量測皆為最新佈局。以末行
|
||||
// rect(lastRect,margin 實際落點)為準,兼顧跨行 pill(行尾 code 折行亦曾致隙誤消)。
|
||||
gaps.forEach((g) => g.classList.add(TRIM));
|
||||
for (const g of gaps)
|
||||
{
|
||||
const r = firstRect(g);
|
||||
if (!r) { continue; }
|
||||
const next = edgeRect(g.nextSibling, "next", g);
|
||||
// 行末:後無內容,或後一字在更低行。
|
||||
if (!next || next.top > r.top + EPS) { g.classList.add(TRIM); }
|
||||
const r = lastRect(g);
|
||||
const next = r ? edgeRect(g.nextSibling, "next", g) : null;
|
||||
const atLineEnd = !r || !next || next.top > r.top + EPS;
|
||||
if (!atLineEnd) { g.classList.remove(TRIM); }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,10 @@ const STRIP_ANS_SPACE_CJK = new RegExp("([" + ANS_BEFORE_CJK + "]) ([" + CJK + "
|
||||
// 用 lookahead 使連續 CJK 空格逐一命中;只匹配 ASCII 空格,全形空格 U+3000 不動。
|
||||
const STRIP_CJK_SPACE_CJK = new RegExp("([" + CJK + "]) (?=[" + CJK + "])", "g");
|
||||
|
||||
const DOTS_CJK = new RegExp("([\\.]{2,}|…)([" + CJK + "])", "g");
|
||||
// 僅 ASCII 省略號 `...中文` 補隙;**不含**全角省略號 U+2026——全角 …/…… 屬 CJK
|
||||
// 標點(已由 jiya charify 為 bd-liga),後接 CJK 不應有中西間隙(下游回報問題 B:
|
||||
// 原 alternation `|…` 誤把全角納入,致 `……留` 間冒出 jz-hws)。
|
||||
const DOTS_CJK = new RegExp("([\\.]{2,})([" + CJK + "])", "g");
|
||||
const CJK_PUNCTUATION = new RegExp("([" + CJK + "])([!;,\\?:]+)(?=[" + CJK + ALNUM + "])", "g");
|
||||
const AN_PUNCTUATION_CJK = new RegExp("([" + ALNUM + "])([!;,\\?]+)([" + CJK + "])", "g");
|
||||
const CJK_TILDE = new RegExp("([" + CJK + "])(~+)(?!=)(?=[" + CJK + ALNUM + "])", "g");
|
||||
@@ -49,7 +52,10 @@ const AN_PERIOD_CJK = new RegExp("([" + ALNUM + "])(\\.)([" + CJK + "])", "g");
|
||||
const AN_COLON_CJK = new RegExp("([" + ALNUM + "])(:)([" + CJK + "])", "g");
|
||||
const CJK_QUOTE = new RegExp("([" + CJK + "])([" + QUOTES_CLS + "])", "g");
|
||||
const QUOTE_CJK = new RegExp("([" + QUOTES_CLS + "])([" + CJK + "])", "g");
|
||||
const QUOTE_AN = new RegExp("([”])([" + ALNUM + "])", "g");
|
||||
// 註:原 pangu 之 QUOTE_AN(`([”])([ALNUM])` → 收尾彎引號後接字母數字補隙)已移除。
|
||||
// 標點稽核發現它令收尾彎引號 ” 成為唯一「後接拉丁/數字會補隙」之收尾標點,與所有其他
|
||||
// 收尾標點(」』)》】〕…)行為不一致(後者皆不補);clreq 下全角收尾標點字身已內含
|
||||
// 右側留白,後接拉丁不需額外隙。故移除以求一致、消除誤隙(下游回饋之標點稽核)。
|
||||
const CJK_QUOTE_AN = new RegExp("([" + CJK + "])(\")([" + ALNUM + "])", "g");
|
||||
const CJK_HASH = new RegExp("([" + CJK + "])(#([^ ]))", "g");
|
||||
const HASH_CJK = new RegExp("(([^ ])#)([" + CJK + "])", "g");
|
||||
@@ -93,7 +99,6 @@ function panguSpace(text: string): string
|
||||
|
||||
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");
|
||||
|
||||
@@ -48,6 +48,61 @@ test("spacing:revert 還原至原文(無 jz-hws,textContent 回到原樣
|
||||
assert.equal(document.querySelector("p").textContent, "我用Neovim寫程式");
|
||||
});
|
||||
|
||||
test("spacing:全角省略號 …… 後接 CJK 不插中西間隙(問題 B:原 DOTS_CJK `|…` 誤匹配)", () =>
|
||||
{
|
||||
const { document } = setupDom('<p lang="zh-Hant">他竟然走了……留下一地</p>');
|
||||
createJuzhen({ lang: { style: "quanjiao" } }).render(document.body);
|
||||
const p = document.querySelector("p");
|
||||
// …… 屬 CJK 標點(已 charify 為 bd-liga),後接 CJK 不應有西文間隙。
|
||||
assert.equal(countTag(p, "jz-hws"), 0, "…… 與後接 CJK 之間不應有 jz-hws");
|
||||
});
|
||||
|
||||
test("spacing:貨幣符號 € 視為西文——與 CJK 邊界補隙、手工空格正規化(問題 D)", () =>
|
||||
{
|
||||
const { document } = setupDom('<p lang="zh-Hant">售價 €1099 都行</p>');
|
||||
createJuzhen({ jiya: false, longWord: false, jinze: false }).render(document.body);
|
||||
const p = document.querySelector("p");
|
||||
assert.equal(/售價\s€/.test(p.textContent), false, "售價↔€ 之手工空格應正規化剝除");
|
||||
assert.ok(countTag(p, "jz-hws") >= 2, "價↔€ 與 9↔都 兩處中西邊界各補 jz-hws");
|
||||
});
|
||||
|
||||
test("spacing:擴充西文集——拼音聲調 ā/希臘 α/西里爾 Я 皆視為西文補隙(問題 D 完整化)", () =>
|
||||
{
|
||||
const { document } = setupDom('<p lang="zh-Hant">中ā文α字Я尾</p>');
|
||||
createJuzhen({ jiya: false, longWord: false, jinze: false }).render(document.body);
|
||||
const p = document.querySelector("p");
|
||||
// ā(U+0101 拉丁擴充)、α(U+03B1 希臘,驗證希臘集補全)、Я(U+042F 西里爾) 皆為西文。
|
||||
assert.ok(countTag(p, "jz-hws") >= 5, "ā/α/Я 與相鄰 CJK 各成邊界、補 jz-hws");
|
||||
});
|
||||
|
||||
test("spacing:CJK 標點介於 CJK 間一律不補中西隙(標點稽核:。!?「」()《》……—— 等)", () =>
|
||||
{
|
||||
const samples = [
|
||||
"前。後", "前!後", "前?後", "前,後", "前、後", "前;後", "前:後",
|
||||
"前「後", "前」後", "前(後", "前)後", "前《後", "前》後", "前【後", "前】後",
|
||||
"前〔後", "前〕後", "前……後", "前——後", "前·後", "前~後", "前“後", "前”後",
|
||||
];
|
||||
for (const s of samples)
|
||||
{
|
||||
const { document } = setupDom('<p lang="zh-Hant">' + s + "</p>");
|
||||
createJuzhen({ lang: { style: "quanjiao" } }).render(document.body);
|
||||
assert.equal(countTag(document.querySelector("p"), "jz-hws"), 0, s + " 不應有 jz-hws");
|
||||
}
|
||||
});
|
||||
|
||||
test("spacing:收尾彎引號 ” 後接拉丁/數字不補隙(標點稽核移除 QUOTE_AN ” 偏例,與 」)》 一致)", () =>
|
||||
{
|
||||
// 原 pangu QUOTE_AN 令 ”+字母數字補隙,使 ” 成唯一會補隙之收尾標點(其他收尾標點皆不)。
|
||||
const { document } = setupDom('<p lang="zh-Hans">“中文”ABC 與 ”123</p>');
|
||||
createJuzhen({ jiya: false, longWord: false, jinze: false }).render(document.body);
|
||||
const p = document.querySelector("p");
|
||||
// “”ABC、”123 中之彎引號鄰拉丁/數字皆不補隙;惟「與」兩側作者空格為 CJK↔Latin
|
||||
// 邊界(與|ABC 之 ABC 結尾、123|與 之類)仍會處理,故只斷言不含「彎引號旁之隙」——
|
||||
// 以 textContent 不變 + 彎引號未被 jz-hws 包裹驗證。
|
||||
const hwsTexts = Array.from(p.querySelectorAll("jz-hws")).map((h) => h.textContent);
|
||||
assert.equal(hwsTexts.includes("”"), false, "收尾彎引號 ” 不應被包入 jz-hws(不補隙)");
|
||||
});
|
||||
|
||||
test("spacing:跨樣式行內邊界(<strong>是</strong>Neovim)亦識別(I7)", () =>
|
||||
{
|
||||
const { document } = setupDom("<p>這<strong>是</strong>Neovim</p>");
|
||||
|
||||
Reference in New Issue
Block a user