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("