feat: 完成 DOCX R4 元素级视觉门禁

建立 Chromium、Word 与 WPS 的可编辑内容、段落几何、页眉页脚、封面、媒体和逐页视觉比较链路。

支持封面独立分节、正文页码重启、主题页边距与横向纸张,并将自然分页差异降为诊断项。

修正代码块内边距和折叠表格单元格边框映射,14 套主题生产矩阵与六组布局视觉矩阵通过。
This commit is contained in:
SkyJourney
2026-08-02 03:27:11 +08:00
parent f9f5fccfc9
commit 58f87cc19f
100 changed files with 10409 additions and 386 deletions
+761
View File
@@ -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`);