feat: 完成 DOCX 通用结构映射与严格收口

This commit is contained in:
SkyJourney
2026-07-31 15:19:34 +08:00
parent 6e40374200
commit f3847f3e4b
31 changed files with 3175 additions and 64 deletions
@@ -32,7 +32,14 @@ function xml(value: string) {
return encoder.encode(value);
}
function createAcceptanceDocx(overrides: { altChunk?: boolean } = {}) {
function createAcceptanceDocx(
overrides: {
altChunk?: boolean;
duplicateText?: boolean;
internalMarker?: boolean;
invalidPropertyOrder?: boolean;
} = {}
) {
return zipSync({
"[Content_Types].xml": xml(
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="png" ContentType="image/png"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/footer1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/></Types>'
@@ -41,7 +48,7 @@ function createAcceptanceDocx(overrides: { altChunk?: boolean } = {}) {
`<Relationships xmlns="${packageRelationships}"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>`
),
"word/document.xml": xml(
`<w:document xmlns:w="${word}" xmlns:r="${relationships}" xmlns:m="${math}" xmlns:wp="${drawing}"><w:body><w:p><w:pPr><w:numPr><w:numId w:val="1"/></w:numPr></w:pPr><w:hyperlink r:id="rIdLink"><w:r><w:t>可编辑正文</w:t></w:r></w:hyperlink><w:r><w:footnoteReference w:id="1"/></w:r><m:oMath><m:r><m:t>x</m:t></m:r></m:oMath></w:p><w:tbl><w:tr><w:tc><w:p><w:r><w:t>原生表格</w:t></w:r></w:p></w:tc></w:tr></w:tbl><w:p><w:r><w:drawing><wp:inline><wp:docPr id="1" name="图片 1" descr="普通图片"/><a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"/></wp:inline></w:drawing></w:r></w:p>${overrides.altChunk ? '<w:altChunk r:id="rIdChunk"/>' : ""}<w:sectPr><w:footerReference w:type="default" r:id="rIdFooter"/><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="907" w:right="907" w:bottom="907" w:left="907"/></w:sectPr></w:body></w:document>`
`<w:document xmlns:w="${word}" xmlns:r="${relationships}" xmlns:m="${math}" xmlns:wp="${drawing}"><w:body><w:p><w:pPr><w:pStyle w:val="Heading1"/><w:numPr><w:numId w:val="1"/></w:numPr></w:pPr><w:hyperlink r:id="rIdLink"><w:r><w:t>可编辑正文</w:t></w:r></w:hyperlink><w:r><w:footnoteReference w:id="1"/></w:r><m:oMath><m:r><m:t>x</m:t></m:r></m:oMath></w:p>${overrides.invalidPropertyOrder ? '<w:p><w:pPr><w:jc w:val="left"/><w:spacing w:after="0"/></w:pPr></w:p>' : ""}${overrides.duplicateText ? "<w:p><w:r><w:t>可编辑正文</w:t></w:r></w:p>" : ""}${overrides.internalMarker ? "<w:p><w:r><w:t>MD_TO_PDF_INTERNAL</w:t></w:r></w:p>" : ""}<w:p><w:pPr><w:sectPr><w:type w:val="nextPage"/><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="907" w:right="907" w:bottom="907" w:left="907"/></w:sectPr></w:pPr></w:p><w:tbl><w:tblPr><w:tblW w:w="5000" w:type="pct"/></w:tblPr><w:tr><w:tc><w:p><w:r><w:t>原生表格</w:t></w:r></w:p></w:tc></w:tr></w:tbl><w:p><w:r><w:drawing><wp:inline><wp:docPr id="1" name="图片 1" descr="普通图片"/><a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"/></wp:inline></w:drawing></w:r></w:p>${overrides.altChunk ? '<w:altChunk r:id="rIdChunk"/>' : ""}<w:sectPr><w:footerReference w:type="default" r:id="rIdFooter"/><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="907" w:right="907" w:bottom="907" w:left="907"/><w:pgNumType w:start="1"/></w:sectPr></w:body></w:document>`
),
"word/styles.xml": xml(
`<w:styles xmlns:w="${word}">${["Normal", "Heading1", "SourceCode", "Table", "Caption"].map((id) => `<w:style w:type="paragraph" w:styleId="${id}"/>`).join("")}</w:styles>`
@@ -53,7 +60,7 @@ function createAcceptanceDocx(overrides: { altChunk?: boolean } = {}) {
'<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"/>'
),
"word/footer1.xml": xml(
`<w:ftr xmlns:w="${word}"><w:p><w:r><w:instrText> PAGE \\* MERGEFORMAT </w:instrText></w:r></w:p></w:ftr>`
`<w:ftr xmlns:w="${word}"><w:p><w:r><w:instrText> PAGE \\* MERGEFORMAT </w:instrText></w:r><w:r><w:instrText> SECTIONPAGES \\* MERGEFORMAT </w:instrText></w:r></w:p></w:ftr>`
),
"word/footnotes.xml": xml(
`<w:footnotes xmlns:w="${word}"><w:footnote w:id="1"><w:p><w:r><w:t>脚注</w:t></w:r></w:p></w:footnote></w:footnotes>`
@@ -93,7 +100,17 @@ const expectation = {
minimumPngImages: 1,
requiredImageAltText: ["普通图片"],
requiredStyleIds: ["Normal", "Heading1", "SourceCode", "Table"],
requirePageField: true
requiredParagraphStyleIds: ["Heading1"],
requirePageField: true,
minimumSections: 2,
requireFirstSectionWithoutHeaderFooter: true,
finalPageNumberStart: 1,
requireSectionPagesField: true,
minimumFullWidthTables: 1,
maximumTextOccurrences: {
可编辑正文: 1
},
forbidInternalMarkers: true
};
describe("DOCX 自动验收器", () => {
@@ -112,6 +129,10 @@ describe("DOCX 自动验收器", () => {
mathObjectCount: 1,
pngImageCount: 1,
pageFieldCount: 1,
sectionPageFieldCount: 1,
sectionCount: 2,
fullWidthTableCount: 1,
internalMarkerCount: 0,
altChunkCount: 0
});
expect(report.checks).toEqual(
@@ -131,4 +152,28 @@ describe("DOCX 自动验收器", () => {
)
).toThrow("altChunk");
});
it("拒绝重复文本和未清理的内部标记", () => {
expect(() =>
inspectDocxAcceptance(
createAcceptanceDocx({ duplicateText: true }),
expectation
)
).toThrow("最多允许 1 次");
expect(() =>
inspectDocxAcceptance(
createAcceptanceDocx({ internalMarker: true }),
expectation
)
).toThrow("internal-markers");
});
it("拒绝 WordprocessingML 属性子节点乱序", () => {
expect(() =>
inspectDocxAcceptance(
createAcceptanceDocx({ invalidPropertyOrder: true }),
expectation
)
).toThrow("w:pPr 子节点顺序无效");
});
});
@@ -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");
});
});
+147
View File
@@ -0,0 +1,147 @@
import { describe, expect, it } from "vitest";
import {
WORD_NAMESPACE,
appendElement,
parseXmlPart,
serializeXmlPart,
validateWordprocessingElementOrder
} from "../src/ooxml.js";
const encoder = new TextEncoder();
const decoder = new TextDecoder();
function paragraphPropertiesDocument(children = "") {
return parseXmlPart(
encoder.encode(
`<w:document xmlns:w="${WORD_NAMESPACE}">` +
`<w:body><w:p><w:pPr>${children}</w:pPr></w:p></w:body>` +
"</w:document>"
),
"word/document.xml"
);
}
describe("OOXML 子节点顺序", () => {
it("按 WordprocessingML 顺序插入新增属性", () => {
const document = paragraphPropertiesDocument(
"<w:pStyle w:val=\"Normal\"/><w:jc w:val=\"left\"/>"
);
const properties = document.getElementsByTagNameNS(
WORD_NAMESPACE,
"pPr"
)[0]!;
appendElement(properties, WORD_NAMESPACE, "w:pBdr");
appendElement(properties, WORD_NAMESPACE, "w:keepNext");
appendElement(properties, WORD_NAMESPACE, "w:spacing");
const xml = decoder.decode(serializeXmlPart(document));
expect(xml.indexOf("<w:keepNext")).toBeLessThan(
xml.indexOf("<w:pBdr")
);
expect(xml.indexOf("<w:pBdr")).toBeLessThan(
xml.indexOf("<w:spacing")
);
expect(xml.indexOf("<w:spacing")).toBeLessThan(
xml.indexOf("<w:jc")
);
expect(() =>
validateWordprocessingElementOrder(
document,
"word/document.xml"
)
).not.toThrow();
});
it("在序列化时规范化已有乱序属性", () => {
const document = paragraphPropertiesDocument(
"<w:pStyle w:val=\"Normal\"/>" +
"<w:pBdr/><w:keepLines/><w:keepNext/>"
);
const serialized = serializeXmlPart(document);
const reparsed = parseXmlPart(
serialized,
"word/document.xml"
);
expect(() =>
validateWordprocessingElementOrder(
reparsed,
"word/document.xml"
)
).not.toThrow();
const xml = decoder.decode(serialized);
expect(xml.indexOf("<w:keepNext")).toBeLessThan(
xml.indexOf("<w:keepLines")
);
expect(xml.indexOf("<w:keepLines")).toBeLessThan(
xml.indexOf("<w:pBdr")
);
});
it("拒绝未经规范化的乱序属性", () => {
const document = paragraphPropertiesDocument(
"<w:pStyle w:val=\"Normal\"/>" +
"<w:jc w:val=\"left\"/><w:spacing/>"
);
expect(() =>
validateWordprocessingElementOrder(
document,
"word/document.xml"
)
).toThrow(
"word/document.xml 的 w:pPr 子节点顺序无效"
);
});
it("规范化表格、单元格和边框顺序", () => {
const document = parseXmlPart(
encoder.encode(
`<w:document xmlns:w="${WORD_NAMESPACE}"><w:body>` +
"<w:tbl><w:tblPr>" +
"<w:tblLook/><w:tblW/><w:tblLayout/>" +
"</w:tblPr><w:tblGrid/><w:tr><w:tc><w:tcPr>" +
"<w:gridSpan/><w:shd/><w:tcW/>" +
"</w:tcPr><w:p/></w:tc></w:tr></w:tbl>" +
"<w:sectPr><w:docGrid/><w:vAlign/></w:sectPr>" +
"</w:body></w:document>"
),
"word/document.xml"
);
const serialized = serializeXmlPart(document);
const reparsed = parseXmlPart(
serialized,
"word/document.xml"
);
expect(() =>
validateWordprocessingElementOrder(
reparsed,
"word/document.xml"
)
).not.toThrow();
});
it("规范化表格条件样式的属性顺序", () => {
const document = parseXmlPart(
encoder.encode(
`<w:styles xmlns:w="${WORD_NAMESPACE}">` +
'<w:style w:type="table" w:styleId="Table">' +
'<w:tblStylePr w:type="firstRow">' +
"<w:tcPr/><w:rPr/><w:pPr/>" +
"</w:tblStylePr></w:style></w:styles>"
),
"word/styles.xml"
);
const xml = decoder.decode(serializeXmlPart(document));
expect(xml.indexOf("<w:pPr")).toBeLessThan(
xml.indexOf("<w:rPr")
);
expect(xml.indexOf("<w:rPr")).toBeLessThan(
xml.indexOf("<w:tcPr")
);
});
});
@@ -75,6 +75,14 @@ function input() {
keywords: [],
language: "zh-CN"
},
semanticDocument: {
schemaVersion: 1,
titlePolicy: {
metadataTitle: "suppress",
firstBodyHeading: "keep"
},
regions: []
},
media: emptyMedia
};
}
@@ -120,6 +128,20 @@ describe("Pandoc DOCX 转换器", () => {
expect(
options.env?.MD_TO_PDF_DOCX_MEDIA_MAP
).toContain("media-map.json");
expect(
options.env?.MD_TO_PDF_DOCX_STRUCTURE_PLAN
).toContain("structure-plan.json");
const structurePlan = JSON.parse(
await import("node:fs/promises").then(({ readFile }) =>
readFile(
options.env!.MD_TO_PDF_DOCX_STRUCTURE_PLAN!,
"utf8"
)
)
);
expect(structurePlan.titlePolicy.metadataTitle).toBe(
"suppress"
);
await copyFile(
argumentAfter(arguments_, "--reference-doc"),
argumentAfter(arguments_, "--output")
@@ -0,0 +1,239 @@
import { describe, expect, it } from "vitest";
import type {
SemanticDocumentModel,
SemanticDocumentNode
} from "@md-to-pdf/core";
import type {
DocxResolvedStyleSlot,
DocxStyleSlotName,
DocxThemeTokenSet
} from "@md-to-pdf/docx-theme-engine";
import {
createPandocStructurePlan,
type PandocStructureBlock
} from "../src/index.js";
function slot(
name: DocxStyleSlotName,
hidden = false
): DocxResolvedStyleSlot {
return {
slot: name,
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: [],
...(hidden ? { hidden: true } : {})
}
};
}
function tokens(
slots: DocxResolvedStyleSlot[] = []
): DocxThemeTokenSet {
return {
schemaVersion: 1,
themeId: "test-theme",
themeFingerprint: "c".repeat(64),
mode: "auto-with-overrides",
basePreset: "technical",
slots,
diagnostics: []
};
}
function text(
role: Extract<SemanticDocumentNode, { kind: "text" }>["role"],
value: string,
label?: string
): SemanticDocumentNode {
return {
kind: "text",
role,
text: value,
...(label ? { label } : {})
};
}
function paragraphSegments(block: PandocStructureBlock): string[] {
if (block.kind === "paragraph") {
return block.segments;
}
if (block.kind === "container") {
return block.blocks.flatMap(paragraphSegments);
}
return [];
}
describe("Pandoc 语义结构投影", () => {
it("将公文分组投影为固定 Word 样式和可编辑段落", () => {
const model: SemanticDocumentModel = {
schemaVersion: 1,
profile: "official",
titlePolicy: {
metadataTitle: "suppress",
firstBodyHeading: "suppress"
},
regions: [
{
kind: "prefix",
nodes: [
{
kind: "group",
role: "official-masthead",
children: [
{
kind: "group",
role: "official-classification",
children: [
text("official-secrecy", "秘密"),
text("official-urgency", "特急")
]
},
text("official-issuer", "示例单位"),
{
kind: "group",
role: "official-issue-row",
children: [
text("official-number", "示例〔20261号"),
text(
"official-signatory",
"张三",
"签发人:"
)
]
}
]
},
text("official-title", "关于开展工作的通知")
]
}
]
};
const plan = createPandocStructurePlan(model, tokens(), {
markerSeed: "official"
});
expect(plan.titlePolicy.firstBodyHeading).toBe("suppress");
expect(plan.prefix[0]).toMatchObject({
kind: "container",
styleId: "MdOfficialMasthead",
slot: "official-masthead",
startMarker:
"MD_TO_PDF_CONTAINER_START_official_1",
endMarker: "MD_TO_PDF_CONTAINER_END_official_2",
blocks: [
{
kind: "paragraph",
styleId: "MdOfficialClassification",
segments: ["秘密", "特急"],
separator: "tab"
},
{
kind: "paragraph",
styleId: "MdOfficialIssuer",
segments: ["示例单位"]
},
{
kind: "paragraph",
styleId: "MdOfficialIssueRow",
segments: ["示例〔20261号", "签发人:张三"],
separator: "tab"
}
]
});
expect(plan.prefix[1]).toMatchObject({
kind: "paragraph",
styleId: "MdOfficialTitle",
segments: ["关于开展工作的通知"]
});
});
it("按主题隐藏令牌过滤标书字段并保留封面分页", () => {
const model: SemanticDocumentModel = {
schemaVersion: 1,
profile: "tender",
titlePolicy: {
metadataTitle: "suppress",
firstBodyHeading: "suppress"
},
regions: [
{
kind: "cover",
section: {
headerFooter: "none",
pageNumber: "hidden",
breakAfter: "next-page",
followingPageNumberStart: 1
},
nodes: [
{
kind: "group",
role: "tender-cover",
children: [
text("tender-title", "投标文件"),
text("tender-bidder", "投标单位"),
text(
"tender-representative",
"授权代表"
),
text("tender-date", "2026年7月")
]
}
]
}
]
};
const plan = createPandocStructurePlan(
model,
tokens([
slot("tender-bidder", true),
slot("tender-representative", true)
]),
{ markerSeed: "tender" }
);
expect(plan.prefix.at(-1)).toEqual({
kind: "section-break",
marker: "MD_TO_PDF_SECTION_BREAK_tender_3",
headerFooter: "none",
pageNumber: "hidden",
followingPageNumberStart: 1
});
expect(plan.prefix[0]).toMatchObject({
kind: "container",
styleId: "MdTenderCover"
});
expect(paragraphSegments(plan.prefix[0]!)).toEqual([
"投标文件",
"2026年7月"
]);
});
it("没有结构区域时只传递标题策略", () => {
const plan = createPandocStructurePlan(
{
schemaVersion: 1,
titlePolicy: {
metadataTitle: "emit",
firstBodyHeading: "keep"
},
regions: []
},
tokens(),
{ markerSeed: "empty" }
);
expect(plan).toEqual({
schemaVersion: 1,
titlePolicy: {
metadataTitle: "emit",
firstBodyHeading: "keep"
},
prefix: [],
suffix: []
});
});
});
@@ -229,7 +229,8 @@ describe("动态 reference.docx", () => {
fontSizePt: 14,
color: "#112233",
lineSpacing: 1.8,
firstLineIndentPt: 28
firstLineIndentPt: 28,
alignment: "justify"
}
},
{
@@ -297,7 +298,9 @@ describe("动态 reference.docx", () => {
expect(stylesXml).toContain('w:val="112233"');
expect(stylesXml).toContain('w:styleId="Heading1"');
expect(stylesXml).toContain('w:val="AA0000"');
expect(stylesXml).toContain('w:w="5000" w:type="pct"');
expect(stylesXml).toContain('<w:jc w:val="both"/>');
expect(stylesXml).not.toContain('w:val="justify"');
expect(stylesXml).not.toContain("<w:tblW");
expect(stylesXml).toContain('w:color="445566"');
expect(fontTableXml).toContain(
'w:name="Source Han Serif SC"'
@@ -435,6 +438,48 @@ describe("动态 reference.docx", () => {
);
});
it("拒绝重复样式 ID 和非法两端对齐枚举", () => {
const result = createDynamicReferenceDocx(
createBaselineReference(),
createOptions(defaultExportConfig)
);
const entries = new Map(
Object.entries(unzipSync(result.content))
);
const styles = decoder.decode(entries.get("word/styles.xml")!);
entries.set(
"word/styles.xml",
encoder.encode(
styles.replace(
"</w:styles>",
'<w:style w:type="paragraph" w:styleId="Normal"/>' +
"</w:styles>"
)
)
);
expect(() =>
validateDynamicReferenceDocx(
writeReferenceDocxPackage(entries)
)
).toThrow(/ IDNormal/u);
entries.set(
"word/styles.xml",
encoder.encode(
styles.replace(
/<w:jc w:val="[^"]+"\/>/u,
'<w:jc w:val="justify"/>'
)
)
);
expect(() =>
validateDynamicReferenceDocx(
writeReferenceDocxPackage(entries)
)
).toThrow(/使/u);
});
it("最终 DOCX 使用媒体输出上限而非模板的 2 MiB 上限", () => {
const result = createDynamicReferenceDocx(
createBaselineReference(),