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:
2026-06-01 21:31:07 +08:00
parent 8098991b6b
commit 2d42c7ecf2
46 changed files with 7737 additions and 27 deletions
+145
View File
@@ -0,0 +1,145 @@
// 聚珍(Juzhen)公開入口(架構 §3.1)。
//
// const jz = createJuzhen(options);
// jz.render(root?); // 套用已啟用之 pass
// jz.revert(root?); // 還原
//
// 全域(IIFE):window.Juzhen.createJuzhen(...);並掛 v1 相容別名
// globalThis.createCjkAutospace(見 build.mjs footer 與 compat/v1.ts)。
import { Finder } from "./core/finder.js";
import { runPasses, revertPasses } from "./core/pass.js";
import { jiyaAdjacencyPass, jiyaPass } from "./typeset/jiya.js";
import { jinzePass } from "./typeset/jinze.js";
import { gapTrimPass, lineEdgePass } from "./typeset/lineedge.js";
import { longWordPass } from "./typeset/longword.js";
import { spacingPass } from "./typeset/spacing.js";
import type { JuzhenOptions, LongWordOptions, Pass, RenderContext, ResolvedOptions } from "./types.js";
export type { JuzhenOptions, ResolvedOptions, Pass };
export { createCjkAutospace } from "./compat/v1.js";
const DEFAULT_STYLE_INLINES = [
"STRONG", "EM", "A", "B", "I", "U", "S", "MARK", "INS", "DEL",
"SUB", "SUP", "SMALL", "ABBR", "CITE", "DFN", "Q", "SPAN",
"VAR", "SAMP", "TIME", "BDO", "BDI", "RUBY", "RB", "RT", "RP", "WBR",
];
const DEFAULT_BLOCK_TAGS = [
"BODY", "P", "DIV", "H1", "H2", "H3", "H4", "H5", "H6", "LI",
"DT", "DD", "BLOCKQUOTE", "FIGURE", "FIGCAPTION", "TD", "TH",
"CAPTION", "SUMMARY", "DETAILS", "HEADER", "FOOTER", "SECTION",
"ARTICLE", "ASIDE", "NAV", "MAIN", "ADDRESS", "PRE", "UL", "OL",
"DL", "TABLE", "TR", "THEAD", "TBODY", "TFOOT", "FORM",
"FIELDSET", "LEGEND", "LABEL", "BR", "HR",
];
const DEFAULT_SKIP_TAGS = [
"CODE", "KBD", "PRE", "SAMP", "TT", "VAR",
"SCRIPT", "STYLE", "TEXTAREA", "INPUT",
];
const PASSES: Pass[] = [
jiyaPass, jiyaAdjacencyPass, longWordPass, spacingPass, jinzePass,
lineEdgePass, gapTrimPass,
];
function feature(v: unknown, dflt: boolean): boolean
{
return v === undefined ? dflt : !!v;
}
function resolveLongWord(v: boolean | LongWordOptions | undefined): { minLength: number; lang: string }
{
if (v === false) { return { minLength: 6, lang: "en" }; } // 值不用(enabled 為 false
if (v === undefined || v === true || v === null) { return { minLength: 6, lang: "en" }; }
const minLength = typeof v.minLength === "number" && v.minLength >= 2 ? v.minLength : 6;
const lang = typeof v.lang === "string" && v.lang ? v.lang : "en";
return { minLength, lang };
}
/** 正規化公開選項為內部 ResolvedOptions。 */
export function normalizeOptions(opts: JuzhenOptions = {}): ResolvedOptions
{
const langOpt = opts.lang || {};
const scope = opts.scope || {};
return {
locale: {
default: langOpt.default || "zh-Hant",
...(langOpt.style ? { style: langOpt.style } : {}),
policy: langOpt.policy || {},
},
autospaceClass: opts.autospaceClass || "",
scope: {
root: scope.root || null,
include: scope.include || null,
avoid: scope.avoid || null,
},
features: {
spacing: feature(opts.spacing, true),
longWord: opts.longWord === false ? false : feature(opts.longWord, true),
jiya: feature(opts.jiya, true),
jinze: feature(opts.jinze, true),
hanging: feature(opts.hanging, false),
biaodian: feature(opts.biaodian, false),
emphasis: feature(opts.emphasis, false),
ruby: feature(opts.ruby, false),
},
longWord: resolveLongWord(opts.longWord),
blocks: Array.isArray(opts.blocks) ? opts.blocks.slice() : [],
finder: {
styleInlines: new Set(opts.styleInlines || DEFAULT_STYLE_INLINES),
blockTags: new Set(opts.blockTags || DEFAULT_BLOCK_TAGS),
skipTags: new Set(opts.skipTags || DEFAULT_SKIP_TAGS),
pillSelector: opts.pillSelector || "code, kbd",
skipAttribute: opts.skipAttribute || null,
isSkipped: typeof opts.isSkipped === "function" ? opts.isSkipped : null,
},
};
}
function resolveRoot(
arg: Element | string | undefined,
options: ResolvedOptions,
): Element | null
{
const pick = arg !== undefined ? arg : options.scope.root;
if (pick && typeof pick !== "string") { return pick; }
if (typeof pick === "string" && typeof document !== "undefined")
{
return document.querySelector(pick);
}
return typeof document !== "undefined" ? document.body : null;
}
export interface Juzhen
{
render(root?: Element | string): void;
revert(root?: Element | string): void;
readonly options: ResolvedOptions;
}
/** 建立一個聚珍實例。 */
export function createJuzhen(opts: JuzhenOptions = {}): Juzhen
{
const options = normalizeOptions(opts);
function makeCtx(root: Element): RenderContext
{
return { root, options, finder: new Finder(options) };
}
return {
options,
render(root?: Element | string): void
{
const el = resolveRoot(root, options);
if (!el) { return; }
runPasses(PASSES, makeCtx(el));
},
revert(root?: Element | string): void
{
const el = resolveRoot(root, options);
if (!el) { return; }
revertPasses(PASSES, makeCtx(el));
},
};
}