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