feat: 完成 DOCX 通用结构映射与严格收口
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DocxThemeTokenSet } from "@md-to-pdf/docx-theme-engine";
|
||||
import {
|
||||
finalizeGeneratedDocxStructure,
|
||||
readGeneratedDocxPackage,
|
||||
readReferenceDocxPackage,
|
||||
writeGeneratedDocxPackage,
|
||||
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";
|
||||
|
||||
function generatedFixture() {
|
||||
const baseline = readReferenceDocxPackage(
|
||||
createTestBaselineReference()
|
||||
);
|
||||
const entries = new Map(baseline.entries);
|
||||
entries.set(
|
||||
"word/document.xml",
|
||||
encoder.encode(
|
||||
`<w:document xmlns:w="${word}" xmlns:r="${relationships}"><w:body>` +
|
||||
`<w:p><w:r><w:t>CONTAINER_START</w:t></w:r></w:p>` +
|
||||
`<w:p><w:pPr><w:pStyle w:val="MdTenderTitle"/></w:pPr><w:r><w:t>可编辑封面</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:t>CONTAINER_END</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:t>SECTION_BREAK</w:t></w:r></w:p>` +
|
||||
`<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>正文标题</w:t></w:r></w:p>` +
|
||||
`<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/></w:tblPr><w:tblGrid><w:gridCol w:w="1000"/><w:gridCol w:w="2000"/></w:tblGrid><w:tr><w:tc><w:tcPr/><w:p><w:r><w:t>A</w:t></w:r></w:p></w:tc><w:tc><w:tcPr/><w:p><w:r><w:t>B</w:t></w:r></w:p></w:tc></w:tr></w:tbl>` +
|
||||
`<w:sectPr><w:footerReference w:type="default" r:id="rIdFooter"/><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1000" w:right="1000" w:bottom="1000" w:left="1000"/><w:pgNumType w:start="5"/><w:titlePg/></w:sectPr>` +
|
||||
`</w:body></w:document>`
|
||||
)
|
||||
);
|
||||
entries.set(
|
||||
"word/footer1.xml",
|
||||
encoder.encode(
|
||||
`<w:ftr xmlns:w="${word}"><w:p><w:r><w:instrText xml:space="preserve"> NUMPAGES \\* MERGEFORMAT </w:instrText></w:r></w:p></w:ftr>`
|
||||
)
|
||||
);
|
||||
return writeGeneratedDocxPackage(entries);
|
||||
}
|
||||
|
||||
const plan: PandocStructurePlan = {
|
||||
schemaVersion: 1,
|
||||
titlePolicy: {
|
||||
metadataTitle: "suppress",
|
||||
firstBodyHeading: "keep"
|
||||
},
|
||||
prefix: [
|
||||
{
|
||||
kind: "container",
|
||||
styleId: "MdTenderCover",
|
||||
slot: "tender-cover",
|
||||
startMarker: "CONTAINER_START",
|
||||
endMarker: "CONTAINER_END",
|
||||
blocks: []
|
||||
},
|
||||
{
|
||||
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",
|
||||
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: "heading-1",
|
||||
source: "computed-css",
|
||||
confidence: "exact",
|
||||
style: {
|
||||
fontCandidates: [],
|
||||
pageBreakAfter: true
|
||||
}
|
||||
}
|
||||
],
|
||||
diagnostics: []
|
||||
};
|
||||
|
||||
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(/<w:sectPr(?:\s|>)/gu)
|
||||
).toHaveLength(2);
|
||||
expect(documentXml).toContain(
|
||||
'<w:type w:val="nextPage"/>'
|
||||
);
|
||||
expect(documentXml).toContain(
|
||||
'<w:vAlign w:val="center"/>'
|
||||
);
|
||||
expect(documentXml).toContain(
|
||||
'<w:pgNumType w:start="1"/>'
|
||||
);
|
||||
expect(documentXml).toContain(
|
||||
'<w:tblW w:w="5000" w:type="pct"/>'
|
||||
);
|
||||
expect(documentXml).toContain(
|
||||
'<w:tblLayout w:type="fixed"/>'
|
||||
);
|
||||
expect(documentXml).toContain('<w:gridCol w:w="3302"/>');
|
||||
expect(documentXml).toContain('<w:gridCol w:w="6604"/>');
|
||||
expect(documentXml).toContain("<w:cantSplit/>");
|
||||
expect(documentXml).toContain('w:fill="F5F5F5"');
|
||||
expect(
|
||||
documentXml.match(/<w:br w:type="page"\/>/gu)
|
||||
).toHaveLength(1);
|
||||
expect(footerXml).toContain("SECTIONPAGES");
|
||||
expect(footerXml).not.toContain(" NUMPAGES ");
|
||||
expect(result.report).toEqual({
|
||||
containerCount: 1,
|
||||
sectionCount: 1,
|
||||
tableCount: 1,
|
||||
pageBreakAfterCount: 1,
|
||||
sectionPageFieldCount: 1
|
||||
});
|
||||
});
|
||||
|
||||
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(
|
||||
"</w:styles>",
|
||||
'<w:style w:type="paragraph" w:styleId="MdTenderTitle">' +
|
||||
'<w:name w:val="重复降级样式"/></w:style>' +
|
||||
'<w:style w:type="paragraph" w:styleId="PandocFallback">' +
|
||||
'<w:pPr><w:jc w:val="justify"/></w:pPr></w:style>' +
|
||||
'<w:style w:type="table" w:styleId="PandocTableFallback">' +
|
||||
'<w:tblPr><w:tblW w:w="5000" w:type="pct"/></w:tblPr>' +
|
||||
"</w:style></w:styles>"
|
||||
);
|
||||
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(
|
||||
'<w:jc w:val="both"/>'
|
||||
);
|
||||
expect(outputStyles).not.toContain('w:val="justify"');
|
||||
expect(outputStyles).not.toContain("<w:tblW");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user