import { describe, expect, it } from "vitest";
import { randomBytes } from "node:crypto";
import { unzipSync, zipSync } from "fflate";
import {
defaultExportConfig,
type ExportConfig,
type ThemeManifest
} from "@md-to-pdf/core";
import type {
DocxResolvedStyleSlot,
DocxThemeTokenSet
} from "@md-to-pdf/docx-theme-engine";
import {
createDynamicReferenceDocx,
validateGeneratedDocx,
validateDynamicReferenceDocx,
writeReferenceDocxPackage
} from "../src/index.js";
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const wordNamespace =
"http://schemas.openxmlformats.org/wordprocessingml/2006/main";
function xml(value: string) {
return encoder.encode(value);
}
function createBaselineReference() {
const styles = [
"Normal",
"BodyText",
"FirstParagraph",
"Compact",
"Title",
"TitleChar",
"Subtitle",
"SubtitleChar",
"Heading1",
"Heading1Char",
"Heading2",
"Heading2Char",
"Heading3",
"Heading3Char",
"Heading4",
"Heading4Char",
"Heading5",
"Heading5Char",
"Heading6",
"Heading6Char",
"BlockText",
"FootnoteText",
"DefaultParagraphFont",
"Table",
"Definition",
"Caption",
"TableCaption",
"ImageCaption",
"Figure",
"BodyTextChar",
"VerbatimChar",
"Hyperlink"
]
.map((styleId) => {
const type =
styleId === "Table"
? "table"
: styleId.endsWith("Char") ||
styleId === "DefaultParagraphFont" ||
styleId === "VerbatimChar" ||
styleId === "Hyperlink"
? "character"
: "paragraph";
return ``;
})
.join("");
return zipSync(
{
"[Content_Types].xml": xml(
''
),
"_rels/.rels": xml(
''
),
"word/document.xml": xml(
``
),
"word/fontTable.xml": xml(
``
),
"word/numbering.xml": xml(
``
),
"word/settings.xml": xml(
``
),
"word/styles.xml": xml(
`${styles}`
),
"word/_rels/document.xml.rels": xml(
''
),
"word/theme/theme1.xml": xml(
''
)
},
{ mtime: new Date("2020-01-01T00:00:00.000Z") }
);
}
function theme(
overrides: Partial = {}
): ThemeManifest {
return {
manifestVersion: 1,
id: "test-theme",
name: "测试主题",
version: "1.0.0",
description: "测试",
author: "测试",
license: "内部许可",
entry: "theme.css",
domPreset: "generic",
defaultFontSize: "16px",
supportedFeatures: [],
category: "general",
compatibleProfiles: [],
docxStyle: { preset: "technical" },
bundled: true,
...overrides
};
}
function themeTokens(
fingerprint = "c".repeat(64),
slots: DocxResolvedStyleSlot[] = []
): DocxThemeTokenSet {
return {
schemaVersion: 1,
themeId: "test-theme",
themeFingerprint: fingerprint,
mode: "auto-with-overrides",
basePreset: "technical",
slots,
diagnostics: []
};
}
function createOptions(exportConfig: ExportConfig) {
return {
exportConfig,
theme: theme(),
fileName: "报告 & 计划.md",
metadata: {
title: "年度 <报告>",
author: "测试人"
},
themeTokens: themeTokens()
};
}
describe("动态 reference.docx", () => {
it("生成稳定的纸张、正文样式和页码模板", () => {
const baseline = createBaselineReference();
const first = createDynamicReferenceDocx(
baseline,
createOptions(defaultExportConfig)
);
const second = createDynamicReferenceDocx(
baseline,
createOptions(defaultExportConfig)
);
const changedMetadata = createDynamicReferenceDocx(baseline, {
...createOptions(defaultExportConfig),
metadata: {
title: "另一份报告",
author: "测试人"
}
});
const changedTokens = createDynamicReferenceDocx(baseline, {
...createOptions(defaultExportConfig),
themeTokens: themeTokens("d".repeat(64))
});
const entries = unzipSync(first.content);
const documentXml = decoder.decode(entries["word/document.xml"]);
const stylesXml = decoder.decode(entries["word/styles.xml"]);
const footerXml = decoder.decode(entries["word/footer1.xml"]);
expect(first.templateFingerprint).toBe(
second.templateFingerprint
);
expect(first.cacheKey).toBe(second.cacheKey);
expect(first.cacheKey).not.toBe(changedMetadata.cacheKey);
expect(first.cacheKey).not.toBe(changedTokens.cacheKey);
expect(documentXml).toContain('w:w="11906"');
expect(documentXml).toContain('w:h="16838"');
expect(documentXml).toContain('w:top="907"');
expect(stylesXml).toContain('w:styleId="SourceCode"');
expect(stylesXml).toContain('w:eastAsia="Microsoft YaHei"');
const imageCaptionStyle = stylesXml.match(
/]*w:styleId="ImageCaption"[\s\S]*?<\/w:style>/u
)?.[0];
expect(imageCaptionStyle).toContain('');
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(" {
const result = createDynamicReferenceDocx(
createBaselineReference(),
{
...createOptions(defaultExportConfig),
themeTokens: themeTokens("e".repeat(64), [
{
slot: "paragraph",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: [
"Source Han Serif SC",
"Times New Roman"
],
fontSizePt: 12.75,
color: "#112233",
lineSpacing: 1.8,
firstLineIndentPt: 28,
alignment: "justify"
}
},
{
slot: "heading-1",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: ["SimHei", "Arial"],
fontSizePt: 22,
bold: true,
color: "#aa0000",
alignment: "center",
keepWithNext: true
}
},
{
slot: "code-block",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: ["Lucida Console"],
fontSizePt: 9,
paddingPt: {
top: 6,
right: 3,
bottom: 5,
left: 3
},
contentPaddingLeftPt: 6,
borders: {
top: { widthPt: 0.75, color: "#e7eaed", style: "single" },
right: { widthPt: 0.75, color: "#e7eaed", style: "single" },
bottom: { widthPt: 0.75, color: "#e7eaed", style: "single" },
left: { widthPt: 0.75, color: "#e7eaed", style: "single" }
}
}
},
{
slot: "code-block-text",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: ["Code Text Face"],
fontSizePt: 10,
color: "#223344"
}
},
{
slot: "code-token-attribute",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: ["Code Text Face"],
fontSizePt: 10,
color: "#166534"
}
},
{
slot: "table",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: [],
widthPercent: 100,
paddingPt: {
top: 3,
right: 4,
bottom: 3,
left: 4
},
borders: {
top: {
widthPt: 1,
color: "#445566",
style: "single"
}
}
}
},
{
slot: "table-cell",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: ["SimSun"],
borders: {
top: {
widthPt: 0.85,
color: "#556677",
style: "single"
},
right: {
widthPt: 0.85,
color: "#556677",
style: "single"
},
bottom: {
widthPt: 0.85,
color: "#556677",
style: "single"
},
left: {
widthPt: 0.85,
color: "#556677",
style: "single"
}
}
}
},
{
slot: "official-title",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: ["SimSun"],
fontSizePt: 22,
color: "#ff0000",
alignment: "center",
keepWithNext: true
}
},
{
slot: "official-edition",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: [],
lineSpacing: 1.5
}
},
{
slot: "official-printing-row",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: [],
paddingPt: {
top: 3,
right: 28,
bottom: 3,
left: 28
}
}
}
])
}
);
const entries = unzipSync(result.content);
const stylesXml = decoder.decode(entries["word/styles.xml"]);
const fontTableXml = decoder.decode(
entries["word/fontTable.xml"]
);
const themeXml = decoder.decode(
entries["word/theme/theme1.xml"]
);
expect(stylesXml).toContain('w:styleId="MdOfficialTitle"');
expect(stylesXml).toMatch(
/w:style[^>]*w:styleId="MdOfficialTitle"[\s\S]*?/u
);
expect(stylesXml).toContain('w:eastAsia="Source Han Serif SC"');
expect(stylesXml).toContain('w:ascii="Times New Roman"');
expect(stylesXml).toContain('w:val="112233"');
expect(stylesXml).toContain('w:styleId="Heading1"');
expect(stylesXml).toContain('w:val="AA0000"');
expect(stylesXml).toContain('');
const sourceCodeStyle = stylesXml.match(
/]*w:styleId="SourceCode"[\s\S]*?<\/w:style>/u
)?.[0];
expect(sourceCodeStyle).not.toContain("'
);
expect(sourceCodeStyle).toMatch(
/]*w:left="195"[^>]*w:right="0"[^>]*\/>/u
);
expect(sourceCodeStyle).toContain('w:ascii="Code Text Face"');
expect(stylesXml).toMatch(
/w:style[^>]*w:styleId="MdSourceCodeChar"[\s\S]*?/u
);
expect(stylesXml).toMatch(
/w:style[^>]*w:styleId="DataTypeTok"[\s\S]*?[\s\S]*?/u
);
expect(stylesXml).not.toContain('w:val="justify"');
expect(stylesXml).toContain(
'w:line="459" w:lineRule="exact"'
);
expect(stylesXml).toMatch(
/w:style[^>]*w:styleId="Normal"[\s\S]*?[\s\S]*?[\s\S]*?/u
);
expect(stylesXml).toMatch(
/w:style[^>]*w:styleId="MdOfficialEdition"[\s\S]*?]*w:line="315"[^>]*w:lineRule="exact"/u
);
expect(stylesXml).toMatch(
/w:style[^>]*w:styleId="MdOfficialPrintingRow"[\s\S]*?]*w:before="60"[^>]*w:after="60"/u
);
expect(stylesXml).toMatch(
/w:style[^>]*w:styleId="MdOfficialPrintingRow"[\s\S]*?]*w:left="560"[^>]*w:right="560"/u
);
expect(stylesXml).not.toContain('w:lineRule="auto"');
expect(stylesXml).not.toContain("[\s\S]*?]*w:sz="7"[^>]*w:color="556677"\/>[\s\S]*?]*w:sz="7"[^>]*w:color="556677"\/>/u
);
expect(fontTableXml).toContain(
'w:name="Source Han Serif SC"'
);
expect(fontTableXml).toContain('w:name="SimHei"');
expect(themeXml).toContain(
' {
const createWithBackground = (backgroundColor?: string) => {
const result = createDynamicReferenceDocx(
createBaselineReference(),
{
...createOptions(defaultExportConfig),
themeTokens: themeTokens("f".repeat(64), [
{
slot: "code-block",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: ["Consolas"],
fontSizePt: 10,
...(backgroundColor ? { backgroundColor } : {})
}
},
{
slot: "code-block-text",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: ["Consolas"],
fontSizePt: 10
}
}
])
}
);
const stylesXml = decoder.decode(
unzipSync(result.content)["word/styles.xml"]
);
return stylesXml.match(
/]*w:styleId="SourceCode"[\s\S]*?<\/w:style>/u
)?.[0];
};
expect(createWithBackground()).not.toContain("'
);
});
it("生成横向、自定义页边距和奇偶首页页眉页脚", () => {
const exportConfig: ExportConfig = {
...defaultExportConfig,
pageDecorationsMode: "custom",
paper: {
...defaultExportConfig.paper,
orientation: "landscape",
marginMode: "custom",
margins: {
top: "20mm",
right: "25mm",
bottom: "22mm",
left: "30mm"
}
},
header: {
...defaultExportConfig.header,
enabled: true,
showDivider: true,
left: {
enabled: true,
content: "${title} / ${filename}"
}
},
footer: {
...defaultExportConfig.footer,
alignment: "outer",
format: "official-page",
startFrom: 5,
showOnFirstPage: false
}
};
const result = createDynamicReferenceDocx(
createBaselineReference(),
createOptions(exportConfig)
);
const entries = unzipSync(result.content);
const documentXml = decoder.decode(entries["word/document.xml"]);
const settingsXml = decoder.decode(entries["word/settings.xml"]);
const stylesXml = decoder.decode(entries["word/styles.xml"]);
const headerXml = decoder.decode(entries["word/header1.xml"]);
const relationships = decoder.decode(
entries["word/_rels/document.xml.rels"]
);
expect(documentXml).toContain('w:orient="landscape"');
expect(documentXml).toContain('w:w="16838"');
expect(documentXml).toContain('w:left="1701"');
expect(documentXml).toContain('w:header="723"');
expect(documentXml).toContain('w:footer="751"');
expect(documentXml).toContain('w:start="5"');
expect(documentXml).toContain("");
expect(headerXml).toContain("]*w:line="213"[^>]*w:lineRule="exact"/u
);
expect(headerXml).not.toContain("]*w:line="213"[^>]*w:lineRule="exact"/u
);
expect(footerXml).not.toContain(" {
const exportConfig: ExportConfig = {
...defaultExportConfig,
pageDecorationsMode: "custom",
footer: {
...defaultExportConfig.footer,
alignment: "outer",
format: "page-total",
startFrom: 1
}
};
const result = createDynamicReferenceDocx(
createBaselineReference(),
{
...createOptions(exportConfig),
semanticDocument: {
schemaVersion: 1,
profile: "project-report",
titlePolicy: {
metadataTitle: "suppress",
firstBodyHeading: "keep"
},
regions: [
{
kind: "cover",
nodes: [
{
kind: "text",
role: "project-report-title",
text: "可研报告"
}
],
section: {
headerFooter: "none",
pageNumber: "hidden",
breakAfter: "next-page",
followingPageNumberStart: 1
}
}
]
}
}
);
const entries = unzipSync(result.content);
const documentXml = decoder.decode(entries["word/document.xml"]);
const settingsXml = decoder.decode(entries["word/settings.xml"]);
const defaultFooterXml = decoder.decode(entries["word/footer1.xml"]);
const evenFooterXml = decoder.decode(entries["word/footer2.xml"]);
expect(documentXml.match(/w:footerReference/gu)).toHaveLength(2);
expect(settingsXml).toContain(" {
const exportConfig: ExportConfig = {
...defaultExportConfig,
pageDecorationsMode: "custom",
header: {
...defaultExportConfig.header,
enabled: true,
showOnFirstPage: false,
showDivider: true,
center: {
enabled: true,
content: "${title}"
}
},
footer: {
...defaultExportConfig.footer,
alignment: "center",
showOnFirstPage: true
}
};
const result = createDynamicReferenceDocx(
createBaselineReference(),
createOptions(exportConfig)
);
const entries = unzipSync(result.content);
const documentXml = decoder.decode(entries["word/document.xml"]);
const regularHeader = decoder.decode(entries["word/header1.xml"]);
const firstHeader = decoder.decode(entries["word/header2.xml"]);
const regularFooter = decoder.decode(entries["word/footer1.xml"]);
const firstFooter = decoder.decode(entries["word/footer2.xml"]);
expect(documentXml).toContain(" {
const exportConfig: ExportConfig = {
...defaultExportConfig,
pageDecorationsMode: "custom",
footer: {
...defaultExportConfig.footer,
showDivider: true,
showOnFirstPage: false
}
};
const result = createDynamicReferenceDocx(
createBaselineReference(),
createOptions(exportConfig)
);
const entries = unzipSync(result.content);
const firstFooter = decoder.decode(entries["word/footer2.xml"]);
expect(firstFooter).not.toContain("PAGE");
expect(firstFooter).not.toContain(" {
const result = createDynamicReferenceDocx(
createBaselineReference(),
createOptions(defaultExportConfig)
);
const entries = new Map(
Object.entries(unzipSync(result.content))
);
const relationships = decoder
.decode(entries.get("word/_rels/document.xml.rels")!)
.replace(
"relationships/footer",
"relationships/header"
);
entries.set(
"word/_rels/document.xml.rels",
encoder.encode(relationships)
);
const damaged = writeReferenceDocxPackage(entries);
expect(() => validateDynamicReferenceDocx(damaged)).toThrow(
/footerReference 引用了无效关系/u
);
});
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(
"",
""
);
entries.set("word/footer1.xml", encoder.encode(footer));
const damaged = writeReferenceDocxPackage(entries);
expect(() => validateDynamicReferenceDocx(damaged)).toThrow(
/不得使用表格模拟页眉页脚布局/u
);
});
it("拒绝重复样式 ID 和非法两端对齐枚举", () => {
const result = createDynamicReferenceDocx(
createBaselineReference(),
createOptions(defaultExportConfig)
);
const entries = new Map(
Object.entries(unzipSync(result.content))
);
const styles = decoder.decode(entries.get("word/styles.xml")!);
entries.set(
"word/styles.xml",
encoder.encode(
styles.replace(
"",
'' +
""
)
)
);
expect(() =>
validateDynamicReferenceDocx(
writeReferenceDocxPackage(entries)
)
).toThrow(/重复样式 ID:Normal/u);
entries.set(
"word/styles.xml",
encoder.encode(
styles.replace(
//u,
''
)
)
);
expect(() =>
validateDynamicReferenceDocx(
writeReferenceDocxPackage(entries)
)
).toThrow(/两端对齐必须使用/u);
});
it("最终 DOCX 使用媒体输出上限而非模板的 2 MiB 上限", () => {
const result = createDynamicReferenceDocx(
createBaselineReference(),
createOptions(defaultExportConfig)
);
const entries = unzipSync(result.content);
const generated = zipSync(
{
...entries,
"word/media/large-test.bin": randomBytes(3 * 1024 * 1024)
},
{ level: 0 }
);
expect(generated.byteLength).toBeGreaterThan(2 * 1024 * 1024);
expect(validateGeneratedDocx(generated).partCount).toBe(
result.partCount + 1
);
expect(() => validateDynamicReferenceDocx(generated)).toThrow(
/大小超过限制/u
);
});
});