建立 Chromium、Word 与 WPS 的可编辑内容、段落几何、页眉页脚、封面、媒体和逐页视觉比较链路。 支持封面独立分节、正文页码重启、主题页边距与横向纸张,并将自然分页差异降为诊断项。 修正代码块内边距和折叠表格单元格边框映射,14 套主题生产矩阵与六组布局视觉矩阵通过。
630 lines
18 KiB
JavaScript
630 lines
18 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,
|
||
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
|
||
)}`
|
||
);
|