// 標點擠壓(架構 §6.3)。完整 clreq 擠壓之「行內」部分——三條規則並集, // 以方向性 jz-cl(收左半形空白)/jz-cr(收右)落地,CSS 以負 margin 渲染, // 跨瀏覽器一致(I4),不依賴 halt/text-spacing-trim。 // // 機制 C baseline 寬度政策(依 lang,I3): // 全角式 quanjiao — 一律不收; // 開明式 kaiming — 句內點號(,、;:)+ 開類/閉類括號引號半形, // 句末點號(。!?)保持全形; // 半角式 banjiao — 全部標點半形。 // 機制 A 行內連續擠壓(jiyaAdjacencyPass,lang 無關):相鄰標點之對向空白 // 對消一次(每相鄰對省半形),如 。」→ 。收右 → 1.5em。 // 機制 B 行端擠壓 → typeset/lineedge.ts(layout pass,瀏覽器專用)。 // // 同時為 §8:所有 jz-char 經 CSS 設 inline-block,成 justify 單一原子項(I5)。 import { bdClassOf, createJz, revertPass } from "../core/dom.js"; import { classify, effectiveLang, resolveStyle } from "../core/locale.js"; import type { LangClass, PunctStyle } from "../core/locale.js"; import { blankSides, classifyBiaodian, inkBias } from "../core/unicode.js"; import type { CharifyPass, RenderContext, StandalonePass, WrapRequest } from "../types.js"; const PASS = "jiya"; /** baseline 政策下,該標點是否擠成半形(機制 C)。半形以字型 halt 渲染, * 方向由字型決定,故此處只需「半形與否」之布林。 */ function baselineHalf(style: PunctStyle, bdClass: string): boolean { if (style === "quanjiao") { return false; } if (style === "banjiao") { // 半角式:所有可壓縮標點半形(句末亦半)。 return bdClass === "bd-pause" || bdClass === "bd-stop" || bdClass === "bd-open" || bdClass === "bd-close"; } // kaiming:句內點號+括號引號半形,句末點號保持全形。 return bdClass === "bd-pause" || bdClass === "bd-open" || bdClass === "bd-close"; } /** parent 已是 jz-char(本 pass 產物)→ 冪等跳過。 */ function inJzChar(node: Node): boolean { let p = node.parentNode; while (p && p.nodeType === 1) { const name = (p as Element).nodeName; if (name === "JZ-CHAR" || name === "JZ-INNER") { return true; } p = p.parentNode; } return false; } // ----- charify + baseline(機制 C)----- export const jiyaPass: CharifyPass = { name: PASS, kind: "charify", order: 20, level: "text", enabled: (o) => o.features.jiya, collect(node, ctx): WrapRequest[] { if (inJzChar(node)) { return []; } if (!ctx.finder.featureEnabledFor(node, PASS)) { return []; } const data = node.data; const out: WrapRequest[] = []; let style: PunctStyle | null = null; let langClass: LangClass | null = null; const biasOverrides = ctx.options.jiyaConfig.bias; const biaodian = ctx.options.ruleset.biaodian; for (let i = 0; i < data.length; i += 1) { const ch = data.charAt(i); const bdClass = classifyBiaodian(ch, biaodian); if (!bdClass) { continue; } if (style === null) { style = resolveStyle(node, ctx.options.locale); } if (langClass === null) { langClass = classify(effectiveLang(node, ctx.options.locale.default)); } const classes = baselineHalf(style, bdClass) ? [ bdClass, "jz-half" ] : [ bdClass ]; // 字形偏側 class(jz-ink-*)— 供 margin 後備模式收對側半形空白;halt 模式 // 之 CSS 不讀,無害。一律於 charify 標記(偏側為字形固有屬性,與何時擠壓 // 無關),鄰接/行端 pass 後續加 jz-half 即可沿用。 const bias = inkBias(ch, langClass, biasOverrides); if (bias) { classes.push("jz-ink-" + bias); } out.push({ start: i, end: i + 1, pass: PASS, build: (text) => { const outer = createJz("jz-char", PASS, "wrap", { classes }); const inner = createJz("jz-inner", PASS, "wrap", { text }); outer.appendChild(inner); return outer; }, }); } return out; }, revert(ctx: RenderContext): void { revertPass(PASS, ctx.root); }, }; // ----- 行內連續擠壓(機制 A)----- // // 對「邏輯相鄰」之標點對 (L, R)——其間只隔**透明行內邊界**(樣式行內 strong/em…), // 無文本、無非透明元素、不跨 block——挤掉兩者「指向彼此」之空白:**兩側獨立判斷** // (非二選一)——L 右側有空白則收 L(半形),R 左側有空白則收 R。故: // ・,「(pause 右空白 + open 左空白):兩側皆收 → 合占 1em。 // ・。」(stop 右空白 + close 右空白,」左為墨):只收 。→ 全角式 1.5em/開明式 // 1em(」baseline 已半)。 // ・。「(句末點號 + 開類):此為**句界**——保留句末點號右側空白(不收 L),只收 // 開類左側 → 全角/開明皆 1.5em(對稱於 。」)。**唯一例外**:限句末點號(。.!?) // 後接開類;句內點號 ,「 與雙括號 」「/》《 仍兩側皆收 1em(屬同句/貼合,非句界)。 // ・·—(中點/連接,皆無指向對方之空白):皆不收 → 2em(不宜挤之例外)。 // 機制依 clreq「相鄰空白重疊即挤」。**透明穿越**:以從 root 之遍歷取代原「同 parent // 直接兄弟」掃描,使跨 strong/em 邊界之連續標點亦正確配對(下游回饋;block 邊界與 // avoid 子樹斷相鄰)。半形以 halt 渲染、冪等並集,與 baseline 疊加。class 掛於 // charify 之 jz-char,隨其解包清除,本 pass 無獨立產物。 export const jiyaAdjacencyPass: StandalonePass = { name: "jiyaAdjacency", kind: "standalone", order: 25, level: "text", enabled: (o) => o.features.jiya, render(ctx: RenderContext): void { const { finder } = ctx; let prev: Element | null = null; const squeeze = (L: Element, R: Element): void => { if (!finder.featureEnabledFor(L, PASS)) { return; } const lClass = bdClassOf(L); const rClass = bdClassOf(R); const lSides = blankSides(lClass); const rSides = blankSides(rClass); // 句界例外:句末點號後接開類(。「)保留句末點號右側空白,不收 L(→ 1.5em, // 對稱於 。」)。僅此一例;句內點號 ,「 與雙括號 」「 仍照常兩側收。 const stopBeforeOpen = lClass === "bd-stop" && rClass === "bd-open"; if (lSides.right && !stopBeforeOpen) { L.classList.add("jz-half"); } if (rSides.left) { R.classList.add("jz-half"); } }; const visit = (node: Node): void => { for (let c: Node | null = node.firstChild; c; c = c.nextSibling) { if (c.nodeType === 3) { if ((c as Text).data.length > 0) { prev = null; } // 文本斷相鄰 continue; } if (c.nodeType !== 1) { continue; } // 註解等:略過、不斷 const el = c as Element; const nm = el.nodeName; if (nm === "JZ-CHAR") { if (prev) { squeeze(prev, el); } prev = el; // jz-char 為原子,不下探(內含 jz-inner) } else { switch (finder.category(el)) // 與 collectRuns 共用同一分類(§4.1) { case "avoid": case "pill": { prev = null; // 斷相鄰、不下探(內容不可見/不處理) break; } case "transparent": { visit(el); // 穿越、相鄰性延續 break; } default: // isolate/block/opaque(未知行內) { prev = null; visit(el); prev = null; // 斷相鄰、內部自成脈絡 break; } } } } }; visit(ctx.root); }, revert(): void { // 無獨立產物:class 掛於 jiya charify 之 jz-char,隨其解包清除。 }, };