feat: 全角句點 . 納入句末挤压 + 連續標點挤压規則(雙側獨立+透明穿越)

本會話標點挤压一致性兩項(改內置默認;皆以 Noto CJK + 真實 Chromium 量測驗證)。

一、全角句點 .(U+FF0E)納入句末標點
- BD_STOP 由 。!? 擴為 。.!?——納入全角句點 .(異於表意句號 。U+3002)。實測
  Noto CJK SC/TC 之 halt(GPOS SinglePos)皆覆蓋 .、挤压收半形 advance 0.5em,與 。
  一致。inkBias 同步補 .(簡 left/繁 center),margin 後備亦可挤。
- 政策行為與 。!? 同:全角式與開明式句末保持全角 1em,banjiao(或鄰接被收)挤半。
- 設計原則:halt 即字型之「挤压意願」——字型不提供某標點 halt(字形占滿、不宜挤,如某些
  字型之全角 ?、〈〉)則信任字型、保持全角不強挤;margin 後備僅用於整體無 halt 之字型。

二、連續標點挤压規則:雙側獨立 + 透明穿越(修正既有不一致)
- 規則(用戶確定):相鄰兩標點,**對每一標點**獨立判斷——若其「有空白之側」緊鄰另一標點,
  即挤掉該側空白(收半形)。原 jiyaAdjacencyPass 為「二選一只收一側」,致:
  · ,「(pause 右空白 + open 左空白)原只收 ,→ 全角式 1.5em;應雙側皆收 → 1em。
  改 else-if 為雙獨立 if。修後:,「 全角式與開明式皆 1em;。」 全角式 1.5em/開明式 1em
  (」左為墨不收);·— 兩式皆 2em(不宜挤之例外)。
- 透明穿越:原以「同 parent 直接兄弟」掃描,漏掉跨 strong/em 等行內邊界之連續標點(致
  跨邊界標點對未挤、達 2em)。改為從 root 之透明遍歷(穿越樣式行內;block 邊界與 avoid
  子樹斷相鄰;jz-char 為原子不下探),跨 inline 邊界之連續標點亦正確配對。

- src/core/unicode.ts:BD_STOP +.;inkBias 表 +.。
- src/typeset/jiya.ts:jiyaAdjacencyPass 重寫(雙側收 + root 透明遍歷)。
- test:. 納入+連續挤压(雙側/句末單側/跨 inline/多標點鏈)回歸,70 測試通過。
- README/ARCHITECTURE:. 與連續挤压規則、halt 挤压意願原則;**涉長度處標明全角式/
  開明式**(。」 全角 1.5em/開明 1em 等)。dist 重建(僅 JS,juzhen.css 不變)。
This commit is contained in:
2026-06-09 15:19:35 +08:00
parent b821cd1134
commit fdf071c5bb
7 changed files with 231 additions and 102 deletions
+61 -12
View File
@@ -199,6 +199,49 @@ test("jiya 機制 A:連續開類,後者半形(「「→ 第二個 jz-half
assert.ok(opens[1].classList.contains("jz-half"), "第二個開類內側 → 半形");
});
test("jiya 機制 A:,「→ 兩側皆收半形(雙側獨立判斷,合占 1em)", () =>
{
// ,右側有空白且右鄰標點「→ 收,;「左側有空白且左鄰標點,→ 收「。兩者皆半 = 1em。
const { document } = setupDom('<p lang="zh-Hant">甲,「乙</p>');
createJuzhen({ spacing: false, longWord: false, jinze: false }).render(document.body);
const comma = document.querySelector("jz-char.bd-pause"); //
const open = document.querySelector("jz-char.bd-open"); // 「
assert.ok(comma.classList.contains("jz-half"), ",右空白鄰標點 → 半形");
assert.ok(open.classList.contains("jz-half"), "「左空白鄰標點 → 半形(雙側收,非二選一)");
});
test("jiya 機制 A:。」→ 僅 。半形(」左為墨、無空白可挤,全角式合占 1.5em)", () =>
{
const { document } = setupDom('<p lang="zh-Hant">甲。」乙</p>');
createJuzhen({ spacing: false, longWord: false, jinze: false }).render(document.body);
const stop = document.querySelector("jz-char.bd-stop"); // 。
const close = document.querySelector("jz-char.bd-close"); // 」
assert.ok(stop.classList.contains("jz-half"), "。右空白鄰標點 → 半形");
assert.ok(!close.classList.contains("jz-half"), "」左為墨、無空白 → 不收(全角式 1.5em)");
});
test("jiya 機制 A:跨透明 inline 邊界之連續標點亦正確擠(,「 分置 strong/em,雙側皆收)", () =>
{
// 下游回饋:原「同 parent 直接兄弟」掃描漏掉跨 strong/em 邊界之連續標點。
const { document } = setupDom('<p lang="zh-Hant">甲<strong></strong><em>「</em>乙</p>');
createJuzhen({ spacing: false, longWord: false, jinze: false }).render(document.body);
assert.ok(document.querySelector("jz-char.bd-pause").classList.contains("jz-half"), "跨邊界 ,半形");
assert.ok(document.querySelector("jz-char.bd-open").classList.contains("jz-half"), "跨邊界 「半形(透明穿越)");
});
test("jiya 機制 A:多標點鏈 ,」。逐對判斷(,半/」半/。全)", () =>
{
const { document } = setupDom('<p lang="zh-Hant">甲,」。乙</p>');
createJuzhen({ spacing: false, longWord: false, jinze: false }).render(document.body);
const p = document.querySelector("p");
const comma = p.querySelector("jz-char.bd-pause"); // ,右空白鄰」→ 半
const close = p.querySelector("jz-char.bd-close"); // 」右空白鄰。→ 半
const stop = p.querySelector("jz-char.bd-stop"); // 。左為墨、右鄰漢字 → 全
assert.ok(comma.classList.contains("jz-half"), ",→ 半");
assert.ok(close.classList.contains("jz-half"), "」右空白鄰標點。→ 半");
assert.ok(!stop.classList.contains("jz-half"), "。左為墨、右側無標點鄰 → 全");
});
test("jiyarevert 還原標點(無 jz-chartextContent 不變)", () =>
{
const { document } = setupDom('<p lang="zh-Hans">好,走。</p>');
@@ -511,21 +554,27 @@ test("CSS:聚珍不內建 font-family(字型交消費端),半形顯式
// ----- charClass:自定義字符分類規則集(向下兼容+擴充)-----
test("charClass:預設不傳時,英文全角句號 . 未分類(不 charify、不挤、不補隙", () =>
test("jiya:英文全角句號 . 已內建為句末標點 bd-stop(本次納入;Noto CJK 有 halt", () =>
{
const { document } = setupDom('<p lang="zh-Hans">中.文</p>');
// U+FF0E(異於表意句號 。U+3002)已納入內置 BD_STOP,預設即 charify 為 bd-stop。
const { document } = setupDom('<p lang="zh-Hans">中.文。</p>');
createJuzhen({ lang: { style: "kaiming" } }).render(document.body);
const p = document.querySelector("p");
assert.equal(countTag(p, "jz-char"), 0, " 預設未分類為標點,不 charify");
assert.equal(countTag(p, "jz-hws"), 0, " 預設非西文,無中西間隙");
const dot = Array.from(p.querySelectorAll("jz-char")).find((c) => c.textContent === "");
assert.ok(dot && dot.classList.contains("bd-stop"), " 預設 charify 為 bd-stop");
// 開明式句末不挤(與 。一致);banjiao 才挤——此處僅確認分類與政策一致。
assert.equal(dot.classList.contains("jz-half"), false, "開明式句末標點不挤半(保持全角,同 。)");
});
test("charClass.biaodian.stop:把 歸為全寬句末標點 → 被 jiya charify 為 bd-stop", () =>
test("charClass.biaodian.stop:把仍未分類之 ‥ 歸為全寬句末標點 → charify 為 bd-stop", () =>
{
const { document } = setupDom('<p lang="zh-Hans">中.文.</p>');
createJuzhen({ lang: { style: "kaiming" }, charClass: { biaodian: { stop: "" } } }).render(document.body);
const p = document.querySelector("p");
assert.ok(countTag(p, "jz-char.bd-stop") >= 1, " 應 charify 為 bd-stop");
// ‥ U+2025 預設未分類;以 charClass 歸句末標點以示「追加」機制。
const base = setupDom('<p lang="zh-Hans">中‥文</p>');
createJuzhen({ lang: { style: "kaiming" } }).render(base.document.body);
assert.equal(countTag(base.document.querySelector("p"), "jz-char"), 0, "‥ 預設未分類、不 charify");
const { document } = setupDom('<p lang="zh-Hans">中‥文</p>');
createJuzhen({ lang: { style: "kaiming" }, charClass: { biaodian: { stop: "‥" } } }).render(document.body);
assert.ok(countTag(document.querySelector("p"), "jz-char.bd-stop") >= 1, "‥ 經 charClass 歸 bd-stop");
});
test("charClass.western:把未分類符號 ★ 歸為西文 → 與 CJK 邊界補 jz-hws", () =>
@@ -586,10 +635,10 @@ test("charClass.western:追加不破壞內置——€(內置)與 ★(
test("charClass.biaodian:多子類同傳,各自生效(stoppause", () =>
{
const { document } = setupDom('<p lang="zh-Hans">甲乙•丙</p>');
createJuzhen({ lang: { style: "kaiming" }, charClass: { biaodian: { stop: "", pause: "•" } } }).render(document.body);
const { document } = setupDom('<p lang="zh-Hans">甲乙•丙</p>');
createJuzhen({ lang: { style: "kaiming" }, charClass: { biaodian: { stop: "", pause: "•" } } }).render(document.body);
const p = document.querySelector("p");
assert.ok(countTag(p, "jz-char.bd-stop") >= 1, " → bd-stop");
assert.ok(countTag(p, "jz-char.bd-stop") >= 1, " → bd-stop");
const pause = p.querySelector("jz-char.bd-pause");
assert.ok(pause && pause.classList.contains("jz-half"), "• → bd-pause 且開明式挤半形");
});