test: 完成 DOCX 全主题视觉验收
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
DOCX_PANDOC_VERSION,
|
||||
defaultExportConfig,
|
||||
themeManifestSchema
|
||||
} from "@md-to-pdf/core";
|
||||
import {
|
||||
PandocDocxConverter,
|
||||
PandocRuntime,
|
||||
resolveReferencePageOptions
|
||||
} from "@md-to-pdf/docx-engine";
|
||||
import { renderMarkdown } from "@md-to-pdf/renderer";
|
||||
import { unzipSync } from "fflate";
|
||||
|
||||
const directory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const packageDirectory = path.resolve(directory, "..");
|
||||
const repositoryDirectory = path.resolve(packageDirectory, "../..");
|
||||
const themesDirectory = path.join(repositoryDirectory, "themes");
|
||||
const samplesDirectory = path.join(repositoryDirectory, "samples", "themes");
|
||||
const outputDirectory = path.join(
|
||||
repositoryDirectory,
|
||||
"output",
|
||||
"docx-theme-matrix"
|
||||
);
|
||||
const decoder = new TextDecoder();
|
||||
const placeholderPng = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
"base64"
|
||||
);
|
||||
|
||||
function decodeXmlText(value) {
|
||||
return value
|
||||
.replace(/</gu, "<")
|
||||
.replace(/>/gu, ">")
|
||||
.replace(/"/gu, '"')
|
||||
.replace(/'/gu, "'")
|
||||
.replace(/&/gu, "&");
|
||||
}
|
||||
|
||||
function extractWordText(xml) {
|
||||
return [...xml.matchAll(/<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>/gu)]
|
||||
.map((match) => decodeXmlText(match[1] ?? ""))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function countMatches(value, pattern) {
|
||||
return value.match(pattern)?.length ?? 0;
|
||||
}
|
||||
|
||||
function readThemeManifests() {
|
||||
return fs
|
||||
.readdirSync(themesDirectory, { withFileTypes: true })
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.isDirectory() &&
|
||||
fs.existsSync(
|
||||
path.join(themesDirectory, entry.name, "theme.json")
|
||||
)
|
||||
)
|
||||
.map((entry) =>
|
||||
themeManifestSchema.parse(
|
||||
JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(
|
||||
themesDirectory,
|
||||
entry.name,
|
||||
"theme.json"
|
||||
),
|
||||
"utf8"
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.sort((first, second) =>
|
||||
first.id.localeCompare(second.id, "en")
|
||||
);
|
||||
}
|
||||
|
||||
function structuralValues(document) {
|
||||
if (!document) {
|
||||
return [];
|
||||
}
|
||||
return Object.entries(document)
|
||||
.filter(([name]) => name !== "profile")
|
||||
.flatMap(([, value]) =>
|
||||
Array.isArray(value) ? value : value ? [value] : []
|
||||
)
|
||||
.map(String);
|
||||
}
|
||||
|
||||
function inspectDocument(content, theme, metadata, exportConfig) {
|
||||
const entries = unzipSync(content);
|
||||
const documentXml = decoder.decode(entries["word/document.xml"]);
|
||||
const stylesXml = decoder.decode(entries["word/styles.xml"]);
|
||||
const bodyText = extractWordText(documentXml);
|
||||
const headerXml = Object.entries(entries)
|
||||
.filter(([name]) => /^word\/header\d+\.xml$/u.test(name))
|
||||
.map(([, value]) => decoder.decode(value))
|
||||
.join("\n");
|
||||
const footerXml = Object.entries(entries)
|
||||
.filter(([name]) => /^word\/footer\d+\.xml$/u.test(name))
|
||||
.map(([, value]) => decoder.decode(value))
|
||||
.join("\n");
|
||||
const expectedStructureValues = structuralValues(metadata.document);
|
||||
const missingStructureValues = expectedStructureValues.filter(
|
||||
(value) => !bodyText.includes(value)
|
||||
);
|
||||
const pageOptions = resolveReferencePageOptions({
|
||||
exportConfig,
|
||||
theme,
|
||||
fileName: `${theme.id}.md`,
|
||||
metadata
|
||||
});
|
||||
const expectsStandaloneCover =
|
||||
metadata.document?.profile === "project-report" ||
|
||||
metadata.document?.profile === "tender";
|
||||
const explicitPageBreaks = countMatches(
|
||||
documentXml,
|
||||
/<w:br[^>]*w:type="page"/gu
|
||||
);
|
||||
|
||||
return {
|
||||
bytes: content.byteLength,
|
||||
page: pageOptions,
|
||||
paragraphCount: countMatches(documentXml, /<w:p(?:\s|>)/gu),
|
||||
tableCount: countMatches(documentXml, /<w:tbl(?:\s|>)/gu),
|
||||
drawingCount: countMatches(documentXml, /<w:drawing(?:\s|>)/gu),
|
||||
explicitPageBreaks,
|
||||
headerPartCount: Object.keys(entries).filter((name) =>
|
||||
/^word\/header\d+\.xml$/u.test(name)
|
||||
).length,
|
||||
footerPartCount: Object.keys(entries).filter((name) =>
|
||||
/^word\/footer\d+\.xml$/u.test(name)
|
||||
).length,
|
||||
nativeHeaderFooter:
|
||||
!headerXml.includes("<w:tbl") && !footerXml.includes("<w:tbl"),
|
||||
pageField: footerXml.includes(" PAGE "),
|
||||
totalPagesField: footerXml.includes(" NUMPAGES "),
|
||||
altChunkCount: countMatches(documentXml, /<w:altChunk(?:\s|>)/gu),
|
||||
styles: {
|
||||
normal: stylesXml.includes('w:styleId="Normal"'),
|
||||
heading1: stylesXml.includes('w:styleId="Heading1"'),
|
||||
sourceCode: stylesXml.includes('w:styleId="SourceCode"'),
|
||||
table: stylesXml.includes('w:styleId="Table"')
|
||||
},
|
||||
profile: metadata.document?.profile ?? null,
|
||||
expectedStructureValues,
|
||||
missingStructureValues,
|
||||
structureCoverage:
|
||||
expectedStructureValues.length === 0
|
||||
? 1
|
||||
: (expectedStructureValues.length -
|
||||
missingStructureValues.length) /
|
||||
expectedStructureValues.length,
|
||||
expectsStandaloneCover,
|
||||
standaloneCoverDetected:
|
||||
expectsStandaloneCover &&
|
||||
explicitPageBreaks > 0 &&
|
||||
missingStructureValues.length === 0
|
||||
};
|
||||
}
|
||||
|
||||
function createMediaResource(kind, ordinal, kindOrdinal) {
|
||||
const dimensions =
|
||||
kind === "image"
|
||||
? { width: 480, height: 270 }
|
||||
: { width: 640, height: 360 };
|
||||
return {
|
||||
id: `docx-media-${ordinal}`,
|
||||
kind,
|
||||
ordinal,
|
||||
kindOrdinal,
|
||||
altText: `${kind} 主题验收图 ${kindOrdinal}`,
|
||||
...(kind === "image"
|
||||
? {}
|
||||
: { caption: `${kind} 主题验收图 ${kindOrdinal}` }),
|
||||
displayWidthPx: dimensions.width,
|
||||
displayHeightPx: dimensions.height,
|
||||
captureX: 0,
|
||||
captureY: 0,
|
||||
captureWidthPx: dimensions.width,
|
||||
captureHeightPx: dimensions.height,
|
||||
rasterScale: 1,
|
||||
fileName: `${kind}-${kindOrdinal}.png`,
|
||||
contentType: "image/png",
|
||||
content: placeholderPng,
|
||||
pixelWidth: 1,
|
||||
pixelHeight: 1
|
||||
};
|
||||
}
|
||||
|
||||
function countMarkdownMedia(markdown, kind) {
|
||||
const patterns = {
|
||||
image: /!\[[^\]]*\]\([^)]*\)/gu,
|
||||
mermaid: /^```mermaid(?:\s.*)?$/gimu,
|
||||
echarts: /^```echarts(?:\s.*)?$/gimu
|
||||
};
|
||||
return countMatches(markdown, patterns[kind]);
|
||||
}
|
||||
|
||||
function createMedia(markdown) {
|
||||
const resources = [];
|
||||
let ordinal = 0;
|
||||
for (const kind of ["image", "mermaid", "echarts"]) {
|
||||
const count = countMarkdownMedia(markdown, kind);
|
||||
for (let kindOrdinal = 1; kindOrdinal <= count; kindOrdinal += 1) {
|
||||
ordinal += 1;
|
||||
resources.push(
|
||||
createMediaResource(kind, ordinal, kindOrdinal)
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
resources,
|
||||
echartsErrors: [],
|
||||
mermaidErrors: [],
|
||||
warnings: [],
|
||||
totalBytes: placeholderPng.byteLength * resources.length
|
||||
};
|
||||
}
|
||||
const runtime = new PandocRuntime(
|
||||
process.env.DOCX_PANDOC_PATH?.trim()
|
||||
? { configuredPath: process.env.DOCX_PANDOC_PATH.trim() }
|
||||
: {}
|
||||
);
|
||||
const capability = await runtime.probe();
|
||||
if (capability.capability.status !== "available") {
|
||||
throw new Error(capability.capability.message);
|
||||
}
|
||||
if (capability.capability.detectedVersion !== DOCX_PANDOC_VERSION) {
|
||||
throw new Error(
|
||||
`Pandoc 版本不匹配:期望 ${DOCX_PANDOC_VERSION},实际 ${capability.capability.detectedVersion}`
|
||||
);
|
||||
}
|
||||
|
||||
fs.mkdirSync(outputDirectory, { recursive: true });
|
||||
const converter = new PandocDocxConverter({ runtime });
|
||||
const themes = readThemeManifests();
|
||||
const results = [];
|
||||
|
||||
for (const theme of themes) {
|
||||
console.error(`[DOCX theme matrix] generating ${theme.id}`);
|
||||
const samplePath = path.join(samplesDirectory, `${theme.id}.md`);
|
||||
if (!fs.existsSync(samplePath)) {
|
||||
throw new Error(`主题缺少验收示例:${theme.id}`);
|
||||
}
|
||||
const markdown = fs.readFileSync(samplePath, "utf8");
|
||||
const rendered = renderMarkdown(markdown, { language: "zh-CN" });
|
||||
const exportConfig = {
|
||||
...defaultExportConfig,
|
||||
name: `${theme.name} DOCX 主题验收`,
|
||||
themeId: theme.id,
|
||||
pageDecorationsMode: "theme",
|
||||
paper: {
|
||||
...defaultExportConfig.paper,
|
||||
format: "A4",
|
||||
orientation: "portrait",
|
||||
marginMode: "theme"
|
||||
}
|
||||
};
|
||||
let conversion;
|
||||
try {
|
||||
conversion = await converter.convert({
|
||||
markdown,
|
||||
fileName: `${theme.id}.md`,
|
||||
language: rendered.metadata.language,
|
||||
exportConfig,
|
||||
theme,
|
||||
metadata: rendered.metadata,
|
||||
media: createMedia(markdown)
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`主题 ${theme.id} 的 DOCX 转换失败`, {
|
||||
cause: error
|
||||
});
|
||||
}
|
||||
const outputPath = path.join(outputDirectory, `${theme.id}.docx`);
|
||||
fs.writeFileSync(outputPath, conversion.docx);
|
||||
results.push({
|
||||
id: theme.id,
|
||||
name: theme.name,
|
||||
category: theme.category,
|
||||
compatibleProfiles: theme.compatibleProfiles,
|
||||
docxPreset: theme.docxStyle?.preset ?? null,
|
||||
sample: path.relative(repositoryDirectory, samplePath),
|
||||
outputFile: path.relative(repositoryDirectory, outputPath),
|
||||
metadata: rendered.metadata,
|
||||
conversion: {
|
||||
templateFingerprint: conversion.templateFingerprint,
|
||||
templateCacheKey: conversion.templateCacheKey,
|
||||
timings: conversion.timings,
|
||||
validation: conversion.validation
|
||||
},
|
||||
inspection: inspectDocument(
|
||||
conversion.docx,
|
||||
theme,
|
||||
rendered.metadata,
|
||||
exportConfig
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
const report = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
pandocVersion: capability.capability.detectedVersion,
|
||||
themeCount: themes.length,
|
||||
outputDirectory: path.relative(
|
||||
repositoryDirectory,
|
||||
outputDirectory
|
||||
),
|
||||
results
|
||||
};
|
||||
const reportPath = path.join(outputDirectory, "theme-matrix-report.json");
|
||||
fs.writeFileSync(
|
||||
reportPath,
|
||||
`${JSON.stringify(report, null, 2)}\n`,
|
||||
"utf8"
|
||||
);
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
Reference in New Issue
Block a user