feat: 实现动态 DOCX 模板与样式映射
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
DOCX_PANDOC_VERSION,
|
||||
defaultExportConfig,
|
||||
themeManifestSchema
|
||||
} from "@md-to-pdf/core";
|
||||
import {
|
||||
createDynamicReferenceDocx,
|
||||
validateDynamicReferenceDocx
|
||||
} 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 fixturePath = path.join(
|
||||
packageDirectory,
|
||||
"fixtures",
|
||||
"pandoc-reference.md"
|
||||
);
|
||||
const themePath = path.join(
|
||||
repositoryDirectory,
|
||||
"themes",
|
||||
"gov-red-standard",
|
||||
"theme.json"
|
||||
);
|
||||
const pandocExecutable =
|
||||
process.env.MD_TO_PDF_PANDOC_PATH?.trim() || "pandoc";
|
||||
const maximumBuffer = 128 * 1024 * 1024;
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
function runPandoc(arguments_, options = {}) {
|
||||
const result = spawnSync(pandocExecutable, arguments_, {
|
||||
encoding: options.binary ? null : "utf8",
|
||||
maxBuffer: maximumBuffer,
|
||||
...options
|
||||
});
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
const stderr = Buffer.isBuffer(result.stderr)
|
||||
? result.stderr.toString("utf8")
|
||||
: result.stderr;
|
||||
throw new Error(
|
||||
stderr?.trim() || `Pandoc 退出码 ${String(result.status)}`
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function countMatches(value, pattern) {
|
||||
return value.match(pattern)?.length ?? 0;
|
||||
}
|
||||
|
||||
function inspectResult(content, expected) {
|
||||
const validation = validateDynamicReferenceDocx(content);
|
||||
const entries = unzipSync(content);
|
||||
const documentXml = decoder.decode(entries["word/document.xml"]);
|
||||
const stylesXml = decoder.decode(entries["word/styles.xml"]);
|
||||
const settingsXml = decoder.decode(entries["word/settings.xml"]);
|
||||
const relationshipsXml = decoder.decode(
|
||||
entries["word/_rels/document.xml.rels"]
|
||||
);
|
||||
const bodyIsEditable =
|
||||
documentXml.includes("可编辑的中文正文") &&
|
||||
documentXml.includes("<w:tbl") &&
|
||||
documentXml.includes("editableDocument");
|
||||
const checks = {
|
||||
pageWidth: documentXml.includes(`w:w="${expected.pageWidth}"`),
|
||||
pageHeight: documentXml.includes(`w:h="${expected.pageHeight}"`),
|
||||
orientation:
|
||||
expected.orientation === "portrait"
|
||||
? !documentXml.includes('w:orient="landscape"')
|
||||
: documentXml.includes('w:orient="landscape"'),
|
||||
leftMargin: documentXml.includes(`w:left="${expected.leftMargin}"`),
|
||||
sourceCodeStyle: stylesXml.includes('w:styleId="SourceCode"'),
|
||||
chineseBodyFont: stylesXml.includes('w:eastAsia="SimSun"'),
|
||||
editableBody: bodyIsEditable,
|
||||
headerReferences:
|
||||
countMatches(documentXml, /w:headerReference/gu) ===
|
||||
expected.headerCount,
|
||||
footerReferences:
|
||||
countMatches(documentXml, /w:footerReference/gu) ===
|
||||
expected.footerCount,
|
||||
pageField: Object.entries(entries)
|
||||
.filter(([name]) => /^word\/footer\d+\.xml$/u.test(name))
|
||||
.some(([, value]) =>
|
||||
decoder.decode(value).includes(" PAGE \\* MERGEFORMAT ")
|
||||
),
|
||||
headerRelationship:
|
||||
expected.headerCount === 0 ||
|
||||
relationshipsXml.includes("relationships/header"),
|
||||
footerRelationship:
|
||||
expected.footerCount === 0 ||
|
||||
relationshipsXml.includes("relationships/footer"),
|
||||
evenOddSetting:
|
||||
expected.usesEvenAndOddPages ===
|
||||
settingsXml.includes("<w:evenAndOddHeaders")
|
||||
};
|
||||
for (const [name, passed] of Object.entries(checks)) {
|
||||
assert(passed, `${expected.id} 的语义检查失败:${name}`);
|
||||
}
|
||||
assert(
|
||||
validation.headerCount === expected.headerCount,
|
||||
`${expected.id} 的页眉部件数量不正确`
|
||||
);
|
||||
assert(
|
||||
validation.footerCount === expected.footerCount,
|
||||
`${expected.id} 的页脚部件数量不正确`
|
||||
);
|
||||
return { validation, checks };
|
||||
}
|
||||
|
||||
function createVariant(
|
||||
temporaryDirectory,
|
||||
baseline,
|
||||
theme,
|
||||
markdown,
|
||||
variant
|
||||
) {
|
||||
const reference = createDynamicReferenceDocx(baseline, {
|
||||
exportConfig: variant.exportConfig,
|
||||
theme,
|
||||
fileName: `${variant.id}.md`,
|
||||
metadata: {
|
||||
title: "阶段六动态模板验收",
|
||||
author: "Markdown PDF 导出器研发组"
|
||||
}
|
||||
});
|
||||
const referencePath = path.join(
|
||||
temporaryDirectory,
|
||||
`${variant.id}-reference.docx`
|
||||
);
|
||||
const markdownPath = path.join(
|
||||
temporaryDirectory,
|
||||
`${variant.id}.md`
|
||||
);
|
||||
const resultPath = path.join(
|
||||
temporaryDirectory,
|
||||
`${variant.id}-result.docx`
|
||||
);
|
||||
fs.writeFileSync(referencePath, reference.content);
|
||||
fs.writeFileSync(markdownPath, markdown);
|
||||
runPandoc([
|
||||
markdownPath,
|
||||
"--from",
|
||||
"markdown",
|
||||
"--to",
|
||||
"docx",
|
||||
"--reference-doc",
|
||||
referencePath,
|
||||
"--output",
|
||||
resultPath
|
||||
]);
|
||||
const result = fs.readFileSync(resultPath);
|
||||
return {
|
||||
id: variant.id,
|
||||
referenceBytes: reference.content.byteLength,
|
||||
resultBytes: result.byteLength,
|
||||
templateFingerprint: reference.templateFingerprint,
|
||||
cacheKey: reference.cacheKey,
|
||||
...inspectResult(result, variant.expected)
|
||||
};
|
||||
}
|
||||
|
||||
const versionResult = runPandoc(["--version"]);
|
||||
const detectedVersion =
|
||||
versionResult.stdout
|
||||
.split(/\r?\n/u)[0]
|
||||
?.replace(/^pandoc\s+/u, "")
|
||||
.trim() ?? "";
|
||||
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 theme = themeManifestSchema.parse(
|
||||
JSON.parse(fs.readFileSync(themePath, "utf8"))
|
||||
);
|
||||
const markdown = fs.readFileSync(fixturePath, "utf8");
|
||||
const temporaryDirectory = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "md-to-pdf-docx-reference-")
|
||||
);
|
||||
|
||||
const customConfig = {
|
||||
...defaultExportConfig,
|
||||
name: "DOCX 横向验收",
|
||||
pageDecorationsMode: "custom",
|
||||
paper: {
|
||||
...defaultExportConfig.paper,
|
||||
orientation: "landscape",
|
||||
marginMode: "custom",
|
||||
margins: {
|
||||
top: "20mm",
|
||||
right: "25mm",
|
||||
bottom: "22mm",
|
||||
left: "30mm"
|
||||
}
|
||||
},
|
||||
header: {
|
||||
...defaultExportConfig.header,
|
||||
enabled: true,
|
||||
showDivider: true,
|
||||
left: {
|
||||
enabled: true,
|
||||
content: "${title}"
|
||||
},
|
||||
center: {
|
||||
enabled: true,
|
||||
content: "${author}"
|
||||
},
|
||||
right: {
|
||||
enabled: true,
|
||||
content: "${filename}"
|
||||
}
|
||||
},
|
||||
footer: {
|
||||
...defaultExportConfig.footer,
|
||||
alignment: "outer",
|
||||
format: "chinese-page-total",
|
||||
startFrom: 5,
|
||||
showOnFirstPage: false
|
||||
}
|
||||
};
|
||||
|
||||
const variants = [
|
||||
{
|
||||
id: "official-a4",
|
||||
exportConfig: defaultExportConfig,
|
||||
expected: {
|
||||
id: "official-a4",
|
||||
pageWidth: 11906,
|
||||
pageHeight: 16838,
|
||||
orientation: "portrait",
|
||||
leftMargin: 1587,
|
||||
headerCount: 0,
|
||||
footerCount: 2,
|
||||
usesEvenAndOddPages: true
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "custom-landscape",
|
||||
exportConfig: customConfig,
|
||||
expected: {
|
||||
id: "custom-landscape",
|
||||
pageWidth: 16838,
|
||||
pageHeight: 11906,
|
||||
orientation: "landscape",
|
||||
leftMargin: 1701,
|
||||
headerCount: 3,
|
||||
footerCount: 3,
|
||||
usesEvenAndOddPages: true
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
try {
|
||||
const results = variants.map((variant) =>
|
||||
createVariant(
|
||||
temporaryDirectory,
|
||||
baseline,
|
||||
theme,
|
||||
markdown,
|
||||
variant
|
||||
)
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
pandocVersion: detectedVersion,
|
||||
baselineBytes: baseline.byteLength,
|
||||
variants: results
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(temporaryDirectory, {
|
||||
recursive: true,
|
||||
force: true
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user