feat: 聚珍切片 1 — 中西間隙/標點擠壓/禁則 + v1 相容層
從零重寫之第一垂直切片(依 ARCHITECTURE.md 契約),涵蓋並超越 v1 全部功能。 核心基座(src/core):unicode 字元表+標點子類+字形側位;locale 語言與 全角/開明政策(全域 lang.style +逐 lang policy);dom(jz-* 工廠+可還原 標記);finder 遍歷引擎(avoid/scope.include/邏輯 run/pill 鄰字/分塊閘); pass 聚合管線。 功能 pass(src/typeset): - spacing:中西間隙採 margin 模型——包裹邊界左側字、以 margin-right 留隙, 不注入空白字元(textContent 完全不變、複製緊湊、justify 不暴增、不撐框、 行首乾淨)。含跨樣式邊界、pill、原檔空格移除。 - gapTrim(lineedge):layout pass,行末去隙,修正 justify 右緣內縮。 - longword:長英文詞 <span lang> + hyphens。 - jiya:標點 charify + 全角/開明/半角(全域 + 逐 lang);半形以字型 OpenType halt 渲染(按字形正確定位、不重疊,居中字形 !? 與繁體 locl 句逗點號皆正確); 行內連續擠壓。 - jinze:行首行末避頭尾(閉類/點號綁前字、開類綁後字,以詞元為邊界、長引文 中段可斷);亦為 justify 綁定載體。 - lineedge:行端擠壓 layout pass(括號引號;ResizeObserver 重算,瀏覽器專用)。 §8 justify:jz-char inline-block 原子,句末標點單份間距。 v1 相容層(compat/v1):createCjkAutospace().apply 映射,IIFE 全域別名。 構建:esbuild → ESM/IIFE/CSS;tsc → d.ts。測試:39 個 jsdom 案例。 demo:簡繁並列+justify+強制繁體開明+標點寬度量測,載 Noto webfont 作參考 (擠壓依賴字型 halt)。間隔號 · 不觸發中西間隙;200% 等詞元不被禁則切斷。
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
// 行端擠壓(架構 §6.4/§6.5 之行端部分;clreq 行首行尾擠壓)。機制 B。
|
||||
// standalone layout pass:量測各 jz-char 之行盒位置,對「行首之開類」收左、
|
||||
// 「行尾之閉類/點號」收右(加 jz-le-cl/jz-le-cr,CSS 同 -0.5em margin)。
|
||||
// 掛 ResizeObserver 於 root,容器寬度變動時重算;字型載入後亦重算。
|
||||
//
|
||||
// ⚠ 純版面、無法於 jsdom 驗證(getClientRects 回傳 0);故無 ResizeObserver
|
||||
// 之環境(jsdom)直接跳過,僅於真實瀏覽器生效(架構 §12/R1/R2)。須跨
|
||||
// Chrome/Safari/Firefox 目視核對。
|
||||
|
||||
import { bdClassOf } from "../core/dom.js";
|
||||
import type { Finder } from "../core/finder.js";
|
||||
import type { RenderContext, StandalonePass } from "../types.js";
|
||||
|
||||
const LE = "jz-half-le"; // 行端半形(CSS 同 halt;獨立 class 以便重算時只清行端結果)
|
||||
const EPS = 4; // px:判定「不同行」之 top 差容差。
|
||||
|
||||
// 每 root 各 layout pass(以 key 區分)一個 ResizeObserver。
|
||||
const observers = new WeakMap<Element, Map<string, ResizeObserver>>();
|
||||
|
||||
function hasLayout(): boolean
|
||||
{
|
||||
return typeof ResizeObserver !== "undefined";
|
||||
}
|
||||
|
||||
/** 安裝一個 layout 重算:立即跑一次,掛 ResizeObserver,字型載入後再跑。 */
|
||||
function installLayout(root: Element, key: string, run: () => void): void
|
||||
{
|
||||
run();
|
||||
let m = observers.get(root);
|
||||
if (!m) { m = new Map(); observers.set(root, m); }
|
||||
const existing = m.get(key);
|
||||
if (existing) { existing.disconnect(); }
|
||||
const ro = new ResizeObserver(() => run());
|
||||
ro.observe(root);
|
||||
m.set(key, ro);
|
||||
const fonts = (root.ownerDocument as Document & { fonts?: { ready?: Promise<unknown> } }).fonts;
|
||||
if (fonts && fonts.ready && typeof fonts.ready.then === "function")
|
||||
{
|
||||
fonts.ready.then(run, () => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function uninstallLayout(root: Element, key: string): void
|
||||
{
|
||||
const m = observers.get(root);
|
||||
if (!m) { return; }
|
||||
const ro = m.get(key);
|
||||
if (ro) { ro.disconnect(); m.delete(key); }
|
||||
}
|
||||
|
||||
// 我們的行內包裹:跨越它們找相鄰流內容(jz-char 可能被 jz-jinze 包住,其流
|
||||
// 鄰居在群組之外)。遇 block 邊界即止(回 null,視為行端)。
|
||||
const INLINE_WRAP = new Set([ "JZ-JINZE", "JZ-CHAR", "JZ-INNER", "JZ-HWS", "SPAN", "STRONG", "EM", "A", "B", "I", "U", "MARK" ]);
|
||||
|
||||
function rectOf(n: Node, side: "prev" | "next"): DOMRect | null
|
||||
{
|
||||
if (n.nodeType === 3 && (n as Text).data.length > 0)
|
||||
{
|
||||
const t = n as Text;
|
||||
const doc = t.ownerDocument;
|
||||
if (!doc) { return null; }
|
||||
const range = doc.createRange();
|
||||
const at = side === "prev" ? t.data.length - 1 : 0;
|
||||
range.setStart(t, at);
|
||||
range.setEnd(t, at + 1);
|
||||
const rects = range.getClientRects();
|
||||
if (rects.length) { return rects[side === "prev" ? rects.length - 1 : 0]!; }
|
||||
}
|
||||
else if (n.nodeType === 1)
|
||||
{
|
||||
const rects = (n as Element).getClientRects();
|
||||
if (rects.length) { return rects[side === "prev" ? rects.length - 1 : 0]!; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 取某節點側邊界字元之矩形(橫排:判定相鄰內容是否同行);跨我們的行內包裹
|
||||
* 向上攀爬,遇 block 邊界回 null(視為行端)。 */
|
||||
function edgeRect(start: Node | null, side: "prev" | "next", from: Node): DOMRect | null
|
||||
{
|
||||
let n = start;
|
||||
let parentRef: Node | null = from.parentNode;
|
||||
for (;;)
|
||||
{
|
||||
while (n)
|
||||
{
|
||||
const r = rectOf(n, side);
|
||||
if (r) { return r; }
|
||||
n = side === "prev" ? n.previousSibling : n.nextSibling;
|
||||
}
|
||||
// 本層無內容:若父為行內包裹,攀出續找其鄰居。
|
||||
if (!parentRef || parentRef.nodeType !== 1) { return null; }
|
||||
if (!INLINE_WRAP.has((parentRef as Element).nodeName)) { return null; }
|
||||
n = side === "prev" ? parentRef.previousSibling : parentRef.nextSibling;
|
||||
parentRef = parentRef.parentNode;
|
||||
}
|
||||
}
|
||||
|
||||
function firstRect(el: Element): DOMRect | null
|
||||
{
|
||||
const rects = el.getClientRects();
|
||||
return rects.length ? rects[0]! : null;
|
||||
}
|
||||
|
||||
function relayout(root: Element, finder: Finder): void
|
||||
{
|
||||
// 清除上次行端 class。
|
||||
root.querySelectorAll("jz-char." + LE).forEach((e) => e.classList.remove(LE));
|
||||
|
||||
const chars = Array.from(root.querySelectorAll("jz-char")).filter(
|
||||
(c) => finder.inScope(c) && !finder.isAvoided(c),
|
||||
);
|
||||
|
||||
for (const el of chars)
|
||||
{
|
||||
if (!finder.featureEnabledFor(el, "jiya")) { continue; }
|
||||
// 行端僅處理括號引號:開類於行首收左、閉類於行尾收右。
|
||||
// **不**處理句末/句內點號(。!?,等)——行尾/行首之點號保持全形,
|
||||
// 避免「孤立句末 。/?!之 ! 落在行末」被誤收為半形(實測之困惑來源)。
|
||||
// `。」` 行尾仍為 1em:其中 。 由鄰接(在 」 前)收,非由行端收。
|
||||
const bd = bdClassOf(el);
|
||||
if (bd !== "bd-open" && bd !== "bd-close") { continue; }
|
||||
const r = firstRect(el);
|
||||
if (!r) { continue; }
|
||||
|
||||
if (bd === "bd-open")
|
||||
{
|
||||
const prev = edgeRect(el.previousSibling, "prev", el);
|
||||
if (!prev || prev.top < r.top - EPS) { el.classList.add(LE); }
|
||||
}
|
||||
else
|
||||
{
|
||||
const next = edgeRect(el.nextSibling, "next", el);
|
||||
if (!next || next.top > r.top + EPS) { el.classList.add(LE); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const lineEdgePass: StandalonePass = {
|
||||
name: "jiyaLineEdge",
|
||||
kind: "standalone",
|
||||
order: 90,
|
||||
enabled: (o) => o.features.jiya,
|
||||
render(ctx: RenderContext): void
|
||||
{
|
||||
if (!hasLayout()) { return; } // jsdom 等無版面環境:跳過。
|
||||
installLayout(ctx.root, "lineEdge", () => relayout(ctx.root, ctx.finder));
|
||||
},
|
||||
revert(ctx: RenderContext): void
|
||||
{
|
||||
uninstallLayout(ctx.root, "lineEdge");
|
||||
ctx.root.querySelectorAll("jz-char." + LE).forEach((e) => e.classList.remove(LE));
|
||||
},
|
||||
};
|
||||
|
||||
// ----- 中西間隙之行末去隙(機制:margin-right 模型之 justify 修正)-----
|
||||
//
|
||||
// jz-hws 以 margin-right 留隙;若其包裹之左字落在**行末**(下一內容在更低行),
|
||||
// 該尾隨 margin 在 justify 下會使右緣內縮 0.25em。行末本不需間隙,故量測後對
|
||||
// 行末之 jz-hws 加 jz-hws-trim(CSS margin-right:0),該行遂齊右、無內縮。
|
||||
// 純版面、瀏覽器專用(jsdom 跳過)。
|
||||
|
||||
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),
|
||||
);
|
||||
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); }
|
||||
}
|
||||
}
|
||||
|
||||
export const gapTrimPass: StandalonePass = {
|
||||
name: "spacingGapTrim",
|
||||
kind: "standalone",
|
||||
order: 92,
|
||||
enabled: (o) => o.features.spacing,
|
||||
render(ctx: RenderContext): void
|
||||
{
|
||||
if (!hasLayout()) { return; }
|
||||
installLayout(ctx.root, "gapTrim", () => trimGaps(ctx.root, ctx.finder));
|
||||
},
|
||||
revert(ctx: RenderContext): void
|
||||
{
|
||||
uninstallLayout(ctx.root, "gapTrim");
|
||||
ctx.root.querySelectorAll("jz-hws." + TRIM).forEach((e) => e.classList.remove(TRIM));
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user