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
+44 -33
View File
@@ -107,14 +107,17 @@ export const jiyaPass: CharifyPass = {
// ----- 行內連續擠壓(機制 A-----
//
// 對 DOM 直接相鄰(nextSibling 即下一 jz-char、其間無文本)之標點對 (L, R):
// 收掉「指向彼此」之那一側——L 若有右空白則收 L(半形),否則 R 若有左空白
// 則收 R。每相鄰對只收一字 → 該對約 1.5em。鏈狀連續標點逐對處理。加 jz-half
// 冪等(並集),與 baseline 疊加(已半形則 no-op)。class 掛於 charify 之
// jz-char,隨其解包清除,本 pass 無獨立產物。
//
// 半形以字型 halt 渲染(居中字形如 ?!亦正確、不重疊);例:?!→ 只收 ?,
// !保持全形 1em。
// 對「邏輯相鄰」之標點對 (L, R)——其間只隔**透明行內邊界**(樣式行內 strong/em…),
// 無文本、無非透明元素、不跨 block——挤掉兩者「指向彼此」之空白:**兩側獨立判斷**
// (非二選一)——L 右側有空白則收 L(半形),R 左側有空白則收 R。故:
// ・,「(pause 右空白 + open 左空白):兩側皆收 → 合占 1em。
// ・。」(stop 右空白 + close 右空白,」左為墨):只收 。→ 全角式 1.5em/開明式
// 1em(」baseline 已半)。
// ・·—(中點/連接,皆無指向對方之空白):皆不收 → 2em(不宜挤之例外)。
// 機制依 clreq「相鄰空白重疊即挤」。**透明穿越**:以從 root 之遍歷取代原「同 parent
// 直接兄弟」掃描,使跨 strong/em 邊界之連續標點亦正確配對(下游回饋;block 邊界與
// avoid 子樹斷相鄰)。半形以 halt 渲染、冪等並集,與 baseline 疊加。class 掛於
// charify 之 jz-char,隨其解包清除,本 pass 無獨立產物。
export const jiyaAdjacencyPass: StandalonePass = {
name: "jiyaAdjacency",
@@ -123,37 +126,45 @@ export const jiyaAdjacencyPass: StandalonePass = {
enabled: (o) => o.features.jiya,
render(ctx: RenderContext): void
{
const { finder } = ctx;
const parents = new Set<Node>();
ctx.root.querySelectorAll("jz-char").forEach((c) =>
{
if (c.parentNode) { parents.add(c.parentNode); }
});
const { finder, options } = ctx;
const blockSet = options.finder.blockTags;
let prev: Element | null = null;
for (const parent of parents)
const squeeze = (L: Element, R: Element): void =>
{
let child: Node | null = parent.firstChild;
while (child)
if (!finder.featureEnabledFor(L, PASS)) { return; }
const lSides = blankSides(bdClassOf(L));
const rSides = blankSides(bdClassOf(R));
if (lSides.right) { L.classList.add("jz-half"); }
if (rSides.left) { R.classList.add("jz-half"); }
};
const visit = (node: Node): void =>
{
for (let c: Node | null = node.firstChild; c; c = c.nextSibling)
{
const next = child.nextSibling;
if (
child.nodeType === 1 && (child as Element).nodeName === "JZ-CHAR"
&& next && next.nodeType === 1 && (next as Element).nodeName === "JZ-CHAR"
)
if (c.nodeType === 3)
{
const L = child as Element;
const R = next as Element;
if (finder.featureEnabledFor(L, PASS))
{
const lSides = blankSides(bdClassOf(L));
const rSides = blankSides(bdClassOf(R));
if (lSides.right) { L.classList.add("jz-half"); }
else if (rSides.left) { R.classList.add("jz-half"); }
}
if ((c as Text).data.length > 0) { prev = null; } // 文本斷相鄰
continue;
}
child = next;
if (c.nodeType !== 1) { continue; } // 註解等:略過、不斷
const el = c as Element;
const nm = el.nodeName;
if (nm === "JZ-CHAR")
{
if (prev) { squeeze(prev, el); }
prev = el; // jz-char 為原子,不下探(內含 jz-inner)
}
else if (finder.isAvoided(el)) { prev = null; } // avoid/skip 子樹:斷、不下探
else if (blockSet.has(nm))
{
prev = null; visit(el); prev = null; // block 邊界:前後皆斷相鄰,內部另計
}
else { visit(el); } // 行內(樣式行內 / pill 等):透明穿越、相鄰性延續
}
}
};
visit(ctx.root);
},
revert(): void
{