feat: 垂懸字避免(方案 C,斷行層無測量,order 70)(§6.5.1)

段末行 CJK 實義字數 ≥ N(預設 2;標點/符號不計)。standalone 段落級 pass:
自段末向前數 N 個實義字,把「第 N 字起至段末」(含其間/尾隨標點)包入
<jz-orphan>(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 選項、標點不計、實義字<N 不綁、跨 jinze、
關閉、冪等、revert、段落級閘)。共 85 測試全綠。

窄容器拖動「末行恒 ≥N」之 layout 驗證留待 demo(Step 4-5,瀏覽器)。
This commit is contained in:
2026-06-09 17:00:04 +08:00
parent c1fbabf5b0
commit 754b00da42
13 changed files with 521 additions and 21 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
/** 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";
export type JzTag = "jz-hws" | "jz-char" | "jz-inner" | "jz-cs" | "jz-jinze" | "jz-orphan" | "jz-em" | "jz-ruby" | "jz-rb" | "jz-rt";
type JzKind = "marker" | "wrap";
export interface CreateOptions {
/** 追加之 class(空白分隔或陣列)。 */
+5
View File
@@ -96,4 +96,9 @@ jz-jinze {
display: inline;
}
/* src/css/_orphan.css */
jz-orphan {
white-space: nowrap;
}
/* src/css/juzhen.css */
+117 -9
View File
@@ -1192,8 +1192,106 @@ var Juzhen = (() => {
}
};
// 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: {
+117 -9
View File
@@ -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: {
+11
View File
@@ -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";
+2
View File
@@ -0,0 +1,2 @@
import type { StandalonePass } from "../types.js";
export declare const orphanPass: StandalonePass;