diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 428ecc6..9276de3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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** 亦會令夾在中間之內容(如 `或$1299`) + 放不下其行、致後字假性換行。 +- **修法**:`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 diff --git a/README.md b/README.md index 0e4723d..d14d889 100644 --- a/README.md +++ b/README.md @@ -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 結構正確性。 --- diff --git a/demo/resize-check.html b/demo/resize-check.html new file mode 100644 index 0000000..b710b55 --- /dev/null +++ b/demo/resize-check.html @@ -0,0 +1,81 @@ + + + + + +聚珍(Juzhen)— 中西間隙/標點 拖曳檢查頁 + + + + +

聚珍 — 中西間隙/標點 拖曳檢查頁

+

拖動每個方框右下角的把手調整寬度,觀察間隙在各寬度下是否恆穩、標點是否無誤隙。政策:quanjiao(全角式)。

+ +
+ + +
+ +

E 中西混排(重點:拖動時「最後一個間隙」不應消失、標點不應緊貼)

+

慢慢拖動寬度,每個 ¥9800/$1299/€1099 與中文間之隙都應始終存在;換行點落於任何邊界皆不致左字與後字緊貼。

+

價格 ¥9800 或 $1299 或 €1099 都可以接受呢,歡迎洽詢 hello@x.com 謝謝惠顧再聯絡。

+ +

C 行內 pill 跨行(重點:code 折行後,其右側與中文之間隙不應消失)

+

把方框拖窄使 code 內容折行,pill 末字與「之後」間應仍有隙。

+

前綴 git rebase --interactive --autosquash HEAD~10 feature 之後接中文,再補一段較長敘述以利觀察換行行為。

+ +

B 全角省略號(重點:…… 與後接中文之間不應有隙)

+

他竟然就這樣走了……留下一地,唉……真教人無言以對。

+ +

D 貨幣/拼音/西里爾/希臘/數學(重點:這些外文字元與中文間應有隙)

+

售價 €1099 或 $50 都行;拼音 Pīnyīn 含 ā ē ǐ ǒ ǔ;俄文 Привет 世界;希臘 α β γ 常見;數學 設 x≥0 且 ∑aᵢ≠0 皆需間隙。

+ +

標點稽核(重點:以下各式中文標點介於中文間,皆不應冒出西文間隙)

+

句號。逗號,頓號、分號;冒號:問號?嘆號!引號「引述」『書名』(圓括)【方頭】《書名》〈單名〉省略……破折——間隔·波浪~皆不應有中西間隙;收尾彎引號“例”後接 ABC 亦不補隙。

+ +

對照:簡體內容(zh-Hans 字形與標點)

+

这是简体测试,包含 Python 与 €99;引号“示例”和标点,;:。混排间隙与标点均应正确。

+ + + + + diff --git a/dist/core/unicode.d.ts b/dist/core/unicode.d.ts index f8461d8..e1b1ca4 100644 --- a/dist/core/unicode.d.ts +++ b/dist/core/unicode.d.ts @@ -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"; diff --git a/dist/juzhen.iife.js b/dist/juzhen.iife.js index 22438f1..18a279e 100644 --- a/dist/juzhen.iife.js +++ b/dist/juzhen.iife.js @@ -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"); diff --git a/dist/juzhen.mjs b/dist/juzhen.mjs index 34ff370..b3a0961 100644 --- a/dist/juzhen.mjs +++ b/dist/juzhen.mjs @@ -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"); diff --git a/src/core/unicode.ts b/src/core/unicode.ts index 61726f7..90d4fff 100644 --- a/src/core/unicode.ts +++ b/src/core/unicode.ts @@ -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 ----- // diff --git a/src/typeset/lineedge.ts b/src/typeset/lineedge.ts index 890db1f..90ba1bc 100644 --- a/src/typeset/lineedge.ts +++ b/src/typeset/lineedge.ts @@ -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** 亦會令夾在中間之內容(如 `或$1299`)放不下該行、致後字 + // 假性換行。 + // + // 解法:**起始全部 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); } } } diff --git a/src/typeset/spacing.ts b/src/typeset/spacing.ts index 6fec3a9..f8e3986 100644 --- a/src/typeset/spacing.ts +++ b/src/typeset/spacing.ts @@ -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"); diff --git a/test/juzhen.test.mjs b/test/juzhen.test.mjs index 2cb0f6c..ea11c3a 100644 --- a/test/juzhen.test.mjs +++ b/test/juzhen.test.mjs @@ -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('

他竟然走了……留下一地

'); + 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('

售價 €1099 都行

'); + 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('

中ā文α字Я尾

'); + 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('

' + s + "

"); + 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('

“中文”ABC 與 ”123

'); + 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:跨樣式行內邊界(Neovim)亦識別(I7)", () => { const { document } = setupDom("

Neovim

");