Files
MorphDoc/scripts/docx-layout-visual-matrix-cases.mjs
T
SkyJourney 64445322eb release: 发布 v0.6.2 DOCX 真实文档修复
新增能力:将 DOCX 发布验收拆分为四套独立 140,支持真实语料冻结、指纹复用、失败与基础设施错误独立统计,并为表格换行、全 JSON 围栏、代码连续性和长文档分页建立通用门禁。

问题修复:冻结 Paged.js 分片前的逻辑表格列轨并传递打印几何,统一 Markdown 表格换行、代码、段落与 OOXML 翻译;改进 PDF 文本流排序、语义块映射、颜色与栅格比较,消除窄字符重叠和跨行范围符号误报。

兼容与部署:版本统一为 0.6.2;正式 Docker 镜像内置固定 Chromium、Pandoc 3.9.0.2 和 Serif/Sans/Mono 字体;Desktop NSIS 与 ZIP 继续直接内置字体,无需系统字体安装。

验证结果:合成基线与长庆严格 280/280,M4N 140/140;健康数据残余误报 6/115(5.22%),均核查为重复表头自动对齐/取样误报且基础设施错误为 0。全项目测试、类型检查、生产构建和 git diff --check 通过;正式 Docker、NSIS、ZIP、离线镜像、部署包、清单及 SHA-256 均已生成并校验。
2026-08-26 10:50:20 +08:00

297 lines
9.1 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from "node:fs";
import path from "node:path";
export const DOCX_LAYOUT_MATRIX_ORIENTATIONS = Object.freeze([
"portrait",
"landscape"
]);
export const DOCX_LAYOUT_MATRIX_MARGIN_SCENARIOS = Object.freeze([
Object.freeze({
id: "theme-default",
label: "主题默认边距",
marginMode: "theme"
}),
Object.freeze({
id: "standard",
label: "标准边距",
marginMode: "custom",
margins: Object.freeze({
top: "25.4mm",
right: "25.4mm",
bottom: "25.4mm",
left: "25.4mm"
})
}),
Object.freeze({
id: "compact",
label: "紧凑边距",
marginMode: "custom",
margins: Object.freeze({
top: "12.7mm",
right: "12.7mm",
bottom: "12.7mm",
left: "12.7mm"
})
}),
Object.freeze({
id: "wide",
label: "宽松边距",
marginMode: "custom",
margins: Object.freeze({
top: "31.8mm",
right: "31.8mm",
bottom: "31.8mm",
left: "31.8mm"
})
}),
Object.freeze({
id: "binding",
label: "非对称装订边距",
marginMode: "custom",
margins: Object.freeze({
top: "25.4mm",
right: "19.1mm",
bottom: "25.4mm",
left: "31.8mm"
})
})
]);
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function hasIndependentCover(manifest) {
const profiles = Array.isArray(manifest.compatibleProfiles)
? manifest.compatibleProfiles
: [];
return profiles.includes("project-report") || profiles.includes("tender");
}
export function resolveEffectiveCoverPageCount({
caseDefinition,
scope,
sourceMetadata
}) {
assert(caseDefinition && Number.isInteger(caseDefinition.coverPageCount),
"矩阵场景缺少有效封面页数");
if (scope !== "corpus") {
return caseDefinition.coverPageCount;
}
const profile = sourceMetadata?.document?.profile;
return profile === "project-report" || profile === "tender" ? 1 : 0;
}
export function isCorpusTextColorEquivalent(metrics) {
return Boolean(
metrics &&
metrics.inkIou >= 0.98 &&
metrics.edgeIou >= 0.98 &&
metrics.meanAbsoluteError <= 8
);
}
export function readBundledThemeMatrixDefinitions(themesDirectory) {
assert(
typeof themesDirectory === "string" && themesDirectory.length > 0,
"主题目录不能为空"
);
return fs.readdirSync(themesDirectory, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.flatMap((entry) => {
const manifestPath = path.join(themesDirectory, entry.name, "theme.json");
if (!fs.existsSync(manifestPath)) {
return [];
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
if (manifest.bundled !== true) {
return [];
}
assert(
typeof manifest.id === "string" && manifest.id.length > 0,
`内置主题清单缺少 id${manifestPath}`
);
return [{
id: manifest.id,
name:
typeof manifest.name === "string" && manifest.name.length > 0
? manifest.name
: manifest.id,
coverPageCount: hasIndependentCover(manifest) ? 1 : 0,
compatibleProfiles: Array.isArray(manifest.compatibleProfiles)
? [...manifest.compatibleProfiles]
: [],
docxFontFaces: Array.isArray(manifest.docxFonts?.faces)
? manifest.docxFonts.faces.map((face) => ({
family: face.family,
aliases: Array.isArray(face.aliases) ? [...face.aliases] : [],
weight: face.weight ?? 400,
style: face.style ?? "normal"
}))
: [],
themeDefaultMargins: manifest.pageDefaults?.margins
? structuredClone(manifest.pageDefaults.margins)
: undefined
}];
})
.sort((left, right) => left.id.localeCompare(right.id, "en"));
}
function normalizeFontName(value) {
return value
.replace(/^[A-Z]{6}\+/u, "")
.replace(/(?:[-, ](?:regular|bold|italic|bolditalic|roman|medium|light|black))+$/iu, "")
.replace(/[\s_-]+/gu, "")
.toLocaleLowerCase("en-US");
}
function isOfficeEmbeddedAlias(value, engine) {
return engine === "word"
? /^___WRD_EMBED_SUB_/iu.test(value)
: engine === "wps"
? /^WPSEMBED\d+$/iu.test(value)
: false;
}
export function getDocxFontGateFailures({
theme,
inspection,
renderedFonts,
engine
}) {
assert(theme && typeof theme.id === "string", "字体门禁缺少主题定义");
assert(inspection && typeof inspection === "object", "字体门禁缺少 DOCX 检查结果");
assert(Array.isArray(renderedFonts), "字体门禁缺少实际渲染字体");
assert(engine === "word" || engine === "wps", "字体门禁引擎必须为 word 或 wps");
const failures = [];
const declaredFaces = Array.isArray(theme.docxFontFaces)
? theme.docxFontFaces
: [];
const embeddedNames = Array.isArray(inspection.embeddedFontNames)
? inspection.embeddedFontNames
: [];
const embeddedPartCount = Number(inspection.embeddedFontPartCount ?? 0);
if (declaredFaces.length === 0) {
failures.push(`主题 ${theme.id} 未声明任何 DOCX 字体`);
}
if (embeddedPartCount === 0) {
failures.push(`主题 ${theme.id} 的 DOCX 没有嵌入字体部件`);
}
if (declaredFaces.length > 0 && embeddedPartCount < declaredFaces.length) {
failures.push(
`主题 ${theme.id} 声明 ${declaredFaces.length} 个字体面,但 DOCX 仅嵌入 ${embeddedPartCount} 个部件`
);
}
if (embeddedPartCount > 0) {
const expected = new Set(embeddedNames.map(normalizeFontName));
const actuallyUsed = renderedFonts.some((font) => {
const name = typeof font === "string" ? font : font?.name;
return typeof name === "string" && (
isOfficeEmbeddedAlias(name, engine) || expected.has(normalizeFontName(name))
);
});
if (!actuallyUsed) {
failures.push(
`${engine === "word" ? "Microsoft Word" : "WPS Writer"} 输出未实际使用 DOCX 嵌入字体`
);
}
}
return failures;
}
function createPaperConfig(orientation, marginScenario) {
return {
format: "A4",
orientation,
marginMode: marginScenario.marginMode,
...(marginScenario.marginMode === "custom"
? { margins: structuredClone(marginScenario.margins) }
: {})
};
}
export function createDocxLayoutVisualMatrixCases(themeDefinitions) {
assert(Array.isArray(themeDefinitions), "主题定义必须是数组");
const ids = new Set();
const cases = [];
for (const theme of themeDefinitions) {
assert(
theme && typeof theme.id === "string" && theme.id.length > 0,
"矩阵主题必须包含非空 id"
);
assert(!ids.has(theme.id), `矩阵主题 ID 重复:${theme.id}`);
ids.add(theme.id);
for (const orientation of DOCX_LAYOUT_MATRIX_ORIENTATIONS) {
for (const marginScenario of DOCX_LAYOUT_MATRIX_MARGIN_SCENARIOS) {
const paper = createPaperConfig(orientation, marginScenario);
cases.push({
id: `${theme.id}--${orientation}--${marginScenario.id}`,
themeId: theme.id,
themeName: theme.name,
orientation,
marginScenarioId: marginScenario.id,
marginScenarioLabel: marginScenario.label,
coverPageCount: theme.coverPageCount,
themeDefaultMargins: theme.themeDefaultMargins
? structuredClone(theme.themeDefaultMargins)
: undefined,
paper: { format: "A4", orientation },
config: { paper }
});
}
}
}
return cases;
}
export function summarizeDocxLayoutVisualMatrix(themeDefinitions, cases) {
const themeIds = themeDefinitions.map((theme) => theme.id);
const caseIds = cases.map((entry) => entry.id);
const expectedCaseCount =
themeIds.length *
DOCX_LAYOUT_MATRIX_ORIENTATIONS.length *
DOCX_LAYOUT_MATRIX_MARGIN_SCENARIOS.length;
assert(
new Set(caseIds).size === caseIds.length,
"DOCX 布局视觉矩阵存在重复场景 ID"
);
assert(
cases.length === expectedCaseCount,
`DOCX 布局视觉矩阵场景数错误:${cases.length},期望 ${expectedCaseCount}`
);
for (const themeId of themeIds) {
const themeCaseCount = cases.filter(
(entry) => entry.themeId === themeId
).length;
assert(
themeCaseCount ===
DOCX_LAYOUT_MATRIX_ORIENTATIONS.length *
DOCX_LAYOUT_MATRIX_MARGIN_SCENARIOS.length,
`主题 ${themeId} 的矩阵场景数错误:${themeCaseCount}`
);
}
return {
themeCount: themeIds.length,
themeIds,
orientationCount: DOCX_LAYOUT_MATRIX_ORIENTATIONS.length,
orientations: [...DOCX_LAYOUT_MATRIX_ORIENTATIONS],
marginScenarioCount: DOCX_LAYOUT_MATRIX_MARGIN_SCENARIOS.length,
marginScenarios: DOCX_LAYOUT_MATRIX_MARGIN_SCENARIOS.map((scenario) => ({
id: scenario.id,
label: scenario.label,
marginMode: scenario.marginMode,
...(scenario.margins
? { margins: structuredClone(scenario.margins) }
: {})
})),
expectedCaseCount,
coverThemeCount: themeDefinitions.filter(
(theme) => theme.coverPageCount === 1
).length,
coverCaseCount: cases.filter((entry) => entry.coverPageCount === 1).length
};
}