Files
MorphDoc/apps/server/scripts/verify-docx-r4-matrix.mjs
T
SkyJourney b275c671fc fix: 修复DOCX独立封面视觉门禁
统一 Chromium 与 DOCX 的受限高度封面布局,补齐可编辑边框、装饰边和 Office 页面舍入适配。

按四套独立封面主题、纵横方向及五组页边距执行 40 个 Word/WPS 真实视觉场景,严格失败为 0,并保留正文诊断供 D4 修复。
2026-08-03 18:15:21 +08:00

553 lines
17 KiB
JavaScript
Raw 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 { createHash } from "node:crypto";
import fs from "node:fs";
import net from "node:net";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defaultExportConfig, themeManifestSchema } from "@md-to-pdf/core";
import { unzipSync } from "fflate";
import { buildApp } from "../dist/app.js";
import { createDocxMediaEngine } from "../dist/docx-media-engine.js";
import { createPdfGenerator } from "../dist/pdf-engine.js";
const directory = path.dirname(fileURLToPath(import.meta.url));
const repositoryDirectory = path.resolve(directory, "../../..");
const themesDirectory = path.join(repositoryDirectory, "themes");
const samplesDirectory = path.join(repositoryDirectory, "samples", "themes");
const webDirectory = path.join(repositoryDirectory, "apps", "web", "dist");
const outputDirectory = path.resolve(
process.env.MD_TO_PDF_R4_OUTPUT_DIR?.trim() ||
path.join(repositoryDirectory, "output", "docx-r4")
);
const pdfDirectory = path.join(outputDirectory, "chromium-pdf");
const docxDirectory = path.join(outputDirectory, "docx");
const standardReportPath = path.join(
repositoryDirectory,
"output",
"docx-theme-matrix",
"theme-matrix-report.json"
);
const fontPackRoot = path.resolve(
process.env.MD_TO_PDF_FONT_PACK_DIR?.trim() ||
path.join(repositoryDirectory, "output", "font-packs", "root")
);
const decoder = new TextDecoder();
const contentTypes = new Map([
[".css", "text/css; charset=utf-8"],
[".html", "text/html; charset=utf-8"],
[".js", "text/javascript; charset=utf-8"],
[".mjs", "text/javascript; charset=utf-8"],
[".png", "image/png"],
[".svg", "image/svg+xml"],
[".ttf", "font/ttf"],
[".woff", "font/woff"],
[".woff2", "font/woff2"]
]);
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function newestModifiedAt(targetPath) {
const stat = fs.statSync(targetPath);
if (stat.isFile()) {
return stat.mtimeMs;
}
return fs.readdirSync(targetPath, { withFileTypes: true }).reduce(
(newest, entry) =>
Math.max(
newest,
newestModifiedAt(path.join(targetPath, entry.name))
),
stat.mtimeMs
);
}
function assertFreshArtifact(label, sourcePaths, artifactPath) {
assert(fs.existsSync(artifactPath), `${label} 构建产物不存在:${artifactPath}`);
const newestSource = Math.max(...sourcePaths.map(newestModifiedAt));
const artifactModifiedAt = fs.statSync(artifactPath).mtimeMs;
assert(
artifactModifiedAt + 1_500 >= newestSource,
`${label} 构建产物早于源码;请按依赖顺序重新构建后再执行门禁`
);
}
function sha256(content) {
return createHash("sha256").update(content).digest("hex");
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function readExportConfigOverride() {
const source = process.env.MD_TO_PDF_R4_EXPORT_CONFIG_JSON?.trim();
if (!source) {
return undefined;
}
const parsed = JSON.parse(source);
assert(
parsed && typeof parsed === "object" && !Array.isArray(parsed),
"R4 导出配置覆盖必须是 JSON 对象"
);
return parsed;
}
function mergeExportConfig(base, override) {
if (!override) {
return base;
}
return {
...base,
...override,
mermaid: { ...base.mermaid, ...override.mermaid },
paper: {
...base.paper,
...override.paper,
margins: { ...base.paper.margins, ...override.paper?.margins }
},
header: {
...base.header,
...override.header,
left: { ...base.header.left, ...override.header?.left },
center: { ...base.header.center, ...override.header?.center },
right: { ...base.header.right, ...override.header?.right }
},
footer: { ...base.footer, ...override.footer },
metadata: { ...base.metadata, ...override.metadata },
print: { ...base.print, ...override.print }
};
}
function readThemes() {
return fs
.readdirSync(themesDirectory, { withFileTypes: true })
.filter(
(entry) =>
entry.isDirectory() &&
fs.existsSync(path.join(themesDirectory, entry.name, "theme.json"))
)
.map((entry) =>
themeManifestSchema.parse(
readJson(path.join(themesDirectory, entry.name, "theme.json"))
)
)
.sort((first, second) => first.id.localeCompare(second.id, "en"));
}
function reservePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port =
typeof address === "object" && address ? address.port : undefined;
server.close((error) =>
error || !port
? reject(error ?? new Error("无法分配 R4 验收端口"))
: resolve(port)
);
});
});
}
function resolveWebFile(relativePath) {
const resolved = path.resolve(webDirectory, relativePath);
const relative = path.relative(webDirectory, resolved);
assert(
relative !== ".." &&
!relative.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relative),
`Web 资源路径越界:${relativePath}`
);
return resolved;
}
function registerWebRuntime(app) {
app.get("/preview-frame.html", async (_request, reply) => {
const content = fs.readFileSync(resolveWebFile("preview-frame.html"));
return reply.type("text/html; charset=utf-8").send(content);
});
app.get("/assets/*", async (request, reply) => {
const relativePath = String(request.params["*"] ?? "");
const filePath = resolveWebFile(path.join("assets", relativePath));
const stat = fs.statSync(filePath);
assert(stat.isFile(), `Web 资源不是普通文件:${relativePath}`);
return reply
.type(
contentTypes.get(path.extname(filePath).toLowerCase()) ||
"application/octet-stream"
)
.send(fs.readFileSync(filePath));
});
}
function inspectDocx(content) {
const entries = unzipSync(content);
const documentXml = decoder.decode(entries["word/document.xml"]);
const stylesXml = decoder.decode(entries["word/styles.xml"]);
const fontTableXml = decoder.decode(entries["word/fontTable.xml"]);
const fontParts = Object.keys(entries).filter((name) =>
name.startsWith("word/fonts/")
);
const fontNames = [
...fontTableXml.matchAll(/<w:font\s+w:name="([^"]+)"/gu)
].map((match) => match[1]);
const embeddedFontNames = [
...fontTableXml.matchAll(/<w:font\s+w:name="([^"]+)"[\s\S]*?<\/w:font>/gu)
].flatMap((match) => /<w:embed(?:Regular|Bold|Italic|BoldItalic)\b/u.test(match[0])
? [match[1]]
: []);
const paragraphs = [
...documentXml.matchAll(/<w:p(?:\s|>)[\s\S]*?<\/w:p>/gu)
].map((match) => match[0]);
const authorParagraphCount = paragraphs.filter((paragraph) =>
/<w:pStyle\s+w:val="Author"\s*\/>/u.test(paragraph)
).length;
const officialIssueRows = paragraphs.filter((paragraph) =>
/<w:pStyle\s+w:val="MdOfficialIssueRow"\s*\/>/u.test(
paragraph
)
);
const officialIssueRowsWithRightTab = officialIssueRows.filter(
(paragraph) =>
[...paragraph.matchAll(/<w:tab(?:\s[^>]*)?\s*\/>/gu)].some(
(match) =>
/\bw:val="right"/u.test(match[0]) &&
/\bw:pos="\d+"/u.test(match[0])
)
);
const officialIssueRowContentTabCount = officialIssueRows.reduce(
(count, paragraph) =>
count + (paragraph.match(/<w:tab\s*\/>/gu)?.length ?? 0),
0
);
return {
bytes: content.byteLength,
sha256: sha256(content),
partCount: Object.keys(entries).length,
sectionCount: documentXml.match(/<w:sectPr(?:\s|>)/gu)?.length ?? 0,
embeddedFontPartCount: fontParts.length,
fontNames: [...new Set(fontNames)].sort((first, second) =>
first.localeCompare(second, "en")
),
embeddedFontNames: [...new Set(embeddedFontNames)].sort((first, second) =>
first.localeCompare(second, "en")
),
optionalFontPackApplied: fontTableXml.includes("MdTP Serif SC"),
hasAltChunk: /<w:altChunk(?:\s|>)/u.test(documentXml),
internalMarkerCount:
documentXml.match(/MD_TO_PDF_(?:CONTAINER|SECTION)_/gu)?.length ?? 0,
authorParagraphCount,
officialIssueRowCount: officialIssueRows.length,
officialIssueRowRightTabCount:
officialIssueRowsWithRightTab.length,
officialIssueRowContentTabCount,
requiredStylesPresent: [
"Normal",
"Heading1",
"SourceCode",
"Table",
"Caption"
].every((styleId) => stylesXml.includes(`w:styleId="${styleId}"`))
};
}
async function requestArtifact(origin, route, payload, contentType) {
const response = await fetch(`${origin}${route}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload)
});
const content = new Uint8Array(await response.arrayBuffer());
assert(
response.ok,
`${route} 返回 ${response.status}${decoder.decode(content)}`
);
assert(
response.headers.get("content-type")?.includes(contentType),
`${route} MIME 无效:${response.headers.get("content-type")}`
);
return { response, content };
}
function responseDiagnostics(response, kind) {
return {
serverTiming: response.headers.get("server-timing"),
pageCount:
kind === "pdf"
? Number(response.headers.get("x-pdf-page-count"))
: undefined,
warningCount:
kind === "docx"
? Number(response.headers.get("x-docx-warning-count"))
: undefined,
echartsErrorCount: Number(
response.headers.get("x-echarts-error-count")
),
mermaidErrorCount: Number(
response.headers.get("x-mermaid-error-count")
)
};
}
assert(fs.existsSync(standardReportPath), "缺少本轮标准 DOCX 主题矩阵报告");
assertFreshArtifact(
"Preview Engine",
[path.join(repositoryDirectory, "packages", "preview-engine", "src")],
path.join(
repositoryDirectory,
"packages",
"preview-engine",
"dist",
"semantic-cover-fit.js"
)
);
assertFreshArtifact(
"DOCX Engine",
[path.join(repositoryDirectory, "packages", "docx-engine", "src")],
path.join(
repositoryDirectory,
"packages",
"docx-engine",
"dist",
"document-structure-transform.js"
)
);
assertFreshArtifact(
"Web Preview Runtime",
[
path.join(repositoryDirectory, "apps", "web", "src"),
path.join(
repositoryDirectory,
"packages",
"preview-engine",
"dist",
"semantic-cover-fit.js"
)
],
path.join(webDirectory, "preview-frame.html")
);
assert(
fs.statSync(fontPackRoot).isDirectory(),
`缺少有效字体包根目录:${fontPackRoot}`
);
const allThemes = readThemes();
assert(
allThemes.length === 14,
`内置主题数量应为 14,实际为 ${allThemes.length}`
);
const selectedThemeId =
process.env.MD_TO_PDF_R4_THEME_ID?.trim() || undefined;
const exportConfigOverride = readExportConfigOverride();
const themes = selectedThemeId
? allThemes.filter((theme) => theme.id === selectedThemeId)
: allThemes;
assert(
themes.length > 0,
`R4 验收主题不存在:${selectedThemeId}`
);
const standardReport = readJson(standardReportPath);
assert(
standardReport.themeCount === allThemes.length,
"标准 DOCX 主题矩阵与 R4 主题数量不一致"
);
const standardByTheme = new Map(
standardReport.results.map((result) => [result.id, result])
);
fs.mkdirSync(pdfDirectory, { recursive: true });
fs.mkdirSync(docxDirectory, { recursive: true });
const port = await reservePort();
const origin = `http://127.0.0.1:${port}`;
const pdfGenerator = createPdfGenerator({ renderOrigin: origin });
const docxMediaAdapter = createDocxMediaEngine({ renderOrigin: origin });
const app = buildApp({
logger: { level: "error" },
pdfGenerator,
docxMediaAdapter,
fontPacks: {
roots: [fontPackRoot],
appVersion: "0.6.0"
}
});
registerWebRuntime(app);
const results = [];
try {
await app.listen({ port, host: "127.0.0.1" });
const capabilityResponse = await fetch(`${origin}/api/docx/capability`);
const capability = await capabilityResponse.json();
assert(
capabilityResponse.ok &&
capability.status === "available" &&
capability.detectedVersion === "3.9.0.2",
`DOCX capability 无效:${JSON.stringify(capability)}`
);
for (const theme of themes) {
process.stderr.write(`[DOCX R4 matrix] generating ${theme.id}\n`);
const samplePath = path.join(samplesDirectory, `${theme.id}.md`);
assert(fs.existsSync(samplePath), `主题缺少验收示例:${theme.id}`);
const markdown = fs.readFileSync(samplePath, "utf8");
const exportConfig = mergeExportConfig({
...defaultExportConfig,
name: `${theme.name} R4 生产链验收`,
themeId: theme.id,
pageDecorationsMode: "theme",
header: structuredClone(
theme.pageDefaults?.header ?? defaultExportConfig.header
),
footer: structuredClone(
theme.pageDefaults?.footer ?? defaultExportConfig.footer
),
paper: {
...defaultExportConfig.paper,
format: "A4",
orientation: "portrait",
marginMode: "theme",
margins: structuredClone(
theme.pageDefaults?.margins ??
defaultExportConfig.paper.margins
)
}
}, exportConfigOverride);
const payload = {
markdown,
fileName: `${theme.id}.md`,
language: "zh-CN",
resources: [],
exportConfig
};
const pdf = await requestArtifact(
origin,
"/api/pdf",
payload,
"application/pdf"
);
assert(
decoder.decode(pdf.content.subarray(0, 5)) === "%PDF-",
`主题 ${theme.id} 的 PDF 签名无效`
);
const docx = await requestArtifact(
origin,
"/api/docx",
payload,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
);
const inspection = inspectDocx(docx.content);
assert(!inspection.hasAltChunk, `主题 ${theme.id} 包含 altChunk`);
assert(
inspection.internalMarkerCount === 0,
`主题 ${theme.id} 残留内部结构标记`
);
assert(
inspection.requiredStylesPresent,
`主题 ${theme.id} 缺少标准 Word 样式`
);
const standard = standardByTheme.get(theme.id);
assert(standard, `标准矩阵缺少主题 ${theme.id}`);
assert(
standard.inspection.structureCoverage === 1 &&
standard.inspection.titlePolicyPassed &&
standard.inspection.titleDeduplicationPassed,
`主题 ${theme.id} 的标准结构门禁未通过`
);
if (standard.inspection.profile) {
assert(
inspection.authorParagraphCount === 0,
`结构化主题 ${theme.id} 残留重复 Author 段落`
);
}
const expectsOfficialDualEndedRow = Boolean(
standard.metadata?.document?.profile === "official" &&
standard.metadata.document.signatory
);
if (expectsOfficialDualEndedRow) {
assert(
inspection.officialIssueRowCount > 0 &&
inspection.officialIssueRowCount ===
inspection.officialIssueRowRightTabCount &&
inspection.officialIssueRowContentTabCount ===
inspection.officialIssueRowCount,
`主题 ${theme.id} 的公文双端行缺少右制表位或签发人分隔`
);
} else {
assert(
inspection.officialIssueRowRightTabCount === 0 &&
inspection.officialIssueRowContentTabCount === 0,
`主题 ${theme.id} 的单端公文行包含无意义制表位`
);
}
const pdfPath = path.join(pdfDirectory, `${theme.id}.pdf`);
const docxPath = path.join(docxDirectory, `${theme.id}.docx`);
fs.writeFileSync(pdfPath, pdf.content);
fs.writeFileSync(docxPath, docx.content);
results.push({
id: theme.id,
name: theme.name,
category: theme.category,
compatibleProfiles: theme.compatibleProfiles,
sample: path.relative(repositoryDirectory, samplePath),
exportConfig,
pdf: {
outputFile: path.relative(repositoryDirectory, pdfPath),
bytes: pdf.content.byteLength,
sha256: sha256(pdf.content),
diagnostics: responseDiagnostics(pdf.response, "pdf")
},
docx: {
outputFile: path.relative(repositoryDirectory, docxPath),
inspection,
diagnostics: responseDiagnostics(docx.response, "docx")
},
standardInspection: standard.inspection,
expectsOfficialDualEndedRow
});
}
} finally {
await app.close();
}
const enhancedThemes = results.filter(
(result) => result.docx.inspection.optionalFontPackApplied
);
for (const result of results) {
assert(
result.pdf.diagnostics.pageCount > 0,
`主题 ${result.id} 的 PDF 页数无效`
);
assert(
result.pdf.diagnostics.echartsErrorCount === 0 &&
result.pdf.diagnostics.mermaidErrorCount === 0,
`主题 ${result.id} 的 PDF 图表渲染失败`
);
assert(
result.docx.diagnostics.echartsErrorCount === 0 &&
result.docx.diagnostics.mermaidErrorCount === 0,
`主题 ${result.id} 的 DOCX 图表渲染失败`
);
}
const report = {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
origin,
themeCount: themes.length,
totalThemeCount: allThemes.length,
selectedThemeId,
fontPackRoot,
standardReport: path.relative(repositoryDirectory, standardReportPath),
outputDirectory: path.relative(repositoryDirectory, outputDirectory),
enhancedThemeIds: enhancedThemes.map((result) => result.id),
results
};
const reportPath = path.join(outputDirectory, "r4-matrix-report.json");
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);