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, getBlockingVisualDiffIssues, renderPdfVisualDiffHtml, serializePdfVisualDiffJson } from "../packages/document-visual-diff/dist/index.js"; import { formatPageNumber, resolvePageNumberAlignment } from "../packages/preview-engine/dist/index.js"; import { createDocxLayoutVisualMatrixCases, readBundledThemeMatrixDefinitions, summarizeDocxLayoutVisualMatrix } from "./docx-layout-visual-matrix-cases.mjs"; 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] } }; const themeDefinitions = readBundledThemeMatrixDefinitions( path.join(repositoryDirectory, "themes") ); const cases = createDocxLayoutVisualMatrixCases(themeDefinitions); const matrixDefinition = summarizeDocxLayoutVisualMatrix( themeDefinitions, cases ); 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 = getBlockingVisualDiffIssues(report) .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 ) ?? []; const semanticBlocks = report.basic.semanticBlockVisuals ?? []; const blockingIssueCodes = [ ...new Set(getBlockingVisualDiffIssues(report).map((issue) => issue.code)) ]; return { status: report.status, issueCodes: [...new Set(report.issues.map((issue) => issue.code))], blockingIssueCodes, strictIssueCodes: blockingIssueCodes, editableExact: report.basic.candidateEditableExact, paragraphIssueCount: paragraphIssues.length, semanticBlockCount: semanticBlocks.length, failedSemanticBlockCount: semanticBlocks.filter( (comparison) => comparison.status === "failed" ).length, bodyFlow: report.basic.bodyFlow }; } function summarizeDimension(entries, key) { return Object.fromEntries( [...new Set(entries.map((entry) => entry[key]))] .sort((left, right) => left.localeCompare(right, "en")) .map((value) => { const dimensionCases = entries.filter((entry) => entry[key] === value); const failedCases = dimensionCases.filter( (entry) => entry.gateFailures.length > 0 ); return [value, { caseCount: dimensionCases.length, passedCaseCount: dimensionCases.length - failedCases.length, failedCaseCount: failedCases.length, gatePassed: failedCases.length === 0 }]; }) ); } function summarizeExecution(entries) { return { byTheme: summarizeDimension(entries, "themeId"), byOrientation: summarizeDimension(entries, "orientation"), byMarginScenario: summarizeDimension(entries, "marginScenarioId") }; } function escapeHtml(value) { return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function renderMatrixSummaryHtml(summary) { const caseByDimension = new Map( summary.cases.map((entry) => [ `${entry.themeId}|${entry.orientation}|${entry.marginScenarioId}`, entry ]) ); const columns = summary.matrixDefinition.orientations.flatMap( (orientation) => summary.matrixDefinition.marginScenarios.map((marginScenario) => ({ orientation, marginScenario })) ); const header = columns.map(({ orientation, marginScenario }) => `${orientation === "portrait" ? "纵向" : "横向"}${escapeHtml(marginScenario.label)}` ).join(""); const rows = summary.matrixDefinition.themeIds.map((themeId) => { const cells = columns.map(({ orientation, marginScenario }) => { const entry = caseByDimension.get( `${themeId}|${orientation}|${marginScenario.id}` ); if (!entry) { return '未执行'; } const failed = entry.gateFailures.length > 0; const base = `./${entry.id}/raster-pages`; return `${failed ? `失败 ${entry.gateFailures.length}` : "通过"}`; }).join(""); const themeName = summary.cases.find( (entry) => entry.themeId === themeId )?.themeName; return `${escapeHtml(themeId)}${escapeHtml(themeName ?? "")}${cells}`; }).join(""); return ` DOCX 视觉门禁矩阵

DOCX 视觉门禁矩阵

定义:${summary.matrixDefinition.themeCount} 套主题 × ${summary.matrixDefinition.orientationCount} 个方向 × ${summary.matrixDefinition.marginScenarioCount} 组页边距 = ${summary.matrixDefinition.expectedCaseCount} 个场景;本次执行 ${summary.execution.executedCaseCount} 个,整体 ${summary.gatePassed ? "通过" : "失败"}。

${header}${rows}
主题
`; } 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 ); } for (const comparison of report.basic.semanticBlockVisuals ?? []) { for (const line of comparison.lines) { if (!line.artifacts) { continue; } const stem = `${prefix}-block-${comparison.expectation.index + 1}-line-${line.lineIndex + 1}`; fs.writeFileSync( path.join(directory, `${stem}-baseline.png`), line.artifacts.baselinePng ); fs.writeFileSync( path.join(directory, `${stem}-candidate.png`), line.artifacts.candidatePng ); fs.writeFileSync( path.join(directory, `${stem}-overlay.png`), line.artifacts.overlayPng ); fs.writeFileSync( path.join(directory, `${stem}-heatmap.png`), line.artifacts.heatmapPng ); } } } fs.mkdirSync(outputDirectory, { recursive: true }); assert( matrixDefinition.themeCount === 14 && matrixDefinition.orientationCount === 2 && matrixDefinition.marginScenarioCount === 5 && matrixDefinition.expectedCaseCount === 140, `布局视觉矩阵定义必须为 14×2×5=140,实际为 ${JSON.stringify(matrixDefinition)}` ); 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, themeName: caseDefinition.themeName, orientation: caseDefinition.orientation, marginScenarioId: caseDefinition.marginScenarioId, marginScenarioLabel: caseDefinition.marginScenarioLabel, themeDefaultMargins: caseDefinition.themeDefaultMargins, 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(), matrixDefinition, selectedCaseId, execution: { mode: selectedCaseId ? "single-case" : "full-matrix", executedCaseCount: summaries.length, expectedFullMatrixCaseCount: matrixDefinition.expectedCaseCount, complete: !selectedCaseId && summaries.length === matrixDefinition.expectedCaseCount }, executionSummary: summarizeExecution(summaries), word: wordCapability, wps: wpsCapability, caseCount: summaries.length, gatePassed: gateFailures.length === 0, gateFailures, cases: summaries }; const summaryPath = path.join( outputDirectory, selectedCaseId ? `summary-${selectedCaseId}.json` : "summary.json" ); const matrixHtmlPath = path.join( outputDirectory, selectedCaseId ? `matrix-${selectedCaseId}.html` : "matrix.html" ); fs.writeFileSync(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, "utf8"); fs.writeFileSync(matrixHtmlPath, renderMatrixSummaryHtml(summary), "utf8"); process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`); assert( gateFailures.length === 0, `布局视觉矩阵存在 ${gateFailures.length} 个严格门禁问题,详见 ${path.relative( repositoryDirectory, summaryPath )}` );