test: 完成 DOCX 全主题视觉验收
This commit is contained in:
@@ -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(/</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));
|
||||
@@ -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(
|
||||
|
||||
@@ -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("年度 <报告>");
|
||||
expect(headerXml).toContain("报告 & 计划.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(),
|
||||
|
||||
Reference in New Issue
Block a user