3a4fa1c932
保留逐字 margin/jiya.bias 字体后备方案;补齐 demo 重排、量测边界说明及共享规则 1.0.1 反例向量。 验证:完整构建通过,Node 376 项、WKWebView 64 项及原生 demo 9 项通过;Firefox 仍暂缓。
525 lines
19 KiB
JavaScript
525 lines
19 KiB
JavaScript
import assert from "node:assert/strict";
|
||
import { readFile } from "node:fs/promises";
|
||
import { dirname, join } from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
import test from "node:test";
|
||
|
||
import Ajv2020 from "ajv/dist/2020.js";
|
||
import { build } from "esbuild";
|
||
import { JSDOM } from "jsdom";
|
||
|
||
const TEST_DIR = dirname(fileURLToPath(import.meta.url));
|
||
const ROOT = dirname(TEST_DIR);
|
||
const RULES_DIR = join(ROOT, "rules");
|
||
|
||
async function readJson(path)
|
||
{
|
||
return JSON.parse(await readFile(path, "utf8"));
|
||
}
|
||
|
||
const [ rules, rulesSchema, vectorsDoc, vectorsSchema, packageJson, spacingCss ] = await Promise.all([
|
||
readJson(join(RULES_DIR, "juzhen-rules.json")),
|
||
readJson(join(RULES_DIR, "juzhen-rules.schema.json")),
|
||
readJson(join(RULES_DIR, "golden-vectors.json")),
|
||
readJson(join(RULES_DIR, "golden-vectors.schema.json")),
|
||
readJson(join(ROOT, "package.json")),
|
||
readFile(join(ROOT, "src/css/_spacing.css"), "utf8"),
|
||
]);
|
||
|
||
const ajv = new Ajv2020({ allErrors: true, strict: true });
|
||
const validateRules = ajv.compile(rulesSchema);
|
||
const validateVectors = ajv.compile(vectorsSchema);
|
||
|
||
async function bundleSourceAdapter()
|
||
{
|
||
const source = [
|
||
'export * from "./src/core/unicode.ts";',
|
||
'export * from "./src/core/locale.ts";',
|
||
'export { createJuzhen, normalizeOptions } from "./src/index.ts";',
|
||
'export { jiyaPass, jiyaAdjacencyPass } from "./src/typeset/jiya.ts";',
|
||
'export { longWordPass } from "./src/typeset/longword.ts";',
|
||
'export { levelMarkPass } from "./src/typeset/levelmark.ts";',
|
||
'export { jinzePass } from "./src/typeset/jinze.ts";',
|
||
'export { spacingPass } from "./src/typeset/spacing.ts";',
|
||
'export { orphanPass } from "./src/typeset/orphan.ts";',
|
||
'export { lineEdgePass, gapTrimPass } from "./src/typeset/lineedge.ts";',
|
||
].join("\n");
|
||
const result = await build({
|
||
stdin: {
|
||
contents: source,
|
||
loader: "ts",
|
||
resolveDir: ROOT,
|
||
sourcefile: "shared-rules-adapter.ts",
|
||
},
|
||
bundle: true,
|
||
format: "esm",
|
||
platform: "node",
|
||
target: "es2022",
|
||
write: false,
|
||
logLevel: "silent",
|
||
});
|
||
const bundled = result.outputFiles[0].text + "\n//# sourceURL=shared-rules-adapter.mjs\n";
|
||
const encoded = Buffer.from(bundled).toString("base64");
|
||
return import("data:text/javascript;base64," + encoded);
|
||
}
|
||
|
||
const adapter = await bundleSourceAdapter();
|
||
const vectors = vectorsDoc.vectors;
|
||
|
||
function vectorsOf(category)
|
||
{
|
||
return vectors.filter((vector) => vector.category === category);
|
||
}
|
||
|
||
function renderText(text, options = {})
|
||
{
|
||
const dom = new JSDOM("<!doctype html><body><p></p></body>");
|
||
const paragraph = dom.window.document.querySelector("p");
|
||
paragraph.textContent = text;
|
||
const instance = adapter.createJuzhen({
|
||
spacing: false,
|
||
jiya: false,
|
||
jinze: false,
|
||
orphan: false,
|
||
longWord: false,
|
||
...options,
|
||
});
|
||
const globals = {
|
||
document: globalThis.document,
|
||
NodeFilter: globalThis.NodeFilter,
|
||
};
|
||
globalThis.document = dom.window.document;
|
||
globalThis.NodeFilter = dom.window.NodeFilter;
|
||
try
|
||
{
|
||
instance.render(paragraph);
|
||
}
|
||
finally
|
||
{
|
||
if (globals.document === undefined) { delete globalThis.document; }
|
||
else { globalThis.document = globals.document; }
|
||
if (globals.NodeFilter === undefined) { delete globalThis.NodeFilter; }
|
||
else { globalThis.NodeFilter = globals.NodeFilter; }
|
||
}
|
||
return { dom, paragraph, instance };
|
||
}
|
||
|
||
function punctuationCharacter(punctuationClass)
|
||
{
|
||
return {
|
||
stop: "。",
|
||
pause: ",",
|
||
open: "「",
|
||
close: "」",
|
||
middle: "·",
|
||
liga: "…",
|
||
}[punctuationClass];
|
||
}
|
||
|
||
function entryBounds(entry)
|
||
{
|
||
const start = Number.parseInt(entry.codePoint || entry.start, 16);
|
||
const end = Number.parseInt(entry.codePoint || entry.end, 16);
|
||
return { start, end };
|
||
}
|
||
|
||
function assertOrderedCodePointSet(name, entries)
|
||
{
|
||
let previousEnd = -1;
|
||
for (const entry of entries)
|
||
{
|
||
const { start, end } = entryBounds(entry);
|
||
assert.ok(start >= 0 && end <= 0x10FFFF, name + " Unicode bounds");
|
||
assert.ok(start <= end, name + " closed range order");
|
||
assert.ok(start > previousEnd, name + " ranges must be sorted and non-overlapping");
|
||
previousEnd = end;
|
||
}
|
||
}
|
||
|
||
function webEscape(codePoint)
|
||
{
|
||
const hex = codePoint.toString(16).toUpperCase();
|
||
return codePoint <= 0xFFFF
|
||
? "\\u" + hex.padStart(4, "0")
|
||
: "\\u{" + hex + "}";
|
||
}
|
||
|
||
function rangesToWebFragment(entries)
|
||
{
|
||
return entries.map((entry) =>
|
||
{
|
||
const { start, end } = entryBounds(entry);
|
||
return start === end
|
||
? webEscape(start)
|
||
: webEscape(start) + "-" + webEscape(end);
|
||
}).join("");
|
||
}
|
||
|
||
function assertEquivalentMembership(name, entries, sourceFragment)
|
||
{
|
||
assertOrderedCodePointSet(name, entries);
|
||
const canonical = new RegExp("^[" + rangesToWebFragment(entries) + "]$", "u");
|
||
const source = new RegExp("^[" + sourceFragment + "]$", "u");
|
||
for (let codePoint = 0; codePoint <= 0x10FFFF; codePoint += 1)
|
||
{
|
||
const character = String.fromCodePoint(codePoint);
|
||
assert.equal(
|
||
canonical.test(character),
|
||
source.test(character),
|
||
name + " differs at U+" + codePoint.toString(16).toUpperCase(),
|
||
);
|
||
}
|
||
}
|
||
|
||
function canonicalCodePoints(entries)
|
||
{
|
||
const out = [];
|
||
for (const entry of entries)
|
||
{
|
||
const { start, end } = entryBounds(entry);
|
||
for (let codePoint = start; codePoint <= end; codePoint += 1) { out.push(codePoint); }
|
||
}
|
||
return out;
|
||
}
|
||
|
||
test("shared rules: Ajv 2020 strict validation, rejection cases, exports, and coverage", () =>
|
||
{
|
||
assert.equal(rulesSchema.$schema, "https://json-schema.org/draft/2020-12/schema");
|
||
assert.equal(vectorsSchema.$schema, "https://json-schema.org/draft/2020-12/schema");
|
||
assert.equal(rules.schemaVersion, 1);
|
||
assert.equal(rules.rulesetVersion, "1.0.1");
|
||
assert.equal(rules.unicodeProfile.version, "17.0.0");
|
||
assert.equal(vectorsDoc.schemaVersion, 1);
|
||
assert.equal(vectorsDoc.rulesetVersion, rules.rulesetVersion);
|
||
assert.equal(validateRules(rules), true, ajv.errorsText(validateRules.errors));
|
||
assert.equal(validateVectors(vectorsDoc), true, ajv.errorsText(validateVectors.errors));
|
||
|
||
const extraRules = structuredClone(rules);
|
||
extraRules.unexpected = true;
|
||
assert.equal(validateRules(extraRules), false, "rules additionalProperties must be rejected");
|
||
|
||
const bogusJinze = structuredClone(vectorsDoc);
|
||
const jinzeIndex = bogusJinze.vectors.findIndex((vector) => vector.category === "jinze");
|
||
assert.notEqual(jinzeIndex, -1, "valid fixture must contain a jinze vector");
|
||
bogusJinze.vectors[jinzeIndex] = {
|
||
id: "bogus-jinze",
|
||
category: "jinze",
|
||
input: {},
|
||
expected: { reason: "bogus" },
|
||
};
|
||
assert.equal(validateVectors(bogusJinze), false, "malformed jinze vector must be rejected");
|
||
assert.equal(
|
||
validateVectors.errors?.some((error) => error.keyword === "minItems"),
|
||
false,
|
||
"bogus jinze rejection must not rely on document-wide vector count",
|
||
);
|
||
|
||
const extraVectorField = structuredClone(vectorsDoc);
|
||
extraVectorField.vectors[0].input.unexpected = true;
|
||
assert.equal(
|
||
validateVectors(extraVectorField),
|
||
false,
|
||
"category input additionalProperties must be rejected",
|
||
);
|
||
|
||
assert.ok(vectors.length >= 20, `expected at least 20 vectors, got ${vectors.length}`);
|
||
const ids = vectors.map((vector) => vector.id);
|
||
assert.equal(new Set(ids).size, ids.length, "golden vector ids must be unique");
|
||
for (const category of [ "jinze", "orphan" ])
|
||
{
|
||
assert.ok(
|
||
vectors.some((vector) => vector.category === category && vector.expected.atoms),
|
||
`${category} must retain at least one cross-feature atom vector`,
|
||
);
|
||
}
|
||
const requiredCategories = [
|
||
"language-classification",
|
||
"punctuation-classification",
|
||
"punctuation-style",
|
||
"punctuation-adjacency",
|
||
"spacing",
|
||
"ivs-atom",
|
||
"jinze",
|
||
"orphan",
|
||
];
|
||
for (const category of requiredCategories)
|
||
{
|
||
assert.ok(vectorsOf(category).length > 0, `missing vector category ${category}`);
|
||
}
|
||
|
||
assert.deepEqual(packageJson.exports["./rules"], "./rules/juzhen-rules.json");
|
||
assert.deepEqual(packageJson.exports["./rules/schema"], "./rules/juzhen-rules.schema.json");
|
||
assert.deepEqual(packageJson.exports["./rules/vectors"], "./rules/golden-vectors.json");
|
||
assert.deepEqual(
|
||
packageJson.exports["./rules/vectors-schema"],
|
||
"./rules/golden-vectors.schema.json",
|
||
);
|
||
assert.ok(packageJson.files.includes("rules"));
|
||
});
|
||
|
||
test("shared rules: canonical character classes equal the Web source constants", () =>
|
||
{
|
||
const canonical = rules.characterClasses;
|
||
assert.equal(canonical.representation, "unicode-code-point-ranges");
|
||
assertEquivalentMembership("cjk", canonical.cjk, adapter.CJK);
|
||
assertEquivalentMembership("ansAfterCjk", canonical.ansAfterCjk, adapter.ANS_AFTER_CJK);
|
||
assertEquivalentMembership("ansBeforeCjk", canonical.ansBeforeCjk, adapter.ANS_BEFORE_CJK);
|
||
assertEquivalentMembership(
|
||
"variationSelectors",
|
||
canonical.variationSelectors,
|
||
adapter.VARIATION_SELECTORS,
|
||
);
|
||
assert.deepEqual(canonical.biaodian.classificationOrder, [
|
||
"stop", "pause", "open", "close", "middle", "liga",
|
||
]);
|
||
for (const punctuationClass of canonical.biaodian.classificationOrder)
|
||
{
|
||
const entries = canonical.biaodian.classes[punctuationClass];
|
||
assertOrderedCodePointSet("biaodian." + punctuationClass, entries);
|
||
assert.deepEqual(
|
||
canonicalCodePoints(entries),
|
||
Array.from(adapter.DEFAULT_RULESET.biaodian[punctuationClass], (character) =>
|
||
character.codePointAt(0)).sort((a, b) => a - b),
|
||
"biaodian." + punctuationClass,
|
||
);
|
||
}
|
||
|
||
const extended = adapter.resolveRuleset({
|
||
cjk: "〇",
|
||
western: "★",
|
||
biaodian: { stop: "‼" },
|
||
});
|
||
assert.equal(extended.cjk, adapter.CJK + "〇");
|
||
assert.equal(extended.ansAfter, adapter.ANS_AFTER_CJK + "★");
|
||
assert.equal(extended.ansBefore, adapter.ANS_BEFORE_CJK + "★");
|
||
assert.equal(extended.biaodian.stop, adapter.DEFAULT_RULESET.biaodian.stop + "‼");
|
||
assert.equal(canonical.overrideSemantics.mode, "append");
|
||
assert.equal(canonical.overrideSemantics.removalAllowed, false);
|
||
assert.equal(canonical.overrideSemantics.reclassificationAllowed, false);
|
||
});
|
||
|
||
test("shared rules: explicit script identifiers match whole subtags", () =>
|
||
{
|
||
for (const [ lang, expected ] of [
|
||
[ "zh-CN-x-shant", "zh-Hans" ],
|
||
[ "zh-TW-x-hansford", "zh-Hant" ],
|
||
[ "ZH-CN-X-EHANT", "zh-Hans" ],
|
||
[ "zh-Hant-CN", "zh-Hant" ],
|
||
[ "zh-Hans-TW", "zh-Hans" ],
|
||
])
|
||
{
|
||
assert.equal(adapter.classify(lang), expected, lang);
|
||
}
|
||
});
|
||
|
||
test("Web locale: own lang and explicit unknown stop inheritance", () =>
|
||
{
|
||
const dom = new JSDOM('<main lang="zh-Hans"><p lang=""><span>甲</span></p></main>');
|
||
const paragraph = dom.window.document.querySelector("p");
|
||
assert.equal(adapter.effectiveLang(paragraph, "ja"), "");
|
||
assert.equal(adapter.effectiveLang(paragraph.firstChild.firstChild, "ja"), "");
|
||
assert.equal(adapter.effectiveLang(dom.window.document.querySelector("main"), "ja"), "zh-hans");
|
||
paragraph.removeAttribute("lang");
|
||
assert.equal(adapter.effectiveLang(paragraph, "ja"), "zh-hans");
|
||
dom.window.close();
|
||
});
|
||
|
||
test("shared rules: locale defaults and all language vectors match the Web adapter", () =>
|
||
{
|
||
const config = { default: "zh-Hant", policy: {} };
|
||
for (const [ langClass, expectedStyle ] of Object.entries(rules.locales.defaultPolicies))
|
||
{
|
||
assert.equal(adapter.styleFor(langClass, config), expectedStyle, langClass);
|
||
}
|
||
for (const vector of vectorsOf("language-classification"))
|
||
{
|
||
assert.equal(
|
||
adapter.classify(vector.input.lang),
|
||
vector.expected.langClass,
|
||
vector.id,
|
||
);
|
||
}
|
||
assert.equal(adapter.classify("zhx"), "other", "BCP-47 boundary must reject zhx");
|
||
});
|
||
|
||
test("shared rules: punctuation classification and style vectors match Web behavior", () =>
|
||
{
|
||
for (const vector of vectorsOf("punctuation-classification"))
|
||
{
|
||
const actual = adapter.classifyBiaodian(vector.input.character);
|
||
const expected = vector.expected.punctuationClass;
|
||
assert.equal(actual, expected === null ? null : "bd-" + expected, vector.id);
|
||
}
|
||
|
||
for (const vector of vectorsOf("punctuation-style"))
|
||
{
|
||
const halfClasses = rules.punctuationStyles.baselineHalfWidthClasses[vector.input.style];
|
||
assert.equal(
|
||
halfClasses.includes(vector.input.punctuationClass),
|
||
vector.expected.halfWidth,
|
||
vector.id + " canonical",
|
||
);
|
||
const character = punctuationCharacter(vector.input.punctuationClass);
|
||
const { paragraph } = renderText(character, {
|
||
jiya: true,
|
||
lang: { default: "zh-Hant", style: vector.input.style },
|
||
});
|
||
const char = paragraph.querySelector("jz-char");
|
||
assert.ok(char, vector.id + " must be classified by Web adapter");
|
||
assert.equal(char.classList.contains("jz-half"), vector.expected.halfWidth, vector.id);
|
||
}
|
||
});
|
||
|
||
test("shared rules: punctuation adjacency vectors match Web behavior", () =>
|
||
{
|
||
for (const vector of vectorsOf("punctuation-adjacency"))
|
||
{
|
||
const leftCharacter = punctuationCharacter(vector.input.left);
|
||
const rightCharacter = punctuationCharacter(vector.input.right);
|
||
const { paragraph } = renderText(leftCharacter + rightCharacter, {
|
||
jiya: true,
|
||
lang: { default: "zh-Hant", style: "quanjiao" },
|
||
});
|
||
const chars = Array.from(paragraph.querySelectorAll("jz-char"));
|
||
assert.equal(chars.length, 2, vector.id);
|
||
assert.equal(
|
||
chars[0].classList.contains("jz-half"),
|
||
vector.expected.squeezeLeft,
|
||
vector.id + " left",
|
||
);
|
||
assert.equal(
|
||
chars[1].classList.contains("jz-half"),
|
||
vector.expected.squeezeRight,
|
||
vector.id + " right",
|
||
);
|
||
}
|
||
});
|
||
|
||
test("shared rules: spacing vectors match Web decisions and author-space normalization", () =>
|
||
{
|
||
assert.equal(rules.spacing.gapEm, 0.25);
|
||
const cssGap = /jz-hws\s*\{[^}]*margin-right:\s*([0-9.]+)em\s*;/s.exec(spacingCss);
|
||
assert.ok(cssGap, "Web adapter spacing CSS must declare jz-hws margin-right in em");
|
||
assert.equal(Number.parseFloat(cssGap[1]), rules.spacing.gapEm, "Web CSS gap drift");
|
||
for (const vector of vectorsOf("spacing"))
|
||
{
|
||
const { paragraph } = renderText(vector.input.text, { spacing: true });
|
||
assert.equal(
|
||
paragraph.querySelectorAll("jz-hws").length,
|
||
vector.expected.managedGaps,
|
||
vector.id + " gaps",
|
||
);
|
||
assert.equal(paragraph.textContent, vector.expected.normalizedText, vector.id + " text");
|
||
if (Object.hasOwn(vector.expected, "gapEm"))
|
||
{
|
||
assert.equal(vector.expected.gapEm, rules.spacing.gapEm, vector.id + " gap width");
|
||
}
|
||
}
|
||
});
|
||
|
||
test("shared rules: IVS vectors enforce base plus at most one selector", () =>
|
||
{
|
||
assert.equal(rules.atoms.maxVariationSelectors, 1);
|
||
for (const vector of vectorsOf("ivs-atom"))
|
||
{
|
||
assert.deepEqual(
|
||
adapter.splitVariationSequences(vector.input.text),
|
||
vector.expected.atoms,
|
||
vector.id,
|
||
);
|
||
}
|
||
});
|
||
|
||
test("Web adapter only: pass groups, orders, levels, and compatibility no-ops do not drift", () =>
|
||
{
|
||
const actual = {
|
||
charify: [ adapter.jiyaPass, adapter.longWordPass ],
|
||
standalone: [
|
||
adapter.levelMarkPass,
|
||
adapter.jiyaAdjacencyPass,
|
||
adapter.jinzePass,
|
||
adapter.spacingPass,
|
||
adapter.orphanPass,
|
||
adapter.lineEdgePass,
|
||
adapter.gapTrimPass,
|
||
],
|
||
};
|
||
assert.deepEqual(
|
||
actual.charify.map(({ name, order, level }) => ({ name, order, level })),
|
||
[
|
||
{ name: "jiya", order: 20, level: "text" },
|
||
{ name: "longWord", order: 60, level: "paragraph" },
|
||
],
|
||
"Web charify group",
|
||
);
|
||
assert.deepEqual(
|
||
actual.standalone.map(({ name, order, level }) => ({ name, order, level })),
|
||
[
|
||
{ name: "levelMark", order: 10, level: "text" },
|
||
{ name: "jiyaAdjacency", order: 25, level: "text" },
|
||
{ name: "jinze", order: 40, level: "paragraph" },
|
||
{ name: "spacing", order: 50, level: "text" },
|
||
{ name: "orphan", order: 70, level: "paragraph" },
|
||
{ name: "jiyaLineEdge", order: 90, level: "paragraph" },
|
||
{ name: "spacingGapTrim", order: 92, level: "paragraph" },
|
||
],
|
||
"Web standalone group",
|
||
);
|
||
const passNames = new Set([ ...actual.charify, ...actual.standalone ].map((pass) => pass.name));
|
||
for (const option of [ "hanging", "biaodian", "emphasis", "ruby" ])
|
||
{
|
||
assert.equal(passNames.has(option), false, option + " must remain unregistered");
|
||
}
|
||
});
|
||
|
||
test("shared rules: jinze and orphan semantic vectors match Web behavior", () =>
|
||
{
|
||
for (const vector of vectorsOf("jinze"))
|
||
{
|
||
if (vector.expected.atoms)
|
||
{
|
||
assert.deepEqual(
|
||
adapter.splitVariationSequences(vector.input.left + vector.input.right),
|
||
vector.expected.atoms,
|
||
vector.id + " atoms",
|
||
);
|
||
}
|
||
const { paragraph } = renderText(vector.input.left + vector.input.right, {
|
||
jiya: true,
|
||
jinze: true,
|
||
lang: { default: "zh-Hant", style: "quanjiao" },
|
||
});
|
||
assert.equal(
|
||
paragraph.querySelector("jz-jinze") !== null,
|
||
vector.expected.prohibitedBreak,
|
||
vector.id,
|
||
);
|
||
}
|
||
|
||
const anyCjk = new RegExp(
|
||
"[" + rangesToWebFragment(rules.characterClasses.cjk) + "]",
|
||
"u",
|
||
);
|
||
const punctuation = new Set(
|
||
Object.values(rules.characterClasses.biaodian.classes)
|
||
.flatMap((entries) => canonicalCodePoints(entries)),
|
||
);
|
||
for (const vector of vectorsOf("orphan"))
|
||
{
|
||
const atoms = adapter.splitVariationSequences(vector.input.text);
|
||
if (vector.expected.atoms)
|
||
{
|
||
assert.deepEqual(atoms, vector.expected.atoms, vector.id + " atoms");
|
||
}
|
||
const meaningful = atoms.filter((atom) =>
|
||
anyCjk.test(atom) && !punctuation.has(atom.codePointAt(0)));
|
||
assert.equal(meaningful.length, vector.expected.meaningfulCjkCount, vector.id + " count");
|
||
|
||
const { paragraph } = renderText(vector.input.text, {
|
||
orphan: { chars: vector.input.minimum },
|
||
});
|
||
const orphan = paragraph.querySelector("jz-orphan");
|
||
assert.equal(orphan ? orphan.textContent : null, vector.expected.protectedSuffix, vector.id);
|
||
}
|
||
});
|