Files
MorphDoc/packages/docx-engine/scripts/verify-theme-docx-matrix.mjs
T

322 lines
9.4 KiB
JavaScript

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(/&lt;/gu, "<")
.replace(/&gt;/gu, ">")
.replace(/&quot;/gu, '"')
.replace(/&apos;/gu, "'")
.replace(/&amp;/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));