673 lines
23 KiB
JavaScript
673 lines
23 KiB
JavaScript
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,
|
||
getDocxFontGateFailures,
|
||
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 }) =>
|
||
`<th><span>${orientation === "portrait" ? "纵向" : "横向"}</span><small>${escapeHtml(marginScenario.label)}</small></th>`
|
||
).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 '<td class="not-run">未执行</td>';
|
||
}
|
||
const failed = entry.gateFailures.length > 0;
|
||
const base = `./${entry.id}/raster-pages`;
|
||
return `<td class="${failed ? "failed" : "passed"}"><strong>${failed ? `失败 ${entry.gateFailures.length}` : "通过"}</strong><nav><a href="${base}/word-report.html">Word</a><a href="${base}/wps-report.html">WPS</a><a href="${base}/office-report.html">Office</a></nav></td>`;
|
||
}).join("");
|
||
const themeName = summary.cases.find(
|
||
(entry) => entry.themeId === themeId
|
||
)?.themeName;
|
||
return `<tr><th class="theme"><strong>${escapeHtml(themeId)}</strong><small>${escapeHtml(themeName ?? "")}</small></th>${cells}</tr>`;
|
||
}).join("");
|
||
return `<!doctype html>
|
||
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>DOCX 视觉门禁矩阵</title>
|
||
<style>
|
||
:root{font-family:Inter,"Microsoft YaHei",sans-serif;color:#172033;background:#f3f5f8}body{margin:0;padding:24px}main{max-width:1800px;margin:auto}h1{margin:0 0 8px}.summary{margin:0 0 20px;color:#475467}.table-wrap{overflow:auto;background:#fff;border:1px solid #d0d5dd;border-radius:10px}table{border-collapse:collapse;min-width:1500px;width:100%}th,td{border:1px solid #e4e7ec;padding:9px;text-align:center;vertical-align:top}thead th{position:sticky;top:0;background:#f8fafc;z-index:1}.theme{position:sticky;left:0;background:#fff;text-align:left;min-width:190px;z-index:2}th span,th small,.theme strong,.theme small{display:block}.passed{background:#e6f7ec}.failed{background:#ffe4e4}.not-run{background:#f2f4f7;color:#667085}nav{display:flex;justify-content:center;gap:6px;margin-top:6px}a{font-size:12px;color:#175cd3}small{margin-top:3px;color:#667085;font-weight:400}
|
||
</style></head><body><main><h1>DOCX 视觉门禁矩阵</h1><p class="summary">定义:${summary.matrixDefinition.themeCount} 套主题 × ${summary.matrixDefinition.orientationCount} 个方向 × ${summary.matrixDefinition.marginScenarioCount} 组页边距 = ${summary.matrixDefinition.expectedCaseCount} 个场景;本次执行 ${summary.execution.executedCaseCount} 个,整体 ${summary.gatePassed ? "通过" : "失败"}。</p><div class="table-wrap"><table><thead><tr><th class="theme">主题</th>${header}</tr></thead><tbody>${rows}</tbody></table></div></main></body></html>`;
|
||
}
|
||
|
||
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 = [...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"
|
||
}),
|
||
...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
|
||
},
|
||
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,
|
||
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
|
||
)}`
|
||
);
|