322 lines
9.4 KiB
JavaScript
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(/</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));
|