feat: 完成 DOCX R4 元素级视觉门禁

建立 Chromium、Word 与 WPS 的可编辑内容、段落几何、页眉页脚、封面、媒体和逐页视觉比较链路。

支持封面独立分节、正文页码重启、主题页边距与横向纸张,并将自然分页差异降为诊断项。

修正代码块内边距和折叠表格单元格边框映射,14 套主题生产矩阵与六组布局视觉矩阵通过。
This commit is contained in:
SkyJourney
2026-08-02 03:27:11 +08:00
parent f9f5fccfc9
commit 58f87cc19f
100 changed files with 10409 additions and 386 deletions
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { zipSync } from "fflate";
import { defaultExportConfig } from "@md-to-pdf/core";
import { inspectDocxAcceptance } from "../src/index.js";
const encoder = new TextEncoder();
@@ -38,36 +39,53 @@ function createAcceptanceDocx(
duplicateText?: boolean;
internalMarker?: boolean;
invalidPropertyOrder?: boolean;
coverPageNumber?: boolean;
bodyTitlePage?: boolean;
footerAlignment?: "left" | "center" | "right";
evenAndOddHeaders?: boolean;
logicalOuterFooter?: boolean;
nativeOuterFooter?: 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>'
`<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"/>${overrides.nativeOuterFooter ? '<Override PartName="/word/footer2.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/>' : ""}</Types>`
),
"_rels/.rels": xml(
`<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: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>`
`<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"/>${overrides.coverPageNumber ? '<w:pgNumType w:start="1"/>' : ""}</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"/>${overrides.nativeOuterFooter ? '<w:footerReference w:type="even" r:id="rIdEvenFooter"/>' : ""}<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="${overrides.nativeOuterFooter ? "2" : "1"}"/>${overrides.bodyTitlePage ? "<w:titlePg/>" : ""}</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>`
),
"word/settings.xml": xml(`<w:settings xmlns:w="${word}"/>`),
"word/settings.xml": xml(
`<w:settings xmlns:w="${word}">${overrides.evenAndOddHeaders ? "<w:evenAndOddHeaders/>" : ""}</w:settings>`
),
"word/fontTable.xml": xml(`<w:fonts xmlns:w="${word}"/>`),
"word/numbering.xml": xml(`<w:numbering xmlns:w="${word}"/>`),
"word/theme/theme1.xml": xml(
'<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:r><w:instrText> SECTIONPAGES \\* MERGEFORMAT </w:instrText></w:r></w:p></w:ftr>`
overrides.logicalOuterFooter
? `<w:ftr xmlns:w="${word}"><w:p><w:pPr><w:tabs><w:tab w:val="right" w:pos="9000"/></w:tabs></w:pPr><w:r><w:instrText> IF </w:instrText></w:r><w:r><w:instrText> MOD( </w:instrText></w:r><w:r><w:instrText> PAGE </w:instrText></w:r><w:r><w:instrText> PAGE </w:instrText></w:r><w:r><w:instrText> SECTIONPAGES </w:instrText></w:r></w:p></w:ftr>`
: `<w:ftr xmlns:w="${word}"><w:p><w:pPr><w:jc w:val="${overrides.nativeOuterFooter ? "left" : (overrides.footerAlignment ?? "center")}"/></w:pPr><w:r><w:instrText> PAGE \\* MERGEFORMAT </w:instrText></w:r><w:r><w:instrText> SECTIONPAGES \\* MERGEFORMAT </w:instrText></w:r></w:p></w:ftr>`
),
...(overrides.nativeOuterFooter
? {
"word/footer2.xml": xml(
`<w:ftr xmlns:w="${word}"><w:p><w:pPr><w:jc w:val="right"/></w:pPr><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>`
),
"word/media/image1.png": png,
"word/_rels/document.xml.rels": xml(
`<Relationships xmlns="${packageRelationships}"><Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/><Relationship Id="rIdFooter" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footer1.xml"/><Relationship Id="rIdImage" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.png"/><Relationship Id="rIdLink" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.invalid/" TargetMode="External"/>${overrides.altChunk ? '<Relationship Id="rIdChunk" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/aFChunk" Target="chunk.html"/>' : ""}</Relationships>`
`<Relationships xmlns="${packageRelationships}"><Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/><Relationship Id="rIdFooter" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footer1.xml"/>${overrides.nativeOuterFooter ? '<Relationship Id="rIdEvenFooter" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footer2.xml"/>' : ""}<Relationship Id="rIdImage" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.png"/><Relationship Id="rIdLink" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.invalid/" TargetMode="External"/>${overrides.altChunk ? '<Relationship Id="rIdChunk" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/aFChunk" Target="chunk.html"/>' : ""}</Relationships>`
),
...(overrides.altChunk
? { "word/chunk.html": encoder.encode("<p>整体 HTML</p>") }
@@ -110,7 +128,38 @@ const expectation = {
maximumTextOccurrences: {
可编辑正文: 1
},
forbidInternalMarkers: true
forbidInternalMarkers: true,
pageDecorations: {
exportConfig: {
header: defaultExportConfig.header,
footer: defaultExportConfig.footer
},
semanticDocument: {
schemaVersion: 1 as const,
titlePolicy: {
metadataTitle: "suppress" as const,
firstBodyHeading: "keep" as const
},
regions: [
{
kind: "cover" as const,
nodes: [
{
kind: "text" as const,
role: "project-report-title" as const,
text: "封面"
}
],
section: {
headerFooter: "none" as const,
pageNumber: "hidden" as const,
breakAfter: "next-page" as const,
followingPageNumberStart: 1
}
}
]
}
}
};
describe("DOCX 自动验收器", () => {
@@ -142,6 +191,15 @@ describe("DOCX 自动验收器", () => {
noAltChunk: true
})
);
expect(report.pageDecorations).toEqual({
hasCover: true,
usesDifferentFirstPage: false,
usesEvenAndOddPages: false,
bodyPageNumberStart: 1,
headerReferenceTypes: [],
footerReferenceTypes: ["default"],
totalPageField: "SECTIONPAGES"
});
});
it("拒绝通过 altChunk 嵌入的正文 HTML", () => {
@@ -176,4 +234,67 @@ describe("DOCX 自动验收器", () => {
)
).toThrow("w:pPr 子节点顺序无效");
});
it("拒绝封面节残留页码和正文节错误首页设置", () => {
expect(() =>
inspectDocxAcceptance(
createAcceptanceDocx({ coverPageNumber: true }),
expectation
)
).toThrow("封面节不得包含 w:pgNumType");
expect(() =>
inspectDocxAcceptance(
createAcceptanceDocx({ bodyTitlePage: true }),
expectation
)
).toThrow("body-title-page");
});
it("拒绝页码位置和奇偶页设置偏离导出配置", () => {
expect(() =>
inspectDocxAcceptance(
createAcceptanceDocx({ footerAlignment: "right" }),
expectation
)
).toThrow("footer-default-alignment");
expect(() =>
inspectDocxAcceptance(
createAcceptanceDocx({ evenAndOddHeaders: true }),
expectation
)
).toThrow("even-and-odd-setting");
});
it("封面重启页码时要求原生奇偶页脚表达外侧页码", () => {
const outerExpectation = {
...expectation,
pageDecorations: {
...expectation.pageDecorations,
exportConfig: {
...expectation.pageDecorations.exportConfig,
footer: {
...expectation.pageDecorations.exportConfig.footer,
alignment: "outer" as const
}
}
}
};
const report = inspectDocxAcceptance(
createAcceptanceDocx({
evenAndOddHeaders: true,
nativeOuterFooter: true,
footerAlignment: "left"
}),
outerExpectation
);
expect(report.pageDecorations).toMatchObject({
hasCover: true,
usesEvenAndOddPages: true,
footerReferenceTypes: ["default", "even"]
});
expect(() =>
inspectDocxAcceptance(createAcceptanceDocx(), outerExpectation)
).toThrow("body-page-number-start");
});
});
@@ -5,6 +5,7 @@ import {
readGeneratedDocxPackage,
readReferenceDocxPackage,
writeGeneratedDocxPackage,
type PandocMediaLayoutItem,
type PandocStructurePlan
} from "../src/index.js";
import { createTestBaselineReference } from "./reference-test-fixture.js";
@@ -15,6 +16,12 @@ 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(
@@ -26,11 +33,17 @@ function generatedFixture() {
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="MdOfficialIssueRow"/></w:pPr><w:r><w:t>发文字号</w:t></w:r><w:r><w:tab/></w:r><w:r><w:t>签发人</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:p><w:pPr><w:pStyle w:val="FirstParagraph"/></w:pPr><w:r><w:drawing>` +
`<wp:inline xmlns:wp="${wordprocessingDrawing}"><wp:extent cx="100" cy="200"/><wp:docPr id="1" name="Picture" descr="Mermaid 图表 1" title="mdtp-media:docx-media-1"/>` +
`<a:graphic xmlns:a="${drawing}"><a:graphicData uri="${picture}"><pic:pic xmlns:pic="${picture}"><pic:nvPicPr><pic:cNvPr id="0" name="image.png"/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdImage"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm rot="60000" flipH="1"><a:off x="0" y="0"/><a:ext cx="100" cy="200"/></a:xfrm></pic:spPr></pic:pic></a:graphicData></a:graphic>` +
`</wp:inline></w:drawing></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:trPr><w:tblHeader/></w:trPr><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:tr><w:tc><w:tcPr/><w:p><w:r><w:t>C</w:t></w:r></w:p></w:tc><w:tc><w:tcPr/><w:p><w:r><w:t>D</w:t></w:r></w:p></w:tc></w:tr></w:tbl>` +
`<w:p><w:pPr><w:pStyle w:val="MdOfficialSignatureDate"/></w:pPr><w:r><w:t>2026年7月29日</w:t></w:r></w:p>` +
`<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>`
)
@@ -46,6 +59,9 @@ function generatedFixture() {
const plan: PandocStructurePlan = {
schemaVersion: 1,
metadataPolicy: {
author: "suppress"
},
titlePolicy: {
metadataTitle: "suppress",
firstBodyHeading: "keep"
@@ -57,7 +73,15 @@ const plan: PandocStructurePlan = {
slot: "tender-cover",
startMarker: "CONTAINER_START",
endMarker: "CONTAINER_END",
blocks: []
blocks: [
{
kind: "paragraph",
styleId: "MdOfficialIssueRow",
layout: "space-between",
segments: ["发文字号", "签发人"],
separator: "tab"
}
]
},
{
kind: "section-break",
@@ -85,6 +109,17 @@ const tokens: DocxThemeTokenSet = {
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: {
@@ -121,6 +156,30 @@ const tokens: DocxThemeTokenSet = {
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",
@@ -134,8 +193,23 @@ const tokens: DocxThemeTokenSet = {
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("生成真实分节、正文页码、节总页数和固定宽度表格", () => {
it("生成真实分节、正文页码、节总页数和全宽自适应表格", () => {
const result = finalizeGeneratedDocxStructure(
generatedFixture(),
plan,
@@ -170,26 +244,367 @@ describe("生成 DOCX 结构收口", () => {
'<w:tblW w:w="5000" w:type="pct"/>'
);
expect(documentXml).toContain(
'<w:tblLayout w:type="fixed"/>'
'<w:tblLayout w:type="autofit"/>'
);
expect(documentXml).toContain('<w:gridCol w:w="3302"/>');
expect(documentXml).toContain('<w:gridCol w:w="6604"/>');
expect(documentXml).toMatch(
/<w:pPr><w:pStyle w:val="Figure"\/>[\s\S]*?<w:autoSpaceDE w:val="0"\/><w:autoSpaceDN w:val="0"\/><w:spacing[^>]*w:line="240"[^>]*w:lineRule="auto"[^>]*w:before="300"[^>]*w:after="300"\/><w:jc w:val="center"\/>/u
);
expect(documentXml).toContain('<w:gridCol w:w="1000"/>');
expect(documentXml).toContain('<w:gridCol w:w="2000"/>');
expect(documentXml).toContain('<w:tcW w:w="0" w:type="auto"/>');
expect(
documentXml.match(
/<w:spacing[^>]*w:before="0"[^>]*w:after="0"[^>]*w:line="300"[^>]*w:lineRule="exact"/gu
)
).toHaveLength(2);
expect(
documentXml.match(
/<w:spacing[^>]*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(/<w:jc w:val="center"\/>/gu)?.length ?? 0
).toBeGreaterThanOrEqual(2);
const tableParagraph = documentXml.match(
/<w:p>[\s\S]*?<w:t>A<\/w:t>[\s\S]*?<\/w:p>/u
)?.[0];
expect(tableParagraph).toContain('<w:autoSpaceDE w:val="0"/>');
expect(tableParagraph).toContain('<w:autoSpaceDN w:val="0"/>');
expect(documentXml).toContain("<w:cantSplit/>");
expect(documentXml).toContain(
'<w:tab w:val="right" w:pos="9806"/>'
);
expect(documentXml).toContain('w:fill="F5F5F5"');
expect(documentXml).toContain(
'<w:spacing w:before="140"/>'
);
expect(documentXml).toContain(
'<w:spacing w:after="1020"/>'
);
expect(
documentXml.match(
/<w:ind w:left="4835" w:right="100"\/>/gu
)
).toHaveLength(2);
expect(documentXml).toContain(
'<w:top w:val="single" w:sz="8" w:space="3" w:color="111111"/>'
);
expect(documentXml).toContain(
'<w:bottom w:val="single" w:sz="8" w:space="11" w:color="111111"/>'
);
expect(
documentXml.match(/<w:br w:type="page"\/>/gu)
).toHaveLength(1);
const coverParagraph = documentXml.match(
/<w:p(?:\s|>)[\s\S]*?[\s\S]*?<\/w:p>/u
)?.[0];
expect(coverParagraph).toContain("<w:sectPr>");
expect(coverParagraph).not.toContain(
'<w:br w:type="page"/>'
);
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
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(
/<wp:inline\b[^>]*distT="0"[^>]*distB="0"[^>]*distL="0"[^>]*distR="0"[^>]*>/u
);
expect(documentXml).toContain(
'<wp:extent cx="3048000" cy="1714500"/>'
);
expect(documentXml).toContain(
'<a:ext cx="3048000" cy="1714500"/>'
);
expect(documentXml).toMatch(
/<a:graphicFrameLocks\b[^>]*noChangeAspect="1"[^>]*\/>/u
);
expect(documentXml).toContain(
'descr="处理流程" title=""'
);
expect(documentXml).toContain('<w:jc w:val="right"/>');
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("<w:tbl>");
expect(documentXml).toContain(
'<w:trHeight w:val="12400" w:hRule="exact"/>'
);
expect(documentXml).toContain(
'<w:tblInd w:w="4608" w:type="dxa"/>'
);
expect(documentXml).toContain('<w:vAlign w:val="top"/>');
expect(documentXml).toContain("<w:tblBorders>");
expect(documentXml).toContain('<w:insideH w:val="nil"/>');
expect(documentXml).toContain('<w:insideV w:val="nil"/>');
expect(documentXml).toMatch(
/<w:pPr><w:pStyle w:val="MdTenderTitle"\/><w:autoSpaceDE w:val="0"\/><w:autoSpaceDN w:val="0"\/><w:spacing w:before="0" w:after="0"\/><w:ind w:left="1456" w:right="1475"\/><w:jc w:val="center"\/>/u
);
expect(documentXml).toMatch(
/<w:pPr><w:pStyle w:val="MdTenderTitle"\/>[\s\S]*?<w:rPr><w:spacing w:val="20"\/><\/w:rPr><w:t><\/w:t>/u
);
expect(documentXml).toContain('<w:tblLook w:val="0000"');
expect(documentXml).toMatch(
/<\/w:tbl><w:p><w:pPr><w:autoSpaceDE w:val="0"\/><w:autoSpaceDN w:val="0"\/><w:sectPr>/u
);
expect(
documentXml.match(/<w:tbl(?:\s|>)/gu)
).toHaveLength(2);
expect(result.report).toMatchObject({
containerCount: 1,
editableContainerTableCount: 1,
sectionCount: 1,
tableCount: 1
});
});
it("将封面最小高度封顶到当前节的页面内容区", () => {
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
}
}
: 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(
'<w:trHeight w:val="14838" w:hRule="exact"/>'
);
expect(documentXml).toContain('<w:top w:w="0" w:type="dxa"/>');
expect(documentXml).toContain('<w:bottom w:w="0" w:type="dxa"/>');
expect(documentXml).toContain('<w:vAlign w:val="top"/>');
expect(documentXml.match(/<w:cantSplit\/>/gu)).toHaveLength(3);
expect(documentXml.match(/<w:t>\.<\/w:t>/gu)).toHaveLength(2);
expect(documentXml.match(/<w:tbl(?:\s|>)/gu)).toHaveLength(2);
expect(documentXml.match(/<w:trHeight\b/gu)).toHaveLength(1);
});
it("将主题项目符号位置与 Word 悬挂缩进合并", () => {
const source = readGeneratedDocxPackage(generatedFixture());
const entries = new Map(source.entries);
entries.set(
"word/numbering.xml",
encoder.encode(
`<w:numbering xmlns:w="${word}"><w:abstractNum w:abstractNumId="1"><w:lvl w:ilvl="0"><w:numFmt w:val="bullet"/><w:pPr><w:ind w:left="720" w:hanging="360"/></w:pPr></w:lvl><w:lvl w:ilvl="1"><w:numFmt w:val="bullet"/><w:pPr><w:ind w:left="1440" w:hanging="360"/></w:pPr></w:lvl></w:abstractNum></w:numbering>`
)
);
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(
'<w:ind w:left="960" w:hanging="360"/>'
);
expect(numberingXml).toContain(
'<w:ind w:left="1680" w:hanging="360"/>'
);
});
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(
/<w:pPr><w:pStyle w:val="MdOfficialIssueRow"\/>[\s\S]*?<w:ind w:left="4755" w:right="0"\/>/u
);
expect(documentXml).not.toContain('w:left="4608"');
expect(documentXml).toMatch(
/<w:pPr><w:pStyle w:val="MdOfficialSignatureDate"\/><w:autoSpaceDE w:val="0"\/><w:autoSpaceDN w:val="0"\/>/u
);
});
it("拒绝缺失或重复的内部结构标记", () => {
expect(() =>
finalizeGeneratedDocxStructure(
@@ -0,0 +1,272 @@
import { describe, expect, it } from "vitest";
import {
inspectDocxMediaAcceptance,
readReferenceDocxPackage,
writeGeneratedDocxPackage,
type DocxMediaAcceptanceExpectation
} from "../src/index.js";
import { createTestBaselineReference } from "./reference-test-fixture.js";
const encoder = new TextEncoder();
const word =
"http://schemas.openxmlformats.org/wordprocessingml/2006/main";
const officeRelationships =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships";
const packageRelationships =
"http://schemas.openxmlformats.org/package/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 imageRelationship =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
const documentXml =
`<w:document xmlns:w="${word}" xmlns:r="${officeRelationships}" xmlns:wp="${wordprocessingDrawing}" xmlns:a="${drawing}" xmlns:pic="${picture}"><w:body>` +
`<w:p><w:pPr><w:pStyle w:val="Figure"/><w:jc w:val="center"/></w:pPr><w:r><w:drawing>` +
`<wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="3048000" cy="1524000"/><wp:docPr id="1" name="Picture" descr="验收图片" title=""/><wp:cNvGraphicFramePr><a:graphicFrameLocks noChangeAspect="1"/></wp:cNvGraphicFramePr>` +
`<a:graphic><a:graphicData uri="${picture}"><pic:pic><pic:nvPicPr><pic:cNvPr id="0" name="image.png"/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdImage"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="3048000" cy="1524000"/></a:xfrm></pic:spPr></pic:pic></a:graphicData></a:graphic>` +
`</wp:inline></w:drawing></w:r></w:p>` +
`<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1000" w:right="1000" w:bottom="1000" w:left="1000"/></w:sectPr>` +
`</w:body></w:document>`;
const relationshipsXml =
`<Relationships xmlns="${packageRelationships}">` +
`<Relationship Id="rIdImage" Type="${imageRelationship}" Target="media/image1.png"/>` +
`</Relationships>`;
const png = Uint8Array.of(
0x89,
0x50,
0x4e,
0x47,
0x0d,
0x0a,
0x1a,
0x0a,
0,
0,
0,
0,
0x49,
0x48,
0x44,
0x52,
0,
0,
0,
1,
0,
0,
0,
1
);
const expectation: DocxMediaAcceptanceExpectation = {
items: [
{
id: "docx-media-1",
binding: "mdtp-media:docx-media-1",
kind: "image",
ordinal: 1,
altText: "验收图片",
alignment: "center",
displayWidthPx: 320,
displayHeightPx: 160,
widthEmu: 3_048_000,
heightEmu: 1_524_000
}
],
maximumWidthEmu: 6_000_000,
maximumHeightEmu: 9_000_000
};
function fixture(options: {
document?: (value: string) => string;
relationships?: (value: string) => string;
image?: Uint8Array;
} = {}) {
const baseline = readReferenceDocxPackage(
createTestBaselineReference()
);
const entries = new Map(baseline.entries);
entries.set(
"word/document.xml",
encoder.encode(options.document?.(documentXml) ?? documentXml)
);
entries.set(
"word/_rels/document.xml.rels",
encoder.encode(
options.relationships?.(relationshipsXml) ?? relationshipsXml
)
);
entries.set("word/media/image1.png", options.image ?? png);
return writeGeneratedDocxPackage(entries);
}
describe("DOCX 媒体 OOXML 门禁", () => {
it("验收内联尺寸、比例、对齐和唯一 PNG 关系", () => {
const report = inspectDocxMediaAcceptance(
fixture(),
expectation,
"media-fixture"
);
expect(report).toMatchObject({
expectedCount: 1,
drawingCount: 1,
inlineCount: 1,
anchorCount: 0,
observations: [
{
id: "docx-media-1",
widthEmu: 3_048_000,
heightEmu: 1_524_000,
alignment: "center",
relationshipId: "rIdImage",
partName: "word/media/image1.png"
}
]
});
});
it.each([
{
name: "浮动锚点",
check: "inline-layout",
document: (value: string) =>
value
.replace("<wp:inline ", "<wp:anchor ")
.replace("</wp:inline>", "</wp:anchor>")
},
{
name: "双层尺寸不一致",
check: "dimensions",
document: (value: string) =>
value.replace(
'<a:ext cx="3048000" cy="1524000"/>',
'<a:ext cx="3047000" cy="1524000"/>'
)
},
{
name: "比例漂移",
check: "aspect-ratio",
document: (value: string) =>
value.replaceAll('cx="3048000"', 'cx="3048010"')
},
{
name: "错误对齐",
check: "alignment",
document: (value: string) =>
value.replace(
'<w:jc w:val="center"/>',
'<w:jc w:val="left"/>'
)
},
{
name: "缺少比例锁",
check: "aspect-lock",
document: (value: string) =>
value.replace('noChangeAspect="1"', 'noChangeAspect="0"')
},
{
name: "图片裁切",
check: "cropping-transform",
document: (value: string) =>
value.replace("<pic:blipFill>", "<pic:blipFill><a:srcRect/>")
},
{
name: "旋转图片",
check: "cropping-transform",
document: (value: string) =>
value.replace("<a:xfrm>", '<a:xfrm rot="60000">')
},
{
name: "替代文本错位",
check: "alternative-text",
document: (value: string) =>
value.replace('descr="验收图片"', 'descr="错误图片"')
},
{
name: "内部绑定残留",
check: "alternative-text",
document: (value: string) =>
value.replace('title=""', 'title="mdtp-media:docx-media-1"')
},
{
name: "图片关系缺失",
check: "relationships",
document: (value: string) =>
value.replace('r:embed="rIdImage"', 'r:embed="missing"')
}
])("拒绝$name", ({ check, document }) => {
expect(() =>
inspectDocxMediaAcceptance(
fixture({ document }),
expectation,
"tampered"
)
).toThrow(check);
});
it("拒绝外部、非 PNG、伪 PNG 和额外图片关系", () => {
expect(() =>
inspectDocxMediaAcceptance(
fixture({
relationships: (value) =>
value.replace("/>", ' TargetMode="External"/>')
}),
expectation,
"external"
)
).toThrow("relationships");
expect(() =>
inspectDocxMediaAcceptance(
fixture({
relationships: (value) =>
value.replace("image1.png", "image1.jpg")
}),
expectation,
"jpeg"
)
).toThrow("png-media");
expect(() =>
inspectDocxMediaAcceptance(
fixture({ image: new Uint8Array(24) }),
expectation,
"fake-png"
)
).toThrow("png-media");
expect(() =>
inspectDocxMediaAcceptance(
fixture({
relationships: (value) =>
value.replace(
"</Relationships>",
`<Relationship Id="extra" Type="${imageRelationship}" Target="media/extra.png"/></Relationships>`
)
}),
expectation,
"extra"
)
).toThrow("额外");
});
it("拒绝超过当前页面内容区的显示尺寸", () => {
expect(() =>
inspectDocxMediaAcceptance(
fixture(),
{
...expectation,
maximumWidthEmu: 3_047_000
},
"oversized"
)
).toThrow("dimensions");
});
});
@@ -24,6 +24,7 @@ function resource(
kindOrdinal,
altText: `${kind} ${kindOrdinal}`,
caption: `${kind} 图注`,
alignment: "center",
displayWidthPx: 384,
displayHeightPx: 192,
captureX: 0,
@@ -70,12 +71,20 @@ describe("Pandoc DOCX 媒体映射", () => {
"media/media-003.png"
]);
expect(result.map.image[0]).toMatchObject({
binding: "mdtp-media:docx-media-1",
path: "media/media-001.png",
width: "101.600mm",
height: "50.800mm"
});
expect(result.map.mermaid).toHaveLength(1);
expect(result.map.echarts).toHaveLength(1);
expect(result.layout[0]).toMatchObject({
id: "docx-media-1",
binding: "mdtp-media:docx-media-1",
alignment: "center",
widthEmu: 3_657_600,
heightEmu: 1_828_800
});
});
it("拒绝顺序错位、无效 PNG 和图表错误", () => {
@@ -85,6 +85,7 @@ describe("Pandoc 语义结构投影", () => {
{
kind: "group",
role: "official-classification",
layout: "space-between",
children: [
text("official-secrecy", "秘密"),
text("official-urgency", "特急")
@@ -94,6 +95,7 @@ describe("Pandoc 语义结构投影", () => {
{
kind: "group",
role: "official-issue-row",
layout: "space-between",
children: [
text("official-number", "示例〔20261号"),
text(
@@ -115,6 +117,7 @@ describe("Pandoc 语义结构投影", () => {
markerSeed: "official"
});
expect(plan.metadataPolicy.author).toBe("suppress");
expect(plan.titlePolicy.firstBodyHeading).toBe("suppress");
expect(plan.prefix[0]).toMatchObject({
kind: "container",
@@ -127,6 +130,7 @@ describe("Pandoc 语义结构投影", () => {
{
kind: "paragraph",
styleId: "MdOfficialClassification",
layout: "space-between",
segments: ["秘密", "特急"],
separator: "tab"
},
@@ -138,6 +142,7 @@ describe("Pandoc 语义结构投影", () => {
{
kind: "paragraph",
styleId: "MdOfficialIssueRow",
layout: "space-between",
segments: ["示例〔20261号", "签发人:张三"],
separator: "tab"
}
@@ -228,6 +233,9 @@ describe("Pandoc 语义结构投影", () => {
expect(plan).toEqual({
schemaVersion: 1,
metadataPolicy: {
author: "suppress"
},
titlePolicy: {
metadataTitle: "emit",
firstBodyHeading: "keep"
@@ -199,6 +199,10 @@ describe("动态 reference.docx", () => {
expect(documentXml).toContain('w:top="907"');
expect(stylesXml).toContain('w:styleId="SourceCode"');
expect(stylesXml).toContain('w:eastAsia="Microsoft YaHei"');
const imageCaptionStyle = stylesXml.match(
/<w:style\b[^>]*w:styleId="ImageCaption"[\s\S]*?<\/w:style>/u
)?.[0];
expect(imageCaptionStyle).toContain('<w:jc w:val="center"/>');
expect(footerXml).toContain(" PAGE \\* MERGEFORMAT ");
expect(footerXml).toContain(" NUMPAGES \\* MERGEFORMAT ");
expect(footerXml).toContain('w:jc w:val="center"');
@@ -226,7 +230,7 @@ describe("动态 reference.docx", () => {
"Source Han Serif SC",
"Times New Roman"
],
fontSizePt: 14,
fontSizePt: 12.75,
color: "#112233",
lineSpacing: 1.8,
firstLineIndentPt: 28,
@@ -246,6 +250,28 @@ describe("动态 reference.docx", () => {
keepWithNext: true
}
},
{
slot: "code-block",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: ["Lucida Console"],
fontSizePt: 9,
paddingPt: {
top: 6,
right: 3,
bottom: 5,
left: 3
},
contentPaddingLeftPt: 6,
borders: {
top: { widthPt: 0.75, color: "#e7eaed", style: "single" },
right: { widthPt: 0.75, color: "#e7eaed", style: "single" },
bottom: { widthPt: 0.75, color: "#e7eaed", style: "single" },
left: { widthPt: 0.75, color: "#e7eaed", style: "single" }
}
}
},
{
slot: "table",
source: "computed-css",
@@ -268,6 +294,36 @@ describe("动态 reference.docx", () => {
}
}
},
{
slot: "table-cell",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: ["SimSun"],
borders: {
top: {
widthPt: 0.85,
color: "#556677",
style: "single"
},
right: {
widthPt: 0.85,
color: "#556677",
style: "single"
},
bottom: {
widthPt: 0.85,
color: "#556677",
style: "single"
},
left: {
widthPt: 0.85,
color: "#556677",
style: "single"
}
}
}
},
{
slot: "official-title",
source: "computed-css",
@@ -288,6 +344,20 @@ describe("动态 reference.docx", () => {
fontCandidates: [],
lineSpacing: 1.5
}
},
{
slot: "official-printing-row",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: [],
paddingPt: {
top: 3,
right: 28,
bottom: 3,
left: 28
}
}
}
])
}
@@ -302,22 +372,46 @@ describe("动态 reference.docx", () => {
);
expect(stylesXml).toContain('w:styleId="MdOfficialTitle"');
expect(stylesXml).toMatch(
/w:style[^>]*w:styleId="MdOfficialTitle"[\s\S]*?<w:autoSpaceDE w:val="0"\/><w:autoSpaceDN w:val="0"\/>/u
);
expect(stylesXml).toContain('w:eastAsia="Source Han Serif SC"');
expect(stylesXml).toContain('w:ascii="Times New Roman"');
expect(stylesXml).toContain('w:val="112233"');
expect(stylesXml).toContain('w:styleId="Heading1"');
expect(stylesXml).toContain('w:val="AA0000"');
expect(stylesXml).toContain('<w:jc w:val="both"/>');
const sourceCodeStyle = stylesXml.match(
/<w:style\b[^>]*w:styleId="SourceCode"[\s\S]*?<\/w:style>/u
)?.[0];
expect(sourceCodeStyle).toContain(
'<w:left w:val="single" w:sz="6" w:space="7" w:color="E7EAED"/>'
);
expect(sourceCodeStyle).toMatch(
/<w:ind\b[^>]*w:left="195"[^>]*w:right="75"[^>]*\/>/u
);
expect(stylesXml).not.toContain('w:val="justify"');
expect(stylesXml).toContain(
'w:line="504" w:lineRule="exact"'
'w:line="459" w:lineRule="exact"'
);
expect(stylesXml).toMatch(
/w:style[^>]*w:styleId="Normal"[\s\S]*?<w:rPr>[\s\S]*?<w:sz w:val="26"\/>[\s\S]*?<w:spacing w:val="-5"\/>/u
);
expect(stylesXml).toMatch(
/w:style[^>]*w:styleId="MdOfficialEdition"[\s\S]*?<w:spacing[^>]*w:line="315"[^>]*w:lineRule="exact"/u
);
expect(stylesXml).toMatch(
/w:style[^>]*w:styleId="MdOfficialPrintingRow"[\s\S]*?<w:spacing[^>]*w:before="60"[^>]*w:after="60"/u
);
expect(stylesXml).toMatch(
/w:style[^>]*w:styleId="MdOfficialPrintingRow"[\s\S]*?<w:ind[^>]*w:left="560"[^>]*w:right="560"/u
);
expect(stylesXml).not.toContain('w:lineRule="auto"');
expect(stylesXml).not.toContain("<w:tblW");
expect(stylesXml).toContain('w:color="445566"');
expect(stylesXml).toMatch(
/<w:tblBorders>[\s\S]*?<w:insideH[^>]*w:sz="7"[^>]*w:color="556677"\/>[\s\S]*?<w:insideV[^>]*w:sz="7"[^>]*w:color="556677"\/>/u
);
expect(fontTableXml).toContain(
'w:name="Source Han Serif SC"'
);
@@ -377,6 +471,8 @@ describe("动态 reference.docx", () => {
expect(documentXml).toContain('w:orient="landscape"');
expect(documentXml).toContain('w:w="16838"');
expect(documentXml).toContain('w:left="1701"');
expect(documentXml).toContain('w:header="723"');
expect(documentXml).toContain('w:footer="751"');
expect(documentXml).toContain('w:start="5"');
expect(documentXml).toContain("<w:titlePg");
expect(documentXml.match(/w:headerReference/gu)).toHaveLength(3);
@@ -390,10 +486,15 @@ describe("动态 reference.docx", () => {
expect(headerXml).toContain('w:pos="13720"');
expect(headerXml).toContain("<w:pBdr>");
expect(headerXml).toContain("<w:bottom");
expect(headerXml).toMatch(
/<w:spacing[^>]*w:line="213"[^>]*w:lineRule="exact"/u
);
expect(headerXml).not.toContain("<w:tbl");
expect(
decoder.decode(entries["word/footer1.xml"])
).not.toContain("<w:tbl");
const footerXml = decoder.decode(entries["word/footer1.xml"]);
expect(footerXml).toMatch(
/<w:spacing[^>]*w:line="213"[^>]*w:lineRule="exact"/u
);
expect(footerXml).not.toContain("<w:tbl");
expect(relationships.match(/relationships\/header/gu)).toHaveLength(
3
);
@@ -407,6 +508,133 @@ describe("动态 reference.docx", () => {
});
});
it("封面页码重启时仍使用原生奇偶页脚保持跨客户端外侧位置", () => {
const exportConfig: ExportConfig = {
...defaultExportConfig,
pageDecorationsMode: "custom",
footer: {
...defaultExportConfig.footer,
alignment: "outer",
format: "page-total",
startFrom: 1
}
};
const result = createDynamicReferenceDocx(
createBaselineReference(),
{
...createOptions(exportConfig),
semanticDocument: {
schemaVersion: 1,
profile: "project-report",
titlePolicy: {
metadataTitle: "suppress",
firstBodyHeading: "keep"
},
regions: [
{
kind: "cover",
nodes: [
{
kind: "text",
role: "project-report-title",
text: "可研报告"
}
],
section: {
headerFooter: "none",
pageNumber: "hidden",
breakAfter: "next-page",
followingPageNumberStart: 1
}
}
]
}
}
);
const entries = unzipSync(result.content);
const documentXml = decoder.decode(entries["word/document.xml"]);
const settingsXml = decoder.decode(entries["word/settings.xml"]);
const defaultFooterXml = decoder.decode(entries["word/footer1.xml"]);
const evenFooterXml = decoder.decode(entries["word/footer2.xml"]);
expect(documentXml.match(/w:footerReference/gu)).toHaveLength(2);
expect(settingsXml).toContain("<w:evenAndOddHeaders");
expect(defaultFooterXml).toContain('w:jc w:val="left"');
expect(evenFooterXml).toContain('w:jc w:val="right"');
expect(defaultFooterXml).toContain(" = ");
expect(defaultFooterXml).toContain(" - 1 ");
expect(defaultFooterXml).toContain(" PAGE \\* MERGEFORMAT ");
expect(defaultFooterXml).toContain(" NUMPAGES \\* MERGEFORMAT ");
expect(defaultFooterXml).not.toContain(" IF ");
expect(defaultFooterXml).not.toContain("<w:tbl");
expect(validateDynamicReferenceDocx(result.content)).toMatchObject({
footerCount: 2
});
});
it("独立控制正文首页页眉和页码", () => {
const exportConfig: ExportConfig = {
...defaultExportConfig,
pageDecorationsMode: "custom",
header: {
...defaultExportConfig.header,
enabled: true,
showOnFirstPage: false,
showDivider: true,
center: {
enabled: true,
content: "${title}"
}
},
footer: {
...defaultExportConfig.footer,
alignment: "center",
showOnFirstPage: true
}
};
const result = createDynamicReferenceDocx(
createBaselineReference(),
createOptions(exportConfig)
);
const entries = unzipSync(result.content);
const documentXml = decoder.decode(entries["word/document.xml"]);
const regularHeader = decoder.decode(entries["word/header1.xml"]);
const firstHeader = decoder.decode(entries["word/header2.xml"]);
const regularFooter = decoder.decode(entries["word/footer1.xml"]);
const firstFooter = decoder.decode(entries["word/footer2.xml"]);
expect(documentXml).toContain("<w:titlePg");
expect(documentXml.match(/w:headerReference/gu)).toHaveLength(2);
expect(documentXml.match(/w:footerReference/gu)).toHaveLength(2);
expect(regularHeader).toContain("年度 &lt;报告&gt;");
expect(regularHeader).toContain("<w:bottom");
expect(firstHeader).not.toContain("年度 &lt;报告&gt;");
expect(firstHeader).not.toContain("<w:bottom");
expect(regularFooter).toContain("PAGE");
expect(firstFooter).toContain("PAGE");
});
it("隐藏正文首页页码时生成无内容且无分隔线的首页页脚", () => {
const exportConfig: ExportConfig = {
...defaultExportConfig,
pageDecorationsMode: "custom",
footer: {
...defaultExportConfig.footer,
showDivider: true,
showOnFirstPage: false
}
};
const result = createDynamicReferenceDocx(
createBaselineReference(),
createOptions(exportConfig)
);
const entries = unzipSync(result.content);
const firstFooter = decoder.decode(entries["word/footer2.xml"]);
expect(firstFooter).not.toContain("PAGE");
expect(firstFooter).not.toContain("<w:top");
});
it("拒绝正文中断裂或类型错误的页眉页脚关系", () => {
const result = createDynamicReferenceDocx(
createBaselineReference(),