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