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:
Vendored
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
/** jz-* 自訂元素名(皆含連字號,合法 custom element,預設 inline)。 */
|
/** 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";
|
type JzKind = "marker" | "wrap";
|
||||||
export interface CreateOptions {
|
export interface CreateOptions {
|
||||||
/** 追加之 class(空白分隔或陣列)。 */
|
/** 追加之 class(空白分隔或陣列)。 */
|
||||||
|
|||||||
Vendored
+5
@@ -96,4 +96,9 @@ jz-jinze {
|
|||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* src/css/_orphan.css */
|
||||||
|
jz-orphan {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* src/css/juzhen.css */
|
/* src/css/juzhen.css */
|
||||||
|
|||||||
Vendored
+117
-9
@@ -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
|
// src/typeset/spacing.ts
|
||||||
var PASS4 = "spacing";
|
var PASS5 = "spacing";
|
||||||
var MARK = "\uE000";
|
var MARK = "\uE000";
|
||||||
var PH_OPEN = "\uE001";
|
var PH_OPEN = "\uE001";
|
||||||
var PH_CLOSE = "\uE002";
|
var PH_CLOSE = "\uE002";
|
||||||
@@ -1294,7 +1392,7 @@ var Juzhen = (() => {
|
|||||||
if (!parent || parent.nodeName === "JZ-HWS") {
|
if (!parent || parent.nodeName === "JZ-HWS") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const gap = createJz("jz-hws", PASS4, "wrap");
|
const gap = createJz("jz-hws", PASS5, "wrap");
|
||||||
parent.insertBefore(gap, node);
|
parent.insertBefore(gap, node);
|
||||||
gap.appendChild(node);
|
gap.appendChild(node);
|
||||||
}
|
}
|
||||||
@@ -1311,7 +1409,7 @@ var Juzhen = (() => {
|
|||||||
}
|
}
|
||||||
wrapNodeInGap(tn);
|
wrapNodeInGap(tn);
|
||||||
}
|
}
|
||||||
function processBlock(block, finder, R) {
|
function processBlock2(block, finder, R) {
|
||||||
const runs = finder.collectRuns(block);
|
const runs = finder.collectRuns(block);
|
||||||
for (const run of runs) {
|
for (const run of runs) {
|
||||||
const { text, map } = run;
|
const { text, map } = run;
|
||||||
@@ -1403,7 +1501,7 @@ var Juzhen = (() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
var spacingPass = {
|
var spacingPass = {
|
||||||
name: PASS4,
|
name: PASS5,
|
||||||
kind: "standalone",
|
kind: "standalone",
|
||||||
order: 50,
|
order: 50,
|
||||||
level: "text",
|
level: "text",
|
||||||
@@ -1412,18 +1510,18 @@ var Juzhen = (() => {
|
|||||||
const { finder, options } = ctx;
|
const { finder, options } = ctx;
|
||||||
const R = makeRules(options.ruleset);
|
const R = makeRules(options.ruleset);
|
||||||
ctx.finder.eachBlock(ctx.root, (block) => {
|
ctx.finder.eachBlock(ctx.root, (block) => {
|
||||||
if (!finder.featureEnabledFor(block, PASS4)) {
|
if (!finder.featureEnabledFor(block, PASS5)) {
|
||||||
return;
|
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)) {
|
if (!options.finder.blockTags.has(ctx.root.nodeName) && finder.featureEnabledFor(ctx.root, PASS5) && finder.inScope(ctx.root) && !finder.isAvoided(ctx.root)) {
|
||||||
processBlock(ctx.root, finder, R);
|
processBlock2(ctx.root, finder, R);
|
||||||
}
|
}
|
||||||
processPills(ctx.root, finder, options.finder.pillSelector, R.pillNeighbor);
|
processPills(ctx.root, finder, options.finder.pillSelector, R.pillNeighbor);
|
||||||
},
|
},
|
||||||
revert(ctx) {
|
revert(ctx) {
|
||||||
revertPass(PASS4, ctx.root);
|
revertPass(PASS5, ctx.root);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1576,6 +1674,7 @@ var Juzhen = (() => {
|
|||||||
longWordPass,
|
longWordPass,
|
||||||
spacingPass,
|
spacingPass,
|
||||||
jinzePass,
|
jinzePass,
|
||||||
|
orphanPass,
|
||||||
lineEdgePass,
|
lineEdgePass,
|
||||||
gapTrimPass
|
gapTrimPass
|
||||||
];
|
];
|
||||||
@@ -1593,6 +1692,13 @@ var Juzhen = (() => {
|
|||||||
const lang = typeof v.lang === "string" && v.lang ? v.lang : "en";
|
const lang = typeof v.lang === "string" && v.lang ? v.lang : "en";
|
||||||
return { minLength, lang };
|
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) {
|
function resolveJiya(v) {
|
||||||
if (v && typeof v === "object") {
|
if (v && typeof v === "object") {
|
||||||
return {
|
return {
|
||||||
@@ -1625,12 +1731,14 @@ var Juzhen = (() => {
|
|||||||
longWord: opts.longWord === false ? false : feature(opts.longWord, true),
|
longWord: opts.longWord === false ? false : feature(opts.longWord, true),
|
||||||
jiya: feature(opts.jiya, true),
|
jiya: feature(opts.jiya, true),
|
||||||
jinze: feature(opts.jinze, true),
|
jinze: feature(opts.jinze, true),
|
||||||
|
orphan: opts.orphan === false ? false : feature(opts.orphan, true),
|
||||||
hanging: feature(opts.hanging, false),
|
hanging: feature(opts.hanging, false),
|
||||||
biaodian: feature(opts.biaodian, false),
|
biaodian: feature(opts.biaodian, false),
|
||||||
emphasis: feature(opts.emphasis, false),
|
emphasis: feature(opts.emphasis, false),
|
||||||
ruby: feature(opts.ruby, false)
|
ruby: feature(opts.ruby, false)
|
||||||
},
|
},
|
||||||
longWord: resolveLongWord(opts.longWord),
|
longWord: resolveLongWord(opts.longWord),
|
||||||
|
orphan: resolveOrphan(opts.orphan),
|
||||||
jiyaConfig: resolveJiya(opts.jiya),
|
jiyaConfig: resolveJiya(opts.jiya),
|
||||||
blocks: Array.isArray(opts.blocks) ? opts.blocks.slice() : [],
|
blocks: Array.isArray(opts.blocks) ? opts.blocks.slice() : [],
|
||||||
finder: {
|
finder: {
|
||||||
|
|||||||
Vendored
+117
-9
@@ -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
|
// src/typeset/spacing.ts
|
||||||
var PASS4 = "spacing";
|
var PASS5 = "spacing";
|
||||||
var MARK = "\uE000";
|
var MARK = "\uE000";
|
||||||
var PH_OPEN = "\uE001";
|
var PH_OPEN = "\uE001";
|
||||||
var PH_CLOSE = "\uE002";
|
var PH_CLOSE = "\uE002";
|
||||||
@@ -1266,7 +1364,7 @@ function wrapNodeInGap(node) {
|
|||||||
if (!parent || parent.nodeName === "JZ-HWS") {
|
if (!parent || parent.nodeName === "JZ-HWS") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const gap = createJz("jz-hws", PASS4, "wrap");
|
const gap = createJz("jz-hws", PASS5, "wrap");
|
||||||
parent.insertBefore(gap, node);
|
parent.insertBefore(gap, node);
|
||||||
gap.appendChild(node);
|
gap.appendChild(node);
|
||||||
}
|
}
|
||||||
@@ -1283,7 +1381,7 @@ function wrapGapLeft(pos) {
|
|||||||
}
|
}
|
||||||
wrapNodeInGap(tn);
|
wrapNodeInGap(tn);
|
||||||
}
|
}
|
||||||
function processBlock(block, finder, R) {
|
function processBlock2(block, finder, R) {
|
||||||
const runs = finder.collectRuns(block);
|
const runs = finder.collectRuns(block);
|
||||||
for (const run of runs) {
|
for (const run of runs) {
|
||||||
const { text, map } = run;
|
const { text, map } = run;
|
||||||
@@ -1375,7 +1473,7 @@ function handlePillSide(adj, pill, dir, pillNeighbor) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
var spacingPass = {
|
var spacingPass = {
|
||||||
name: PASS4,
|
name: PASS5,
|
||||||
kind: "standalone",
|
kind: "standalone",
|
||||||
order: 50,
|
order: 50,
|
||||||
level: "text",
|
level: "text",
|
||||||
@@ -1384,18 +1482,18 @@ var spacingPass = {
|
|||||||
const { finder, options } = ctx;
|
const { finder, options } = ctx;
|
||||||
const R = makeRules(options.ruleset);
|
const R = makeRules(options.ruleset);
|
||||||
ctx.finder.eachBlock(ctx.root, (block) => {
|
ctx.finder.eachBlock(ctx.root, (block) => {
|
||||||
if (!finder.featureEnabledFor(block, PASS4)) {
|
if (!finder.featureEnabledFor(block, PASS5)) {
|
||||||
return;
|
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)) {
|
if (!options.finder.blockTags.has(ctx.root.nodeName) && finder.featureEnabledFor(ctx.root, PASS5) && finder.inScope(ctx.root) && !finder.isAvoided(ctx.root)) {
|
||||||
processBlock(ctx.root, finder, R);
|
processBlock2(ctx.root, finder, R);
|
||||||
}
|
}
|
||||||
processPills(ctx.root, finder, options.finder.pillSelector, R.pillNeighbor);
|
processPills(ctx.root, finder, options.finder.pillSelector, R.pillNeighbor);
|
||||||
},
|
},
|
||||||
revert(ctx) {
|
revert(ctx) {
|
||||||
revertPass(PASS4, ctx.root);
|
revertPass(PASS5, ctx.root);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1548,6 +1646,7 @@ var PASSES = [
|
|||||||
longWordPass,
|
longWordPass,
|
||||||
spacingPass,
|
spacingPass,
|
||||||
jinzePass,
|
jinzePass,
|
||||||
|
orphanPass,
|
||||||
lineEdgePass,
|
lineEdgePass,
|
||||||
gapTrimPass
|
gapTrimPass
|
||||||
];
|
];
|
||||||
@@ -1565,6 +1664,13 @@ function resolveLongWord(v) {
|
|||||||
const lang = typeof v.lang === "string" && v.lang ? v.lang : "en";
|
const lang = typeof v.lang === "string" && v.lang ? v.lang : "en";
|
||||||
return { minLength, lang };
|
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) {
|
function resolveJiya(v) {
|
||||||
if (v && typeof v === "object") {
|
if (v && typeof v === "object") {
|
||||||
return {
|
return {
|
||||||
@@ -1597,12 +1703,14 @@ function normalizeOptions(opts = {}) {
|
|||||||
longWord: opts.longWord === false ? false : feature(opts.longWord, true),
|
longWord: opts.longWord === false ? false : feature(opts.longWord, true),
|
||||||
jiya: feature(opts.jiya, true),
|
jiya: feature(opts.jiya, true),
|
||||||
jinze: feature(opts.jinze, true),
|
jinze: feature(opts.jinze, true),
|
||||||
|
orphan: opts.orphan === false ? false : feature(opts.orphan, true),
|
||||||
hanging: feature(opts.hanging, false),
|
hanging: feature(opts.hanging, false),
|
||||||
biaodian: feature(opts.biaodian, false),
|
biaodian: feature(opts.biaodian, false),
|
||||||
emphasis: feature(opts.emphasis, false),
|
emphasis: feature(opts.emphasis, false),
|
||||||
ruby: feature(opts.ruby, false)
|
ruby: feature(opts.ruby, false)
|
||||||
},
|
},
|
||||||
longWord: resolveLongWord(opts.longWord),
|
longWord: resolveLongWord(opts.longWord),
|
||||||
|
orphan: resolveOrphan(opts.orphan),
|
||||||
jiyaConfig: resolveJiya(opts.jiya),
|
jiyaConfig: resolveJiya(opts.jiya),
|
||||||
blocks: Array.isArray(opts.blocks) ? opts.blocks.slice() : [],
|
blocks: Array.isArray(opts.blocks) ? opts.blocks.slice() : [],
|
||||||
finder: {
|
finder: {
|
||||||
|
|||||||
Vendored
+11
@@ -7,6 +7,11 @@ export interface LongWordOptions {
|
|||||||
minLength?: number;
|
minLength?: number;
|
||||||
lang?: string;
|
lang?: string;
|
||||||
}
|
}
|
||||||
|
/** 垂懸字避免設定(§6.5.1)。布林 true 等同 { chars: 2 }。 */
|
||||||
|
export interface OrphanOptions {
|
||||||
|
/** 段末行至少保留之 CJK 實義字數(標點/符號不計;預設 2)。 */
|
||||||
|
chars?: number;
|
||||||
|
}
|
||||||
/** 標點擠壓設定(架構 §6.3)。布林 true 等同 { halfWidth: "halt" }。 */
|
/** 標點擠壓設定(架構 §6.3)。布林 true 等同 { halfWidth: "halt" }。 */
|
||||||
export interface JiyaOptions {
|
export interface JiyaOptions {
|
||||||
/** 半形渲染機制:
|
/** 半形渲染機制:
|
||||||
@@ -62,6 +67,7 @@ export interface JuzhenOptions {
|
|||||||
longWord?: boolean | LongWordOptions;
|
longWord?: boolean | LongWordOptions;
|
||||||
jiya?: boolean | JiyaOptions;
|
jiya?: boolean | JiyaOptions;
|
||||||
jinze?: boolean;
|
jinze?: boolean;
|
||||||
|
orphan?: boolean | OrphanOptions;
|
||||||
hanging?: boolean;
|
hanging?: boolean;
|
||||||
biaodian?: boolean;
|
biaodian?: boolean;
|
||||||
emphasis?: boolean;
|
emphasis?: boolean;
|
||||||
@@ -94,6 +100,7 @@ export interface ResolvedOptions {
|
|||||||
longWord: boolean;
|
longWord: boolean;
|
||||||
jiya: boolean;
|
jiya: boolean;
|
||||||
jinze: boolean;
|
jinze: boolean;
|
||||||
|
orphan: boolean;
|
||||||
hanging: boolean;
|
hanging: boolean;
|
||||||
biaodian: boolean;
|
biaodian: boolean;
|
||||||
emphasis: boolean;
|
emphasis: boolean;
|
||||||
@@ -103,6 +110,10 @@ export interface ResolvedOptions {
|
|||||||
minLength: number;
|
minLength: number;
|
||||||
lang: string;
|
lang: string;
|
||||||
};
|
};
|
||||||
|
/** 垂懸字避免設定(解析後)。 */
|
||||||
|
orphan: {
|
||||||
|
chars: number;
|
||||||
|
};
|
||||||
/** 擠壓半形渲染設定(halt 預設/margin 後備)。 */
|
/** 擠壓半形渲染設定(halt 預設/margin 後備)。 */
|
||||||
jiyaConfig: {
|
jiyaConfig: {
|
||||||
halfWidth: "halt" | "margin";
|
halfWidth: "halt" | "margin";
|
||||||
|
|||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
import type { StandalonePass } from "../types.js";
|
||||||
|
export declare const orphanPass: StandalonePass;
|
||||||
@@ -11,6 +11,7 @@ export type JzTag =
|
|||||||
| "jz-inner"
|
| "jz-inner"
|
||||||
| "jz-cs"
|
| "jz-cs"
|
||||||
| "jz-jinze"
|
| "jz-jinze"
|
||||||
|
| "jz-orphan"
|
||||||
| "jz-em"
|
| "jz-em"
|
||||||
| "jz-ruby"
|
| "jz-ruby"
|
||||||
| "jz-rb"
|
| "jz-rb"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -9,3 +9,4 @@
|
|||||||
@import "./_spacing.css";
|
@import "./_spacing.css";
|
||||||
@import "./_jiya.css";
|
@import "./_jiya.css";
|
||||||
@import "./_jinze.css";
|
@import "./_jinze.css";
|
||||||
|
@import "./_orphan.css";
|
||||||
|
|||||||
+15
-2
@@ -13,9 +13,10 @@ import { jiyaAdjacencyPass, jiyaPass } from "./typeset/jiya.js";
|
|||||||
import { jinzePass } from "./typeset/jinze.js";
|
import { jinzePass } from "./typeset/jinze.js";
|
||||||
import { gapTrimPass, lineEdgePass } from "./typeset/lineedge.js";
|
import { gapTrimPass, lineEdgePass } from "./typeset/lineedge.js";
|
||||||
import { longWordPass } from "./typeset/longword.js";
|
import { longWordPass } from "./typeset/longword.js";
|
||||||
|
import { orphanPass } from "./typeset/orphan.js";
|
||||||
import { spacingPass } from "./typeset/spacing.js";
|
import { spacingPass } from "./typeset/spacing.js";
|
||||||
import { resolveRuleset } from "./core/unicode.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 { JuzhenOptions, ResolvedOptions, Pass };
|
||||||
export type { CharClassOverride, BdSubclass, Ruleset, Bias, BiasTable } from "./types.js";
|
export type { CharClassOverride, BdSubclass, Ruleset, Bias, BiasTable } from "./types.js";
|
||||||
@@ -41,7 +42,7 @@ const DEFAULT_SKIP_TAGS = [
|
|||||||
|
|
||||||
const PASSES: Pass[] = [
|
const PASSES: Pass[] = [
|
||||||
jiyaPass, jiyaAdjacencyPass, longWordPass, spacingPass, jinzePass,
|
jiyaPass, jiyaAdjacencyPass, longWordPass, spacingPass, jinzePass,
|
||||||
lineEdgePass, gapTrimPass,
|
orphanPass, lineEdgePass, gapTrimPass,
|
||||||
];
|
];
|
||||||
|
|
||||||
function feature(v: unknown, dflt: boolean): boolean
|
function feature(v: unknown, dflt: boolean): boolean
|
||||||
@@ -58,6 +59,16 @@ function resolveLongWord(v: boolean | LongWordOptions | undefined): { minLength:
|
|||||||
return { minLength, lang };
|
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(
|
function resolveJiya(
|
||||||
v: boolean | JiyaOptions | undefined,
|
v: boolean | JiyaOptions | undefined,
|
||||||
): { halfWidth: "halt" | "margin"; bias: BiasTable }
|
): { halfWidth: "halt" | "margin"; bias: BiasTable }
|
||||||
@@ -97,12 +108,14 @@ export function normalizeOptions(opts: JuzhenOptions = {}): ResolvedOptions
|
|||||||
longWord: opts.longWord === false ? false : feature(opts.longWord, true),
|
longWord: opts.longWord === false ? false : feature(opts.longWord, true),
|
||||||
jiya: feature(opts.jiya, true),
|
jiya: feature(opts.jiya, true),
|
||||||
jinze: feature(opts.jinze, true),
|
jinze: feature(opts.jinze, true),
|
||||||
|
orphan: opts.orphan === false ? false : feature(opts.orphan, true),
|
||||||
hanging: feature(opts.hanging, false),
|
hanging: feature(opts.hanging, false),
|
||||||
biaodian: feature(opts.biaodian, false),
|
biaodian: feature(opts.biaodian, false),
|
||||||
emphasis: feature(opts.emphasis, false),
|
emphasis: feature(opts.emphasis, false),
|
||||||
ruby: feature(opts.ruby, false),
|
ruby: feature(opts.ruby, false),
|
||||||
},
|
},
|
||||||
longWord: resolveLongWord(opts.longWord),
|
longWord: resolveLongWord(opts.longWord),
|
||||||
|
orphan: resolveOrphan(opts.orphan),
|
||||||
jiyaConfig: resolveJiya(opts.jiya),
|
jiyaConfig: resolveJiya(opts.jiya),
|
||||||
blocks: Array.isArray(opts.blocks) ? opts.blocks.slice() : [],
|
blocks: Array.isArray(opts.blocks) ? opts.blocks.slice() : [],
|
||||||
finder: {
|
finder: {
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ export interface LongWordOptions
|
|||||||
lang?: string;
|
lang?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 垂懸字避免設定(§6.5.1)。布林 true 等同 { chars: 2 }。 */
|
||||||
|
export interface OrphanOptions
|
||||||
|
{
|
||||||
|
/** 段末行至少保留之 CJK 實義字數(標點/符號不計;預設 2)。 */
|
||||||
|
chars?: number;
|
||||||
|
}
|
||||||
|
|
||||||
/** 標點擠壓設定(架構 §6.3)。布林 true 等同 { halfWidth: "halt" }。 */
|
/** 標點擠壓設定(架構 §6.3)。布林 true 等同 { halfWidth: "halt" }。 */
|
||||||
export interface JiyaOptions
|
export interface JiyaOptions
|
||||||
{
|
{
|
||||||
@@ -80,6 +87,7 @@ export interface JuzhenOptions
|
|||||||
longWord?: boolean | LongWordOptions;
|
longWord?: boolean | LongWordOptions;
|
||||||
jiya?: boolean | JiyaOptions;
|
jiya?: boolean | JiyaOptions;
|
||||||
jinze?: boolean;
|
jinze?: boolean;
|
||||||
|
orphan?: boolean | OrphanOptions;
|
||||||
hanging?: boolean;
|
hanging?: boolean;
|
||||||
biaodian?: boolean;
|
biaodian?: boolean;
|
||||||
emphasis?: boolean;
|
emphasis?: boolean;
|
||||||
@@ -117,12 +125,15 @@ export interface ResolvedOptions
|
|||||||
longWord: boolean;
|
longWord: boolean;
|
||||||
jiya: boolean;
|
jiya: boolean;
|
||||||
jinze: boolean;
|
jinze: boolean;
|
||||||
|
orphan: boolean;
|
||||||
hanging: boolean;
|
hanging: boolean;
|
||||||
biaodian: boolean;
|
biaodian: boolean;
|
||||||
emphasis: boolean;
|
emphasis: boolean;
|
||||||
ruby: boolean;
|
ruby: boolean;
|
||||||
};
|
};
|
||||||
longWord: { minLength: number; lang: string };
|
longWord: { minLength: number; lang: string };
|
||||||
|
/** 垂懸字避免設定(解析後)。 */
|
||||||
|
orphan: { chars: number };
|
||||||
/** 擠壓半形渲染設定(halt 預設/margin 後備)。 */
|
/** 擠壓半形渲染設定(halt 預設/margin 後備)。 */
|
||||||
jiyaConfig: { halfWidth: "halt" | "margin"; bias: BiasTable };
|
jiyaConfig: { halfWidth: "halt" | "margin"; bias: BiasTable };
|
||||||
blocks: BlockRule[];
|
blocks: BlockRule[];
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
// 垂懸字避免(架構 §6.5.1,方案 C:斷行層、無測量)。standalone 段落級 pass。
|
||||||
|
//
|
||||||
|
// 目標:段末行之 CJK 實義字數 ≥ N(預設 2;標點/符號不計)。單行段天然無害。
|
||||||
|
// 機制:自段末向前數 N 個 CJK 實義字,把「第 N 個實義字起至段末」(含其間/尾隨
|
||||||
|
// 標點)包入 <jz-orphan>(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<string>,
|
||||||
|
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<string>,
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -702,3 +702,83 @@ test("分級:預設(無標記)塊級元素為段落級——禁則照常",
|
|||||||
createJuzhen().render(document.body);
|
createJuzhen().render(document.body);
|
||||||
assert.ok(countTag(document.querySelector("p"), "jz-jinze") >= 1, "預設段落級,禁則綁定");
|
assert.ok(countTag(document.querySelector("p"), "jz-jinze") >= 1, "預設段落級,禁則綁定");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ----- 垂懸字避免(§6.5.1,方案 C)-----
|
||||||
|
|
||||||
|
test("垂懸字:末 N 實義字+尾隨標點包入 jz-orphan(預設 chars=2)", () =>
|
||||||
|
{
|
||||||
|
const { document } = setupDom('<p lang="zh-Hant">甲乙,丙丁。</p>');
|
||||||
|
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('<p lang="zh-Hant">甲乙,丙丁。</p>');
|
||||||
|
createJuzhen({ orphan: { chars: 3 } }).render(document.body);
|
||||||
|
const orphan = document.querySelector("jz-orphan");
|
||||||
|
assert.equal(orphan.textContent, "乙,丙丁。", "綁定末 3 實義字(乙丙丁)+其間/尾標點");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("垂懸字:標點/符號不計實義字數(逗號不計)", () =>
|
||||||
|
{
|
||||||
|
const { document } = setupDom('<p lang="zh-Hant">讀書,思考,寫作。</p>');
|
||||||
|
createJuzhen().render(document.body);
|
||||||
|
const orphan = document.querySelector("jz-orphan");
|
||||||
|
// 實義字 讀書思考寫作(6),末 2=寫作;逗號不計入。
|
||||||
|
assert.equal(orphan.textContent, "寫作。", "末 2 實義字=寫作,標點不計");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("垂懸字:段落實義字 < N 不綁(單字段)", () =>
|
||||||
|
{
|
||||||
|
const { document } = setupDom('<p lang="zh-Hant">嗯。</p>');
|
||||||
|
createJuzhen().render(document.body);
|
||||||
|
assert.equal(countTag(document.querySelector("p"), "jz-orphan"), 0, "實義字 1 < 2,不綁");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("垂懸字:邊界字落於 jz-jinze 內 → 綁定整個群組(跨 jinze 攀爬、不切割)", () =>
|
||||||
|
{
|
||||||
|
const { document } = setupDom('<p lang="zh-Hant">他說「好」</p>');
|
||||||
|
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('<p lang="zh-Hant">甲乙,丙丁。</p>');
|
||||||
|
createJuzhen({ orphan: false }).render(document.body);
|
||||||
|
assert.equal(countTag(document.querySelector("p"), "jz-orphan"), 0, "關閉時不產生 jz-orphan");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("垂懸字:冪等——重跑不重複包裹", () =>
|
||||||
|
{
|
||||||
|
const { document } = setupDom('<p lang="zh-Hant">甲乙,丙丁。</p>');
|
||||||
|
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('<p lang="zh-Hant">甲乙,丙丁。</p>');
|
||||||
|
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('<p data-jz-level="text" lang="zh-Hant">甲乙,丙丁。</p>');
|
||||||
|
createJuzhen().render(document.body);
|
||||||
|
assert.equal(countTag(document.querySelector("p"), "jz-orphan"), 0, "text-level 不跑垂懸字");
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user