04f2b25e4c
行端 lineedge 升級為可配模式(squeeze|hang|none),抽出共用行端標點偵測; squeeze(現有行端半形)保留並修正「行歸屬判定」bug:jz-char 為 inline-block,其行盒 top 與相鄰純文本字形 top 因基線差約 5px、遠小於行高卻超舊容差(EPS=4px),致同行誤判 為下一行;改判「垂直區間」(下一行=b.top≥a.bottom、上一行=b.bottom≤a.top,容 2px)。 標點懸掛 hang 模式經 headless + 真實瀏覽器實測**確認不可行、全面停用**(純負 margin): 1. 句號墨色偏框左下,安全凸出量(<1em)只推出空白半框、墨色仍在版心內(視覺不懸掛); 2. 凸出滿 1em 釋放一整字寬、把下一字拉上本行致重疊; 3. 與 inline-block 對齊原子互斥(justifyAtoms:true 下 margin 被 justify 吸收); 4. ResizeObserver 重排時兩階段標記錯亂。 真正的解為原生 hanging-punctuation,惟 Chrome 不支援、且與本庫原子結構衝突。 **三重防逃逸**(下游以任何方式皆無法啟用):① index 鎖 lineEnd:"squeeze"(忽略 hanging 選項);② lineedge.modeForChar 無條件降 squeeze;③ applyEdge 之 HANG_ENABLED=false 總開關。 hang 之 lineedge 程式碼、CSS(jz-hang-*)、LineEndMode 型別保留休眠,俟原生支援再議。 hanging 選項仍被接受(API 相容)但無效果。 單元測試 90 通過,含防逃逸覆蓋(hanging:true/data-juzhen/blocks 各路徑皆不產生 jz-hang-*)。
199 lines
7.9 KiB
TypeScript
199 lines
7.9 KiB
TypeScript
// 聚珍(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 { orphanPass } from "./typeset/orphan.js";
|
||
import { spacingPass } from "./typeset/spacing.js";
|
||
import { resolveRuleset } from "./core/unicode.js";
|
||
import type { BiasTable, JiyaOptions, JuzhenOptions, LongWordOptions, OrphanOptions, Pass, RenderContext, ResolvedOptions } from "./types.js";
|
||
|
||
export type { JuzhenOptions, ResolvedOptions, Pass };
|
||
export type { CharClassOverride, BdSubclass, Ruleset, Bias, BiasTable } from "./types.js";
|
||
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,
|
||
orphanPass, 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 };
|
||
}
|
||
|
||
function resolveOrphan(v: boolean | OrphanOptions | undefined): { chars: number }
|
||
{
|
||
if (v && typeof v === "object")
|
||
{
|
||
const chars = typeof v.chars === "number" && v.chars >= 1 ? Math.floor(v.chars) : 2;
|
||
return { chars };
|
||
}
|
||
return { chars: 2 };
|
||
}
|
||
|
||
function resolveJiya(
|
||
v: boolean | JiyaOptions | undefined,
|
||
): { halfWidth: "halt" | "margin"; bias: BiasTable }
|
||
{
|
||
if (v && typeof v === "object")
|
||
{
|
||
return {
|
||
halfWidth: v.halfWidth === "margin" ? "margin" : "halt",
|
||
bias: v.bias || {},
|
||
};
|
||
}
|
||
return { halfWidth: "halt", bias: {} };
|
||
}
|
||
|
||
/** 正規化公開選項為內部 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 || {},
|
||
},
|
||
ruleset: resolveRuleset(opts.charClass),
|
||
autospaceClass: opts.autospaceClass || "",
|
||
justifyAtoms: opts.justifyAtoms === false ? false : true,
|
||
levelText: (opts.level && opts.level.text) || null,
|
||
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),
|
||
orphan: opts.orphan === false ? false : feature(opts.orphan, 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),
|
||
orphan: resolveOrphan(opts.orphan),
|
||
// 行端模式恒為 squeeze(行端半形)。**標點懸掛 hang 已全面停用**(§6.5.2/§6.5.6):
|
||
// 經 headless + 真實瀏覽器實測,純負 margin 機制不可行——句號墨色偏框左下角,安全
|
||
// 凸出量(<1em)只把空白半框推出版心、墨色仍在內(視覺不懸掛);凸出滿 1em 又會釋放
|
||
// 一字寬、把下一字拉上本行致重疊;且 ResizeObserver 重排時兩階段標記錯亂。原生
|
||
// hanging-punctuation 為正解但 Chrome 不支援、且與本庫 inline-block 原子衝突。
|
||
// 故此處**唯一關卡**鎖死為 squeeze——即使 opts.hanging 為 true 亦不啟用 hang(無逃逸);
|
||
// hang 之 CSS/lineedge 程式碼保留(休眠),俟原生支援成熟再議。
|
||
lineEnd: "squeeze",
|
||
jiyaConfig: resolveJiya(opts.jiya),
|
||
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));
|
||
// margin 後備模式以 root 屬性作 CSS 開關(見 css/_jiya.css);halt 模式不設。
|
||
if (options.features.jiya && options.jiyaConfig.halfWidth === "margin")
|
||
{
|
||
el.setAttribute("data-jz-halfwidth", "margin");
|
||
}
|
||
// justifyAtoms:false → jz-char/jz-jinze 改 inline(分頁器相容,見 css)。
|
||
if (!options.justifyAtoms)
|
||
{
|
||
el.setAttribute("data-jz-atoms", "inline");
|
||
}
|
||
},
|
||
revert(root?: Element | string): void
|
||
{
|
||
const el = resolveRoot(root, options);
|
||
if (!el) { return; }
|
||
revertPasses(PASSES, makeCtx(el));
|
||
el.removeAttribute("data-jz-halfwidth");
|
||
el.removeAttribute("data-jz-atoms");
|
||
},
|
||
};
|
||
}
|