feat: 標點半形 margin 後備模式+下游回饋修復

margin 後備(供缺 OpenType halt 之字型):
- jiya.halfWidth: "halt"(預設)|"margin"。margin 以 width:0.5em+字身依墨色
  偏側溢出定位,於 CSS 盒模型重現 halt 之半形 advance(真 0.5em;相鄰標點各
  0.5em、合佔 1em,無負 margin 之鄰接疊加/單側重疊)。模式由 data-jz-halfwidth
  子樹屬性決定(可逐區塊/逐 lang,含 halt-island 反置)。
- 偏側 inkBias 依字身墨色中心給 left/center/right/full;參考表據方正書宋 GBK
  實測,consumer 可經 jiya.bias 逐 lang 逐字覆寫。
- _lang.css 字型堆疊僅作後備(全包 :where 零特異度、只命中帶 lang 屬性之元素、
  不直接覆寫 jz-inner)→ 消費端 font-family 恆優先,標點隨之以消費端字型渲染。
- 新增 tools/measure-bias.py(量 glyph ink 中心、產偏側建議表)+ README
  「字型適配工作流」。demo/fallback.html:方正書宋 GBK 對照 halt 失效 vs margin。

下游回饋修復:
- 1a:adjacentLogicalChar 透明穿越 jz-jinze(同 jz-hws/樣式行內)→ pill 緊鄰
  CJK 標點時,左緣間隙與作者空格剝除復原(jinze 先於 spacing 之交互)。
- 1b:CJK↔CJK 贅餘作者空格(含跨 inline 標籤)剝除、不留隙,與 CJK↔Latin 之
  邊界正規化一致(DEL 哨符,全形空格 U+3000 不動)。
- 問題二:新增 justifyAtoms 選項;false → jz-char/jz-jinze 改 display:inline,
  相容分頁器(Paged.js)對齊引擎(halt 半形與禁則 nowrap 保留)。

測試 43→49 全通過;dist 重建;README/ARCHITECTURE 同步更新契約。
This commit is contained in:
2026-06-02 01:21:52 +08:00
parent 6834029c31
commit 7b4a051a1f
20 changed files with 1028 additions and 66 deletions
+16 -4
View File
@@ -14,9 +14,9 @@
// 同時為 §8:所有 jz-char 經 CSS 設 inline-block,成 justify 單一原子項(I5)。
import { bdClassOf, createJz, revertPass } from "../core/dom.js";
import { resolveStyle } from "../core/locale.js";
import type { PunctStyle } from "../core/locale.js";
import { blankSides, classifyBiaodian } from "../core/unicode.js";
import { classify, effectiveLang, resolveStyle } from "../core/locale.js";
import type { LangClass, PunctStyle } from "../core/locale.js";
import { blankSides, classifyBiaodian, inkBias } from "../core/unicode.js";
import type { CharifyPass, RenderContext, StandalonePass, WrapRequest } from "../types.js";
const PASS = "jiya";
@@ -64,13 +64,25 @@ export const jiyaPass: CharifyPass = {
const data = node.data;
const out: WrapRequest[] = [];
let style: PunctStyle | null = null;
let langClass: LangClass | null = null;
const biasOverrides = ctx.options.jiyaConfig.bias;
for (let i = 0; i < data.length; i += 1)
{
const bdClass = classifyBiaodian(data.charAt(i));
const ch = data.charAt(i);
const bdClass = classifyBiaodian(ch);
if (!bdClass) { continue; }
if (style === null) { style = resolveStyle(node, ctx.options.locale); }
if (langClass === null)
{
langClass = classify(effectiveLang(node, ctx.options.locale.default));
}
const classes = baselineHalf(style, bdClass) ? [ bdClass, "jz-half" ] : [ bdClass ];
// 字形偏側 classjz-ink-*)— 供 margin 後備模式收對側半形空白;halt 模式
// 之 CSS 不讀,無害。一律於 charify 標記(偏側為字形固有屬性,與何時擠壓
// 無關),鄰接/行端 pass 後續加 jz-half 即可沿用。
const bias = inkBias(ch, langClass, biasOverrides);
if (bias) { classes.push("jz-ink-" + bias); }
out.push({
start: i,
end: i + 1,
+20 -3
View File
@@ -28,8 +28,16 @@ const RBR_BASIC = "\\)\\]\\}";
const LBR_EXT = "\\(\\[\\{<>";
const RBR_EXT = "\\)\\]\\}<>";
// 純刪除哨符(U+E003):標記「此處原作者空格應刪除、不留隙」(CJK↔CJK,問題 1b)。
// 與空格 1:1 替換以保持 spacedtext 對齊(processBlock 據此刪空格而不插 jz-hws)。
const DEL = String.fromCharCode(0xE003);
const STRIP_CJK_SPACE_ANS = new RegExp("([" + CJK + "]) ([" + ANS_AFTER_CJK + "])", "g");
const STRIP_ANS_SPACE_CJK = new RegExp("([" + ANS_BEFORE_CJK + "]) ([" + CJK + "])", "g");
// CJK↔CJK 之單一作者空格(含跨樣式行內標籤,collectRuns 已透明合併):中文不用
// 詞間空格,此類空格幾乎必為贅餘 → 以 DEL 標記刪除(1b,邊界正規化一致性)。
// 用 lookahead 使連續 CJK 空格逐一命中;只匹配 ASCII 空格,全形空格 U+3000 不動。
const STRIP_CJK_SPACE_CJK = new RegExp("([" + CJK + "]) (?=[" + CJK + "])", "g");
const DOTS_CJK = new RegExp("([\\.]{2,}|…)([" + CJK + "])", "g");
const CJK_PUNCTUATION = new RegExp("([" + CJK + "])([!;,\\?:]+)(?=[" + CJK + ALNUM + "])", "g");
@@ -72,6 +80,7 @@ function panguSpace(text: string): string
let s = text;
s = s.replace(STRIP_CJK_SPACE_ANS, "$1$2");
s = s.replace(STRIP_ANS_SPACE_CJK, "$1$2");
s = s.replace(STRIP_CJK_SPACE_CJK, "$1" + DEL);
s = s.replace(DOTS_CJK, "$1" + MARK + "$2");
s = s.replace(CJK_PUNCTUATION, "$1$2" + MARK);
@@ -164,6 +173,14 @@ function processBlock(block: Element, finder: Finder): void
while (spacedIdx < spaced.length)
{
const sc = spaced.charAt(spacedIdx);
if (sc === DEL)
{
// CJK↔CJK 贅餘空格:純刪除、不留隙(before 留空)。
actions.push({ before: undefined, space: map[origIdx]! });
origIdx += 1;
spacedIdx += 1;
continue;
}
if (sc === MARK)
{
const oc = origIdx < text.length ? text.charAt(origIdx) : null;
@@ -183,17 +200,17 @@ function processBlock(block: Element, finder: Finder): void
spacedIdx += 1;
}
// 倒序執行,避免前面動作之 offset 被後面變更影響。
// 倒序執行,避免前面動作之 offset 被後面變更影響。先刪空格(若有)、再包左字
// 留隙(若有 before)。刪空格在較高 offset、不擾動更前之 before。
for (let i = actions.length - 1; i >= 0; i -= 1)
{
const act = actions[i]!;
if (!act.before) { continue; }
if (act.space)
{
const tn = act.space.textNode;
tn.data = tn.data.slice(0, act.space.offset) + tn.data.slice(act.space.offset + 1);
}
wrapGapLeft(act.before);
if (act.before) { wrapGapLeft(act.before); }
}
}
}