feat: 实现 Pandoc DOCX 转换服务
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
defaultExportConfig,
|
||||
themeManifestSchema
|
||||
} from "@md-to-pdf/core";
|
||||
import {
|
||||
PandocDocxConverter,
|
||||
PandocRuntime
|
||||
} from "@md-to-pdf/docx-engine";
|
||||
import { unzipSync } from "fflate";
|
||||
|
||||
const directory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const packageDirectory = path.resolve(directory, "..");
|
||||
const repositoryDirectory = path.resolve(packageDirectory, "../..");
|
||||
const markdown = fs.readFileSync(
|
||||
path.join(packageDirectory, "fixtures", "pandoc-conversion.md"),
|
||||
"utf8"
|
||||
);
|
||||
const theme = themeManifestSchema.parse(
|
||||
JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(
|
||||
repositoryDirectory,
|
||||
"themes",
|
||||
"gov-red-standard",
|
||||
"theme.json"
|
||||
),
|
||||
"utf8"
|
||||
)
|
||||
)
|
||||
);
|
||||
const png = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
"base64"
|
||||
);
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function resource(kind, ordinal, dimensions, caption) {
|
||||
return {
|
||||
id: `docx-media-${ordinal}`,
|
||||
kind,
|
||||
ordinal,
|
||||
kindOrdinal: 1,
|
||||
altText:
|
||||
kind === "image"
|
||||
? "普通图片"
|
||||
: kind === "mermaid"
|
||||
? "Mermaid 流程图"
|
||||
: "ECharts 柱状图",
|
||||
...(caption ? { caption } : {}),
|
||||
displayWidthPx: dimensions.width,
|
||||
displayHeightPx: dimensions.height,
|
||||
captureX: 0,
|
||||
captureY: 0,
|
||||
captureWidthPx: dimensions.width,
|
||||
captureHeightPx: dimensions.height,
|
||||
rasterScale: 1,
|
||||
fileName: `media-${ordinal}.png`,
|
||||
contentType: "image/png",
|
||||
content: png,
|
||||
pixelWidth: 1,
|
||||
pixelHeight: 1
|
||||
};
|
||||
}
|
||||
|
||||
const runtime = new PandocRuntime(
|
||||
process.env.DOCX_PANDOC_PATH?.trim()
|
||||
? { configuredPath: process.env.DOCX_PANDOC_PATH.trim() }
|
||||
: {}
|
||||
);
|
||||
const converter = new PandocDocxConverter({ runtime });
|
||||
const conversionInput = {
|
||||
markdown,
|
||||
fileName: "Pandoc 媒体转换验收.md",
|
||||
language: "zh-CN",
|
||||
exportConfig: {
|
||||
...defaultExportConfig,
|
||||
themeId: theme.id
|
||||
},
|
||||
theme,
|
||||
metadata: {
|
||||
title: "Pandoc 媒体转换验收",
|
||||
author: "Markdown PDF 导出器研发组",
|
||||
subject: "",
|
||||
keywords: [],
|
||||
language: "zh-CN"
|
||||
},
|
||||
media: {
|
||||
resources: [
|
||||
resource("image", 1, { width: 384, height: 192 }),
|
||||
resource(
|
||||
"mermaid",
|
||||
2,
|
||||
{ width: 480, height: 240 },
|
||||
"Mermaid 流程图"
|
||||
),
|
||||
resource(
|
||||
"echarts",
|
||||
3,
|
||||
{ width: 576, height: 288 },
|
||||
"ECharts 柱状图"
|
||||
)
|
||||
],
|
||||
echartsErrors: [],
|
||||
mermaidErrors: [],
|
||||
warnings: [],
|
||||
totalBytes: png.byteLength * 3
|
||||
}
|
||||
};
|
||||
const result = await converter.convert(conversionInput);
|
||||
|
||||
const entries = unzipSync(result.docx);
|
||||
const documentXml = new TextDecoder().decode(
|
||||
entries["word/document.xml"]
|
||||
);
|
||||
const mediaParts = Object.keys(entries).filter((name) =>
|
||||
/^word\/media\/.+\.png$/u.test(name)
|
||||
);
|
||||
const physicalExtents = Array.from(
|
||||
documentXml.matchAll(
|
||||
/<wp:extent\b[^>]*\bcx="(\d+)"[^>]*\bcy="(\d+)"/gu
|
||||
),
|
||||
(match) => ({
|
||||
width: Number(match[1]),
|
||||
height: Number(match[2])
|
||||
})
|
||||
);
|
||||
const checks = {
|
||||
editableText: documentXml.includes("标准文本编辑"),
|
||||
editableTable: documentXml.includes("<w:tbl"),
|
||||
editableCode: documentXml.includes("editable"),
|
||||
imageAlt: documentXml.includes("普通图片"),
|
||||
mermaidAlt: documentXml.includes("Mermaid 流程图"),
|
||||
echartsAlt: documentXml.includes("ECharts 柱状图"),
|
||||
threeImageExtents:
|
||||
physicalExtents.length === 3,
|
||||
physicalDimensions: physicalExtents.every(
|
||||
(extent, index) =>
|
||||
Math.abs(
|
||||
extent.width - [3657600, 4572000, 5486400][index]
|
||||
) <= 360 &&
|
||||
Math.abs(
|
||||
extent.height - [1828800, 2286000, 2743200][index]
|
||||
) <= 360
|
||||
),
|
||||
embeddedPng: mediaParts.length >= 1
|
||||
};
|
||||
for (const [name, passed] of Object.entries(checks)) {
|
||||
assert(
|
||||
passed,
|
||||
`真实 DOCX 媒体转换检查失败:${name},实际尺寸 ${JSON.stringify(physicalExtents)}`
|
||||
);
|
||||
}
|
||||
|
||||
let mismatchedMediaRejected = false;
|
||||
try {
|
||||
await converter.convert({
|
||||
...conversionInput,
|
||||
media: {
|
||||
...conversionInput.media,
|
||||
resources: conversionInput.media.resources.slice(0, 2),
|
||||
totalBytes: png.byteLength * 2
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
mismatchedMediaRejected =
|
||||
error?.code === "DOCX_GENERATION_FAILED";
|
||||
}
|
||||
assert(
|
||||
mismatchedMediaRejected,
|
||||
"Lua Filter 未拒绝缺失的 ECharts PNG 映射"
|
||||
);
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
bytes: result.docx.byteLength,
|
||||
templateFingerprint: result.templateFingerprint,
|
||||
templateCacheKey: result.templateCacheKey,
|
||||
validation: result.validation,
|
||||
mediaParts,
|
||||
physicalExtents,
|
||||
mismatchedMediaRejected,
|
||||
checks,
|
||||
timings: result.timings
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
} from "@md-to-pdf/core";
|
||||
import {
|
||||
createDynamicReferenceDocx,
|
||||
validateDynamicReferenceDocx
|
||||
PandocRuntime,
|
||||
validateGeneratedDocx
|
||||
} from "@md-to-pdf/docx-engine";
|
||||
import { unzipSync } from "fflate";
|
||||
|
||||
@@ -28,8 +29,19 @@ const themePath = path.join(
|
||||
"gov-red-standard",
|
||||
"theme.json"
|
||||
);
|
||||
const pandocExecutable =
|
||||
process.env.MD_TO_PDF_PANDOC_PATH?.trim() || "pandoc";
|
||||
const runtime = new PandocRuntime(
|
||||
process.env.DOCX_PANDOC_PATH?.trim()
|
||||
? { configuredPath: process.env.DOCX_PANDOC_PATH.trim() }
|
||||
: {}
|
||||
);
|
||||
const runtimeResolution = await runtime.probe();
|
||||
assert(
|
||||
runtimeResolution.capability.status === "available",
|
||||
runtimeResolution.capability.status === "available"
|
||||
? ""
|
||||
: runtimeResolution.capability.message
|
||||
);
|
||||
const pandocExecutable = await runtime.getExecutablePath();
|
||||
const maximumBuffer = 128 * 1024 * 1024;
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
@@ -64,7 +76,7 @@ function countMatches(value, pattern) {
|
||||
}
|
||||
|
||||
function inspectResult(content, expected) {
|
||||
const validation = validateDynamicReferenceDocx(content);
|
||||
const validation = validateGeneratedDocx(content);
|
||||
const entries = unzipSync(content);
|
||||
const documentXml = decoder.decode(entries["word/document.xml"]);
|
||||
const stylesXml = decoder.decode(entries["word/styles.xml"]);
|
||||
@@ -174,22 +186,13 @@ function createVariant(
|
||||
};
|
||||
}
|
||||
|
||||
const versionResult = runPandoc(["--version"]);
|
||||
const detectedVersion =
|
||||
versionResult.stdout
|
||||
.split(/\r?\n/u)[0]
|
||||
?.replace(/^pandoc\s+/u, "")
|
||||
.trim() ?? "";
|
||||
const detectedVersion = runtimeResolution.capability.detectedVersion;
|
||||
assert(
|
||||
detectedVersion === DOCX_PANDOC_VERSION,
|
||||
`Pandoc 版本不匹配:期望 ${DOCX_PANDOC_VERSION},实际 ${detectedVersion || "未知"}`
|
||||
);
|
||||
|
||||
const baselineResult = runPandoc(
|
||||
["--print-default-data-file", "reference.docx"],
|
||||
{ binary: true }
|
||||
);
|
||||
const baseline = new Uint8Array(baselineResult.stdout);
|
||||
const baseline = await runtime.getDefaultReferenceDocx();
|
||||
const theme = themeManifestSchema.parse(
|
||||
JSON.parse(fs.readFileSync(themePath, "utf8"))
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user