feat: 实现动态 DOCX 模板与样式映射
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { unzipSync, zipSync } from "fflate";
|
||||
import {
|
||||
defaultExportConfig,
|
||||
type ExportConfig,
|
||||
type ThemeManifest
|
||||
} from "@md-to-pdf/core";
|
||||
import {
|
||||
createDynamicReferenceDocx,
|
||||
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 `<w:style w:type="${type}" w:styleId="${styleId}"><w:name w:val="${styleId}"/></w:style>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
return zipSync(
|
||||
{
|
||||
"[Content_Types].xml": xml(
|
||||
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>'
|
||||
),
|
||||
"_rels/.rels": xml(
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"/>'
|
||||
),
|
||||
"word/document.xml": xml(
|
||||
`<w:document xmlns:w="${wordNamespace}" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:p/><w:sectPr><w:footnotePr/></w:sectPr></w:body></w:document>`
|
||||
),
|
||||
"word/fontTable.xml": xml(
|
||||
`<w:fonts xmlns:w="${wordNamespace}"><w:font w:name="Arial"/></w:fonts>`
|
||||
),
|
||||
"word/numbering.xml": xml(
|
||||
`<w:numbering xmlns:w="${wordNamespace}"/>`
|
||||
),
|
||||
"word/settings.xml": xml(
|
||||
`<w:settings xmlns:w="${wordNamespace}"/>`
|
||||
),
|
||||
"word/styles.xml": xml(
|
||||
`<w:styles xmlns:w="${wordNamespace}"><w:docDefaults><w:rPrDefault><w:rPr/></w:rPrDefault><w:pPrDefault><w:pPr/></w:pPrDefault></w:docDefaults>${styles}</w:styles>`
|
||||
),
|
||||
"word/_rels/document.xml.rels": xml(
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>'
|
||||
),
|
||||
"word/theme/theme1.xml": xml(
|
||||
'<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:themeElements><a:fontScheme><a:majorFont><a:latin typeface="Arial"/><a:ea typeface=""/></a:majorFont><a:minorFont><a:latin typeface="Arial"/><a:ea typeface=""/></a:minorFont></a:fontScheme></a:themeElements></a:theme>'
|
||||
)
|
||||
},
|
||||
{ mtime: new Date("2020-01-01T00:00:00.000Z") }
|
||||
);
|
||||
}
|
||||
|
||||
function theme(
|
||||
overrides: Partial<ThemeManifest> = {}
|
||||
): 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 createOptions(exportConfig: ExportConfig) {
|
||||
return {
|
||||
exportConfig,
|
||||
theme: theme(),
|
||||
fileName: "报告 & 计划.md",
|
||||
metadata: {
|
||||
title: "年度 <报告>",
|
||||
author: "测试人"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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 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(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"');
|
||||
expect(footerXml).toContain(" PAGE \\* MERGEFORMAT ");
|
||||
expect(footerXml).toContain(" NUMPAGES \\* MERGEFORMAT ");
|
||||
expect(validateDynamicReferenceDocx(first.content)).toMatchObject({
|
||||
partCount: first.partCount,
|
||||
headerCount: 0,
|
||||
footerCount: 1
|
||||
});
|
||||
});
|
||||
|
||||
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 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:start="5"');
|
||||
expect(documentXml).toContain("<w:titlePg");
|
||||
expect(documentXml.match(/w:headerReference/gu)).toHaveLength(3);
|
||||
expect(documentXml.match(/w:footerReference/gu)).toHaveLength(3);
|
||||
expect(settingsXml).toContain("<w:evenAndOddHeaders");
|
||||
expect(headerXml).toContain("年度 <报告>");
|
||||
expect(headerXml).toContain("报告 & 计划.md");
|
||||
expect(relationships.match(/relationships\/header/gu)).toHaveLength(
|
||||
3
|
||||
);
|
||||
expect(relationships.match(/relationships\/footer/gu)).toHaveLength(
|
||||
3
|
||||
);
|
||||
expect(entries["word/footer3.xml"]).toBeDefined();
|
||||
expect(validateDynamicReferenceDocx(result.content)).toMatchObject({
|
||||
headerCount: 3,
|
||||
footerCount: 3
|
||||
});
|
||||
});
|
||||
|
||||
it("拒绝正文中断裂或类型错误的页眉页脚关系", () => {
|
||||
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
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user