fix: 標點擠壓句界例外——句末點號接開類保留右空白(。「 全角/開明皆 1.5em)

機制 A 行內連續擠壓原對 (L,R) 兩側獨立收:L 有右空白即收,不看 R 是誰,致句末
點號接開類(。「/?「)兩側皆收、合占 1em,丟失句界視覺。修為:L 為句末點號
(bd-stop)且 R 為開類(bd-open)時保留 L 右空白、僅收開類左側 → 全角式與開明式
皆 1.5em,對稱於 。」。例外僅限句末點號+開類;句內點號 ,「 與雙括號 」「/》《
仍兩側皆收 1em。

半角式不回歸:句末點號於 banjiao 由 baseline(機制 C)已收半形,守衛僅跳過
adjacency 之重複追加、不移除 baseline 之 jz-half;實測 32 可壓縮標點全半形、句界
組合句末點號仍半形(0 回歸)。唯非可壓縮 ·・‧/…— 各式皆全寬,屬設計。

唯一改動點 jiyaAdjacencyPass.squeeze() 加 stopBeforeOpen 守衛;lineedge/baseline
/jinze/spacing 不波及。新增 3 契約測試(116 全綠);README/ARCHITECTURE §6.3
同步句界例外。
This commit is contained in:
2026-06-13 15:35:03 +08:00
parent 65a9df561e
commit 6c57461b61
6 changed files with 70 additions and 11 deletions
+4 -1
View File
@@ -467,7 +467,10 @@ class 約定:`bd`(標點)、`bd-open/close/cop/stop/sep/liga`、`cjk`、`j
判斷——若其「有空白之側」緊鄰另一標點,則挤掉該側空白(收半形 `jz-half`)。故 `,「`
pause 右空白 + open 左空白)兩側皆收 → 全角式與開明式皆合占 1em;`。」`stop 右空白;
」左為墨)只收 ``→ 全角式 1.5em/開明式 1em(」baseline 已半);`·—`(皆無指向對方之
空白)皆不收 → 兩式皆 2em(不宜挤之例外)。多標點鏈逐對處理。`jiyaAdjacencyPass` 以**從 root 之透明遍歷**(穿越 strong
空白)皆不收 → 兩式皆 2em(不宜挤之例外)。**句界例外**:句末點號(`。.!?`)後接開類
`。「``?「`)為句界,**保留句末點號右側空白、不收 L**,僅收開類左側 → 全角式與開明式皆
1.5em(對稱於 `。」`,半角式句末點號本已半形故仍 1em)。例外**僅限**句末點號+開類;句內點號
`,「` 與雙括號 `」「``》《`(同句相連/貼合,非句界)仍兩側皆收 1em。多標點鏈逐對處理。`jiyaAdjacencyPass` 以**從 root 之透明遍歷**(穿越 strong
em… 等樣式行內;block/pill/avoid(自身判定)斷相鄰且不下探,isolate 斷相鄰但內部
自成脈絡照常配對——§4.1 三分類;jz-char 為原子不下探)取代原「同 parent
直接兄弟」掃描,使**跨 inline 邊界**之連續標點亦正確配對(原漏致跨 strong/em 之標點對未挤、
+1 -1
View File
@@ -67,7 +67,7 @@ jz.render(document.querySelector("main"));
|---|---|
| **中西間隙** `spacing` | CJK↔西文邊界插入 copy-clean 之 `jz-hws``user-select:none`,剪貼板跳過)。「西文」涵蓋拉丁(含擴充 A/B,拼音聲調 `ā ǎ ē`)、希臘、西里爾、數字、貨幣(`€ ₩ ₿`)、數學運算符等;CJK 標點(`。,「」()……` 等)不誤判為西文、不生隙。含跨樣式行內邊界與 pill/隔離元素邊界(`code``kbd``.katex``mark`,兩端補隙口徑一致;pill 緊鄰標點時穿越 `jz-jinze` 仍補間隙;pill 跨行右隙、間隙落行末之自我應驗皆已修)。**pill 之邏輯鄰字為標點時(如 `漢字。<code>`)不誤加間隙**(標點非 pill 鄰字;`jz-char``jz-inner` 透明穿越,與 `collectRuns` 一致)。贅餘作者空格一律正規化:CJK↔西文吸收為受管間隙、CJK↔CJK(含跨 inline 標籤)剝除不留隙。 |
| **長英文詞** `longWord` | `[A-Za-z]{6,}` 包入 `<span lang="en">`,配合 CSS `hyphens:auto` 原生斷詞(DOM 無連字符 → 複製不污染)。 |
| **標點擠壓** `jiya` | 標點 charify 為 `jz-char``bd-*` class。三機制:①baseline 政策(全角式不擠/開明式擠句內點號+括號引號/半角式全擠);②行內連續擠壓(相鄰標點**雙側獨立**收——各標點若其有空白之側緊鄰另一標點即收半,故 `,「` 全角式與開明式皆 1em、`。」` 全角式 1.5em/開明式 1em;透明穿越 `strong``em` 等行內邊界);③行端擠壓(`lineedge` layout pass)。半形預設以字型 `halt` 渲染(按字形正確定位,居中字形如 `!?`/繁體 `。,` 不重疊;不依賴 `text-spacing-trim`)。**字型缺 `halt` 時可切 `margin` 後備模式**(見下節)。 |
| **標點擠壓** `jiya` | 標點 charify 為 `jz-char``bd-*` class。三機制:①baseline 政策(全角式不擠/開明式擠句內點號+括號引號/半角式全擠);②行內連續擠壓(相鄰標點**雙側獨立**收——各標點若其有空白之側緊鄰另一標點即收半,故 `,「` 全角式與開明式皆 1em、`。」` 全角式 1.5em/開明式 1em**唯句末點號接開類為句界**——`。「``?「` 保留句末點號右空白、僅收開類,全角式與開明式皆 1.5em;透明穿越 `strong``em` 等行內邊界);③行端擠壓(`lineedge` layout pass)。半形預設以字型 `halt` 渲染(按字形正確定位,居中字形如 `!?`/繁體 `。,` 不重疊;不依賴 `text-spacing-trim`)。**字型缺 `halt` 時可切 `margin` 後備模式**(見下節)。 |
| **禁則** `jinze` | 行首行末避頭尾:閉類/點號綁前一字(不置行首)、開類綁後一字(不置行末),只隔離邊界字故長引文中段仍可斷。亦為 `justify` 綁定載體。邊界字落於**透明**行內樣式元素(`strong``em` 等)內時**克隆切分**只綁邊界詞元(避免整個元素入 `nowrap` 致行末整體掉行、上行留巨大空隙),切出之克隆標 `data-jz-kind="splitoff"``revert` 時自動併回原元素;不可切元素(`a``abbr``ruby`/帶 `id`,以及 **pill/隔離/avoid**——見「行內元素三分類」)退化為**整元素綁定**——pill(如行內公式 `.katex`)即作為原子鄰字單位整體綁入 `jz-jinze`,絕不下潛肢解其內部。 |
| **垂懸字避免** `orphan` | 段末行 CJK 實義字 ≥ N(預設 2;標點/符號不計)。方案 C(斷行層、無測量):把段末「第 N 個實義字起至段末」包入 `jz-orphan``white-space:nowrap`),整體不可斷 → 末行恒含之 ⇒ 末行實義字 ≥ N,任意寬度/字型成立。單行段無害。邊界字落於行內元素內時同樣**克隆切分**(只綁末 N 字、避免整個長元素入 `nowrap` 溢出容器;機制同禁則)。**預設開**(`orphan:false` 關,`{chars:n}` 調)。段落級功能。 |
| ~~**標點懸掛** `hanging`~~ | **已移除**(§6.5.6):純負 margin 機制為機制級死路(寬度守恆矛盾,CSS 接觸不到斷行器),原生 `hanging-punctuation` 為唯一正解但僅 Safari 支援、且與本庫 inline-block 原子互斥。`hanging` 選項仍被接受(API 相容)但**無任何效果**;相關程式碼/CSS 已全數刪除,原生路徑屬遠期「原生管線」新功能。 |
+6 -3
View File
@@ -858,9 +858,12 @@ var Juzhen = (() => {
if (!finder.featureEnabledFor(L, PASS)) {
return;
}
const lSides = blankSides(bdClassOf(L));
const rSides = blankSides(bdClassOf(R));
if (lSides.right) {
const lClass = bdClassOf(L);
const rClass = bdClassOf(R);
const lSides = blankSides(lClass);
const rSides = blankSides(rClass);
const stopBeforeOpen = lClass === "bd-stop" && rClass === "bd-open";
if (lSides.right && !stopBeforeOpen) {
L.classList.add("jz-half");
}
if (rSides.left) {
+6 -3
View File
@@ -831,9 +831,12 @@ var jiyaAdjacencyPass = {
if (!finder.featureEnabledFor(L, PASS)) {
return;
}
const lSides = blankSides(bdClassOf(L));
const rSides = blankSides(bdClassOf(R));
if (lSides.right) {
const lClass = bdClassOf(L);
const rClass = bdClassOf(R);
const lSides = blankSides(lClass);
const rSides = blankSides(rClass);
const stopBeforeOpen = lClass === "bd-stop" && rClass === "bd-open";
if (lSides.right && !stopBeforeOpen) {
L.classList.add("jz-half");
}
if (rSides.left) {
+11 -3
View File
@@ -114,6 +114,9 @@ export const jiyaPass: CharifyPass = {
// ・,「(pause 右空白 + open 左空白):兩側皆收 → 合占 1em。
// ・。」(stop 右空白 + close 右空白,」左為墨):只收 。→ 全角式 1.5em/開明式
// 1em(」baseline 已半)。
// ・。「(句末點號 + 開類):此為**句界**——保留句末點號右側空白(不收 L),只收
// 開類左側 → 全角/開明皆 1.5em(對稱於 。」)。**唯一例外**:限句末點號(。.!?)
// 後接開類;句內點號 ,「 與雙括號 」「/》《 仍兩側皆收 1em(屬同句/貼合,非句界)。
// ・·—(中點/連接,皆無指向對方之空白):皆不收 → 2em(不宜挤之例外)。
// 機制依 clreq「相鄰空白重疊即挤」。**透明穿越**:以從 root 之遍歷取代原「同 parent
// 直接兄弟」掃描,使跨 strong/em 邊界之連續標點亦正確配對(下游回饋;block 邊界與
@@ -135,9 +138,14 @@ export const jiyaAdjacencyPass: StandalonePass = {
const squeeze = (L: Element, R: Element): void =>
{
if (!finder.featureEnabledFor(L, PASS)) { return; }
const lSides = blankSides(bdClassOf(L));
const rSides = blankSides(bdClassOf(R));
if (lSides.right) { L.classList.add("jz-half"); }
const lClass = bdClassOf(L);
const rClass = bdClassOf(R);
const lSides = blankSides(lClass);
const rSides = blankSides(rClass);
// 句界例外:句末點號後接開類(。「)保留句末點號右側空白,不收 L(→ 1.5em,
// 對稱於 。」)。僅此一例;句內點號 ,「 與雙括號 」「 仍照常兩側收。
const stopBeforeOpen = lClass === "bd-stop" && rClass === "bd-open";
if (lSides.right && !stopBeforeOpen) { L.classList.add("jz-half"); }
if (rSides.left) { R.classList.add("jz-half"); }
};
+42
View File
@@ -241,6 +241,48 @@ test("jiya 機制 A:。」→ 僅 。半形(」左為墨、無空白可挤
assert.ok(!close.classList.contains("jz-half"), "」左為墨、無空白 → 不收(全角式 1.5em)");
});
test("jiya 機制 A 句界:。「→ 句末點號保留右空白(。全形、「半形;全角/開明皆 1.5em)", () =>
{
for (const lang of [ "zh-Hant", "zh-Hans" ]) // 全角式/開明式皆 1.5em
{
const { document } = setupDom(`<p lang="${lang}">甲。「乙</p>`);
createJuzhen({ spacing: false, longWord: false, jinze: false }).render(document.body);
const stop = document.querySelector("jz-char.bd-stop"); // 。
const open = document.querySelector("jz-char.bd-open"); // 「
assert.ok(!stop.classList.contains("jz-half"), `${lang}:。句末點號接開類為句界 → 保留右空白(全形)`);
assert.ok(open.classList.contains("jz-half"), `${lang}:「開類左空白仍收(半形)→ 合占 1.5em`);
}
});
test("jiya 機制 A 句界:例外僅限句末點號+開類——,「 與 」「 仍兩側皆收 1em", () =>
{
// 句內點號(pause)與閉類後接開類不屬句界,照舊兩側收。
const c = setupDom('<p lang="zh-Hant">甲,「乙</p>');
createJuzhen({ spacing: false, longWord: false, jinze: false }).render(c.document.body);
assert.ok(c.document.querySelector("jz-char.bd-pause").classList.contains("jz-half"), ",仍收(句內點號非句界)");
assert.ok(c.document.querySelector("jz-char.bd-open").classList.contains("jz-half"), ",「之「仍收");
const b = setupDom('<p lang="zh-Hant">甲」「乙</p>');
createJuzhen({ spacing: false, longWord: false, jinze: false }).render(b.document.body);
assert.ok(b.document.querySelector("jz-char.bd-close").classList.contains("jz-half"), "」仍收(雙括號非句界)");
assert.ok(b.document.querySelector("jz-char.bd-open").classList.contains("jz-half"), "」「之「仍收");
});
test("jiya 機制 A 句界:半角式不回歸——。「 句末點號仍半形(守衛僅跳過 adjacency 追加,baseline 已半)", () =>
{
// 半角式所有可壓縮標點皆半形(baseline 機制 C)。句界守衛只跳過 adjacency 之重複
// 追加,絕不移除 baseline 之 jz-half,故半角式句末點號不得退回全寬。
const { document } = setupDom("<p>甲。「乙</p>");
createJuzhen({
lang: { default: "zh-Hans", style: "banjiao" },
spacing: false, longWord: false, jinze: false,
}).render(document.body);
const stop = document.querySelector("jz-char.bd-stop"); // 。
const open = document.querySelector("jz-char.bd-open"); // 「
assert.ok(stop.classList.contains("jz-half"), "半角式 。仍半形(baseline 已收,守衛不回退)");
assert.ok(open.classList.contains("jz-half"), "半角式 「仍半形 → 合占 1em");
});
test("jiya 機制 A:跨透明 inline 邊界之連續標點亦正確擠(,「 分置 strong/em,雙側皆收)", () =>
{
// 下游回饋:原「同 parent 直接兄弟」掃描漏掉跨 strong/em 邊界之連續標點。