feat: 完成 DOCX R4 元素级视觉门禁
建立 Chromium、Word 与 WPS 的可编辑内容、段落几何、页眉页脚、封面、媒体和逐页视觉比较链路。 支持封面独立分节、正文页码重启、主题页边距与横向纸张,并将自然分页差异降为诊断项。 修正代码块内边距和折叠表格单元格边框映射,14 套主题生产矩阵与六组布局视觉矩阵通过。
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import fs from "node:fs";
|
||||
import { serialize } from "node:v8";
|
||||
import { createCanvas, loadImage } from "@napi-rs/canvas";
|
||||
|
||||
import { createPdfDocumentSnapshot } from "../packages/document-visual-diff/dist/index.js";
|
||||
|
||||
const [pdfPath, outputPath, sourceJson, dpiText, markersJson] =
|
||||
process.argv.slice(2);
|
||||
if (!pdfPath || !outputPath || !sourceJson) {
|
||||
throw new Error("缺少 PDF 快照 worker 参数");
|
||||
}
|
||||
|
||||
const source = JSON.parse(sourceJson);
|
||||
const snapshot = await createPdfDocumentSnapshot(fs.readFileSync(pdfPath), {
|
||||
source,
|
||||
dpi: Number(dpiText || 144)
|
||||
});
|
||||
const markerDefinitions = JSON.parse(markersJson || "[]");
|
||||
const markers = [];
|
||||
for (const definition of markerDefinitions) {
|
||||
const target = {
|
||||
r: Number.parseInt(definition.color.slice(1, 3), 16),
|
||||
g: Number.parseInt(definition.color.slice(3, 5), 16),
|
||||
b: Number.parseInt(definition.color.slice(5, 7), 16)
|
||||
};
|
||||
let best;
|
||||
for (const page of snapshot.pages) {
|
||||
if (!page.raster) {
|
||||
continue;
|
||||
}
|
||||
const image = await loadImage(Buffer.from(page.raster.png));
|
||||
const canvas = createCanvas(page.raster.widthPx, page.raster.heightPx);
|
||||
const context = canvas.getContext("2d");
|
||||
context.drawImage(image, 0, 0);
|
||||
const pixels = context.getImageData(
|
||||
0,
|
||||
0,
|
||||
page.raster.widthPx,
|
||||
page.raster.heightPx
|
||||
).data;
|
||||
let count = 0;
|
||||
let minX = Number.POSITIVE_INFINITY;
|
||||
let minY = Number.POSITIVE_INFINITY;
|
||||
let maxX = -1;
|
||||
let maxY = -1;
|
||||
for (let offset = 0; offset < pixels.length; offset += 4) {
|
||||
if (
|
||||
Math.abs(pixels[offset] - target.r) <= 12 &&
|
||||
Math.abs(pixels[offset + 1] - target.g) <= 12 &&
|
||||
Math.abs(pixels[offset + 2] - target.b) <= 12 &&
|
||||
pixels[offset + 3] >= 200
|
||||
) {
|
||||
const pixelIndex = offset / 4;
|
||||
const x = pixelIndex % page.raster.widthPx;
|
||||
const y = Math.floor(pixelIndex / page.raster.widthPx);
|
||||
count += 1;
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x);
|
||||
maxY = Math.max(maxY, y);
|
||||
}
|
||||
}
|
||||
if (!best || count > best.count) {
|
||||
best = {
|
||||
pageNumber: page.pageNumber,
|
||||
pageWidthPt: page.widthPt,
|
||||
dpi: page.raster.dpi,
|
||||
count,
|
||||
minX,
|
||||
minY,
|
||||
maxX,
|
||||
maxY
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!best || best.count < 100) {
|
||||
throw new Error(`${source.label} 未定位 ${definition.label}`);
|
||||
}
|
||||
const scale = 72 / best.dpi;
|
||||
markers.push({
|
||||
id: definition.id,
|
||||
label: definition.label,
|
||||
pageNumber: best.pageNumber,
|
||||
pixelCount: best.count,
|
||||
xPt: best.minX * scale,
|
||||
yPt: best.minY * scale,
|
||||
widthPt: (best.maxX - best.minX + 1) * scale,
|
||||
heightPt: (best.maxY - best.minY + 1) * scale,
|
||||
centerXPt: ((best.minX + best.maxX + 1) / 2) * scale,
|
||||
pageWidthPt: best.pageWidthPt
|
||||
});
|
||||
}
|
||||
fs.writeFileSync(outputPath, serialize({ snapshot, markers }));
|
||||
@@ -0,0 +1,629 @@
|
||||
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
|
||||
)}`
|
||||
);
|
||||
@@ -0,0 +1,761 @@
|
||||
import fs from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
import { deserialize } from "node:v8";
|
||||
import { createCanvas } from "@napi-rs/canvas";
|
||||
import { DOMParser } from "@xmldom/xmldom";
|
||||
import { unzipSync } from "fflate";
|
||||
|
||||
import { defaultExportConfig } from "../packages/core/dist/index.js";
|
||||
import {
|
||||
createPdfVisualDiffReport,
|
||||
createWordDocxRoundTripAdapter,
|
||||
createWordPdfAdapter,
|
||||
createWpsDocxRoundTripAdapter,
|
||||
createWpsPdfAdapter,
|
||||
renderPdfVisualDiffHtml,
|
||||
serializePdfVisualDiffJson
|
||||
} from "../packages/document-visual-diff/dist/index.js";
|
||||
import { buildApp } from "../apps/server/dist/app.js";
|
||||
import { createDocxMediaEngine } from "../apps/server/dist/docx-media-engine.js";
|
||||
import { createPdfGenerator } from "../apps/server/dist/pdf-engine.js";
|
||||
|
||||
const repositoryDirectory = path.resolve(import.meta.dirname, "..");
|
||||
const fixturePath = path.join(
|
||||
repositoryDirectory,
|
||||
"packages",
|
||||
"docx-engine",
|
||||
"fixtures",
|
||||
"docx-media-visual.md"
|
||||
);
|
||||
const webDirectory = path.join(
|
||||
repositoryDirectory,
|
||||
"apps",
|
||||
"web",
|
||||
"dist"
|
||||
);
|
||||
const outputDirectory = path.join(
|
||||
repositoryDirectory,
|
||||
"output",
|
||||
"docx-media-gate"
|
||||
);
|
||||
const snapshotWorkerPath = path.join(
|
||||
repositoryDirectory,
|
||||
"scripts",
|
||||
"create-pdf-snapshot-artifact.mjs"
|
||||
);
|
||||
const rasterDirectory = path.join(outputDirectory, "raster-pages");
|
||||
const decoder = new TextDecoder();
|
||||
const WORD_NAMESPACE =
|
||||
"http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
||||
const MATH_NAMESPACE =
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/math";
|
||||
const contentTypes = new Map([
|
||||
[".css", "text/css; charset=utf-8"],
|
||||
[".html", "text/html; charset=utf-8"],
|
||||
[".js", "text/javascript; charset=utf-8"],
|
||||
[".mjs", "text/javascript; charset=utf-8"],
|
||||
[".png", "image/png"],
|
||||
[".svg", "image/svg+xml"],
|
||||
[".woff", "font/woff"],
|
||||
[".woff2", "font/woff2"]
|
||||
]);
|
||||
const markerDefinitions = [
|
||||
{ id: "normal", label: "普通图片", color: "#7DD3FC" },
|
||||
{ id: "wide", label: "宽图", color: "#FDBA74" },
|
||||
{
|
||||
id: "tall",
|
||||
label: "高图",
|
||||
color: "#86EFAC",
|
||||
paginationScaleFlexible: true
|
||||
},
|
||||
{
|
||||
id: "mermaid",
|
||||
label: "Mermaid",
|
||||
color: "#C4B5FD",
|
||||
markerRatioTolerance: 0.08
|
||||
},
|
||||
{ id: "echarts", label: "ECharts", color: "#F9A8D4" }
|
||||
];
|
||||
const captionDefinitions = [
|
||||
{ id: "normal", text: "普通图片:蓝色4:3" },
|
||||
{ id: "wide", text: "宽图:橙色8:3" },
|
||||
{ id: "tall", text: "高图:绿色3:8" },
|
||||
{ id: "echarts", text: "ECharts紫色柱状图" }
|
||||
];
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function createIsolatedPdfSnapshot(pdfPath, source) {
|
||||
const snapshotPath = path.join(
|
||||
outputDirectory,
|
||||
`.snapshot-${randomUUID()}.bin`
|
||||
);
|
||||
try {
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
snapshotWorkerPath,
|
||||
pdfPath,
|
||||
snapshotPath,
|
||||
JSON.stringify(source),
|
||||
"144",
|
||||
JSON.stringify(markerDefinitions)
|
||||
],
|
||||
{ cwd: repositoryDirectory, stdio: "inherit", timeout: 180_000 }
|
||||
);
|
||||
return deserialize(fs.readFileSync(snapshotPath));
|
||||
} finally {
|
||||
fs.rmSync(snapshotPath, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function reservePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
const port =
|
||||
typeof address === "object" && address ? address.port : undefined;
|
||||
server.close((error) =>
|
||||
error || !port
|
||||
? reject(error ?? new Error("无法分配媒体验收端口"))
|
||||
: resolve(port)
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function resolveWebFile(relativePath) {
|
||||
const resolved = path.resolve(webDirectory, relativePath);
|
||||
const relative = path.relative(webDirectory, resolved);
|
||||
assert(
|
||||
relative !== ".." &&
|
||||
!relative.startsWith(`..${path.sep}`) &&
|
||||
!path.isAbsolute(relative),
|
||||
`Web 资源路径越界:${relativePath}`
|
||||
);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function registerWebRuntime(app) {
|
||||
app.get("/preview-frame.html", async (_request, reply) =>
|
||||
reply
|
||||
.type("text/html; charset=utf-8")
|
||||
.send(fs.readFileSync(resolveWebFile("preview-frame.html")))
|
||||
);
|
||||
app.get("/assets/*", async (request, reply) => {
|
||||
const relativePath = String(request.params["*"] ?? "");
|
||||
const filePath = resolveWebFile(path.join("assets", relativePath));
|
||||
assert(fs.statSync(filePath).isFile(), `Web 资源无效:${relativePath}`);
|
||||
return reply
|
||||
.type(
|
||||
contentTypes.get(path.extname(filePath).toLowerCase()) ||
|
||||
"application/octet-stream"
|
||||
)
|
||||
.send(fs.readFileSync(filePath));
|
||||
});
|
||||
}
|
||||
|
||||
function createMarkerPng(width, height, background, accent, label) {
|
||||
const canvas = createCanvas(width, height);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = background;
|
||||
context.fillRect(0, 0, width, height);
|
||||
context.strokeStyle = accent;
|
||||
context.lineWidth = Math.max(8, Math.round(Math.min(width, height) / 35));
|
||||
context.strokeRect(
|
||||
context.lineWidth / 2,
|
||||
context.lineWidth / 2,
|
||||
width - context.lineWidth,
|
||||
height - context.lineWidth
|
||||
);
|
||||
context.globalAlpha = 0.22;
|
||||
context.lineWidth = 2;
|
||||
for (let index = 1; index < 8; index += 1) {
|
||||
context.beginPath();
|
||||
context.moveTo((width * index) / 8, 0);
|
||||
context.lineTo((width * index) / 8, height);
|
||||
context.stroke();
|
||||
}
|
||||
for (let index = 1; index < 6; index += 1) {
|
||||
context.beginPath();
|
||||
context.moveTo(0, (height * index) / 6);
|
||||
context.lineTo(width, (height * index) / 6);
|
||||
context.stroke();
|
||||
}
|
||||
context.globalAlpha = 1;
|
||||
context.fillStyle = accent;
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
context.font = `bold ${Math.max(28, Math.round(Math.min(width, height) / 7))}px sans-serif`;
|
||||
context.fillText(label, width / 2, height / 2);
|
||||
return canvas.toBuffer("image/png");
|
||||
}
|
||||
|
||||
function createResources() {
|
||||
return [
|
||||
{
|
||||
path: "media/normal.png",
|
||||
contentType: "image/png",
|
||||
data: createMarkerPng(
|
||||
640,
|
||||
480,
|
||||
"#7DD3FC",
|
||||
"#1D4ED8",
|
||||
"NORMAL 4:3"
|
||||
).toString("base64")
|
||||
},
|
||||
{
|
||||
path: "media/wide.png",
|
||||
contentType: "image/png",
|
||||
data: createMarkerPng(
|
||||
1600,
|
||||
600,
|
||||
"#FDBA74",
|
||||
"#C2410C",
|
||||
"WIDE 8:3"
|
||||
).toString("base64")
|
||||
},
|
||||
{
|
||||
path: "media/tall.png",
|
||||
contentType: "image/png",
|
||||
data: createMarkerPng(
|
||||
600,
|
||||
1600,
|
||||
"#86EFAC",
|
||||
"#15803D",
|
||||
"TALL 3:8"
|
||||
).toString("base64")
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
async function requestArtifact(origin, route, payload, contentType) {
|
||||
const response = await fetch(`${origin}${route}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const content = new Uint8Array(await response.arrayBuffer());
|
||||
assert(
|
||||
response.ok,
|
||||
`${route} 返回 ${response.status}:${decoder.decode(content)}`
|
||||
);
|
||||
assert(
|
||||
response.headers.get("content-type")?.includes(contentType),
|
||||
`${route} MIME 无效`
|
||||
);
|
||||
assert(
|
||||
Number(response.headers.get("x-echarts-error-count")) === 0 &&
|
||||
Number(response.headers.get("x-mermaid-error-count")) === 0,
|
||||
`${route} 图表渲染存在错误`
|
||||
);
|
||||
return content;
|
||||
}
|
||||
|
||||
function extractEditableContract(docx) {
|
||||
const entries = unzipSync(docx);
|
||||
const documentXml = entries["word/document.xml"];
|
||||
assert(documentXml, "DOCX 缺少 document.xml");
|
||||
const document = new DOMParser().parseFromString(
|
||||
decoder.decode(documentXml),
|
||||
"application/xml"
|
||||
);
|
||||
const text = Array.from(document.getElementsByTagName("*")).flatMap(
|
||||
(node) =>
|
||||
node.localName === "t" &&
|
||||
(node.namespaceURI === WORD_NAMESPACE ||
|
||||
node.namespaceURI === MATH_NAMESPACE)
|
||||
? [node.textContent ?? ""]
|
||||
: []
|
||||
).join("");
|
||||
const paragraphs = Array.from(document.getElementsByTagName("*"))
|
||||
.filter(
|
||||
(node) =>
|
||||
node.localName === "p" && node.namespaceURI === WORD_NAMESPACE
|
||||
)
|
||||
.flatMap((paragraph) => {
|
||||
const descendants = Array.from(paragraph.getElementsByTagName("*"));
|
||||
const paragraphText = descendants.flatMap((node) =>
|
||||
node.localName === "t" &&
|
||||
(node.namespaceURI === WORD_NAMESPACE ||
|
||||
node.namespaceURI === MATH_NAMESPACE)
|
||||
? [node.textContent ?? ""]
|
||||
: []
|
||||
).join("");
|
||||
if (!paragraphText) {
|
||||
return [];
|
||||
}
|
||||
const style = descendants.find(
|
||||
(node) =>
|
||||
node.localName === "pStyle" &&
|
||||
node.namespaceURI === WORD_NAMESPACE
|
||||
);
|
||||
const styleId =
|
||||
style?.getAttribute("w:val") || style?.getAttribute("val") || undefined;
|
||||
return [
|
||||
{
|
||||
index: 0,
|
||||
text: paragraphText,
|
||||
...(styleId ? { styleId } : {}),
|
||||
role:
|
||||
styleId === "Title" || /Heading/u.test(styleId ?? "")
|
||||
? "heading"
|
||||
: /Caption/u.test(styleId ?? "")
|
||||
? "caption"
|
||||
: "body",
|
||||
section: "body"
|
||||
}
|
||||
];
|
||||
})
|
||||
.map((paragraph, index) => ({ ...paragraph, index }));
|
||||
return { text, paragraphs };
|
||||
}
|
||||
|
||||
function readDrawingExtents(documentXml) {
|
||||
return [...documentXml.matchAll(/<wp:extent cx="(\d+)" cy="(\d+)"/gu)].map(
|
||||
(match) => ({ cx: Number(match[1]), cy: Number(match[2]) })
|
||||
);
|
||||
}
|
||||
|
||||
function inspectRoundTripDocx(docx, sourceDocx, label) {
|
||||
const entries = unzipSync(docx);
|
||||
const sourceEntries = unzipSync(sourceDocx);
|
||||
const documentXml = decoder.decode(entries["word/document.xml"]);
|
||||
const sourceDocumentXml = decoder.decode(
|
||||
sourceEntries["word/document.xml"]
|
||||
);
|
||||
const relationshipsXml = decoder.decode(
|
||||
entries["word/_rels/document.xml.rels"]
|
||||
);
|
||||
const drawingCount = documentXml.match(/<w:drawing(?:\s|>)/gu)?.length ?? 0;
|
||||
const inlineCount = documentXml.match(/<wp:inline(?:\s|>)/gu)?.length ?? 0;
|
||||
const anchorCount = documentXml.match(/<wp:anchor(?:\s|>)/gu)?.length ?? 0;
|
||||
const pngRelationships = [
|
||||
...relationshipsXml.matchAll(
|
||||
/Type="[^"]*\/image"[^>]*Target="([^"]+)"/gu
|
||||
)
|
||||
].filter((match) => match[1]?.toLowerCase().endsWith(".png"));
|
||||
assert(drawingCount === 5, `${label} Drawing 数量为 ${drawingCount}`);
|
||||
assert(inlineCount === 5 && anchorCount === 0, `${label} 出现浮动图片`);
|
||||
assert(pngRelationships.length === 5, `${label} 未保留五个 PNG 关系`);
|
||||
assert(!documentXml.includes("mdtp-media:"), `${label} 残留内部标记`);
|
||||
assert(!documentXml.includes("<a:srcRect"), `${label} 出现图片裁切`);
|
||||
const sourceExtents = readDrawingExtents(sourceDocumentXml);
|
||||
const savedExtents = readDrawingExtents(documentXml);
|
||||
assert(
|
||||
sourceExtents.length === 5 && savedExtents.length === 5,
|
||||
`${label} 图片尺寸记录数量无效`
|
||||
);
|
||||
let maxUntargetedDriftPt = 0;
|
||||
for (let index = 0; index < sourceExtents.length; index += 1) {
|
||||
const expectedScale = index === 0 ? 0.9 : 1;
|
||||
const expectedCx = sourceExtents[index].cx * expectedScale;
|
||||
const expectedCy = sourceExtents[index].cy * expectedScale;
|
||||
const driftPt = Math.max(
|
||||
Math.abs(savedExtents[index].cx - expectedCx) / 12700,
|
||||
Math.abs(savedExtents[index].cy - expectedCy) / 12700
|
||||
);
|
||||
assert(
|
||||
driftPt <= 1,
|
||||
`${label} 图片 ${index + 1} OOXML 尺寸漂移 ${driftPt.toFixed(3)} pt`
|
||||
);
|
||||
if (index > 0) {
|
||||
maxUntargetedDriftPt = Math.max(maxUntargetedDriftPt, driftPt);
|
||||
}
|
||||
}
|
||||
return {
|
||||
drawingCount,
|
||||
inlineCount,
|
||||
anchorCount,
|
||||
pngRelationshipCount: 5,
|
||||
maxUntargetedDriftPt
|
||||
};
|
||||
}
|
||||
|
||||
function assertRoundTrip(result, label) {
|
||||
assert(result.before.length === 5, `${label} 打开时图片数量无效`);
|
||||
assert(result.saved.length === 5, `${label} 保存后图片数量无效`);
|
||||
const before = result.before[0];
|
||||
const saved = result.saved[0];
|
||||
assert(before && saved, `${label} 缺少第一张图片尺寸`);
|
||||
const widthScale = saved.widthPt / before.widthPt;
|
||||
const heightScale = saved.heightPt / before.heightPt;
|
||||
assert(
|
||||
Math.abs(widthScale - 0.9) <= 0.02 &&
|
||||
Math.abs(heightScale - 0.9) <= 0.02,
|
||||
`${label} 未按比例保存缩放:${widthScale}/${heightScale}`
|
||||
);
|
||||
return { widthScale, heightScale };
|
||||
}
|
||||
|
||||
function compareMarkers(baseline, candidate, label, options = {}) {
|
||||
return baseline.map((expected) => {
|
||||
const actual = candidate.find((entry) => entry.id === expected.id);
|
||||
const definition = markerDefinitions.find(
|
||||
(entry) => entry.id === expected.id
|
||||
);
|
||||
assert(actual, `${label} 缺少 ${expected.label}`);
|
||||
const widthDelta = Math.abs(actual.widthPt / expected.widthPt - 1);
|
||||
const heightDelta = Math.abs(actual.heightPt / expected.heightPt - 1);
|
||||
const expectedRatio = expected.widthPt / expected.heightPt;
|
||||
const actualRatio = actual.widthPt / actual.heightPt;
|
||||
const ratioDelta = Math.abs(actualRatio / expectedRatio - 1);
|
||||
const expectedCenterOffset =
|
||||
expected.centerXPt - expected.pageWidthPt / 2;
|
||||
const actualCenterOffset = actual.centerXPt - actual.pageWidthPt / 2;
|
||||
const centerDeltaPt = Math.abs(actualCenterOffset - expectedCenterOffset);
|
||||
const physicalSizeRequired =
|
||||
options.requirePhysicalSize === true ||
|
||||
!definition?.paginationScaleFlexible;
|
||||
const sizeTolerance = options.sizeTolerance ?? 0.08;
|
||||
if (physicalSizeRequired) {
|
||||
assert(
|
||||
widthDelta <= sizeTolerance,
|
||||
`${label} ${expected.label} 宽度偏差过大:` +
|
||||
`${expected.widthPt}/${actual.widthPt} pt, ` +
|
||||
`${(widthDelta * 100).toFixed(2)}%`
|
||||
);
|
||||
assert(
|
||||
heightDelta <= sizeTolerance,
|
||||
`${label} ${expected.label} 高度偏差过大:` +
|
||||
`${expected.heightPt}/${actual.heightPt} pt, ` +
|
||||
`${(heightDelta * 100).toFixed(2)}%`
|
||||
);
|
||||
}
|
||||
const ratioTolerance = definition?.markerRatioTolerance ?? 0.04;
|
||||
assert(
|
||||
ratioDelta <= ratioTolerance,
|
||||
`${label} ${expected.label} 比例偏差过大:` +
|
||||
`${expectedRatio.toFixed(4)}/${actualRatio.toFixed(4)}, ` +
|
||||
`${(ratioDelta * 100).toFixed(2)}%`
|
||||
);
|
||||
assert(centerDeltaPt <= 8, `${label} ${expected.label} 水平对齐偏差过大`);
|
||||
return {
|
||||
id: expected.id,
|
||||
widthDelta,
|
||||
heightDelta,
|
||||
ratioDelta,
|
||||
centerDeltaPt,
|
||||
physicalSizeRequired,
|
||||
baselinePage: expected.pageNumber,
|
||||
candidatePage: actual.pageNumber
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function inspectCaptionCenters(snapshot, label) {
|
||||
return captionDefinitions.map((definition) => {
|
||||
let observation;
|
||||
for (const page of snapshot.pages) {
|
||||
const line = page.lines.find(
|
||||
(entry) => entry.normalizedText.replace(/\s+/gu, "") === definition.text
|
||||
);
|
||||
if (line) {
|
||||
const centerXPt = line.bounds.x + line.bounds.width / 2;
|
||||
observation = {
|
||||
id: definition.id,
|
||||
text: definition.text,
|
||||
pageNumber: page.pageNumber,
|
||||
centerXPt,
|
||||
pageWidthPt: page.widthPt,
|
||||
centerDeltaPt: Math.abs(centerXPt - page.widthPt / 2)
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert(observation, `${label} 缺少题注:${definition.text}`);
|
||||
assert(
|
||||
observation.centerDeltaPt <= 8,
|
||||
`${label} 题注未居中:${definition.text},` +
|
||||
`${observation.centerDeltaPt.toFixed(2)} pt`
|
||||
);
|
||||
return observation;
|
||||
});
|
||||
}
|
||||
|
||||
function writeReportArtifacts(prefix, report, snapshot) {
|
||||
fs.writeFileSync(
|
||||
path.join(outputDirectory, `${prefix}-report.json`),
|
||||
`${serializePdfVisualDiffJson(report)}\n`,
|
||||
"utf8"
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(outputDirectory, `${prefix}-report.html`),
|
||||
renderPdfVisualDiffHtml(report),
|
||||
"utf8"
|
||||
);
|
||||
for (const page of snapshot.pages) {
|
||||
if (page.raster) {
|
||||
fs.writeFileSync(
|
||||
path.join(rasterDirectory, `${prefix}-${page.pageNumber}.png`),
|
||||
page.raster.png
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const page of report.pages) {
|
||||
const pageNumber =
|
||||
page.pair.baselinePageNumber ?? page.pair.candidatePageNumber;
|
||||
if (!pageNumber || !page.artifacts) {
|
||||
continue;
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(rasterDirectory, `${prefix}-overlay-${pageNumber}.png`),
|
||||
page.artifacts.overlayPng
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(rasterDirectory, `${prefix}-heatmap-${pageNumber}.png`),
|
||||
page.artifacts.heatmapPng
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fs.mkdirSync(outputDirectory, { recursive: true });
|
||||
fs.mkdirSync(rasterDirectory, { recursive: true });
|
||||
const markdown = fs.readFileSync(fixturePath, "utf8");
|
||||
const exportConfig = {
|
||||
...defaultExportConfig,
|
||||
name: "DOCX 媒体视觉专项验收",
|
||||
themeId: "typora-github",
|
||||
pageDecorationsMode: "custom",
|
||||
header: { ...defaultExportConfig.header, enabled: false },
|
||||
footer: { ...defaultExportConfig.footer, enabled: false },
|
||||
paper: {
|
||||
...defaultExportConfig.paper,
|
||||
format: "A4",
|
||||
orientation: "portrait",
|
||||
marginMode: "custom",
|
||||
margins: {
|
||||
top: "20mm",
|
||||
right: "20mm",
|
||||
bottom: "20mm",
|
||||
left: "20mm"
|
||||
}
|
||||
}
|
||||
};
|
||||
const payload = {
|
||||
markdown,
|
||||
fileName: "docx-media-visual.md",
|
||||
language: "zh-CN",
|
||||
resources: createResources(),
|
||||
exportConfig
|
||||
};
|
||||
const port = await reservePort();
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const pdfGenerator = createPdfGenerator({ renderOrigin: origin });
|
||||
const docxMediaAdapter = createDocxMediaEngine({ renderOrigin: origin });
|
||||
const app = buildApp({
|
||||
logger: { level: "error" },
|
||||
pdfGenerator,
|
||||
docxMediaAdapter
|
||||
});
|
||||
registerWebRuntime(app);
|
||||
|
||||
let chromiumPdf;
|
||||
let docx;
|
||||
try {
|
||||
await app.listen({ port, host: "127.0.0.1" });
|
||||
chromiumPdf = await requestArtifact(
|
||||
origin,
|
||||
"/api/pdf",
|
||||
payload,
|
||||
"application/pdf"
|
||||
);
|
||||
docx = await requestArtifact(
|
||||
origin,
|
||||
"/api/docx",
|
||||
payload,
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
|
||||
const chromiumPdfPath = path.join(outputDirectory, "chromium.pdf");
|
||||
const docxPath = path.join(outputDirectory, "source.docx");
|
||||
fs.writeFileSync(chromiumPdfPath, chromiumPdf);
|
||||
fs.writeFileSync(docxPath, docx);
|
||||
const editableContract = extractEditableContract(docx);
|
||||
|
||||
const wordAdapter = createWordPdfAdapter({ timeoutMs: 240_000 });
|
||||
const wpsAdapter = createWpsPdfAdapter({ timeoutMs: 240_000 });
|
||||
const wordRoundTripAdapter = createWordDocxRoundTripAdapter({
|
||||
timeoutMs: 240_000
|
||||
});
|
||||
const wpsRoundTripAdapter = createWpsDocxRoundTripAdapter({
|
||||
timeoutMs: 240_000
|
||||
});
|
||||
const capabilities = {
|
||||
word: await wordAdapter.probe(),
|
||||
wps: await wpsAdapter.probe(),
|
||||
wordRoundTrip: await wordRoundTripAdapter.probe(),
|
||||
wpsRoundTrip: await wpsRoundTripAdapter.probe()
|
||||
};
|
||||
for (const [name, capability] of Object.entries(capabilities)) {
|
||||
assert(capability.available, `${name} 不可用:${capability.detail}`);
|
||||
}
|
||||
|
||||
const wordGeneration = await wordAdapter.generate({ docxPath });
|
||||
const wpsGeneration = await wpsAdapter.generate({ docxPath });
|
||||
const wordPdfPath = path.join(outputDirectory, "word.pdf");
|
||||
const wpsPdfPath = path.join(outputDirectory, "wps.pdf");
|
||||
fs.writeFileSync(wordPdfPath, wordGeneration.pdf);
|
||||
fs.writeFileSync(wpsPdfPath, wpsGeneration.pdf);
|
||||
assert(
|
||||
Buffer.from(fs.readFileSync(wordPdfPath)).equals(
|
||||
Buffer.from(wordGeneration.pdf)
|
||||
),
|
||||
"Word PDF 落盘字节与生成结果不一致"
|
||||
);
|
||||
assert(
|
||||
Buffer.from(fs.readFileSync(wpsPdfPath)).equals(
|
||||
Buffer.from(wpsGeneration.pdf)
|
||||
),
|
||||
"WPS PDF 落盘字节与生成结果不一致"
|
||||
);
|
||||
|
||||
const wordRoundTrip = await wordRoundTripAdapter.generate({ docxPath });
|
||||
const wpsRoundTrip = await wpsRoundTripAdapter.generate({ docxPath });
|
||||
const wordRoundTripPath = path.join(outputDirectory, "word-round-trip.docx");
|
||||
const wpsRoundTripPath = path.join(outputDirectory, "wps-round-trip.docx");
|
||||
fs.writeFileSync(wordRoundTripPath, wordRoundTrip.docx);
|
||||
fs.writeFileSync(wpsRoundTripPath, wpsRoundTrip.docx);
|
||||
const roundTripScale = {
|
||||
word: assertRoundTrip(wordRoundTrip, "Microsoft Word"),
|
||||
wps: assertRoundTrip(wpsRoundTrip, "WPS Writer")
|
||||
};
|
||||
const roundTripStructure = {
|
||||
word: inspectRoundTripDocx(wordRoundTrip.docx, docx, "Microsoft Word"),
|
||||
wps: inspectRoundTripDocx(wpsRoundTrip.docx, docx, "WPS Writer")
|
||||
};
|
||||
const wordRoundTripPdf = await wordAdapter.generate({
|
||||
docxPath: wordRoundTripPath
|
||||
});
|
||||
const wpsRoundTripPdf = await wpsAdapter.generate({
|
||||
docxPath: wpsRoundTripPath
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(outputDirectory, "word-round-trip.pdf"),
|
||||
wordRoundTripPdf.pdf
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(outputDirectory, "wps-round-trip.pdf"),
|
||||
wpsRoundTripPdf.pdf
|
||||
);
|
||||
|
||||
const baselineArtifact = createIsolatedPdfSnapshot(chromiumPdfPath, {
|
||||
kind: "chromium",
|
||||
label: "Chromium 媒体基线"
|
||||
});
|
||||
const wordArtifact = createIsolatedPdfSnapshot(wordPdfPath, {
|
||||
kind: "word",
|
||||
label: "Microsoft Word"
|
||||
});
|
||||
const wpsArtifact = createIsolatedPdfSnapshot(wpsPdfPath, {
|
||||
kind: "wps",
|
||||
label: "WPS Writer"
|
||||
});
|
||||
const baseline = baselineArtifact.snapshot;
|
||||
const wordSnapshot = wordArtifact.snapshot;
|
||||
const wpsSnapshot = wpsArtifact.snapshot;
|
||||
const chromiumMarkers = baselineArtifact.markers;
|
||||
const wordMarkers = wordArtifact.markers;
|
||||
const wpsMarkers = wpsArtifact.markers;
|
||||
const captionCenters = {
|
||||
chromium: inspectCaptionCenters(baseline, "Chromium"),
|
||||
word: inspectCaptionCenters(wordSnapshot, "Microsoft Word"),
|
||||
wps: inspectCaptionCenters(wpsSnapshot, "WPS Writer")
|
||||
};
|
||||
const reportOptions = {
|
||||
expectedEditableText: editableContract.text,
|
||||
expectedEditableParagraphs: editableContract.paragraphs
|
||||
};
|
||||
const wordReport = await createPdfVisualDiffReport(
|
||||
baseline,
|
||||
wordSnapshot,
|
||||
reportOptions
|
||||
);
|
||||
const wpsReport = await createPdfVisualDiffReport(
|
||||
baseline,
|
||||
wpsSnapshot,
|
||||
reportOptions
|
||||
);
|
||||
assert(
|
||||
wordReport.basic.candidateEditableExact,
|
||||
"Word 导出 PDF 的可编辑文本不完整"
|
||||
);
|
||||
assert(
|
||||
wpsReport.basic.candidateEditableExact,
|
||||
"WPS 导出 PDF 的可编辑文本不完整"
|
||||
);
|
||||
const markerComparisons = {
|
||||
word: compareMarkers(chromiumMarkers, wordMarkers, "Microsoft Word"),
|
||||
wps: compareMarkers(chromiumMarkers, wpsMarkers, "WPS Writer"),
|
||||
officeParity: compareMarkers(wordMarkers, wpsMarkers, "Word/WPS", {
|
||||
requirePhysicalSize: true,
|
||||
sizeTolerance: 0.04
|
||||
})
|
||||
};
|
||||
|
||||
writeReportArtifacts("chromium", wordReport, baseline);
|
||||
writeReportArtifacts("word", wordReport, wordSnapshot);
|
||||
writeReportArtifacts("wps", wpsReport, wpsSnapshot);
|
||||
const summary = {
|
||||
schemaVersion: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
fixture: path.relative(repositoryDirectory, fixturePath),
|
||||
exportConfig,
|
||||
capabilities,
|
||||
pages: {
|
||||
chromium: baseline.pageCount,
|
||||
word: wordSnapshot.pageCount,
|
||||
wps: wpsSnapshot.pageCount
|
||||
},
|
||||
reports: {
|
||||
word: {
|
||||
status: wordReport.status,
|
||||
issueCodes: [...new Set(wordReport.issues.map((issue) => issue.code))],
|
||||
editableExact: wordReport.basic.candidateEditableExact
|
||||
},
|
||||
wps: {
|
||||
status: wpsReport.status,
|
||||
issueCodes: [...new Set(wpsReport.issues.map((issue) => issue.code))],
|
||||
editableExact: wpsReport.basic.candidateEditableExact
|
||||
}
|
||||
},
|
||||
markers: {
|
||||
chromium: chromiumMarkers,
|
||||
word: wordMarkers,
|
||||
wps: wpsMarkers,
|
||||
comparisons: markerComparisons
|
||||
},
|
||||
captionCenters,
|
||||
roundTrip: {
|
||||
scale: roundTripScale,
|
||||
structure: roundTripStructure,
|
||||
word: {
|
||||
bytes: wordRoundTrip.docx.byteLength,
|
||||
before: wordRoundTrip.before,
|
||||
saved: wordRoundTrip.saved
|
||||
},
|
||||
wps: {
|
||||
bytes: wpsRoundTrip.docx.byteLength,
|
||||
before: wpsRoundTrip.before,
|
||||
saved: wpsRoundTrip.saved
|
||||
}
|
||||
}
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(outputDirectory, "summary.json"),
|
||||
`${JSON.stringify(summary, null, 2)}\n`,
|
||||
"utf8"
|
||||
);
|
||||
process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
|
||||
@@ -0,0 +1,519 @@
|
||||
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 textDecoder = new TextDecoder();
|
||||
const repositoryDirectory = path.resolve(import.meta.dirname, "..");
|
||||
const outputDirectory = path.join(
|
||||
repositoryDirectory,
|
||||
"output",
|
||||
"docx-page-decoration-gate"
|
||||
);
|
||||
const matrixScript = path.join(
|
||||
repositoryDirectory,
|
||||
"apps",
|
||||
"server",
|
||||
"scripts",
|
||||
"verify-docx-r4-matrix.mjs"
|
||||
);
|
||||
|
||||
const commonHeader = {
|
||||
enabled: true,
|
||||
height: "8mm",
|
||||
showDivider: true,
|
||||
fontSize: "3mm",
|
||||
fontFamily: '"Microsoft YaHei", sans-serif',
|
||||
color: "#44546a",
|
||||
left: { enabled: true, content: "页眉左栏" },
|
||||
center: { enabled: true, content: "页眉中栏" },
|
||||
right: { enabled: true, content: "页眉右栏" }
|
||||
};
|
||||
|
||||
const cases = [
|
||||
{
|
||||
id: "no-cover",
|
||||
themeId: "typora-github",
|
||||
coverPageCount: 0,
|
||||
config: {
|
||||
pageDecorationsMode: "custom",
|
||||
header: { ...commonHeader, showOnFirstPage: false },
|
||||
footer: {
|
||||
enabled: true,
|
||||
height: "8mm",
|
||||
showDivider: true,
|
||||
fontSize: "3mm",
|
||||
fontFamily: '"Microsoft YaHei", sans-serif',
|
||||
color: "#44546a",
|
||||
alignment: "center",
|
||||
format: "page-total",
|
||||
startFrom: 1,
|
||||
showOnFirstPage: true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "official",
|
||||
themeId: "gov-red-standard",
|
||||
coverPageCount: 0,
|
||||
config: {
|
||||
pageDecorationsMode: "custom",
|
||||
header: { ...commonHeader, showOnFirstPage: true },
|
||||
footer: {
|
||||
enabled: true,
|
||||
height: "7mm",
|
||||
showDivider: false,
|
||||
fontSize: "3mm",
|
||||
fontFamily: '"FangSong", "SimSun", serif',
|
||||
color: "#000000",
|
||||
alignment: "outer",
|
||||
format: "official-page",
|
||||
startFrom: 1,
|
||||
showOnFirstPage: false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "project-cover",
|
||||
themeId: "formal-feasibility",
|
||||
coverPageCount: 1,
|
||||
config: {
|
||||
pageDecorationsMode: "custom",
|
||||
header: { ...commonHeader, showOnFirstPage: true },
|
||||
footer: {
|
||||
enabled: true,
|
||||
height: "8mm",
|
||||
showDivider: true,
|
||||
fontSize: "3mm",
|
||||
fontFamily: '"Microsoft YaHei", sans-serif',
|
||||
color: "#44546a",
|
||||
alignment: "outer",
|
||||
format: "page-total",
|
||||
startFrom: 1,
|
||||
showOnFirstPage: true
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
const selectedCaseId =
|
||||
process.env.MD_TO_PDF_PAGE_DECORATION_CASE?.trim() || undefined;
|
||||
const selectedCases = selectedCaseId
|
||||
? cases.filter((caseDefinition) => caseDefinition.id === selectedCaseId)
|
||||
: cases;
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function extractDocxEditableText(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(
|
||||
textDecoder.decode(documentXml),
|
||||
"application/xml"
|
||||
);
|
||||
return Array.from(document.getElementsByTagName("*")).flatMap((node) =>
|
||||
node.localName === "t" &&
|
||||
(node.namespaceURI === WORD_NAMESPACE || node.namespaceURI === MATH_NAMESPACE)
|
||||
? [node.textContent ?? ""]
|
||||
: []
|
||||
).join("");
|
||||
}
|
||||
|
||||
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 extractDocxEditableParagraphs(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(
|
||||
textDecoder.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) {
|
||||
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 paragraphs;
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
: {})
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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: 300_000,
|
||||
maxBuffer: 16 * 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 matrix = JSON.parse(stdout);
|
||||
const result = matrix.results?.[0];
|
||||
assert(result, `代表用例 ${caseDefinition.id} 没有生成矩阵结果`);
|
||||
return { caseDirectory, result };
|
||||
}
|
||||
|
||||
fs.mkdirSync(outputDirectory, { recursive: true });
|
||||
assert(
|
||||
selectedCases.length > 0,
|
||||
`页装饰视觉门禁用例不存在:${selectedCaseId}`
|
||||
);
|
||||
const wordAdapter = createWordPdfAdapter({ timeoutMs: 240_000 });
|
||||
const wordCapability = await wordAdapter.probe();
|
||||
assert(wordCapability.available, `Microsoft Word 不可用:${wordCapability.detail}`);
|
||||
const wpsAdapter = createWpsPdfAdapter({ timeoutMs: 240_000 });
|
||||
const wpsCapability = await wpsAdapter.probe();
|
||||
assert(wpsCapability.available, `WPS Writer 不可用:${wpsCapability.detail}`);
|
||||
|
||||
const summaries = [];
|
||||
for (const caseDefinition of selectedCases) {
|
||||
process.stderr.write(
|
||||
`[page decoration gate] generating ${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 expectedEditableText = extractDocxEditableText(docxPath);
|
||||
const expectedEditableParagraphs = extractDocxEditableParagraphs(docxPath);
|
||||
const chromiumPdf = Uint8Array.from(fs.readFileSync(chromiumPdfPath));
|
||||
const wordGeneration = await wordAdapter.generate({ docxPath });
|
||||
const wordPdfPath = path.join(caseDirectory, "word.pdf");
|
||||
fs.writeFileSync(wordPdfPath, wordGeneration.pdf);
|
||||
|
||||
const baseline = await createPdfDocumentSnapshot(chromiumPdf, {
|
||||
source: { kind: "chromium", label: `${caseDefinition.id} Chromium` },
|
||||
dpi: 144
|
||||
});
|
||||
const candidate = await createPdfDocumentSnapshot(wordGeneration.pdf, {
|
||||
source: { kind: "word", label: `${caseDefinition.id} Microsoft Word` },
|
||||
dpi: 144
|
||||
});
|
||||
const baselinePageSemantics = buildPageSemantics(
|
||||
baseline.pageCount,
|
||||
caseDefinition.coverPageCount,
|
||||
result.exportConfig
|
||||
);
|
||||
const wordPageSemantics = buildPageSemantics(
|
||||
candidate.pageCount,
|
||||
caseDefinition.coverPageCount,
|
||||
result.exportConfig
|
||||
);
|
||||
const report = await createPdfVisualDiffReport(baseline, candidate, {
|
||||
baselinePageSemantics,
|
||||
candidatePageSemantics: wordPageSemantics,
|
||||
expectedEditableText,
|
||||
expectedEditableParagraphs
|
||||
});
|
||||
const wpsGeneration = await wpsAdapter.generate({ docxPath });
|
||||
const wpsPdfPath = path.join(caseDirectory, "wps.pdf");
|
||||
fs.writeFileSync(wpsPdfPath, wpsGeneration.pdf);
|
||||
const wpsCandidate = await createPdfDocumentSnapshot(wpsGeneration.pdf, {
|
||||
source: { kind: "wps", label: `${caseDefinition.id} WPS Writer` },
|
||||
dpi: 144
|
||||
});
|
||||
const wpsPageSemantics = buildPageSemantics(
|
||||
wpsCandidate.pageCount,
|
||||
caseDefinition.coverPageCount,
|
||||
result.exportConfig
|
||||
);
|
||||
const wpsReport = await createPdfVisualDiffReport(
|
||||
baseline,
|
||||
wpsCandidate,
|
||||
{
|
||||
baselinePageSemantics,
|
||||
candidatePageSemantics: wpsPageSemantics,
|
||||
expectedEditableText,
|
||||
expectedEditableParagraphs
|
||||
}
|
||||
);
|
||||
const rasterDirectory = path.join(caseDirectory, "raster-pages");
|
||||
fs.mkdirSync(rasterDirectory, { recursive: true });
|
||||
for (const page of baseline.pages) {
|
||||
if (page.raster) {
|
||||
fs.writeFileSync(
|
||||
path.join(rasterDirectory, `chromium-${page.pageNumber}.png`),
|
||||
page.raster.png
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const page of candidate.pages) {
|
||||
if (page.raster) {
|
||||
fs.writeFileSync(
|
||||
path.join(rasterDirectory, `word-${page.pageNumber}.png`),
|
||||
page.raster.png
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const page of wpsCandidate.pages) {
|
||||
if (page.raster) {
|
||||
fs.writeFileSync(
|
||||
path.join(rasterDirectory, `wps-${page.pageNumber}.png`),
|
||||
page.raster.png
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const page of report.pages) {
|
||||
const pageNumber = page.pair.baselinePageNumber ??
|
||||
page.pair.candidatePageNumber;
|
||||
if (!pageNumber || !page.artifacts) {
|
||||
continue;
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(rasterDirectory, `overlay-${pageNumber}.png`),
|
||||
page.artifacts.overlayPng
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(rasterDirectory, `heatmap-${pageNumber}.png`),
|
||||
page.artifacts.heatmapPng
|
||||
);
|
||||
}
|
||||
for (const page of wpsReport.pages) {
|
||||
const pageNumber = page.pair.baselinePageNumber ??
|
||||
page.pair.candidatePageNumber;
|
||||
if (!pageNumber || !page.artifacts) {
|
||||
continue;
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(rasterDirectory, `wps-overlay-${pageNumber}.png`),
|
||||
page.artifacts.overlayPng
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(rasterDirectory, `wps-heatmap-${pageNumber}.png`),
|
||||
page.artifacts.heatmapPng
|
||||
);
|
||||
}
|
||||
const reportJsonPath = path.join(caseDirectory, "report.json");
|
||||
const reportHtmlPath = path.join(caseDirectory, "report.html");
|
||||
const wpsReportJsonPath = path.join(caseDirectory, "wps-report.json");
|
||||
const wpsReportHtmlPath = path.join(caseDirectory, "wps-report.html");
|
||||
fs.writeFileSync(
|
||||
reportJsonPath,
|
||||
`${serializePdfVisualDiffJson(report)}\n`,
|
||||
"utf8"
|
||||
);
|
||||
fs.writeFileSync(reportHtmlPath, renderPdfVisualDiffHtml(report), "utf8");
|
||||
fs.writeFileSync(
|
||||
wpsReportJsonPath,
|
||||
`${serializePdfVisualDiffJson(wpsReport)}\n`,
|
||||
"utf8"
|
||||
);
|
||||
fs.writeFileSync(
|
||||
wpsReportHtmlPath,
|
||||
renderPdfVisualDiffHtml(wpsReport),
|
||||
"utf8"
|
||||
);
|
||||
summaries.push({
|
||||
id: caseDefinition.id,
|
||||
themeId: caseDefinition.themeId,
|
||||
status: report.status,
|
||||
chromiumPages: baseline.pageCount,
|
||||
wordPages: candidate.pageCount,
|
||||
editableTextLength: expectedEditableText.length,
|
||||
editableParagraphCount: expectedEditableParagraphs.length,
|
||||
paragraphLayoutIssueCount: report.basic.paragraphLayouts
|
||||
?.flatMap((paragraph) => paragraph.issues).length ?? 0,
|
||||
bodyFlow: report.basic.bodyFlow,
|
||||
baselineEditableCoverage: report.basic.baselineEditableCoverage,
|
||||
wordEditableSimilarity: report.basic.candidateEditableSimilarity,
|
||||
wordEditableExact: report.basic.candidateEditableExact,
|
||||
semanticIssueCount: report.issues.filter((issue) =>
|
||||
[
|
||||
"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"
|
||||
].includes(issue.code)
|
||||
).length,
|
||||
issueCodes: [...new Set(report.issues.map((issue) => issue.code))],
|
||||
wpsStatus: wpsReport.status,
|
||||
wpsPages: wpsCandidate.pageCount,
|
||||
wpsEditableSimilarity: wpsReport.basic.candidateEditableSimilarity,
|
||||
wpsEditableExact: wpsReport.basic.candidateEditableExact,
|
||||
wpsParagraphLayoutIssueCount: wpsReport.basic.paragraphLayouts
|
||||
?.flatMap((paragraph) => paragraph.issues).length ?? 0,
|
||||
wpsBodyFlow: wpsReport.basic.bodyFlow,
|
||||
wpsSemanticIssueCount: wpsReport.issues.filter((issue) =>
|
||||
[
|
||||
"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"
|
||||
].includes(issue.code)
|
||||
).length,
|
||||
wpsIssueCodes: [
|
||||
...new Set(wpsReport.issues.map((issue) => issue.code))
|
||||
],
|
||||
reportHtml: path.relative(repositoryDirectory, reportHtmlPath),
|
||||
reportJson: path.relative(repositoryDirectory, reportJsonPath),
|
||||
wpsReportHtml: path.relative(repositoryDirectory, wpsReportHtmlPath),
|
||||
wpsReportJson: path.relative(repositoryDirectory, wpsReportJsonPath),
|
||||
chromiumPdf: path.relative(repositoryDirectory, chromiumPdfPath),
|
||||
wordPdf: path.relative(repositoryDirectory, wordPdfPath),
|
||||
wpsPdf: path.relative(repositoryDirectory, wpsPdfPath),
|
||||
docx: path.relative(repositoryDirectory, docxPath)
|
||||
});
|
||||
}
|
||||
|
||||
const summary = {
|
||||
schemaVersion: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
selectedCaseId,
|
||||
word: wordCapability,
|
||||
wps: wpsCapability,
|
||||
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`);
|
||||
Reference in New Issue
Block a user