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 matter from "gray-matter"; import { createPdfDocumentSnapshot, createPdfVisualDiffReport, createWordPdfAdapter, createWpsPdfAdapter, getBlockingVisualDiffIssues, isCrossEngineRasterEquivalent, normalizePdfEditableText, renderPdfVisualDiffHtml, serializePdfVisualDiffJson } from "../packages/document-visual-diff/dist/index.js"; import { formatPageNumber, resolvePageNumberAlignment } from "../packages/preview-engine/dist/index.js"; import { createDocxLayoutVisualMatrixCases, getDocxFontGateFailures, isCorpusTextColorEquivalent, readBundledThemeMatrixDefinitions, resolveEffectiveCoverPageCount, summarizeDocxLayoutVisualMatrix } from "./docx-layout-visual-matrix-cases.mjs"; import { DOCX_REAL_WORLD_CORPUS } from "./docx-real-world-corpus.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.resolve( repositoryDirectory, process.env.MD_TO_PDF_LAYOUT_VISUAL_OUTPUT_DIR?.trim() || path.join("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 selectedScope = process.env.MD_TO_PDF_LAYOUT_VISUAL_SCOPE?.trim() || "full"; const selectedOrientation = process.env.MD_TO_PDF_LAYOUT_VISUAL_ORIENTATION?.trim() || undefined; const selectedMarginScenarioId = process.env.MD_TO_PDF_LAYOUT_VISUAL_MARGIN?.trim() || undefined; const expectInlineCodeProbes = process.env.MD_TO_PDF_R4_APPEND_INLINE_CODE_PROBES?.trim() !== "0"; const selectedCorpusId = process.env.MD_TO_PDF_CORPUS_ID?.trim() || undefined; const corpusDefinition = selectedCorpusId ? DOCX_REAL_WORLD_CORPUS.find((entry) => entry.id === selectedCorpusId) : undefined; const corpusSourceMetadata = selectedScope === "corpus" && process.env.MD_TO_PDF_R4_MARKDOWN_PATH?.trim() ? matter(fs.readFileSync( path.resolve( repositoryDirectory, process.env.MD_TO_PDF_R4_MARKDOWN_PATH.trim() ), "utf8" )).data : undefined; const scopedCases = selectedCaseId ? cases.filter((entry) => entry.id === selectedCaseId) : selectedScope === "cover" ? cases.filter((entry) => entry.coverPageCount > 0) : cases; const selectedCases = scopedCases.filter( (entry) => (!selectedOrientation || entry.orientation === selectedOrientation) && (!selectedMarginScenarioId || entry.marginScenarioId === selectedMarginScenarioId) ); 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 hasAncestor(paragraph, localName) { let current = paragraph.parentNode; while (current) { if ( current.localName === localName && current.namespaceURI === WORD_NAMESPACE ) { return true; } current = current.parentNode; } return false; } function isTableHeaderParagraph(paragraph) { let current = paragraph.parentNode; while (current) { if (current.localName === "tr" && current.namespaceURI === WORD_NAMESPACE) { return Array.from(current.getElementsByTagName("*")).some( (node) => node.localName === "tblHeader" && node.namespaceURI === WORD_NAMESPACE ); } current = current.parentNode; } return false; } function paragraphBlockKind(paragraph, styleId) { if (hasAncestor(paragraph, "tc")) { return isTableHeaderParagraph(paragraph) ? "table-header" : "table-cell"; } if (styleId === "SourceCode") { return "code-block"; } if (styleId === "BlockText") { return "block-quote"; } if (/(?:Caption)$/u.test(styleId)) { return "caption"; } if (/^Heading[1-6]$/u.test(styleId)) { return "heading"; } if (styleId === "Compact" || styleId === "ListParagraph") { return "list-item"; } if (/^Md(?:Official|Briefing|ProjectReport|Tender)/u.test(styleId)) { return "semantic-region"; } return "paragraph"; } function directWordChildren(node, localName) { return Array.from(node?.childNodes ?? []).filter( (child) => child.nodeType === 1 && child.localName === localName && child.namespaceURI === WORD_NAMESPACE ); } function nearestWordAncestor(node, localName) { let current = node?.parentNode; while (current) { if ( current.localName === localName && current.namespaceURI === WORD_NAMESPACE ) { return current; } current = current.parentNode; } return undefined; } function paragraphTableCoordinates(paragraph, tableIndexes) { const cell = nearestWordAncestor(paragraph, "tc"); const row = nearestWordAncestor(paragraph, "tr"); const table = nearestWordAncestor(paragraph, "tbl"); if (!cell || !row || !table) { return undefined; } return { tableGroupId: `table-${tableIndexes.get(table)}`, tableRowIndex: directWordChildren(table, "tr").indexOf(row), tableColumnIndex: directWordChildren(row, "tc").indexOf(cell) }; } 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" ); const tableIndexes = new Map( Array.from(document.getElementsByTagName("*")).filter( (node) => node.localName === "tbl" && node.namespaceURI === WORD_NAMESPACE ).map((table, index) => [table, index]) ); let section = "cover"; let hasCoverSection = false; const paragraphs = []; let inlineCodeParagraphCount = 0; let inlineCodeExactLineRuleCount = 0; let pureMathTableParagraphCount = 0; let stabilizedPureMathTableParagraphCount = 0; for (const paragraph of Array.from(document.getElementsByTagName("*")).filter( (node) => node.localName === "p" && node.namespaceURI === WORD_NAMESPACE )) { const descendants = Array.from(paragraph.getElementsByTagName("*")); const inlineCode = descendants.some( (node) => node.localName === "rStyle" && node.namespaceURI === WORD_NAMESPACE && (node.getAttribute("w:val") || node.getAttribute("val")) === "VerbatimChar" ); if (inlineCode) { inlineCodeParagraphCount += 1; if ( descendants.some( (node) => node.localName === "spacing" && node.namespaceURI === WORD_NAMESPACE && (node.getAttribute("w:lineRule") || node.getAttribute("lineRule")) === "exact" ) ) { inlineCodeExactLineRuleCount += 1; } } if (hasAncestor(paragraph, "tc")) { const directContent = Array.from(paragraph.childNodes).filter( (node) => node.nodeType === 1 && !( node.localName === "pPr" && node.namespaceURI === WORD_NAMESPACE ) ); const directMath = directContent.filter( (node) => node.localName === "oMath" && node.namespaceURI === MATH_NAMESPACE ); const stabilizers = directContent.filter( (node) => node.localName === "r" && node.namespaceURI === WORD_NAMESPACE && Array.from(node.getElementsByTagName("*")).some( (descendant) => descendant.localName === "noProof" && descendant.namespaceURI === WORD_NAMESPACE ) && Array.from(node.getElementsByTagName("*")).some( (descendant) => descendant.localName === "t" && descendant.namespaceURI === WORD_NAMESPACE && descendant.textContent === "\u200B" ) ); if ( directMath.length > 0 && directContent.length === directMath.length + stabilizers.length ) { pureMathTableParagraphCount += 1; if (stabilizers.length > 0) { stabilizedPureMathTableParagraphCount += 1; } } } const hasSectionBreak = descendants.some( (node) => node.localName === "sectPr" && node.namespaceURI === WORD_NAMESPACE ); if (hasSectionBreak) { hasCoverSection = true; section = "body"; continue; } const hardBreakSegments = [""]; const mathCharacterIndexes = []; let normalizedCharacterOffset = 0; for (const node of descendants) { if ( node.localName === "t" && (node.namespaceURI === WORD_NAMESPACE || node.namespaceURI === MATH_NAMESPACE) ) { const nodeText = (node.textContent ?? "").replace( /[\u200B\u2060\uFEFF]/gu, "" ); hardBreakSegments[hardBreakSegments.length - 1] += nodeText; const normalizedNodeLength = Array.from( normalizePdfEditableText(nodeText) ).length; if (node.namespaceURI === MATH_NAMESPACE) { for (let index = 0; index < normalizedNodeLength; index += 1) { mathCharacterIndexes.push(normalizedCharacterOffset + index); } } normalizedCharacterOffset += normalizedNodeLength; } else if ( node.localName === "br" && node.namespaceURI === WORD_NAMESPACE ) { hardBreakSegments.push(""); } } const text = hardBreakSegments.join(""); if (!text || isInternalLayoutSpacerParagraph(descendants, text)) { continue; } const styleId = paragraphStyleId(paragraph); const tableCoordinates = paragraphTableCoordinates(paragraph, tableIndexes); paragraphs.push({ index: paragraphs.length, text, ...(mathCharacterIndexes.length > 0 ? { mathCharacterIndexes } : {}), ...(hardBreakSegments.length > 1 ? { hardBreakSegments } : {}), ...(inlineCode ? { hasInlineCode: true } : {}), ...(tableCoordinates ?? {}), ...(styleId ? { styleId } : {}), role: paragraphRole(styleId), blockKind: paragraphBlockKind(paragraph, styleId), section }); } if (!hasCoverSection) { for (const paragraph of paragraphs) { paragraph.section = "body"; } } return { text: paragraphs.map((paragraph) => paragraph.text).join(""), paragraphs, translationInvariants: { inlineCodeParagraphCount, inlineCodeExactLineRuleCount, pureMathTableParagraphCount, stabilizedPureMathTableParagraphCount } }; } 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` ]; }); } const inlineCodeContinuityProbes = [ { id: "paragraph", code: "mdtp_ic_p", continuousText: "段落前mdtp_ic_p段落后" }, { id: "list-item", code: "mdtp_ic_l", continuousText: "列表前mdtp_ic_l列表后" }, { id: "table-cell", code: "mdtp_ic_c", continuousText: "单元格前mdtp_ic_c单元格后" } ]; function inspectInlineCodeContinuity(snapshot, label) { const normalizedLines = snapshot.pages.flatMap((page) => page.lines.map((line) => ({ pageNumber: page.pageNumber, text: line.text, normalizedText: line.text.normalize("NFKC").replace(/\s+/gu, "") })) ); const probes = inlineCodeContinuityProbes.map((probe) => { const matchingLines = normalizedLines.filter((line) => line.normalizedText.includes(probe.code) ); const passed = matchingLines.length === 1 && matchingLines[0].normalizedText.includes(probe.continuousText); return { ...probe, passed, matchingLines: matchingLines.map(({ pageNumber, text }) => ({ pageNumber, text })) }; }); return { label, passed: probes.every((probe) => probe.passed), probes, failures: probes.flatMap((probe) => probe.passed ? [] : [ `${label}: INLINE_CODE_CONTINUITY_MISMATCH - ${probe.id} 行内代码未与前后文本保持同一视觉行` ] ) }; } function inspectCodeIndentation(snapshot, label, probe) { if (!probe) { return { label, applicable: false, passed: true, failures: [] }; } for (const page of snapshot.pages) { const nestedIndex = page.lines.findIndex((line) => line.text.includes(probe.nestedLineIncludes) ); if (nestedIndex < 0) { continue; } const nested = page.lines[nestedIndex]; const root = page.lines .slice(0, nestedIndex) .reverse() .find( (line) => line.text.trim() === probe.rootLine && nested.baselineY - line.baselineY <= 30 ); if (!root) { continue; } const indentPt = nested.bounds.x - root.bounds.x; const passed = indentPt >= probe.minimumIndentPt; return { label, applicable: true, passed, pageNumber: page.pageNumber, rootText: root.text, nestedText: nested.text, rootXPt: root.bounds.x, nestedXPt: nested.bounds.x, indentPt, failures: passed ? [] : [ `${label}: CODE_INDENTATION_MISMATCH - JSON 嵌套行缩进 ${indentPt.toFixed(2)}pt,小于 ${probe.minimumIndentPt}pt` ] }; } return { label, applicable: true, passed: false, failures: [ `${label}: CODE_INDENTATION_PROBE_UNAVAILABLE - 未定位 JSON 根行与嵌套行` ] }; } function docxTranslationInvariantFailures(invariants) { const failures = []; if (invariants.inlineCodeExactLineRuleCount > 0) { failures.push( `DOCX: INLINE_CODE_EXACT_LINE_BOX - ${invariants.inlineCodeExactLineRuleCount} 个行内代码段落仍使用 exact 行盒` ); } if ( invariants.pureMathTableParagraphCount !== invariants.stabilizedPureMathTableParagraphCount ) { failures.push( "DOCX: TABLE_MATH_ALIGNMENT_UNSTABLE - " + `${invariants.pureMathTableParagraphCount - invariants.stabilizedPureMathTableParagraphCount} 个纯公式表格段落缺少对齐稳定结构` ); } return failures; } 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 reportCoverGateFailures(report, label) { return getBlockingVisualDiffIssues(report) .filter( (issue) => issue.code === "COVER_LAYOUT_MISMATCH" || issue.baselinePageNumber === 1 || issue.candidatePageNumber === 1 ) .map((issue) => `${label}: ${issue.code} - ${issue.message}`); } function metricRatio(first, second) { const maximum = Math.max(first, second); return maximum > 0 ? Math.min(first, second) / maximum : 1; } const MAXIMUM_CORPUS_ELEMENT_COLOR_DELTA = 55; function reportCorpusGateFailures( report, label, coverPageCount ) { const failures = []; if ((report.basic.candidateEditableSimilarity ?? 0) < 0.995) { failures.push( `${label}: EDITABLE_CONTENT_SIMILARITY - Office 可编辑正文相似度 ` + `${((report.basic.candidateEditableSimilarity ?? 0) * 100).toFixed(3)}% 低于 99.5%` ); } const strictKinds = new Set([ "heading", "title", "caption", "table-header", "code-block" ]); for (const comparison of report.basic.semanticBlockVisuals ?? []) { const index = comparison.expectation.index + 1; const visualUnavailable = comparison.status === "unavailable" || comparison.lines.length === 0 || comparison.issues?.some( (issue) => issue.code === "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE" ); if (visualUnavailable) { failures.push( `${label}: SEMANTIC_BLOCK_VISUAL_UNAVAILABLE - 第 ${index} 个元素块无法建立视觉观测` ); continue; } for (const line of comparison.lines) { const metrics = line.metrics; if (!metrics) { continue; } const heightRatio = metricRatio( metrics.baselineHeightPx, metrics.candidateHeightPx ); if (heightRatio < 0.78) { failures.push( `${label}: ELEMENT_LINE_BOX_MISMATCH - 第 ${index} 个元素块第 ${line.lineIndex + 1} 行高度比例 ${heightRatio.toFixed(3)} 低于 0.78` ); } const widthRatio = metricRatio( metrics.baselineWidthPx, metrics.candidateWidthPx ); if (comparison.lines.length === 1 && widthRatio < 0.85) { failures.push( `${label}: ELEMENT_WIDTH_MISMATCH - 第 ${index} 个单行元素块宽度比例 ${widthRatio.toFixed(3)} 低于 0.85` ); } const minimumIou = strictKinds.has( comparison.expectation.blockKind ) ? 0.45 : 0.3; if ( comparison.lines.length === 1 && metrics.inkIou < minimumIou && metrics.edgeIou < minimumIou ) { failures.push( `${label}: ELEMENT_RASTER_MISMATCH - 第 ${index} 个元素块第 ${line.lineIndex + 1} 行墨迹与边缘 IoU 均低于 ${minimumIou}` ); } const textReflow = line.issues?.some( (issue) => issue.details?.textReflow === true ); const crossEngineRasterEquivalent = isCrossEngineRasterEquivalent( metrics, textReflow, comparison.expectation.blockKind, comparison.expectation.hasInlineCode === true ); if (!textReflow && !crossEngineRasterEquivalent && !isCorpusTextColorEquivalent(metrics) && ( metrics.backgroundColorDelta > MAXIMUM_CORPUS_ELEMENT_COLOR_DELTA || metrics.foregroundColorDelta > MAXIMUM_CORPUS_ELEMENT_COLOR_DELTA )) { failures.push( `${label}: ELEMENT_COLOR_MISMATCH - 第 ${index} 个元素块第 ${line.lineIndex + 1} 行颜色差异超限` ); } } } if (coverPageCount > 0) { failures.push(...reportCoverGateFailures(report, label)); } return [...new Set(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}` ); assert( ["full", "cover", "corpus"].includes(selectedScope), `不支持的视觉矩阵作用域:${selectedScope}` ); assert( selectedScope !== "corpus" || corpusDefinition, `真实语料门禁缺少有效 MD_TO_PDF_CORPUS_ID:${selectedCorpusId}` ); assert( selectedScope !== "corpus" || Boolean(process.env.MD_TO_PDF_R4_MARKDOWN_PATH?.trim()), "真实语料门禁必须显式提供 MD_TO_PDF_R4_MARKDOWN_PATH" ); assert( !selectedOrientation || ["portrait", "landscape"].includes(selectedOrientation), `不支持的视觉矩阵方向:${selectedOrientation}` ); assert( !selectedMarginScenarioId || matrixDefinition.marginScenarios.some( (entry) => entry.id === selectedMarginScenarioId ), `不支持的视觉矩阵边距场景:${selectedMarginScenarioId}` ); 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 = []; const diagnosticFailures = []; 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 effectiveCoverPageCount = resolveEffectiveCoverPageCount({ caseDefinition, scope: selectedScope, sourceMetadata: corpusSourceMetadata }); 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, effectiveCoverPageCount, result.exportConfig ); const wordPageSemantics = buildPageSemantics( word.pageCount, effectiveCoverPageCount, result.exportConfig ); const wpsPageSemantics = buildPageSemantics( wps.pageCount, effectiveCoverPageCount, 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 inlineCodeContinuity = { chromium: inspectInlineCodeContinuity(chromium, "Chromium"), word: inspectInlineCodeContinuity(word, "Microsoft Word"), wps: inspectInlineCodeContinuity(wps, "WPS Writer") }; const codeIndentation = { chromium: inspectCodeIndentation( chromium, "Chromium", corpusDefinition?.codeIndentationProbe ), word: inspectCodeIndentation( word, "Microsoft Word", corpusDefinition?.codeIndentationProbe ), wps: inspectCodeIndentation( wps, "WPS Writer", corpusDefinition?.codeIndentationProbe ) }; 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 allCurrentFailures = [...new Set([ ...pageGeometryFailures(chromium, caseDefinition, "Chromium"), ...pageGeometryFailures(word, caseDefinition, "Microsoft Word"), ...pageGeometryFailures(wps, caseDefinition, "WPS Writer"), ...getDocxFontGateFailures({ theme: themeDefinitions.find((theme) => theme.id === caseDefinition.themeId), inspection: result.docx.inspection, renderedFonts: word.fonts, engine: "word" }), ...getDocxFontGateFailures({ theme: themeDefinitions.find((theme) => theme.id === caseDefinition.themeId), inspection: result.docx.inspection, renderedFonts: wps.fonts, engine: "wps" }), ...(expectInlineCodeProbes ? [ ...inlineCodeContinuity.chromium.failures, ...inlineCodeContinuity.word.failures, ...inlineCodeContinuity.wps.failures ] : []), ...reportGateFailures(wordReport, "Chromium/Word"), ...reportGateFailures(wpsReport, "Chromium/WPS"), ...reportGateFailures(officeReport, "Word/WPS") ])]; const currentFailures = selectedScope === "cover" ? [...new Set([ ...reportCoverGateFailures(wordReport, "Chromium/Word"), ...reportCoverGateFailures(wpsReport, "Chromium/WPS"), ...reportCoverGateFailures(officeReport, "Word/WPS") ])] : selectedScope === "corpus" ? [...new Set([ ...pageGeometryFailures(chromium, caseDefinition, "Chromium"), ...pageGeometryFailures(word, caseDefinition, "Microsoft Word"), ...pageGeometryFailures(wps, caseDefinition, "WPS Writer"), ...getDocxFontGateFailures({ theme: themeDefinitions.find((theme) => theme.id === caseDefinition.themeId), inspection: result.docx.inspection, renderedFonts: word.fonts, engine: "word" }), ...getDocxFontGateFailures({ theme: themeDefinitions.find((theme) => theme.id === caseDefinition.themeId), inspection: result.docx.inspection, renderedFonts: wps.fonts, engine: "wps" }), ...docxTranslationInvariantFailures( editable.translationInvariants ), ...codeIndentation.chromium.failures, ...codeIndentation.word.failures, ...codeIndentation.wps.failures, ...reportCorpusGateFailures( wordReport, "Chromium/Word", effectiveCoverPageCount ), ...reportCorpusGateFailures( wpsReport, "Chromium/WPS", effectiveCoverPageCount ), ...reportCorpusGateFailures( officeReport, "Word/WPS", effectiveCoverPageCount ) ])] : allCurrentFailures; gateFailures.push( ...currentFailures.map((failure) => `${caseDefinition.id}: ${failure}`) ); diagnosticFailures.push( ...allCurrentFailures.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: effectiveCoverPageCount, pages: { chromium: chromium.pageCount, word: word.pageCount, wps: wps.pageCount }, fonts: { declared: themeDefinitions.find((theme) => theme.id === caseDefinition.themeId)?.docxFontFaces ?? [], embeddedNames: result.docx.inspection.embeddedFontNames, embeddedPartCount: result.docx.inspection.embeddedFontPartCount, chromium: chromium.fonts, word: word.fonts, wps: wps.fonts }, editableParagraphCount: editable.paragraphs.length, translationInvariants: editable.translationInvariants, inlineCodeContinuity, codeIndentation, reports: { word: reportSummary(wordReport), wps: reportSummary(wpsReport), office: reportSummary(officeReport) }, gateFailures: currentFailures, diagnosticFailures: allCurrentFailures, 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, selectedScope, corpus: corpusDefinition ? { id: corpusDefinition.id, label: corpusDefinition.label, markdownPath: corpusDefinition.markdownPath, requiredFeatures: corpusDefinition.requiredFeatures } : undefined, selectedOrientation, selectedMarginScenarioId, execution: { mode: selectedCaseId ? "single-case" : selectedOrientation || selectedMarginScenarioId ? "filtered-matrix" : selectedScope === "cover" ? "cover-matrix" : "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, diagnosticFailures, cases: summaries }; const summaryPath = path.join( outputDirectory, selectedCaseId ? `summary-${selectedCaseId}.json` : selectedOrientation || selectedMarginScenarioId ? `summary-${selectedOrientation ?? "all"}-${selectedMarginScenarioId ?? "all"}.json` : selectedScope === "cover" ? "summary-cover.json" : "summary.json" ); const matrixHtmlPath = path.join( outputDirectory, selectedCaseId ? `matrix-${selectedCaseId}.html` : selectedOrientation || selectedMarginScenarioId ? `matrix-${selectedOrientation ?? "all"}-${selectedMarginScenarioId ?? "all"}.html` : selectedScope === "cover" ? "matrix-cover.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 )}` );