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:
+145
@@ -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));
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user