test: 完成 DOCX 全主题视觉验收

This commit is contained in:
SkyJourney
2026-07-30 19:04:12 +08:00
parent aff2830c28
commit e7c688df6e
10 changed files with 804 additions and 63 deletions
+2 -1
View File
@@ -22,7 +22,8 @@
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false",
"verify:pandoc": "node scripts/verify-pandoc-reference.mjs",
"verify:conversion": "node scripts/verify-pandoc-conversion.mjs",
"verify:acceptance": "node scripts/verify-docx-acceptance.mjs"
"verify:acceptance": "node scripts/verify-docx-acceptance.mjs",
"verify:themes": "node scripts/verify-theme-docx-matrix.mjs"
},
"dependencies": {
"@md-to-pdf/core": "0.1.0",
@@ -0,0 +1,68 @@
param(
[Parameter(Mandatory = $true)]
[string]$InputPath,
[Parameter(Mandatory = $true)]
[string]$OutputPath
)
$ErrorActionPreference = 'Stop'
$resolvedInput = (Resolve-Path -LiteralPath $InputPath).Path
$resolvedOutput = [System.IO.Path]::GetFullPath($OutputPath)
$outputDirectory = [System.IO.Path]::GetDirectoryName($resolvedOutput)
if (-not [System.IO.Directory]::Exists($outputDirectory)) {
[System.IO.Directory]::CreateDirectory($outputDirectory) | Out-Null
}
if ([System.IO.File]::Exists($resolvedOutput)) {
throw "Word PDF 输出已存在:$resolvedOutput"
}
$word = $null
$document = $null
try {
$word = New-Object -ComObject Word.Application
$word.Visible = $false
$word.DisplayAlerts = 0
$word.AutomationSecurity = 3
$word.Options.SaveNormalPrompt = $false
$document = $word.Documents.Open(
$resolvedInput,
$false,
$true,
$false,
'',
'',
$false,
'',
'',
0,
0,
$false,
$true,
0,
$true
)
$pageCount = $document.ComputeStatistics(2)
$document.ExportAsFixedFormat($resolvedOutput, 17)
[pscustomobject]@{
input = $resolvedInput
output = $resolvedOutput
pages = $pageCount
bytes = (Get-Item -LiteralPath $resolvedOutput).Length
} | ConvertTo-Json -Compress
} finally {
if ($null -ne $document) {
$document.Close($false)
[System.Runtime.InteropServices.Marshal]::ReleaseComObject(
$document
) | Out-Null
}
if ($null -ne $word) {
$word.Quit()
[System.Runtime.InteropServices.Marshal]::ReleaseComObject(
$word
) | Out-Null
}
[GC]::Collect()
[GC]::WaitForPendingFinalizers()
}
@@ -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(/&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));
@@ -10,6 +10,7 @@ import {
WORD_NAMESPACE,
appendElement,
colorValue,
millimetersToTwips,
parseXmlPart,
pointsToHalfPoints,
serializeXmlPart,
@@ -173,37 +174,50 @@ function appendFooterContent(
}
}
function appendThreeColumnTable(
root: XmlElement,
function appendParagraphProperties(
paragraph: XmlElement,
options: {
alignment?: "left" | "center" | "right";
position: "header" | "footer";
divider: boolean;
dividerColor: string;
alignments: readonly ["left", "center", "right"];
render: (
paragraph: XmlElement,
alignment: "left" | "center" | "right"
) => void;
centerTabTwips?: number;
rightTabTwips?: number;
}
) {
const table = appendElement(root, WORD_NAMESPACE, "w:tbl");
const tableProperties = appendElement(
table,
const properties = appendElement(
paragraph,
WORD_NAMESPACE,
"w:tblPr"
"w:pPr"
);
appendElement(tableProperties, WORD_NAMESPACE, "w:tblW", {
"w:w": "5000",
"w:type": "pct"
});
appendElement(tableProperties, WORD_NAMESPACE, "w:tblLayout", {
"w:type": "fixed"
appendElement(properties, WORD_NAMESPACE, "w:spacing", {
"w:before": "0",
"w:after": "0"
});
if (options.alignment) {
appendElement(properties, WORD_NAMESPACE, "w:jc", {
"w:val": options.alignment
});
}
if (
options.centerTabTwips !== undefined &&
options.rightTabTwips !== undefined
) {
const tabs = appendElement(properties, WORD_NAMESPACE, "w:tabs");
appendElement(tabs, WORD_NAMESPACE, "w:tab", {
"w:val": "center",
"w:pos": String(options.centerTabTwips)
});
appendElement(tabs, WORD_NAMESPACE, "w:tab", {
"w:val": "right",
"w:pos": String(options.rightTabTwips)
});
}
if (options.divider) {
const borders = appendElement(
tableProperties,
properties,
WORD_NAMESPACE,
"w:tblBorders"
"w:pBdr"
);
appendElement(
borders,
@@ -217,41 +231,46 @@ function appendThreeColumnTable(
}
);
}
const grid = appendElement(table, WORD_NAMESPACE, "w:tblGrid");
for (let index = 0; index < 3; index += 1) {
appendElement(grid, WORD_NAMESPACE, "w:gridCol", {
"w:w": "2400"
});
}
const row = appendElement(table, WORD_NAMESPACE, "w:tr");
for (const alignment of options.alignments) {
const cell = appendElement(row, WORD_NAMESPACE, "w:tc");
const cellProperties = appendElement(
cell,
WORD_NAMESPACE,
"w:tcPr"
);
appendElement(cellProperties, WORD_NAMESPACE, "w:tcW", {
"w:w": "1667",
"w:type": "pct"
});
const paragraph = appendElement(cell, WORD_NAMESPACE, "w:p");
const paragraphProperties = appendElement(
paragraph,
WORD_NAMESPACE,
"w:pPr"
);
appendElement(paragraphProperties, WORD_NAMESPACE, "w:jc", {
"w:val": alignment
});
options.render(paragraph, alignment);
}
function appendTab(paragraph: XmlElement) {
const run = appendElement(paragraph, WORD_NAMESPACE, "w:r");
appendElement(run, WORD_NAMESPACE, "w:tab");
}
function appendHeaderParagraph(
root: XmlElement,
options: {
divider: boolean;
dividerColor: string;
contentWidthMm: number;
render: (
paragraph: XmlElement,
alignment: "left" | "center" | "right"
) => void;
}
) {
const paragraph = appendElement(root, WORD_NAMESPACE, "w:p");
const contentWidthTwips = millimetersToTwips(options.contentWidthMm);
appendParagraphProperties(paragraph, {
position: "header",
divider: options.divider,
dividerColor: options.dividerColor,
centerTabTwips: Math.round(contentWidthTwips / 2),
rightTabTwips: contentWidthTwips
});
options.render(paragraph, "left");
appendTab(paragraph);
options.render(paragraph, "center");
appendTab(paragraph);
options.render(paragraph, "right");
}
function createHeaderPart(
header: HeaderConfig,
options: DynamicReferenceDocxOptions,
fallbackFont: string
fallbackFont: string,
contentWidthMm: number
) {
const document = createWordPart("hdr");
const style: RunStyle = {
@@ -259,11 +278,10 @@ function createHeaderPart(
sizePt: (lengthToMillimeters(header.fontSize) * 72) / 25.4,
color: header.color
};
appendThreeColumnTable(document.documentElement!, {
position: "header",
appendHeaderParagraph(document.documentElement!, {
divider: header.showDivider,
dividerColor: header.color,
alignments: ["left", "center", "right"],
contentWidthMm,
render: (paragraph, alignment) => {
const slot = header[alignment];
if (slot.enabled) {
@@ -290,17 +308,20 @@ function createFooterPart(
sizePt: (lengthToMillimeters(footer.fontSize) * 72) / 25.4,
color: footer.color
};
appendThreeColumnTable(document.documentElement!, {
const paragraph = appendElement(
document.documentElement!,
WORD_NAMESPACE,
"w:p"
);
appendParagraphProperties(paragraph, {
position: "footer",
divider: footer.showDivider,
dividerColor: footer.color,
alignments: ["left", "center", "right"],
render: (paragraph, cellAlignment) => {
if (!empty && cellAlignment === alignment) {
appendFooterContent(paragraph, footer, style);
}
}
alignment
});
if (!empty) {
appendFooterContent(paragraph, footer, style);
}
return serializeXmlPart(document);
}
@@ -427,7 +448,8 @@ export function createHeaderFooterParts(
options: DynamicReferenceDocxOptions,
header: HeaderConfig,
footer: FooterConfig,
fallbackFont: string
fallbackFont: string,
contentWidthMm: number
): HeaderFooterTransformResult {
const entries = new Map(sourceEntries);
removeExistingHeaderFooterParts(entries);
@@ -447,7 +469,8 @@ export function createHeaderFooterParts(
const headerContent = createHeaderPart(
header,
options,
fallbackFont
fallbackFont,
contentWidthMm
);
for (const type of [
"default",
@@ -91,7 +91,8 @@ export function createDynamicReferenceDocx(
options,
page.header,
page.footer,
style.body.fonts.eastAsia
style.body.fonts.eastAsia,
page.dimensions.width - page.margins.left - page.margins.right
);
const entries = withDecorations.entries;
entries.set(
+7
View File
@@ -191,6 +191,13 @@ function validateReferencedParts(
`${relationship.targetPart} 的页眉页脚根元素无效`
);
}
if (
root.getElementsByTagNameNS(WORD_NAMESPACE, "tbl").length > 0
) {
throw new Error(
`${relationship.targetPart} 不得使用表格模拟页眉页脚布局`
);
}
}
return references.length;
}
@@ -176,6 +176,9 @@ describe("动态 reference.docx", () => {
expect(stylesXml).toContain('w:eastAsia="Microsoft YaHei"');
expect(footerXml).toContain(" PAGE \\* MERGEFORMAT ");
expect(footerXml).toContain(" NUMPAGES \\* MERGEFORMAT ");
expect(footerXml).toContain('w:jc w:val="center"');
expect(footerXml).toContain('w:before="0"');
expect(footerXml).not.toContain("<w:tbl");
expect(validateDynamicReferenceDocx(first.content)).toMatchObject({
partCount: first.partCount,
headerCount: 0,
@@ -237,6 +240,16 @@ describe("动态 reference.docx", () => {
expect(settingsXml).toContain("<w:evenAndOddHeaders");
expect(headerXml).toContain("年度 &lt;报告&gt;");
expect(headerXml).toContain("报告 &amp; 计划.md");
expect(headerXml).toContain('w:tab w:val="center"');
expect(headerXml).toContain('w:tab w:val="right"');
expect(headerXml).toContain('w:pos="6860"');
expect(headerXml).toContain('w:pos="13720"');
expect(headerXml).toContain("<w:pBdr>");
expect(headerXml).toContain("<w:bottom");
expect(headerXml).not.toContain("<w:tbl");
expect(
decoder.decode(entries["word/footer1.xml"])
).not.toContain("<w:tbl");
expect(relationships.match(/relationships\/header/gu)).toHaveLength(
3
);
@@ -275,6 +288,28 @@ describe("动态 reference.docx", () => {
);
});
it("拒绝使用表格模拟页眉页脚布局", () => {
const result = createDynamicReferenceDocx(
createBaselineReference(),
createOptions(defaultExportConfig)
);
const entries = new Map(
Object.entries(unzipSync(result.content))
);
const footer = decoder
.decode(entries.get("word/footer1.xml")!)
.replace(
"</w:ftr>",
"<w:tbl><w:tr><w:tc><w:p/></w:tc></w:tr></w:tbl></w:ftr>"
);
entries.set("word/footer1.xml", encoder.encode(footer));
const damaged = writeReferenceDocxPackage(entries);
expect(() => validateDynamicReferenceDocx(damaged)).toThrow(
/使/u
);
});
it("最终 DOCX 使用媒体输出上限而非模板的 2 MiB 上限", () => {
const result = createDynamicReferenceDocx(
createBaselineReference(),