Files
MorphDoc/scripts/verify-docx-page-decoration-visuals.mjs
SkyJourney 58f87cc19f feat: 完成 DOCX R4 元素级视觉门禁
建立 Chromium、Word 与 WPS 的可编辑内容、段落几何、页眉页脚、封面、媒体和逐页视觉比较链路。

支持封面独立分节、正文页码重启、主题页边距与横向纸张,并将自然分页差异降为诊断项。

修正代码块内边距和折叠表格单元格边框映射,14 套主题生产矩阵与六组布局视觉矩阵通过。
2026-08-02 03:27:11 +08:00

520 lines
16 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 { execFile } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { promisify } from "node:util";
import { DOMParser } from "@xmldom/xmldom";
import { unzipSync } from "fflate";
import {
createPdfDocumentSnapshot,
createPdfVisualDiffReport,
createWordPdfAdapter,
createWpsPdfAdapter,
renderPdfVisualDiffHtml,
serializePdfVisualDiffJson
} from "../packages/document-visual-diff/dist/index.js";
import {
formatPageNumber,
resolvePageNumberAlignment
} from "../packages/preview-engine/dist/index.js";
const execFileAsync = promisify(execFile);
const WORD_NAMESPACE =
"http://schemas.openxmlformats.org/wordprocessingml/2006/main";
const MATH_NAMESPACE =
"http://schemas.openxmlformats.org/officeDocument/2006/math";
const textDecoder = new TextDecoder();
const repositoryDirectory = path.resolve(import.meta.dirname, "..");
const outputDirectory = path.join(
repositoryDirectory,
"output",
"docx-page-decoration-gate"
);
const matrixScript = path.join(
repositoryDirectory,
"apps",
"server",
"scripts",
"verify-docx-r4-matrix.mjs"
);
const commonHeader = {
enabled: true,
height: "8mm",
showDivider: true,
fontSize: "3mm",
fontFamily: '"Microsoft YaHei", sans-serif',
color: "#44546a",
left: { enabled: true, content: "页眉左栏" },
center: { enabled: true, content: "页眉中栏" },
right: { enabled: true, content: "页眉右栏" }
};
const cases = [
{
id: "no-cover",
themeId: "typora-github",
coverPageCount: 0,
config: {
pageDecorationsMode: "custom",
header: { ...commonHeader, showOnFirstPage: false },
footer: {
enabled: true,
height: "8mm",
showDivider: true,
fontSize: "3mm",
fontFamily: '"Microsoft YaHei", sans-serif',
color: "#44546a",
alignment: "center",
format: "page-total",
startFrom: 1,
showOnFirstPage: true
}
}
},
{
id: "official",
themeId: "gov-red-standard",
coverPageCount: 0,
config: {
pageDecorationsMode: "custom",
header: { ...commonHeader, showOnFirstPage: true },
footer: {
enabled: true,
height: "7mm",
showDivider: false,
fontSize: "3mm",
fontFamily: '"FangSong", "SimSun", serif',
color: "#000000",
alignment: "outer",
format: "official-page",
startFrom: 1,
showOnFirstPage: false
}
}
},
{
id: "project-cover",
themeId: "formal-feasibility",
coverPageCount: 1,
config: {
pageDecorationsMode: "custom",
header: { ...commonHeader, showOnFirstPage: true },
footer: {
enabled: true,
height: "8mm",
showDivider: true,
fontSize: "3mm",
fontFamily: '"Microsoft YaHei", sans-serif',
color: "#44546a",
alignment: "outer",
format: "page-total",
startFrom: 1,
showOnFirstPage: true
}
}
}
];
const selectedCaseId =
process.env.MD_TO_PDF_PAGE_DECORATION_CASE?.trim() || undefined;
const selectedCases = selectedCaseId
? cases.filter((caseDefinition) => caseDefinition.id === selectedCaseId)
: cases;
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function extractDocxEditableText(docxPath) {
const entries = unzipSync(Uint8Array.from(fs.readFileSync(docxPath)));
const documentXml = entries["word/document.xml"];
assert(documentXml, `DOCX 缺少 word/document.xml${docxPath}`);
const document = new DOMParser().parseFromString(
textDecoder.decode(documentXml),
"application/xml"
);
return Array.from(document.getElementsByTagName("*")).flatMap((node) =>
node.localName === "t" &&
(node.namespaceURI === WORD_NAMESPACE || node.namespaceURI === MATH_NAMESPACE)
? [node.textContent ?? ""]
: []
).join("");
}
function paragraphStyleId(paragraph) {
const style = Array.from(paragraph.getElementsByTagName("*")).find(
(node) => node.localName === "pStyle" && node.namespaceURI === WORD_NAMESPACE
);
return style?.getAttribute("w:val") || style?.getAttribute("val") || undefined;
}
function paragraphRole(styleId) {
if (!styleId) {
return "body";
}
if (/^Heading[1-6]$/u.test(styleId)) {
return "heading";
}
if (
styleId === "Title" ||
/(?:Title|Masthead|ProjectName)$/u.test(styleId)
) {
return "title";
}
if (/Caption/u.test(styleId)) {
return "caption";
}
return "body";
}
function extractDocxEditableParagraphs(docxPath) {
const entries = unzipSync(Uint8Array.from(fs.readFileSync(docxPath)));
const documentXml = entries["word/document.xml"];
assert(documentXml, `DOCX 缺少 word/document.xml${docxPath}`);
const document = new DOMParser().parseFromString(
textDecoder.decode(documentXml),
"application/xml"
);
let section = "cover";
let hasCoverSection = false;
const paragraphs = [];
for (const paragraph of Array.from(document.getElementsByTagName("*")).filter(
(node) => node.localName === "p" && node.namespaceURI === WORD_NAMESPACE
)) {
const descendants = Array.from(paragraph.getElementsByTagName("*"));
const hasSectionBreak = descendants.some(
(node) => node.localName === "sectPr" && node.namespaceURI === WORD_NAMESPACE
);
if (hasSectionBreak) {
hasCoverSection = true;
section = "body";
continue;
}
const text = descendants.flatMap((node) =>
node.localName === "t" &&
(node.namespaceURI === WORD_NAMESPACE || node.namespaceURI === MATH_NAMESPACE)
? [node.textContent ?? ""]
: []
).join("");
if (!text) {
continue;
}
const styleId = paragraphStyleId(paragraph);
paragraphs.push({
index: paragraphs.length,
text,
...(styleId ? { styleId } : {}),
role: paragraphRole(styleId),
section
});
}
if (!hasCoverSection) {
for (const paragraph of paragraphs) {
paragraph.section = "body";
}
}
return paragraphs;
}
function buildPageSemantics(pageCount, coverPageCount, config) {
assert(
pageCount > coverPageCount,
`物理页数 ${pageCount} 必须大于封面页数 ${coverPageCount}`
);
const logicalPageCount = pageCount - coverPageCount;
const headerSlots = ["left", "center", "right"].flatMap((alignment) => {
const slot = config.header[alignment];
return config.header.enabled && slot.enabled && slot.content
? [{ alignment, text: slot.content }]
: [];
});
return Array.from({ length: pageCount }, (_, index) => {
const physicalPageNumber = index + 1;
if (physicalPageNumber <= coverPageCount) {
return {
physicalPageNumber,
kind: "cover",
logicalPageCount,
headerVisible: false,
headerSlots,
footerVisible: false
};
}
const bodyPageIndex = index - coverPageCount;
const isBodyFirst = bodyPageIndex === 0;
const headerVisible =
config.header.enabled &&
(!isBodyFirst || config.header.showOnFirstPage);
const footerVisible =
config.footer.enabled &&
(!isBodyFirst || config.footer.showOnFirstPage);
return {
physicalPageNumber,
kind: isBodyFirst ? "body-first" : "body-rest",
logicalPageNumber: config.footer.startFrom + bodyPageIndex,
logicalPageCount,
headerVisible,
headerSlots,
footerVisible,
...(footerVisible
? {
footerAlignment: resolvePageNumberAlignment(
config.footer,
bodyPageIndex
),
pageNumberText: formatPageNumber(
config.footer,
bodyPageIndex,
logicalPageCount
)
}
: {})
};
});
}
async function generateCase(caseDefinition) {
const caseDirectory = path.join(outputDirectory, caseDefinition.id);
fs.mkdirSync(caseDirectory, { recursive: true });
const { stdout } = await execFileAsync(process.execPath, [matrixScript], {
cwd: repositoryDirectory,
windowsHide: true,
timeout: 300_000,
maxBuffer: 16 * 1024 * 1024,
env: {
...process.env,
MD_TO_PDF_R4_THEME_ID: caseDefinition.themeId,
MD_TO_PDF_R4_OUTPUT_DIR: caseDirectory,
MD_TO_PDF_R4_EXPORT_CONFIG_JSON: JSON.stringify(caseDefinition.config)
}
});
const matrix = JSON.parse(stdout);
const result = matrix.results?.[0];
assert(result, `代表用例 ${caseDefinition.id} 没有生成矩阵结果`);
return { caseDirectory, result };
}
fs.mkdirSync(outputDirectory, { recursive: true });
assert(
selectedCases.length > 0,
`页装饰视觉门禁用例不存在:${selectedCaseId}`
);
const wordAdapter = createWordPdfAdapter({ timeoutMs: 240_000 });
const wordCapability = await wordAdapter.probe();
assert(wordCapability.available, `Microsoft Word 不可用:${wordCapability.detail}`);
const wpsAdapter = createWpsPdfAdapter({ timeoutMs: 240_000 });
const wpsCapability = await wpsAdapter.probe();
assert(wpsCapability.available, `WPS Writer 不可用:${wpsCapability.detail}`);
const summaries = [];
for (const caseDefinition of selectedCases) {
process.stderr.write(
`[page decoration gate] generating ${caseDefinition.id}\n`
);
const { caseDirectory, result } = await generateCase(caseDefinition);
const chromiumPdfPath = path.resolve(
repositoryDirectory,
result.pdf.outputFile
);
const docxPath = path.resolve(repositoryDirectory, result.docx.outputFile);
const expectedEditableText = extractDocxEditableText(docxPath);
const expectedEditableParagraphs = extractDocxEditableParagraphs(docxPath);
const chromiumPdf = Uint8Array.from(fs.readFileSync(chromiumPdfPath));
const wordGeneration = await wordAdapter.generate({ docxPath });
const wordPdfPath = path.join(caseDirectory, "word.pdf");
fs.writeFileSync(wordPdfPath, wordGeneration.pdf);
const baseline = await createPdfDocumentSnapshot(chromiumPdf, {
source: { kind: "chromium", label: `${caseDefinition.id} Chromium` },
dpi: 144
});
const candidate = await createPdfDocumentSnapshot(wordGeneration.pdf, {
source: { kind: "word", label: `${caseDefinition.id} Microsoft Word` },
dpi: 144
});
const baselinePageSemantics = buildPageSemantics(
baseline.pageCount,
caseDefinition.coverPageCount,
result.exportConfig
);
const wordPageSemantics = buildPageSemantics(
candidate.pageCount,
caseDefinition.coverPageCount,
result.exportConfig
);
const report = await createPdfVisualDiffReport(baseline, candidate, {
baselinePageSemantics,
candidatePageSemantics: wordPageSemantics,
expectedEditableText,
expectedEditableParagraphs
});
const wpsGeneration = await wpsAdapter.generate({ docxPath });
const wpsPdfPath = path.join(caseDirectory, "wps.pdf");
fs.writeFileSync(wpsPdfPath, wpsGeneration.pdf);
const wpsCandidate = await createPdfDocumentSnapshot(wpsGeneration.pdf, {
source: { kind: "wps", label: `${caseDefinition.id} WPS Writer` },
dpi: 144
});
const wpsPageSemantics = buildPageSemantics(
wpsCandidate.pageCount,
caseDefinition.coverPageCount,
result.exportConfig
);
const wpsReport = await createPdfVisualDiffReport(
baseline,
wpsCandidate,
{
baselinePageSemantics,
candidatePageSemantics: wpsPageSemantics,
expectedEditableText,
expectedEditableParagraphs
}
);
const rasterDirectory = path.join(caseDirectory, "raster-pages");
fs.mkdirSync(rasterDirectory, { recursive: true });
for (const page of baseline.pages) {
if (page.raster) {
fs.writeFileSync(
path.join(rasterDirectory, `chromium-${page.pageNumber}.png`),
page.raster.png
);
}
}
for (const page of candidate.pages) {
if (page.raster) {
fs.writeFileSync(
path.join(rasterDirectory, `word-${page.pageNumber}.png`),
page.raster.png
);
}
}
for (const page of wpsCandidate.pages) {
if (page.raster) {
fs.writeFileSync(
path.join(rasterDirectory, `wps-${page.pageNumber}.png`),
page.raster.png
);
}
}
for (const page of report.pages) {
const pageNumber = page.pair.baselinePageNumber ??
page.pair.candidatePageNumber;
if (!pageNumber || !page.artifacts) {
continue;
}
fs.writeFileSync(
path.join(rasterDirectory, `overlay-${pageNumber}.png`),
page.artifacts.overlayPng
);
fs.writeFileSync(
path.join(rasterDirectory, `heatmap-${pageNumber}.png`),
page.artifacts.heatmapPng
);
}
for (const page of wpsReport.pages) {
const pageNumber = page.pair.baselinePageNumber ??
page.pair.candidatePageNumber;
if (!pageNumber || !page.artifacts) {
continue;
}
fs.writeFileSync(
path.join(rasterDirectory, `wps-overlay-${pageNumber}.png`),
page.artifacts.overlayPng
);
fs.writeFileSync(
path.join(rasterDirectory, `wps-heatmap-${pageNumber}.png`),
page.artifacts.heatmapPng
);
}
const reportJsonPath = path.join(caseDirectory, "report.json");
const reportHtmlPath = path.join(caseDirectory, "report.html");
const wpsReportJsonPath = path.join(caseDirectory, "wps-report.json");
const wpsReportHtmlPath = path.join(caseDirectory, "wps-report.html");
fs.writeFileSync(
reportJsonPath,
`${serializePdfVisualDiffJson(report)}\n`,
"utf8"
);
fs.writeFileSync(reportHtmlPath, renderPdfVisualDiffHtml(report), "utf8");
fs.writeFileSync(
wpsReportJsonPath,
`${serializePdfVisualDiffJson(wpsReport)}\n`,
"utf8"
);
fs.writeFileSync(
wpsReportHtmlPath,
renderPdfVisualDiffHtml(wpsReport),
"utf8"
);
summaries.push({
id: caseDefinition.id,
themeId: caseDefinition.themeId,
status: report.status,
chromiumPages: baseline.pageCount,
wordPages: candidate.pageCount,
editableTextLength: expectedEditableText.length,
editableParagraphCount: expectedEditableParagraphs.length,
paragraphLayoutIssueCount: report.basic.paragraphLayouts
?.flatMap((paragraph) => paragraph.issues).length ?? 0,
bodyFlow: report.basic.bodyFlow,
baselineEditableCoverage: report.basic.baselineEditableCoverage,
wordEditableSimilarity: report.basic.candidateEditableSimilarity,
wordEditableExact: report.basic.candidateEditableExact,
semanticIssueCount: report.issues.filter((issue) =>
[
"PAGE_NUMBER_VISIBILITY_MISMATCH",
"PAGE_NUMBER_TEXT_MISMATCH",
"PAGE_NUMBER_ALIGNMENT_MISMATCH",
"PAGE_NUMBER_VERTICAL_POSITION_MISMATCH",
"HEADER_VISIBILITY_MISMATCH",
"HEADER_TEXT_MISSING",
"HEADER_VERTICAL_POSITION_MISMATCH"
].includes(issue.code)
).length,
issueCodes: [...new Set(report.issues.map((issue) => issue.code))],
wpsStatus: wpsReport.status,
wpsPages: wpsCandidate.pageCount,
wpsEditableSimilarity: wpsReport.basic.candidateEditableSimilarity,
wpsEditableExact: wpsReport.basic.candidateEditableExact,
wpsParagraphLayoutIssueCount: wpsReport.basic.paragraphLayouts
?.flatMap((paragraph) => paragraph.issues).length ?? 0,
wpsBodyFlow: wpsReport.basic.bodyFlow,
wpsSemanticIssueCount: wpsReport.issues.filter((issue) =>
[
"PAGE_NUMBER_VISIBILITY_MISMATCH",
"PAGE_NUMBER_TEXT_MISMATCH",
"PAGE_NUMBER_ALIGNMENT_MISMATCH",
"PAGE_NUMBER_VERTICAL_POSITION_MISMATCH",
"HEADER_VISIBILITY_MISMATCH",
"HEADER_TEXT_MISSING",
"HEADER_VERTICAL_POSITION_MISMATCH"
].includes(issue.code)
).length,
wpsIssueCodes: [
...new Set(wpsReport.issues.map((issue) => issue.code))
],
reportHtml: path.relative(repositoryDirectory, reportHtmlPath),
reportJson: path.relative(repositoryDirectory, reportJsonPath),
wpsReportHtml: path.relative(repositoryDirectory, wpsReportHtmlPath),
wpsReportJson: path.relative(repositoryDirectory, wpsReportJsonPath),
chromiumPdf: path.relative(repositoryDirectory, chromiumPdfPath),
wordPdf: path.relative(repositoryDirectory, wordPdfPath),
wpsPdf: path.relative(repositoryDirectory, wpsPdfPath),
docx: path.relative(repositoryDirectory, docxPath)
});
}
const summary = {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
selectedCaseId,
word: wordCapability,
wps: wpsCapability,
cases: summaries
};
const summaryPath = path.join(outputDirectory, "summary.json");
fs.writeFileSync(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, "utf8");
process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);