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(/]*)?>([\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:type="page"/gu ); return { bytes: content.byteLength, page: pageOptions, paragraphCount: countMatches(documentXml, /)/gu), tableCount: countMatches(documentXml, /)/gu), drawingCount: countMatches(documentXml, /)/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(")/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));