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:
+136
@@ -0,0 +1,136 @@
|
||||
// jz-* 元素工廠與還原(架構 §4.4/§5)。
|
||||
//
|
||||
// 所有注入結構皆帶 data-jz="<pass>" 標記(冪等 + 可定址還原,I6),並以
|
||||
// data-jz-kind 區分「marker(純注入,還原時移除)」與「wrap(包裝原內容,
|
||||
// 還原時解包)」。視覺尺寸一律在 CSS(I2);本模組只造結構。
|
||||
|
||||
/** jz-* 自訂元素名(皆含連字號,合法 custom element,預設 inline)。 */
|
||||
export type JzTag =
|
||||
| "jz-hws"
|
||||
| "jz-char"
|
||||
| "jz-inner"
|
||||
| "jz-cs"
|
||||
| "jz-jinze"
|
||||
| "jz-em"
|
||||
| "jz-ruby"
|
||||
| "jz-rb"
|
||||
| "jz-rt";
|
||||
|
||||
type JzKind = "marker" | "wrap";
|
||||
|
||||
export interface CreateOptions
|
||||
{
|
||||
/** 追加之 class(空白分隔或陣列)。 */
|
||||
classes?: string | string[];
|
||||
/** 文本內容。 */
|
||||
text?: string;
|
||||
/** 其餘屬性。 */
|
||||
attrs?: Record<string, string>;
|
||||
}
|
||||
|
||||
const KIND_ATTR = "data-jz-kind";
|
||||
const PASS_ATTR = "data-jz";
|
||||
|
||||
function doc(): Document
|
||||
{
|
||||
if (typeof document === "undefined")
|
||||
{
|
||||
throw new Error("juzhen: 無 document(需於瀏覽器或 jsdom 環境執行)。");
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
/** 造一個帶 pass 標記之 jz-* 元素。 */
|
||||
export function createJz(
|
||||
tag: JzTag,
|
||||
pass: string,
|
||||
kind: JzKind,
|
||||
opts: CreateOptions = {},
|
||||
): HTMLElement
|
||||
{
|
||||
const el = doc().createElement(tag);
|
||||
el.setAttribute(PASS_ATTR, pass);
|
||||
el.setAttribute(KIND_ATTR, kind);
|
||||
if (opts.classes)
|
||||
{
|
||||
const list = Array.isArray(opts.classes) ? opts.classes : [ opts.classes ];
|
||||
for (const c of list) { if (c) { el.classList.add(c); } }
|
||||
}
|
||||
if (opts.attrs)
|
||||
{
|
||||
for (const k in opts.attrs)
|
||||
{
|
||||
const v = opts.attrs[k];
|
||||
if (v !== undefined) { el.setAttribute(k, v); }
|
||||
}
|
||||
}
|
||||
if (opts.text !== undefined) { el.textContent = opts.text; }
|
||||
return el;
|
||||
}
|
||||
|
||||
/** 造一個 copy-clean 間隙標記(內含真實空白,user-select 由 CSS 關閉)。 */
|
||||
export function createMarker(tag: JzTag, pass: string): HTMLElement
|
||||
{
|
||||
const el = createJz(tag, pass, "marker", { text: " " });
|
||||
el.setAttribute("aria-hidden", "true");
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* 還原某 pass 之全部產物(I6)。以 data-jz 定址,逆文件序處理(先內層後
|
||||
* 外層),marker 移除、wrap 解包,最後 normalize 受影響父節點合併文本。
|
||||
*/
|
||||
export function revertPass(pass: string, root: ParentNode): void
|
||||
{
|
||||
const nodes = Array.from(
|
||||
root.querySelectorAll<HTMLElement>('[' + PASS_ATTR + '="' + pass + '"]'),
|
||||
);
|
||||
// 逆序:子元素先於父元素處理,避免解包父後子引用失效。
|
||||
const touched = new Set<Node>();
|
||||
for (let i = nodes.length - 1; i >= 0; i -= 1)
|
||||
{
|
||||
const el = nodes[i]!;
|
||||
const parent = el.parentNode;
|
||||
if (!parent) { continue; }
|
||||
touched.add(parent);
|
||||
const kind = el.getAttribute(KIND_ATTR);
|
||||
if (kind === "marker")
|
||||
{
|
||||
parent.removeChild(el);
|
||||
}
|
||||
else
|
||||
{
|
||||
// wrap:把子節點搬出後移除外殼。
|
||||
while (el.firstChild)
|
||||
{
|
||||
parent.insertBefore(el.firstChild, el);
|
||||
}
|
||||
parent.removeChild(el);
|
||||
}
|
||||
}
|
||||
for (const p of touched)
|
||||
{
|
||||
if ((p as Element).normalize) { (p as Element).normalize(); }
|
||||
}
|
||||
}
|
||||
|
||||
const BD_CLASSES = [ "bd-open", "bd-close", "bd-pause", "bd-stop", "bd-middle", "bd-liga" ];
|
||||
|
||||
/** 讀取 jz-char 之 bd-* 子類(無則 null)。 */
|
||||
export function bdClassOf(el: Element): string | null
|
||||
{
|
||||
for (const c of BD_CLASSES)
|
||||
{
|
||||
if (el.classList.contains(c)) { return c; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 該元素是否為某/任一 pass 之 jz 產物。 */
|
||||
export function isJz(node: Node | null, pass?: string): boolean
|
||||
{
|
||||
if (!node || node.nodeType !== 1) { return false; }
|
||||
const el = node as Element;
|
||||
if (!el.hasAttribute(PASS_ATTR)) { return false; }
|
||||
return pass === undefined || el.getAttribute(PASS_ATTR) === pass;
|
||||
}
|
||||
Reference in New Issue
Block a user