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).toContain( '' ); expect(sourceCodeStyle).toMatch( /]*w:left="195"[^>]*w:right="75"[^>]*\/>/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 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 ); }); });