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` + `正文标题` + `` + `` + `` + `` + `ABC单元格前mdtp_ic_c单元格后` + `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: ["Body Face"], fontSizePt: 10, lineSpacing: 1.8 } }, { slot: "inline-code", source: "computed-css", confidence: "exact", style: { fontCandidates: ["Code Face"], fontSizePt: 9, color: "#112233", backgroundColor: "#f5f5f5", paddingPt: { top: 3, right: 6, bottom: 3, left: 6 } } }, { 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, [], { tables: [], textBlocks: [{ ordinal: 1, text: "期号发布单位签发人日期", letterSpacingPt: 0, linePitchPt: 17.25, lineBreakOffsets: [6] }] } ); 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( 'w:line="345" w:lineRule="exact"' ); 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).not.toContain('"); expect(documentXml).toContain('w:fill="F5F5F5"'); expect(documentXml).toContain( 'w:spacing w:before="140"' ); 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("将 Chromium 避头尾规则留下的单个汉字尾行翻译为显式换行", () => { const fixture = readGeneratedDocxPackage(generatedFixture()); const documentXml = decoder.decode( fixture.entries.get("word/document.xml")! ).replace( '正文标题', '正文标题' + '浏览器末行保留项目。' ); fixture.entries.set("word/document.xml", encoder.encode(documentXml)); const result = finalizeGeneratedDocxStructure( writeGeneratedDocxPackage(fixture.entries), plan, tokens, [], { tables: [], textBlocks: [{ ordinal: 1, text: "浏览器末行保留项目。", letterSpacingPt: 0, alignment: "justify", lineBreakOffsets: [8] }] } ); const outputXml = decoder.decode( readGeneratedDocxPackage(result.content).entries.get( "word/document.xml" )! ); expect(outputXml).toContain( '浏览器末行保留项目。' ); }); it("为右对齐盒模型保留 CSS 右侧内容内缩", () => { const fixture = readGeneratedDocxPackage(generatedFixture()); const documentXml = decoder.decode( fixture.entries.get("word/document.xml")! ).replace( '可编辑封面', '正本' ); fixture.entries.set("word/document.xml", encoder.encode(documentXml)); const copyMarkTokens: DocxThemeTokenSet = { ...tokens, slots: [ ...tokens.slots.map((entry) => entry.slot === "tender-cover" ? { ...entry, style: { ...entry.style, minimumHeightPt: 500, verticalAlignment: "center" as const } } : entry ), { slot: "tender-copy-mark", source: "computed-css", confidence: "exact", style: { fontCandidates: [], fontSizePt: 13, selfAlignment: "right", paddingPt: { top: 3, right: 11.34, bottom: 3, left: 11.34 }, borders: { right: { widthPt: 1.13, style: "single", color: "#222222" } } } } ] }; const result = finalizeGeneratedDocxStructure( writeGeneratedDocxPackage(fixture.entries), plan, copyMarkTokens ); const outputXml = decoder.decode( readGeneratedDocxPackage(result.content).entries.get( "word/document.xml" )! ); expect(outputXml).toMatch( /[\s\S]*?正本<\/w:t>[\s\S]*?[\s\S]*?\u00a0\u00a0<\/w:t>/u ); }); it("按 Chromium 实测比例写入固定 DXA 表格网格和单元格宽度", () => { const result = finalizeGeneratedDocxStructure( generatedFixture(), plan, tokens, [], { tables: [ { ordinal: 1, widthPercent: 90, leftOffsetPercent: 5, columnWidthPercents: [30, 70], rows: [] } ], textBlocks: [ { ordinal: 1, text: "单元格前mdtp_ic_c单元格后", letterSpacingPt: 0, alignment: "distribute", lineBreakOffsets: [] } ], inlineCodes: [ { ordinal: 1, text: "mdtp_ic_c", fontSizePt: 8, letterSpacingPt: 0.2, color: "#334455", backgroundColor: "#abcdef", paddingPt: { top: 1, right: 2, bottom: 1, left: 2 }, borderPt: { top: 0.5, right: 0.5, bottom: 0.5, left: 0.5 } } ] } ); 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.match(//gu) ).toHaveLength(2); expect( documentXml.match(//gu) ).toHaveLength(2); expect(documentXml).toMatch( /]*w:ascii="Code Face"[^>]*\/>[\s\S]*?[\s\S]*?[\s\S]*?]*w:sz="20"[^>]*w:space="0"[^>]*w:color="ABCDEF"[^>]*\/>[\s\S]*?]*w:fill="ABCDEF"[^>]*\/>[\s\S]*?mdtp_ic_c<\/w:t><\/w:r>/u ); expect(documentXml).toMatch( /]*w:ascii="Body Face"[^>]*\/>[\s\S]*?单元格前<\/w:t><\/w:r>/u ); expect(documentXml).toContain(''); }); 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("将 Chromium 实测列表项文字起点映射为段落直接缩进", () => { const source = readGeneratedDocxPackage(generatedFixture()); const entries = new Map(source.entries); const documentXml = decoder.decode(entries.get("word/document.xml")!); entries.set( "word/document.xml", encoder.encode(documentXml.replace( "", `一级列表` + `二级代码` + `` )) ); entries.set( "word/numbering.xml", encoder.encode( `` ) ); const result = finalizeGeneratedDocxStructure( writeGeneratedDocxPackage(entries), plan, tokens, [], { tables: [], listItems: [ { ordinal: 1, text: "一级列表", depth: 0, textStartPt: 10 }, { ordinal: 2, text: "二级代码", depth: 1, textStartPt: 25 } ] } ); const finalizedXml = decoder.decode( readGeneratedDocxPackage(result.content).entries.get( "word/document.xml" )! ); expect(finalizedXml).toMatch( /[\s\S]*?[\s\S]*?一级列表<\/w:t>/u ); expect(finalizedXml).toMatch( /[\s\S]*?[\s\S]*?二级<\/w:t>[\s\S]*?代码<\/w:t>/u ); }); 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("