From 754b00da42486f4209cbf277e69a6f15b428bb07 Mon Sep 17 00:00:00 2001 From: commilitia Date: Tue, 9 Jun 2026 17:00:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=9E=82=E6=87=B8=E5=AD=97=E9=81=BF?= =?UTF-8?q?=E5=85=8D=EF=BC=88=E6=96=B9=E6=A1=88=20C=EF=BC=8C=E6=96=B7?= =?UTF-8?q?=E8=A1=8C=E5=B1=A4=E7=84=A1=E6=B8=AC=E9=87=8F=EF=BC=8Corder=207?= =?UTF-8?q?0=EF=BC=89=EF=BC=88=C2=A76.5.1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 段末行 CJK 實義字數 ≥ N(預設 2;標點/符號不計)。standalone 段落級 pass: 自段末向前數 N 個實義字,把「第 N 字起至段末」(含其間/尾隨標點)包入 (white-space:nowrap)。該整體不可斷 → 末行恒含之 ⇒ 末行實義字 ≥ N, 任意寬度/字型/瀏覽器成立,無需量測 layout。 要點: - 邊界字定位跨 jz-jinze/行內元素攀爬:實義字落於行內元素內時綁定整個塊級 直接子(不切割 nowrap 群、revert 乾淨);僅裸文本直接子才精確切分(最小綁定)。 - 僅處理葉段落(無塊級後代);段落實義字 < N 不綁;單行段方案 C 本就無害。 - 選項 orphan?: boolean | { chars? };**預設開 chars=2**(§6.5.6 建議); 下游可 orphan:false 關閉。段落級,data-jz-level=text 子樹自動跳過。 - order=70:jinze(40)/spacing(50)/longWord(60) 後、layout pass(90/92) 前。 新增 _orphan.css(jz-orphan{white-space:nowrap});JzTag 加 jz-orphan。 9 項單元測試(末 N+尾標點、chars 選項、標點不計、實義字 { } }; + // src/typeset/orphan.ts + var PASS4 = "orphan"; + function hasOrphan(block) { + for (let c = block.firstChild; c; c = c.nextSibling) { + if (c.nodeType === 1 && c.nodeName === "JZ-ORPHAN") { + return true; + } + } + return false; + } + function collectSubst(block, finder, blockSet, anyCjk, biaodian) { + const out = []; + const visit = (node, directChild) => { + if (node.nodeType === 3) { + const t = node; + const d = t.data; + for (let i = 0; i < d.length; i += 1) { + const ch = d.charAt(i); + if (anyCjk.test(ch) && !classifyBiaodian(ch, biaodian)) { + out.push({ directChild, textNode: t, offset: i }); + } + } + return; + } + if (node.nodeType !== 1) { + return; + } + const el = node; + if (finder.isAvoided(el)) { + return; + } + if (blockSet.has(el.nodeName) || finder.pillMatches(el)) { + return; + } + for (let c = el.firstChild; c; c = c.nextSibling) { + visit(c, directChild); + } + }; + for (let dc = block.firstChild; dc; dc = dc.nextSibling) { + visit(dc, dc); + } + return out; + } + function processBlock(block, finder, blockSet, anyCjk, biaodian, chars) { + if (hasOrphan(block)) { + return; + } + const subst = collectSubst(block, finder, blockSet, anyCjk, biaodian); + if (subst.length < chars) { + return; + } + const boundary = subst[subst.length - chars]; + let startChild = boundary.directChild; + if (boundary.textNode && boundary.textNode === startChild) { + if (boundary.offset > 0) { + startChild = boundary.textNode.splitText(boundary.offset); + } + } + const orphan = createJz("jz-orphan", PASS4, "wrap"); + block.insertBefore(orphan, startChild); + let n = startChild; + while (n) { + const next = n.nextSibling; + orphan.appendChild(n); + n = next; + } + } + var orphanPass = { + name: PASS4, + kind: "standalone", + order: 70, + level: "paragraph", + enabled: (o) => o.features.orphan, + render(ctx) { + const { finder, options } = ctx; + const blockSet = options.finder.blockTags; + const anyCjk = new RegExp("[" + options.ruleset.cjk + "]"); + const biaodian = options.ruleset.biaodian; + const chars = options.orphan.chars; + finder.eachBlock(ctx.root, (block) => { + if (!finder.levelAllows(block, "paragraph")) { + return; + } + if (!finder.featureEnabledFor(block, PASS4)) { + return; + } + const sel = Array.from(blockSet).map((t) => t.toLowerCase()).join(","); + if (sel && block.querySelector(sel)) { + return; + } + processBlock(block, finder, blockSet, anyCjk, biaodian, chars); + }); + }, + revert(ctx) { + revertPass(PASS4, ctx.root); + } + }; + // src/typeset/spacing.ts - var PASS4 = "spacing"; + var PASS5 = "spacing"; var MARK = "\uE000"; var PH_OPEN = "\uE001"; var PH_CLOSE = "\uE002"; @@ -1294,7 +1392,7 @@ var Juzhen = (() => { if (!parent || parent.nodeName === "JZ-HWS") { return; } - const gap = createJz("jz-hws", PASS4, "wrap"); + const gap = createJz("jz-hws", PASS5, "wrap"); parent.insertBefore(gap, node); gap.appendChild(node); } @@ -1311,7 +1409,7 @@ var Juzhen = (() => { } wrapNodeInGap(tn); } - function processBlock(block, finder, R) { + function processBlock2(block, finder, R) { const runs = finder.collectRuns(block); for (const run of runs) { const { text, map } = run; @@ -1403,7 +1501,7 @@ var Juzhen = (() => { } } var spacingPass = { - name: PASS4, + name: PASS5, kind: "standalone", order: 50, level: "text", @@ -1412,18 +1510,18 @@ var Juzhen = (() => { const { finder, options } = ctx; const R = makeRules(options.ruleset); ctx.finder.eachBlock(ctx.root, (block) => { - if (!finder.featureEnabledFor(block, PASS4)) { + if (!finder.featureEnabledFor(block, PASS5)) { return; } - processBlock(block, finder, R); + processBlock2(block, finder, R); }); - if (!options.finder.blockTags.has(ctx.root.nodeName) && finder.featureEnabledFor(ctx.root, PASS4) && finder.inScope(ctx.root) && !finder.isAvoided(ctx.root)) { - processBlock(ctx.root, finder, R); + if (!options.finder.blockTags.has(ctx.root.nodeName) && finder.featureEnabledFor(ctx.root, PASS5) && finder.inScope(ctx.root) && !finder.isAvoided(ctx.root)) { + processBlock2(ctx.root, finder, R); } processPills(ctx.root, finder, options.finder.pillSelector, R.pillNeighbor); }, revert(ctx) { - revertPass(PASS4, ctx.root); + revertPass(PASS5, ctx.root); } }; @@ -1576,6 +1674,7 @@ var Juzhen = (() => { longWordPass, spacingPass, jinzePass, + orphanPass, lineEdgePass, gapTrimPass ]; @@ -1593,6 +1692,13 @@ var Juzhen = (() => { const lang = typeof v.lang === "string" && v.lang ? v.lang : "en"; return { minLength, lang }; } + function resolveOrphan(v) { + 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) { if (v && typeof v === "object") { return { @@ -1625,12 +1731,14 @@ var Juzhen = (() => { 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), jiyaConfig: resolveJiya(opts.jiya), blocks: Array.isArray(opts.blocks) ? opts.blocks.slice() : [], finder: { diff --git a/dist/juzhen.mjs b/dist/juzhen.mjs index 62c039b..9f7d536 100644 --- a/dist/juzhen.mjs +++ b/dist/juzhen.mjs @@ -1164,8 +1164,106 @@ var longWordPass = { } }; +// src/typeset/orphan.ts +var PASS4 = "orphan"; +function hasOrphan(block) { + for (let c = block.firstChild; c; c = c.nextSibling) { + if (c.nodeType === 1 && c.nodeName === "JZ-ORPHAN") { + return true; + } + } + return false; +} +function collectSubst(block, finder, blockSet, anyCjk, biaodian) { + const out = []; + const visit = (node, directChild) => { + if (node.nodeType === 3) { + const t = node; + const d = t.data; + for (let i = 0; i < d.length; i += 1) { + const ch = d.charAt(i); + if (anyCjk.test(ch) && !classifyBiaodian(ch, biaodian)) { + out.push({ directChild, textNode: t, offset: i }); + } + } + return; + } + if (node.nodeType !== 1) { + return; + } + const el = node; + if (finder.isAvoided(el)) { + return; + } + if (blockSet.has(el.nodeName) || finder.pillMatches(el)) { + return; + } + for (let c = el.firstChild; c; c = c.nextSibling) { + visit(c, directChild); + } + }; + for (let dc = block.firstChild; dc; dc = dc.nextSibling) { + visit(dc, dc); + } + return out; +} +function processBlock(block, finder, blockSet, anyCjk, biaodian, chars) { + if (hasOrphan(block)) { + return; + } + const subst = collectSubst(block, finder, blockSet, anyCjk, biaodian); + if (subst.length < chars) { + return; + } + const boundary = subst[subst.length - chars]; + let startChild = boundary.directChild; + if (boundary.textNode && boundary.textNode === startChild) { + if (boundary.offset > 0) { + startChild = boundary.textNode.splitText(boundary.offset); + } + } + const orphan = createJz("jz-orphan", PASS4, "wrap"); + block.insertBefore(orphan, startChild); + let n = startChild; + while (n) { + const next = n.nextSibling; + orphan.appendChild(n); + n = next; + } +} +var orphanPass = { + name: PASS4, + kind: "standalone", + order: 70, + level: "paragraph", + enabled: (o) => o.features.orphan, + render(ctx) { + const { finder, options } = ctx; + const blockSet = options.finder.blockTags; + const anyCjk = new RegExp("[" + options.ruleset.cjk + "]"); + const biaodian = options.ruleset.biaodian; + const chars = options.orphan.chars; + finder.eachBlock(ctx.root, (block) => { + if (!finder.levelAllows(block, "paragraph")) { + return; + } + if (!finder.featureEnabledFor(block, PASS4)) { + return; + } + const sel = Array.from(blockSet).map((t) => t.toLowerCase()).join(","); + if (sel && block.querySelector(sel)) { + return; + } + processBlock(block, finder, blockSet, anyCjk, biaodian, chars); + }); + }, + revert(ctx) { + revertPass(PASS4, ctx.root); + } +}; + // src/typeset/spacing.ts -var PASS4 = "spacing"; +var PASS5 = "spacing"; var MARK = "\uE000"; var PH_OPEN = "\uE001"; var PH_CLOSE = "\uE002"; @@ -1266,7 +1364,7 @@ function wrapNodeInGap(node) { if (!parent || parent.nodeName === "JZ-HWS") { return; } - const gap = createJz("jz-hws", PASS4, "wrap"); + const gap = createJz("jz-hws", PASS5, "wrap"); parent.insertBefore(gap, node); gap.appendChild(node); } @@ -1283,7 +1381,7 @@ function wrapGapLeft(pos) { } wrapNodeInGap(tn); } -function processBlock(block, finder, R) { +function processBlock2(block, finder, R) { const runs = finder.collectRuns(block); for (const run of runs) { const { text, map } = run; @@ -1375,7 +1473,7 @@ function handlePillSide(adj, pill, dir, pillNeighbor) { } } var spacingPass = { - name: PASS4, + name: PASS5, kind: "standalone", order: 50, level: "text", @@ -1384,18 +1482,18 @@ var spacingPass = { const { finder, options } = ctx; const R = makeRules(options.ruleset); ctx.finder.eachBlock(ctx.root, (block) => { - if (!finder.featureEnabledFor(block, PASS4)) { + if (!finder.featureEnabledFor(block, PASS5)) { return; } - processBlock(block, finder, R); + processBlock2(block, finder, R); }); - if (!options.finder.blockTags.has(ctx.root.nodeName) && finder.featureEnabledFor(ctx.root, PASS4) && finder.inScope(ctx.root) && !finder.isAvoided(ctx.root)) { - processBlock(ctx.root, finder, R); + if (!options.finder.blockTags.has(ctx.root.nodeName) && finder.featureEnabledFor(ctx.root, PASS5) && finder.inScope(ctx.root) && !finder.isAvoided(ctx.root)) { + processBlock2(ctx.root, finder, R); } processPills(ctx.root, finder, options.finder.pillSelector, R.pillNeighbor); }, revert(ctx) { - revertPass(PASS4, ctx.root); + revertPass(PASS5, ctx.root); } }; @@ -1548,6 +1646,7 @@ var PASSES = [ longWordPass, spacingPass, jinzePass, + orphanPass, lineEdgePass, gapTrimPass ]; @@ -1565,6 +1664,13 @@ function resolveLongWord(v) { const lang = typeof v.lang === "string" && v.lang ? v.lang : "en"; return { minLength, lang }; } +function resolveOrphan(v) { + 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) { if (v && typeof v === "object") { return { @@ -1597,12 +1703,14 @@ function normalizeOptions(opts = {}) { 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), jiyaConfig: resolveJiya(opts.jiya), blocks: Array.isArray(opts.blocks) ? opts.blocks.slice() : [], finder: { diff --git a/dist/types.d.ts b/dist/types.d.ts index aa5667e..ce21123 100644 --- a/dist/types.d.ts +++ b/dist/types.d.ts @@ -7,6 +7,11 @@ export interface LongWordOptions { minLength?: number; lang?: string; } +/** 垂懸字避免設定(§6.5.1)。布林 true 等同 { chars: 2 }。 */ +export interface OrphanOptions { + /** 段末行至少保留之 CJK 實義字數(標點/符號不計;預設 2)。 */ + chars?: number; +} /** 標點擠壓設定(架構 §6.3)。布林 true 等同 { halfWidth: "halt" }。 */ export interface JiyaOptions { /** 半形渲染機制: @@ -62,6 +67,7 @@ export interface JuzhenOptions { longWord?: boolean | LongWordOptions; jiya?: boolean | JiyaOptions; jinze?: boolean; + orphan?: boolean | OrphanOptions; hanging?: boolean; biaodian?: boolean; emphasis?: boolean; @@ -94,6 +100,7 @@ export interface ResolvedOptions { longWord: boolean; jiya: boolean; jinze: boolean; + orphan: boolean; hanging: boolean; biaodian: boolean; emphasis: boolean; @@ -103,6 +110,10 @@ export interface ResolvedOptions { minLength: number; lang: string; }; + /** 垂懸字避免設定(解析後)。 */ + orphan: { + chars: number; + }; /** 擠壓半形渲染設定(halt 預設/margin 後備)。 */ jiyaConfig: { halfWidth: "halt" | "margin"; diff --git a/dist/typeset/orphan.d.ts b/dist/typeset/orphan.d.ts new file mode 100644 index 0000000..c78618e --- /dev/null +++ b/dist/typeset/orphan.d.ts @@ -0,0 +1,2 @@ +import type { StandalonePass } from "../types.js"; +export declare const orphanPass: StandalonePass; diff --git a/src/core/dom.ts b/src/core/dom.ts index 0f3f099..4bea7ab 100644 --- a/src/core/dom.ts +++ b/src/core/dom.ts @@ -11,6 +11,7 @@ export type JzTag = | "jz-inner" | "jz-cs" | "jz-jinze" + | "jz-orphan" | "jz-em" | "jz-ruby" | "jz-rb" diff --git a/src/css/_orphan.css b/src/css/_orphan.css new file mode 100644 index 0000000..675d5d8 --- /dev/null +++ b/src/css/_orphan.css @@ -0,0 +1,9 @@ +/* 垂懸字避免(§6.5.1,方案 C)。jz-orphan 包裹段末「第 N 個 CJK 實義字起至段末」 + 之內容,以 white-space:nowrap 使其不可斷 → 末行恒含此整體 ⇒ 末行實義字 ≥ N。 + 保持 inline(非 inline-block):其內含 jz-char/jz-hws/文字之多字 run,須維持 + 正常行內排版與字間對齊,僅禁止內部換行。 */ + +jz-orphan +{ + white-space: nowrap; +} diff --git a/src/css/juzhen.css b/src/css/juzhen.css index e877017..5c1aadf 100644 --- a/src/css/juzhen.css +++ b/src/css/juzhen.css @@ -9,3 +9,4 @@ @import "./_spacing.css"; @import "./_jiya.css"; @import "./_jinze.css"; +@import "./_orphan.css"; diff --git a/src/index.ts b/src/index.ts index 359258d..88f37d6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,9 +13,10 @@ 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, Pass, RenderContext, ResolvedOptions } from "./types.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"; @@ -41,7 +42,7 @@ const DEFAULT_SKIP_TAGS = [ const PASSES: Pass[] = [ jiyaPass, jiyaAdjacencyPass, longWordPass, spacingPass, jinzePass, - lineEdgePass, gapTrimPass, + orphanPass, lineEdgePass, gapTrimPass, ]; function feature(v: unknown, dflt: boolean): boolean @@ -58,6 +59,16 @@ function resolveLongWord(v: boolean | LongWordOptions | undefined): { minLength: 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 } @@ -97,12 +108,14 @@ export function normalizeOptions(opts: JuzhenOptions = {}): ResolvedOptions 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), jiyaConfig: resolveJiya(opts.jiya), blocks: Array.isArray(opts.blocks) ? opts.blocks.slice() : [], finder: { diff --git a/src/types.ts b/src/types.ts index 181e47e..6792399 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,13 @@ export interface LongWordOptions lang?: string; } +/** 垂懸字避免設定(§6.5.1)。布林 true 等同 { chars: 2 }。 */ +export interface OrphanOptions +{ + /** 段末行至少保留之 CJK 實義字數(標點/符號不計;預設 2)。 */ + chars?: number; +} + /** 標點擠壓設定(架構 §6.3)。布林 true 等同 { halfWidth: "halt" }。 */ export interface JiyaOptions { @@ -80,6 +87,7 @@ export interface JuzhenOptions longWord?: boolean | LongWordOptions; jiya?: boolean | JiyaOptions; jinze?: boolean; + orphan?: boolean | OrphanOptions; hanging?: boolean; biaodian?: boolean; emphasis?: boolean; @@ -117,12 +125,15 @@ export interface ResolvedOptions longWord: boolean; jiya: boolean; jinze: boolean; + orphan: boolean; hanging: boolean; biaodian: boolean; emphasis: boolean; ruby: boolean; }; longWord: { minLength: number; lang: string }; + /** 垂懸字避免設定(解析後)。 */ + orphan: { chars: number }; /** 擠壓半形渲染設定(halt 預設/margin 後備)。 */ jiyaConfig: { halfWidth: "halt" | "margin"; bias: BiasTable }; blocks: BlockRule[]; diff --git a/src/typeset/orphan.ts b/src/typeset/orphan.ts new file mode 100644 index 0000000..9d18408 --- /dev/null +++ b/src/typeset/orphan.ts @@ -0,0 +1,151 @@ +// 垂懸字避免(架構 §6.5.1,方案 C:斷行層、無測量)。standalone 段落級 pass。 +// +// 目標:段末行之 CJK 實義字數 ≥ N(預設 2;標點/符號不計)。單行段天然無害。 +// 機制:自段末向前數 N 個 CJK 實義字,把「第 N 個實義字起至段末」(含其間/尾隨 +// 標點)包入 (CSS white-space:nowrap)。該整體不可斷 → 末行恒含之 ⇒ +// 末行實義字 ≥ N,任意寬度/字型/瀏覽器成立,無需量測 layout(異於行端/懸掛)。 +// +// 邊界字定位「跨 jz-jinze/jz-char 等行內包裹攀爬」:實義字若落於某行內元素(禁則群組 +// 強調等)內,則綁定整個**塊級直接子**——不切割該元素(免破壞 jz-jinze 之 nowrap 群、 +// 免 revert 後殘留半個元素)。代價是偶爾多綁少許字(保證仍成立、無害)。僅在實義字 +// 直接位於塊之裸文本子節點時才精確切分該文本(最小綁定)。 +// +// order=70:jinze(40)/spacing(50)/longWord(60) 後、layout pass(90/92) 前。 + +import { createJz, revertPass } from "../core/dom.js"; +import type { Finder } from "../core/finder.js"; +import { classifyBiaodian } from "../core/unicode.js"; +import type { RenderContext, Ruleset, StandalonePass } from "../types.js"; + +type Biaodian = Ruleset["biaodian"]; + +const PASS = "orphan"; + +/** 單一實義字之來源定位:所屬塊級直接子;若該直接子即裸文本節點,記其字內 offset。 */ +interface SubstPos +{ + directChild: Node; + textNode: Text | null; // 非 null 且 === directChild 時表示「裸文本直接子」,可精確切分 + offset: number; +} + +/** block 已含 jz-orphan(前次 render 未 revert)→ 冪等跳過。 */ +function hasOrphan(block: Element): boolean +{ + for (let c = block.firstChild; c; c = c.nextSibling) + { + if (c.nodeType === 1 && (c as Element).nodeName === "JZ-ORPHAN") { return true; } + } + return false; +} + +/** 收集 block 內各 CJK 實義字之定位(文件序);descend 透明行內,遇塊/pill/avoid 即止。 */ +function collectSubst( + block: Element, + finder: Finder, + blockSet: Set, + anyCjk: RegExp, + biaodian: Biaodian, +): SubstPos[] +{ + const out: SubstPos[] = []; + + const visit = (node: Node, directChild: Node): void => + { + if (node.nodeType === 3) + { + const t = node as Text; + const d = t.data; + for (let i = 0; i < d.length; i += 1) + { + const ch = d.charAt(i); + // 實義字:屬 CJK 字集且非標點(標點/符號不計)。 + if (anyCjk.test(ch) && !classifyBiaodian(ch, biaodian)) + { + out.push({ directChild, textNode: t, offset: i }); + } + } + return; + } + if (node.nodeType !== 1) { return; } + const el = node as Element; + if (finder.isAvoided(el)) { return; } + if (blockSet.has(el.nodeName) || finder.pillMatches(el)) { return; } // 邊界:不計、不下探 + for (let c = el.firstChild; c; c = c.nextSibling) { visit(c, directChild); } + }; + + for (let dc = block.firstChild; dc; dc = dc.nextSibling) { visit(dc, dc); } + return out; +} + +function processBlock( + block: Element, + finder: Finder, + blockSet: Set, + anyCjk: RegExp, + biaodian: Biaodian, + chars: number, +): void +{ + if (hasOrphan(block)) { return; } + + const subst = collectSubst(block, finder, blockSet, anyCjk, biaodian); + // 段落實義字 < N → 不綁(無法保證、亦無意義)。單行段:方案 C 之 nowrap 本就無害。 + if (subst.length < chars) { return; } + + const boundary = subst[subst.length - chars]!; + let startChild: Node = boundary.directChild; + + // 邊界落於裸文本直接子 → 精確切分(最小綁定);否則綁定整個直接子(含其內行內元素, + // 不切割,跨 jz-jinze 等攀爬)。 + if (boundary.textNode && boundary.textNode === startChild) + { + if (boundary.offset > 0) + { + startChild = (boundary.textNode as Text).splitText(boundary.offset); + } + // offset === 0:實義字即文本起點,整個文本節點入綁,無需切分。 + } + + // 把 startChild 及其後全部直接子搬入 jz-orphan。 + const orphan = createJz("jz-orphan", PASS, "wrap"); + block.insertBefore(orphan, startChild); + let n: Node | null = startChild; + while (n) + { + const next: Node | null = n.nextSibling; + orphan.appendChild(n); + n = next; + } +} + +export const orphanPass: StandalonePass = { + name: PASS, + kind: "standalone", + order: 70, + level: "paragraph", + enabled: (o) => o.features.orphan, + render(ctx: RenderContext): void + { + const { finder, options } = ctx; + const blockSet = options.finder.blockTags; + const anyCjk = new RegExp("[" + options.ruleset.cjk + "]"); + const biaodian = options.ruleset.biaodian; + const chars = options.orphan.chars; + + finder.eachBlock(ctx.root, (block) => + { + // 段落級閘 + per-node 功能閘。 + if (!finder.levelAllows(block, "paragraph")) { return; } + if (!finder.featureEnabledFor(block, PASS)) { return; } + // 僅處理葉段落(無塊級後代):含巢狀塊之容器,其實義字屬內層段落,另行處理。 + const sel = Array.from(blockSet).map((t) => t.toLowerCase()).join(","); + if (sel && block.querySelector(sel)) { return; } + processBlock(block, finder, blockSet, anyCjk, biaodian, chars); + }); + }, + revert(ctx: RenderContext): void + { + revertPass(PASS, ctx.root); + }, +}; diff --git a/test/juzhen.test.mjs b/test/juzhen.test.mjs index 7d957a0..8e2b9e5 100644 --- a/test/juzhen.test.mjs +++ b/test/juzhen.test.mjs @@ -702,3 +702,83 @@ test("分級:預設(無標記)塊級元素為段落級——禁則照常", createJuzhen().render(document.body); assert.ok(countTag(document.querySelector("p"), "jz-jinze") >= 1, "預設段落級,禁則綁定"); }); + +// ----- 垂懸字避免(§6.5.1,方案 C)----- + +test("垂懸字:末 N 實義字+尾隨標點包入 jz-orphan(預設 chars=2)", () => +{ + const { document } = setupDom('

甲乙,丙丁。

'); + createJuzhen().render(document.body); + const p = document.querySelector("p"); + const orphan = p.querySelector("jz-orphan"); + assert.ok(orphan, "應產生 jz-orphan"); + assert.equal(orphan.textContent, "丙丁。", "綁定末 2 實義字(丙丁)+尾隨句號"); + assert.equal(p.textContent, "甲乙,丙丁。", "textContent 不變"); +}); + +test("垂懸字:chars 選項可調(chars=3 綁末 3 實義字)", () => +{ + const { document } = setupDom('

甲乙,丙丁。

'); + createJuzhen({ orphan: { chars: 3 } }).render(document.body); + const orphan = document.querySelector("jz-orphan"); + assert.equal(orphan.textContent, "乙,丙丁。", "綁定末 3 實義字(乙丙丁)+其間/尾標點"); +}); + +test("垂懸字:標點/符號不計實義字數(逗號不計)", () => +{ + const { document } = setupDom('

讀書,思考,寫作。

'); + createJuzhen().render(document.body); + const orphan = document.querySelector("jz-orphan"); + // 實義字 讀書思考寫作(6),末 2=寫作;逗號不計入。 + assert.equal(orphan.textContent, "寫作。", "末 2 實義字=寫作,標點不計"); +}); + +test("垂懸字:段落實義字 < N 不綁(單字段)", () => +{ + const { document } = setupDom('

嗯。

'); + createJuzhen().render(document.body); + assert.equal(countTag(document.querySelector("p"), "jz-orphan"), 0, "實義字 1 < 2,不綁"); +}); + +test("垂懸字:邊界字落於 jz-jinze 內 → 綁定整個群組(跨 jinze 攀爬、不切割)", () => +{ + const { document } = setupDom('

他說「好」

'); + createJuzhen({ orphan: { chars: 1 } }).render(document.body); + const orphan = document.querySelector("jz-orphan"); + assert.equal(orphan.textContent, "「好」", "末 1 實義字 好 在 jz-jinze 內 → 綁整個 「好」"); + assert.equal(orphan.querySelectorAll("jz-jinze").length, 1, "jz-jinze 整體納入、未被切割"); +}); + +test("垂懸字:orphan:false 關閉(無 jz-orphan)", () => +{ + const { document } = setupDom('

甲乙,丙丁。

'); + createJuzhen({ orphan: false }).render(document.body); + assert.equal(countTag(document.querySelector("p"), "jz-orphan"), 0, "關閉時不產生 jz-orphan"); +}); + +test("垂懸字:冪等——重跑不重複包裹", () => +{ + const { document } = setupDom('

甲乙,丙丁。

'); + const jz = createJuzhen(); + jz.render(document.body); + const first = countTag(document.body, "jz-orphan"); + jz.render(document.body); + assert.equal(countTag(document.body, "jz-orphan"), first, "第二次 render 不增加 jz-orphan"); +}); + +test("垂懸字:revert 還原(無 jz-orphan,textContent 回原樣)", () => +{ + const { document } = setupDom('

甲乙,丙丁。

'); + const jz = createJuzhen(); + jz.render(document.body); + jz.revert(document.body); + assert.equal(countTag(document.body, "jz-orphan"), 0, "revert 清除 jz-orphan"); + assert.equal(document.querySelector("p").textContent, "甲乙,丙丁。", "文字回原樣"); +}); + +test("垂懸字:段落級——data-jz-level=text 子樹跳過", () => +{ + const { document } = setupDom('

甲乙,丙丁。

'); + createJuzhen().render(document.body); + assert.equal(countTag(document.querySelector("p"), "jz-orphan"), 0, "text-level 不跑垂懸字"); +});