import { describe, expect, it } from "vitest"; import type { DocxThemeTokenSet } from "@md-to-pdf/docx-theme-engine"; import { finalizeGeneratedDocxStructure, readGeneratedDocxPackage, readReferenceDocxPackage, writeGeneratedDocxPackage, type PandocMediaLayoutItem, type PandocStructurePlan } from "../src/index.js"; import { createTestBaselineReference } from "./reference-test-fixture.js"; const encoder = new TextEncoder(); const decoder = new TextDecoder(); const word = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; const relationships = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; const wordprocessingDrawing = "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"; const drawing = "http://schemas.openxmlformats.org/drawingml/2006/main"; const picture = "http://schemas.openxmlformats.org/drawingml/2006/picture"; function generatedFixture() { const baseline = readReferenceDocxPackage( createTestBaselineReference() ); const entries = new Map(baseline.entries); entries.set( "word/document.xml", encoder.encode( `` + `CONTAINER_START` + `发文字号签发人` + `可编辑封面` + `CONTAINER_END` + `SECTION_BREAK` + `正文标题` + `` + `` + `` + `` + `ABCD` + `2026年7月29日` + `` + `` ) ); entries.set( "word/footer1.xml", encoder.encode( ` NUMPAGES \\* MERGEFORMAT ` ) ); return writeGeneratedDocxPackage(entries); } const plan: PandocStructurePlan = { schemaVersion: 1, metadataPolicy: { author: "suppress" }, titlePolicy: { metadataTitle: "suppress", firstBodyHeading: "keep" }, prefix: [ { kind: "container", styleId: "MdTenderCover", slot: "tender-cover", startMarker: "CONTAINER_START", endMarker: "CONTAINER_END", blocks: [ { kind: "paragraph", styleId: "MdOfficialIssueRow", layout: "space-between", segments: ["发文字号", "签发人"], separator: "tab" } ] }, { kind: "section-break", marker: "SECTION_BREAK", headerFooter: "none", pageNumber: "hidden", followingPageNumberStart: 1, verticalAlignment: "center" } ], suffix: [] }; const tokens: DocxThemeTokenSet = { schemaVersion: 1, themeId: "test-theme", themeFingerprint: "c".repeat(64), mode: "auto-with-overrides", basePreset: "tender", slots: [ { slot: "tender-cover", source: "computed-css", confidence: "approximate", style: { fontCandidates: [], backgroundColor: "#f5f5f5", spacingBeforePt: 7, spacingAfterPt: 51, paddingPt: { top: 3, right: 5, bottom: 11, left: 4 }, widthPercent: 52, leftIndentPt: 230.4, rightIndentPt: 0, pageBreakAfter: true, keepLines: true, borders: { top: { widthPt: 1, style: "single", color: "#111111" }, right: { widthPt: 1, style: "single", color: "#111111" }, bottom: { widthPt: 1, style: "single", color: "#111111" }, left: { widthPt: 1, style: "single", color: "#111111" } } } }, { slot: "table", source: "computed-css", confidence: "exact", style: { fontCandidates: [], widthPercent: 100, keepLines: true } }, { slot: "table-cell", source: "computed-css", confidence: "exact", style: { fontCandidates: [], fontSizePt: 10, lineSpacing: 1.8 } }, { slot: "table-header", source: "computed-css", confidence: "exact", style: { fontCandidates: [], fontSizePt: 10, bold: true, lineSpacing: 1.5, alignment: "center", color: "#ffffff", backgroundColor: "#17324d" } }, { slot: "heading-1", source: "computed-css", confidence: "exact", style: { fontCandidates: [], pageBreakAfter: true } } ], diagnostics: [] }; const mediaLayout: PandocMediaLayoutItem[] = [ { id: "docx-media-1", binding: "mdtp-media:docx-media-1", kind: "mermaid", ordinal: 1, altText: "处理流程", alignment: "right", displayWidthPx: 320, displayHeightPx: 180, widthEmu: 3_048_000, heightEmu: 1_714_500 } ]; describe("生成 DOCX 结构收口", () => { it("生成真实分节、正文页码、节总页数和全宽自适应表格", () => { const result = finalizeGeneratedDocxStructure( generatedFixture(), plan, tokens ); const entries = readGeneratedDocxPackage( result.content ).entries; const documentXml = decoder.decode( entries.get("word/document.xml")! ); const footerXml = decoder.decode( entries.get("word/footer1.xml")! ); expect(documentXml).not.toContain("CONTAINER_START"); expect(documentXml).not.toContain("CONTAINER_END"); expect(documentXml).not.toContain("SECTION_BREAK"); expect( documentXml.match(/)/gu) ).toHaveLength(2); expect(documentXml).toContain( '' ); expect(documentXml).toContain( '' ); expect(documentXml).toContain( '' ); expect(documentXml).toContain( '' ); expect(documentXml).toContain( '' ); expect(documentXml).toMatch( /[\s\S]*?]*w:line="240"[^>]*w:lineRule="auto"[^>]*w:before="300"[^>]*w:after="300"\/>/u ); expect(documentXml).toContain(''); expect(documentXml).toContain(''); expect(documentXml).toContain(''); expect( documentXml.match( /]*w:before="0"[^>]*w:after="0"[^>]*w:line="300"[^>]*w:lineRule="exact"/gu ) ).toHaveLength(2); expect( documentXml.match( /]*w:before="0"[^>]*w:after="0"[^>]*w:line="360"[^>]*w:lineRule="exact"/gu ) ).toHaveLength(2); expect(documentXml.match(/w:fill="17324D"/gu)).toHaveLength(2); expect(documentXml.match(/w:val="FFFFFF"/gu)).toHaveLength(2); expect( documentXml.match(//gu)?.length ?? 0 ).toBeGreaterThanOrEqual(2); const tableParagraph = documentXml.match( /[\s\S]*?A<\/w:t>[\s\S]*?<\/w:p>/u )?.[0]; expect(tableParagraph).toContain(''); expect(tableParagraph).toContain(''); expect(documentXml).toContain(""); expect(documentXml).toContain( '' ); expect(documentXml).toContain('w:fill="F5F5F5"'); expect(documentXml).toContain( '' ); expect(documentXml).toContain( '' ); expect( documentXml.match( //gu ) ).toHaveLength(2); expect(documentXml).toContain( '' ); expect(documentXml).toContain( '' ); expect( documentXml.match(//gu) ).toHaveLength(1); const coverParagraph = documentXml.match( /)[\s\S]*?可编辑封面[\s\S]*?<\/w:p>/u )?.[0]; expect(coverParagraph).toContain(""); expect(coverParagraph).not.toContain( '' ); expect(footerXml).toContain("SECTIONPAGES"); expect(footerXml).not.toContain(" NUMPAGES "); expect(result.report).toEqual({ containerCount: 1, editableContainerTableCount: 0, sectionCount: 1, tableCount: 1, pageBreakAfterCount: 1, sectionPageFieldCount: 1, spaceBetweenParagraphCount: 1, mediaDrawingCount: 1 }); }); it("按稳定媒体计划规范化内联尺寸、比例锁和对齐", () => { const result = finalizeGeneratedDocxStructure( generatedFixture(), plan, tokens, mediaLayout ); const documentXml = decoder.decode( readGeneratedDocxPackage(result.content).entries.get( "word/document.xml" )! ); expect(result.report.mediaDrawingCount).toBe(1); expect(documentXml).toMatch( /]*distT="0"[^>]*distB="0"[^>]*distL="0"[^>]*distR="0"[^>]*>/u ); expect(documentXml).toContain( '' ); expect(documentXml).toContain( '' ); expect(documentXml).toMatch( /]*noChangeAspect="1"[^>]*\/>/u ); expect(documentXml).toContain( 'descr="处理流程" title=""' ); expect(documentXml).toContain(''); expect(documentXml).not.toContain("mdtp-media:"); expect(documentXml).not.toContain('rot="60000"'); expect(documentXml).not.toContain('flipH="1"'); }); it("拒绝缺失或重复的媒体布局绑定", () => { expect(() => finalizeGeneratedDocxStructure( generatedFixture(), plan, tokens, [{ ...mediaLayout[0]!, binding: "mdtp-media:missing" }] ) ).toThrow("稳定绑定"); expect(() => finalizeGeneratedDocxStructure( generatedFixture(), plan, tokens, [mediaLayout[0]!, { ...mediaLayout[0]!, id: "duplicate" }] ) ).toThrow("重复绑定"); }); it("将带最小高度的分节封面映射为可编辑单元格容器", () => { const tableTokens: DocxThemeTokenSet = { ...tokens, slots: tokens.slots.map((entry) => entry.slot === "tender-cover" ? { ...entry, style: { ...entry.style, minimumHeightPt: 620, verticalAlignment: "center" as const, childAlignment: "center" as const } } : entry ).concat({ slot: "official-issue-row" as const, source: "computed-css" as const, confidence: "exact" as const, style: { fontCandidates: [], spacingAfterPt: 4 } }, { slot: "tender-title" as const, source: "computed-css" as const, confidence: "exact" as const, style: { fontCandidates: [], fontSizePt: 20, letterSpacingPt: 1, spacingBeforePt: 6, borders: { bottom: { widthPt: 1, style: "single" as const, color: "#111111" } } } }) }; const result = finalizeGeneratedDocxStructure( generatedFixture(), plan, tableTokens ); const documentXml = decoder.decode( readGeneratedDocxPackage(result.content).entries.get( "word/document.xml" )! ); expect(documentXml).toContain(""); expect(documentXml).toContain( '' ); expect(documentXml).toContain( '' ); expect(documentXml).toContain(''); expect(documentXml).toContain(""); expect(documentXml).toContain(''); expect(documentXml).toContain(''); expect(documentXml).toMatch( //u ); expect(documentXml).toMatch( /[\s\S]*?<\/w:rPr>可编辑封面<\/w:t>/u ); expect(documentXml).toContain('[\s\S]*?[\s\S]*?/u ); expect( documentXml.match(/)/gu) ).toHaveLength(2); expect(result.report).toMatchObject({ containerCount: 1, editableContainerTableCount: 1, sectionCount: 1, tableCount: 1 }); }); it("将封面 border 与内缩 outline 映射为双层可编辑容器边框", () => { const outlinedTokens: DocxThemeTokenSet = { ...tokens, slots: tokens.slots.map((entry) => entry.slot === "tender-cover" ? { ...entry, style: { ...entry.style, minimumHeightPt: 620, verticalAlignment: "center" as const, outline: { widthPt: 0.75, style: "single" as const, color: "#777777" }, outlineOffsetPt: -12 } } : entry ) }; const result = finalizeGeneratedDocxStructure( generatedFixture(), plan, outlinedTokens ); const documentXml = decoder.decode( readGeneratedDocxPackage(result.content).entries.get( "word/document.xml" )! ); expect(documentXml).toContain( '' ); expect(documentXml).toMatch( / { const oversizedTokens: DocxThemeTokenSet = { ...tokens, slots: tokens.slots.map((entry) => entry.slot === "tender-cover" ? { ...entry, style: { ...entry.style, minimumHeightPt: 900, verticalAlignment: "center" as const, childAlignment: "left" as const, borders: { ...entry.style.borders, top: { widthPt: 20, style: "single" as const, color: "#111111" } } } } : entry ).concat( { slot: "official-issue-row" as const, source: "computed-css" as const, confidence: "exact" as const, style: { fontCandidates: [], fontSizePt: 12, lineSpacing: 1.5 } }, { slot: "tender-title" as const, source: "computed-css" as const, confidence: "exact" as const, style: { fontCandidates: [], fontSizePt: 20, lineSpacing: 1.2 } } ) }; const result = finalizeGeneratedDocxStructure( generatedFixture(), plan, oversizedTokens ); const documentXml = decoder.decode( readGeneratedDocxPackage(result.content).entries.get( "word/document.xml" )! ); expect(documentXml).toContain( '' ); expect(documentXml).toContain( '' ); expect(documentXml).toContain( '' ); expect(documentXml).toContain(''); expect(documentXml).toContain(''); expect(documentXml).toContain(''); expect(documentXml.match(//gu)).toHaveLength(3); expect(documentXml.match(/\.<\/w:t>/gu)).toHaveLength(3); expect(documentXml.match(/)/gu)).toHaveLength(2); expect(documentXml.match(/ { const source = readGeneratedDocxPackage(generatedFixture()); const entries = new Map(source.entries); entries.set( "word/numbering.xml", encoder.encode( `` ) ); const listTokens: DocxThemeTokenSet = { ...tokens, slots: tokens.slots.concat({ slot: "unordered-list" as const, source: "computed-css" as const, confidence: "exact" as const, style: { fontCandidates: [], paddingPt: { top: 0, right: 0, bottom: 0, left: 30 } } }) }; const result = finalizeGeneratedDocxStructure( writeGeneratedDocxPackage(entries), plan, listTokens ); const numberingXml = decoder.decode( readGeneratedDocxPackage(result.content).entries.get( "word/numbering.xml" )! ); expect(numberingXml).toContain( '' ); expect(numberingXml).toContain( '' ); }); it("按最终页面内容区重算右置百分比容器缩进", () => { const relativeTokens: DocxThemeTokenSet = { ...tokens, slots: tokens.slots.map((entry) => entry.slot === "tender-cover" ? { ...entry, confidence: "exact" as const, style: { fontCandidates: [], widthPercent: 52, leftIndentPt: 230.4, rightIndentPt: 0, keepLines: true } } : entry ) }; const result = finalizeGeneratedDocxStructure( generatedFixture(), plan, relativeTokens ); const documentXml = decoder.decode( readGeneratedDocxPackage(result.content).entries.get( "word/document.xml" )! ); // A4 内容区为 11906 - 1000 - 1000 = 9906 twips; // 52% 右置容器的左侧空余应为 9906 × 48% = 4755 twips。 expect(documentXml).toMatch( /[\s\S]*?/u ); expect(documentXml).not.toContain('w:left="4608"'); expect(documentXml).toMatch( //u ); }); it("拒绝缺失或重复的内部结构标记", () => { expect(() => finalizeGeneratedDocxStructure( generatedFixture(), { ...plan, prefix: [ { ...plan.prefix[0]!, startMarker: "MISSING" }, plan.prefix[1]! ] }, tokens ) ).toThrow("结构标记数量无效"); }); it("清理 Pandoc 追加的重复和降级样式", () => { const source = readGeneratedDocxPackage( generatedFixture() ); const entries = new Map(source.entries); const styles = decoder .decode(entries.get("word/styles.xml")!) .replace( "", '' + '' + '' + '' + '' + '' + "" ); entries.set("word/styles.xml", encoder.encode(styles)); const result = finalizeGeneratedDocxStructure( writeGeneratedDocxPackage(entries), plan, tokens ); const outputStyles = decoder.decode( readGeneratedDocxPackage(result.content).entries.get( "word/styles.xml" )! ); expect( outputStyles.match(/w:styleId="MdTenderTitle"/gu) ).toHaveLength(1); expect(outputStyles).toContain( '' ); expect(outputStyles).not.toContain('w:val="justify"'); expect(outputStyles).not.toContain("