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";
const math =
"http://schemas.openxmlformats.org/officeDocument/2006/math";
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` +
`正文标题` +
`` +
`<` +
`br` +
`>` +
`` +
` method` +
`:` +
` "POST"` +
`` +
`` +
`` +
`` +
`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: "code-block-text",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: ["Code Face"],
fontSizePt: 9,
color: "#24292e"
}
},
{
slot: "code-token-punctuation",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: ["Code Face"],
fontSizePt: 9,
color: "#7a7a7a"
}
},
{
slot: "code-token-name",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: ["Code Face"],
fontSizePt: 9,
color: "#22863a"
}
},
{
slot: "code-token-attribute",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: ["Code Face"],
fontSizePt: 9,
color: "#005cc5"
}
},
{
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(1);
expect(
documentXml.match(
/]*w:before="0"[^>]*w:after="0"[^>]*w:line="360"[^>]*w:lineRule="atLeast"/gu
)
).toHaveLength(1);
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).toMatch(
/[\s\S]*?[\s\S]*?<<\/w:t>[\s\S]*?[\s\S]*?br<\/w:t>[\s\S]*?[\s\S]*?><\/w:t>[\s\S]*?<\/w:p>/u
);
expect(documentXml).toMatch(
/[\s\S]*?[\s\S]*?[\s\S]*? method<\/w:t>[\s\S]*?<\/w:p>/u
);
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("含行内代码的实测行距允许 Word 扩展行盒", () => {
const fixture = readGeneratedDocxPackage(generatedFixture());
const documentXml = decoder.decode(
fixture.entries.get("word/document.xml")!
).replace(
"",
'前缀inline后缀'
);
fixture.entries.set("word/document.xml", encoder.encode(documentXml));
const result = finalizeGeneratedDocxStructure(
writeGeneratedDocxPackage(fixture.entries),
plan,
tokens,
[],
{
tables: [],
textBlocks: [{
ordinal: 1,
text: "前缀inline后缀",
letterSpacingPt: 0,
linePitchPt: 11.15,
lineBreakOffsets: []
}]
}
);
const outputXml = decoder.decode(
readGeneratedDocxPackage(result.content).entries.get(
"word/document.xml"
)!
);
expect(outputXml).toMatch(
/w:line="223" w:lineRule="atLeast"[\s\S]*?前缀<\/w:t>[\s\S]*?/u
);
});
it("未匹配实测布局的行内代码段落也不保留 exact 行盒", () => {
const fixture = readGeneratedDocxPackage(generatedFixture());
const documentXml = decoder.decode(
fixture.entries.get("word/document.xml")!
).replace(
"",
'前缀inline'
);
fixture.entries.set("word/document.xml", encoder.encode(documentXml));
const result = finalizeGeneratedDocxStructure(
writeGeneratedDocxPackage(fixture.entries),
plan,
tokens
);
const outputXml = decoder.decode(
readGeneratedDocxPackage(result.content).entries.get(
"word/document.xml"
)!
);
expect(outputXml).toMatch(
/w:line="312" w:lineRule="atLeast"[\s\S]*?/u
);
});
it("表格纯公式段落加入零宽普通 Run 以服从段落对齐", () => {
const fixture = readGeneratedDocxPackage(generatedFixture());
const documentXml = decoder.decode(
fixture.entries.get("word/document.xml")!
).replace(
`xmlns:r="${relationships}"`,
`xmlns:r="${relationships}" xmlns:m="${math}"`
).replace(
"B",
'≥0.80'
);
fixture.entries.set("word/document.xml", encoder.encode(documentXml));
const result = finalizeGeneratedDocxStructure(
writeGeneratedDocxPackage(fixture.entries),
plan,
tokens
);
const outputXml = decoder.decode(
readGeneratedDocxPackage(result.content).entries.get(
"word/document.xml"
)!
);
expect(outputXml).toMatch(
/[\s\S]*?[\s\S]*?≥0\.80<\/m:t>[\s\S]*?<\/m:oMath><\/w:rPr>\u200B<\/w:t><\/w:r>/u
);
});
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).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("将超出内容盒的实测表格等比收缩到 Word 可用宽度", () => {
const result = finalizeGeneratedDocxStructure(
generatedFixture(),
plan,
tokens,
[],
{
tables: [
{
ordinal: 1,
widthPercent: 300,
leftOffsetPercent: 0,
columnWidthPercents: [40, 10, 10, 10, 10, 20],
rows: []
}
]
}
);
const documentXml = decoder.decode(
readGeneratedDocxPackage(result.content).entries.get(
"word/document.xml"
)!
);
expect(documentXml).toContain(
''
);
expect(documentXml).toContain('');
expect(documentXml).toContain('');
});
it("按 Chromium 实测 emoji 调色保留可编辑 Unicode 文本", () => {
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: [],
emojiRuns: [
{ ordinal: 1, text: "⭐⭐⭐⭐", color: "#e7bf36" }
]
}
);
const finalizedXml = decoder.decode(
readGeneratedDocxPackage(result.content).entries.get(
"word/document.xml"
)!
);
expect(finalizedXml).toMatch(
/<\/w:rPr>⭐⭐⭐⭐<\/w:t><\/w:r>/u
);
});
it("实测表格缩进补偿 Word 以单元格内容而非外边框对齐的行为", () => {
const paddedTokens = {
...tokens,
slots: tokens.slots.map((slot) =>
slot.slot === "table-cell"
? {
...slot,
style: {
...slot.style,
paddingPt: { top: 3, right: 6, bottom: 3, left: 6 }
}
}
: slot
)
} satisfies DocxThemeTokenSet;
const result = finalizeGeneratedDocxStructure(
generatedFixture(),
plan,
paddedTokens,
[],
{
tables: [{
ordinal: 1,
widthPercent: 90,
leftOffsetPercent: 5,
columnWidthPercents: [30, 70],
rows: []
}]
}
);
const documentXml = decoder.decode(
readGeneratedDocxPackage(result.content).entries.get(
"word/document.xml"
)!
);
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("将 Pandoc 拆分的标准 Alert 正文恢复为引用块样式", () => {
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(
"",
`Caution` +
`警告正文` +
``
))
);
const result = finalizeGeneratedDocxStructure(
writeGeneratedDocxPackage(entries),
plan,
tokens
);
const finalizedXml = decoder.decode(
readGeneratedDocxPackage(result.content).entries.get(
"word/document.xml"
)!
);
expect(finalizedXml).toMatch(
/[\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("