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

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

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

630 lines
18 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 { 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 decoder = new TextDecoder();
const repositoryDirectory = path.resolve(import.meta.dirname, "..");
const outputDirectory = path.join(
repositoryDirectory,
"output",
"docx-layout-visual-matrix"
);
const matrixScript = path.join(
repositoryDirectory,
"apps",
"server",
"scripts",
"verify-docx-r4-matrix.mjs"
);
const pageSizes = {
A4: { portrait: [595.276, 841.89], landscape: [841.89, 595.276] },
Letter: { portrait: [612, 792], landscape: [792, 612] }
};
const strictIssueCodes = new Set([
"PAGE_SIZE_MISMATCH",
"PAGE_ORIENTATION_MISMATCH",
"CONTENT_MISMATCH",
"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",
"EDITABLE_PARAGRAPH_MAPPING_MISMATCH",
"TEXT_BLOCK_LINE_COUNT_MISMATCH",
"PARAGRAPH_LINE_BREAK_MISMATCH",
"PARAGRAPH_LINE_GEOMETRY_MISMATCH",
"COVER_LAYOUT_MISMATCH"
]);
const cases = [
{
id: "official-theme-a4-portrait",
themeId: "gov-red-standard",
coverPageCount: 0,
paper: { format: "A4", orientation: "portrait" },
config: {}
},
{
id: "official-asymmetric-a4-portrait",
themeId: "gov-red-standard",
coverPageCount: 0,
paper: { format: "A4", orientation: "portrait" },
config: {
paper: {
format: "A4",
orientation: "portrait",
marginMode: "custom",
margins: {
top: "18mm",
right: "14mm",
bottom: "30mm",
left: "32mm"
}
}
}
},
{
id: "project-cover-custom-a4-portrait",
themeId: "formal-feasibility",
coverPageCount: 1,
paper: { format: "A4", orientation: "portrait" },
config: {
paper: {
format: "A4",
orientation: "portrait",
marginMode: "custom",
margins: {
top: "20mm",
right: "18mm",
bottom: "25mm",
left: "30mm"
}
}
}
},
{
id: "tender-cover-custom-letter-landscape",
themeId: "tender-business-blue",
coverPageCount: 1,
paper: { format: "Letter", orientation: "landscape" },
config: {
paper: {
format: "Letter",
orientation: "landscape",
marginMode: "custom",
margins: {
top: "16mm",
right: "18mm",
bottom: "20mm",
left: "24mm"
}
}
}
},
{
id: "formal-custom-a4-landscape",
themeId: "formal-regulation",
coverPageCount: 0,
paper: { format: "A4", orientation: "landscape" },
config: {
paper: {
format: "A4",
orientation: "landscape",
marginMode: "custom",
margins: {
top: "15mm",
right: "22mm",
bottom: "18mm",
left: "25mm"
}
}
}
},
{
id: "general-compact-letter-portrait",
themeId: "typora-github",
coverPageCount: 0,
paper: { format: "Letter", orientation: "portrait" },
config: {
paper: {
format: "Letter",
orientation: "portrait",
marginMode: "custom",
margins: {
top: "12.7mm",
right: "12.7mm",
bottom: "12.7mm",
left: "12.7mm"
}
}
}
}
];
const selectedCaseId =
process.env.MD_TO_PDF_LAYOUT_VISUAL_CASE?.trim() || undefined;
const selectedCases = selectedCaseId
? cases.filter((entry) => entry.id === selectedCaseId)
: cases;
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
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 isInternalLayoutSpacerParagraph(descendants, text) {
if (text !== ".") {
return false;
}
const attributeValue = (node, name) =>
node.getAttribute(`w:${name}`) || node.getAttribute(name) || undefined;
return (
descendants.some(
(node) =>
node.localName === "sz" &&
node.namespaceURI === WORD_NAMESPACE &&
attributeValue(node, "val") === "2"
) &&
descendants.some(
(node) =>
node.localName === "spacing" &&
node.namespaceURI === WORD_NAMESPACE &&
attributeValue(node, "lineRule") === "exact"
)
);
}
function extractEditableContract(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(
decoder.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 || isInternalLayoutSpacerParagraph(descendants, 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 {
text: paragraphs.map((paragraph) => paragraph.text).join(""),
paragraphs
};
}
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: 360_000,
maxBuffer: 24 * 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 result = JSON.parse(stdout).results?.[0];
assert(result, `视觉矩阵用例 ${caseDefinition.id} 没有生成产物`);
return { caseDirectory, result };
}
function expectedPageSize(caseDefinition) {
const dimensions =
pageSizes[caseDefinition.paper.format]?.[caseDefinition.paper.orientation];
assert(dimensions, `未知纸张配置:${JSON.stringify(caseDefinition.paper)}`);
return dimensions;
}
function pageGeometryFailures(snapshot, caseDefinition, label) {
const [expectedWidth, expectedHeight] = expectedPageSize(caseDefinition);
return snapshot.pages.flatMap((page) => {
const widthDelta = Math.abs(page.widthPt - expectedWidth);
const heightDelta = Math.abs(page.heightPt - expectedHeight);
return widthDelta <= 0.5 && heightDelta <= 0.5
? []
: [
`${label}${page.pageNumber} 页纸张尺寸为 ` +
`${page.widthPt.toFixed(3)}×${page.heightPt.toFixed(3)} pt` +
`期望 ${expectedWidth.toFixed(3)}×${expectedHeight.toFixed(3)} pt`
];
});
}
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
)
}
: {})
};
});
}
function reportGateFailures(report, label) {
const failures = report.issues
.filter((issue) => strictIssueCodes.has(issue.code))
.map((issue) => `${label}: ${issue.code} - ${issue.message}`);
if (report.basic.baselineEditableCoverage !== 1) {
failures.push(
`${label}: Chromium 可编辑正文有序覆盖率不是 100%`
);
}
if (report.basic.candidateEditableExact !== true) {
failures.push(`${label}: Office 可编辑正文未精确匹配`);
}
if (report.basic.bodyFlow?.detected && !report.basic.bodyFlow.legal) {
failures.push(`${label}: 分页差异未满足合法正文流条件`);
}
return failures;
}
function reportSummary(report) {
const paragraphIssues = report.basic.paragraphLayouts?.flatMap(
(paragraph) => paragraph.issues
) ?? [];
return {
status: report.status,
issueCodes: [...new Set(report.issues.map((issue) => issue.code))],
strictIssueCodes: [
...new Set(
report.issues
.filter((issue) => strictIssueCodes.has(issue.code))
.map((issue) => issue.code)
)
],
editableExact: report.basic.candidateEditableExact,
paragraphIssueCount: paragraphIssues.length,
bodyFlow: report.basic.bodyFlow
};
}
function writeSnapshotPages(directory, prefix, snapshot) {
for (const page of snapshot.pages) {
if (page.raster) {
fs.writeFileSync(
path.join(directory, `${prefix}-${page.pageNumber}.png`),
page.raster.png
);
}
}
}
function writeReportArtifacts(directory, prefix, report) {
fs.writeFileSync(
path.join(directory, `${prefix}-report.json`),
`${serializePdfVisualDiffJson(report)}\n`,
"utf8"
);
fs.writeFileSync(
path.join(directory, `${prefix}-report.html`),
renderPdfVisualDiffHtml(report),
"utf8"
);
for (const page of report.pages) {
const pageNumber =
page.pair.baselinePageNumber ?? page.pair.candidatePageNumber;
if (!pageNumber || !page.artifacts) {
continue;
}
fs.writeFileSync(
path.join(directory, `${prefix}-overlay-${pageNumber}.png`),
page.artifacts.overlayPng
);
fs.writeFileSync(
path.join(directory, `${prefix}-heatmap-${pageNumber}.png`),
page.artifacts.heatmapPng
);
}
}
fs.mkdirSync(outputDirectory, { recursive: true });
assert(
selectedCases.length > 0,
`视觉矩阵用例不存在:${selectedCaseId}`
);
const wordAdapter = createWordPdfAdapter({ timeoutMs: 240_000 });
const wpsAdapter = createWpsPdfAdapter({ timeoutMs: 240_000 });
const wordCapability = await wordAdapter.probe();
const wpsCapability = await wpsAdapter.probe();
assert(wordCapability.available, `Microsoft Word 不可用:${wordCapability.detail}`);
assert(wpsCapability.available, `WPS Writer 不可用:${wpsCapability.detail}`);
const summaries = [];
const gateFailures = [];
for (const caseDefinition of selectedCases) {
process.stderr.write(`[layout visual matrix] ${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 editable = extractEditableContract(docxPath);
const wordGeneration = await wordAdapter.generate({ docxPath });
const wpsGeneration = await wpsAdapter.generate({ docxPath });
const wordPdfPath = path.join(caseDirectory, "word.pdf");
const wpsPdfPath = path.join(caseDirectory, "wps.pdf");
fs.writeFileSync(wordPdfPath, wordGeneration.pdf);
fs.writeFileSync(wpsPdfPath, wpsGeneration.pdf);
const chromium = await createPdfDocumentSnapshot(
fs.readFileSync(chromiumPdfPath),
{
source: {
kind: "chromium",
label: `${caseDefinition.id} Chromium`,
fileName: path.basename(chromiumPdfPath)
},
dpi: 144
}
);
const word = await createPdfDocumentSnapshot(wordGeneration.pdf, {
source: {
kind: "word",
label: `${caseDefinition.id} Microsoft Word`,
fileName: path.basename(wordPdfPath)
},
dpi: 144
});
const wps = await createPdfDocumentSnapshot(wpsGeneration.pdf, {
source: {
kind: "wps",
label: `${caseDefinition.id} WPS Writer`,
fileName: path.basename(wpsPdfPath)
},
dpi: 144
});
const chromiumPageSemantics = buildPageSemantics(
chromium.pageCount,
caseDefinition.coverPageCount,
result.exportConfig
);
const wordPageSemantics = buildPageSemantics(
word.pageCount,
caseDefinition.coverPageCount,
result.exportConfig
);
const wpsPageSemantics = buildPageSemantics(
wps.pageCount,
caseDefinition.coverPageCount,
result.exportConfig
);
const reportOptions = {
thresholds: { strictPageCount: false },
expectedEditableText: editable.text,
expectedEditableParagraphs: editable.paragraphs
};
const wordReport = await createPdfVisualDiffReport(
chromium,
word,
{
...reportOptions,
baselinePageSemantics: chromiumPageSemantics,
candidatePageSemantics: wordPageSemantics
}
);
const wpsReport = await createPdfVisualDiffReport(
chromium,
wps,
{
...reportOptions,
baselinePageSemantics: chromiumPageSemantics,
candidatePageSemantics: wpsPageSemantics
}
);
const officeReport = await createPdfVisualDiffReport(word, wps, {
...reportOptions,
expectedEditableText: editable.text,
baselinePageSemantics: wordPageSemantics,
candidatePageSemantics: wpsPageSemantics
});
const rasterDirectory = path.join(caseDirectory, "raster-pages");
fs.rmSync(rasterDirectory, { recursive: true, force: true });
fs.mkdirSync(rasterDirectory, { recursive: true });
writeSnapshotPages(rasterDirectory, "chromium", chromium);
writeSnapshotPages(rasterDirectory, "word", word);
writeSnapshotPages(rasterDirectory, "wps", wps);
writeReportArtifacts(rasterDirectory, "word", wordReport);
writeReportArtifacts(rasterDirectory, "wps", wpsReport);
writeReportArtifacts(rasterDirectory, "office", officeReport);
const currentFailures = [
...pageGeometryFailures(chromium, caseDefinition, "Chromium"),
...pageGeometryFailures(word, caseDefinition, "Microsoft Word"),
...pageGeometryFailures(wps, caseDefinition, "WPS Writer"),
...reportGateFailures(wordReport, "Chromium/Word"),
...reportGateFailures(wpsReport, "Chromium/WPS"),
...reportGateFailures(officeReport, "Word/WPS")
];
gateFailures.push(
...currentFailures.map((failure) => `${caseDefinition.id}: ${failure}`)
);
summaries.push({
id: caseDefinition.id,
themeId: caseDefinition.themeId,
paper: caseDefinition.paper,
exportConfig: result.exportConfig,
coverPageCount: caseDefinition.coverPageCount,
pages: {
chromium: chromium.pageCount,
word: word.pageCount,
wps: wps.pageCount
},
editableParagraphCount: editable.paragraphs.length,
reports: {
word: reportSummary(wordReport),
wps: reportSummary(wpsReport),
office: reportSummary(officeReport)
},
gateFailures: currentFailures,
files: {
chromiumPdf: path.relative(repositoryDirectory, chromiumPdfPath),
docx: path.relative(repositoryDirectory, docxPath),
wordPdf: path.relative(repositoryDirectory, wordPdfPath),
wpsPdf: path.relative(repositoryDirectory, wpsPdfPath),
rasterDirectory: path.relative(repositoryDirectory, rasterDirectory)
}
});
}
const summary = {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
selectedCaseId,
word: wordCapability,
wps: wpsCapability,
caseCount: summaries.length,
gatePassed: gateFailures.length === 0,
gateFailures,
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`);
assert(
gateFailures.length === 0,
`布局视觉矩阵存在 ${gateFailures.length} 个严格门禁问题,详见 ${path.relative(
repositoryDirectory,
summaryPath
)}`
);