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
@@ -9,7 +9,9 @@ import {
import {
PandocDocxConverter,
PandocRuntime,
inspectDocxAcceptance
createDocxMediaAcceptanceExpectation,
inspectDocxAcceptance,
resolveReferencePageOptions
} from "@md-to-pdf/docx-engine";
import { DOCX_STYLE_SLOT_NAMES } from "@md-to-pdf/docx-theme-engine";
@@ -49,6 +51,7 @@ function mediaResource(kind, ordinal, dimensions, altText, caption) {
kindOrdinal: 1,
altText,
...(caption ? { caption } : {}),
alignment: "center",
displayWidthPx: dimensions.width,
displayHeightPx: dimensions.height,
captureX: 0,
@@ -115,6 +118,58 @@ function createThemeTokens(theme) {
const technicalTheme = loadTheme("typora-like");
const officialTheme = loadTheme("gov-red-standard");
const coverTheme = loadTheme("formal-feasibility");
const semanticDocument = {
schemaVersion: 1,
titlePolicy: {
metadataTitle: "emit",
firstBodyHeading: "keep"
},
regions: []
};
const coverSemanticDocument = {
schemaVersion: 1,
profile: "project-report",
titlePolicy: {
metadataTitle: "suppress",
firstBodyHeading: "keep"
},
regions: [
{
kind: "cover",
nodes: [
{
kind: "group",
role: "project-report-cover",
children: [
{
kind: "text",
role: "project-report-project-name",
text: "智慧园区建设项目"
},
{
kind: "text",
role: "project-report-title",
text: "可行性研究报告"
},
{
kind: "text",
role: "project-report-owner",
label: "建设单位:",
text: "示例建设集团"
}
]
}
],
section: {
headerFooter: "none",
pageNumber: "hidden",
breakAfter: "next-page",
followingPageNumberStart: 1
}
}
]
};
const commonExpectation = {
requiredText: [
"可编辑的中文正文",
@@ -214,6 +269,18 @@ const landscapeLetterConfig = {
startFrom: 3
}
};
const coverA4Config = {
...defaultExportConfig,
name: "可研封面 A4 纵向验收",
themeId: coverTheme.id,
pageDecorationsMode: "theme",
paper: {
...defaultExportConfig.paper,
format: "A4",
orientation: "portrait",
marginMode: "theme"
}
};
const variants = [
{
@@ -275,6 +342,41 @@ const variants = [
}
}
}
},
{
id: "project-cover-a4-portrait",
theme: coverTheme,
exportConfig: coverA4Config,
semanticDocument: coverSemanticDocument,
expectation: {
...commonExpectation,
id: "project-cover-a4-portrait",
requiredText: [
...commonExpectation.requiredText,
"智慧园区建设项目",
"可行性研究报告"
],
page: {
widthTwips: 11906,
heightTwips: 16838,
orientation: "portrait",
marginsTwips: {
top: 1417,
right: 1134,
bottom: 1417,
left: 1701
}
},
minimumSections: 2,
requireFirstSectionWithoutHeaderFooter: true,
finalPageNumberStart: 1,
requireSectionPagesField: true,
requiredParagraphStyleIds: [
"MdProjectReportProjectName",
"MdProjectReportTitle",
"MdProjectReportOwner"
]
}
}
];
@@ -299,33 +401,51 @@ fs.mkdirSync(outputDirectory, { recursive: true });
const results = [];
for (const variant of variants) {
const variantSemanticDocument =
variant.semanticDocument ?? semanticDocument;
const fileName = `${variant.id}.md`;
const metadata = {
title: "v0.6.0 DOCX 自动验收",
author: "Markdown PDF 导出器研发组",
subject: "Word 与 WPS 可编辑性验收",
keywords: ["DOCX", "Pandoc", "OOXML"],
language: "zh-CN"
};
const resolvedPage = resolveReferencePageOptions({
exportConfig: variant.exportConfig,
theme: variant.theme,
fileName,
metadata
});
const media = createMedia();
const result = await converter.convert({
markdown,
fileName: `${variant.id}.md`,
fileName,
language: "zh-CN",
exportConfig: variant.exportConfig,
theme: variant.theme,
themeTokens: createThemeTokens(variant.theme),
metadata: {
title: "v0.6.0 DOCX 自动验收",
author: "Markdown PDF 导出器研发组",
subject: "Word 与 WPS 可编辑性验收",
keywords: ["DOCX", "Pandoc", "OOXML"],
language: "zh-CN"
},
semanticDocument: {
schemaVersion: 1,
titlePolicy: {
metadataTitle: "emit",
firstBodyHeading: "keep"
},
regions: []
},
media: createMedia()
fonts: [],
metadata,
semanticDocument: variantSemanticDocument,
media
});
const report = inspectDocxAcceptance(
result.docx,
variant.expectation
{
...variant.expectation,
media: createDocxMediaAcceptanceExpectation(
media,
variant.expectation.page
),
pageDecorations: {
exportConfig: {
header: resolvedPage.header,
footer: resolvedPage.footer
},
semanticDocument: variantSemanticDocument
}
}
);
const outputPath = path.join(outputDirectory, `${variant.id}.docx`);
fs.writeFileSync(outputPath, result.docx);
@@ -56,6 +56,7 @@ function resource(kind, ordinal, dimensions, caption) {
? "Mermaid 流程图"
: "ECharts 柱状图",
...(caption ? { caption } : {}),
alignment: "center",
displayWidthPx: dimensions.width,
displayHeightPx: dimensions.height,
captureX: 0,
@@ -11,6 +11,7 @@ import {
PandocRuntime,
resolveReferencePageOptions
} from "@md-to-pdf/docx-engine";
import { DOCX_STYLE_SLOT_NAMES } from "@md-to-pdf/docx-theme-engine";
import { renderMarkdown } from "@md-to-pdf/renderer";
import { unzipSync } from "fflate";
@@ -185,10 +186,17 @@ function inspectDocument(
/<w:sectPr(?:\s[^>]*)?>[\s\S]*?<\/w:sectPr>/gu
)
].map((match) => match[0]);
const fullWidthTableCount = countMatches(
documentXml,
/<w:tblW\s+w:w="5000"\s+w:type="pct"\s*\/>/gu
const tables = [
...documentXml.matchAll(
/<w:tbl(?:\s|>)[\s\S]*?<\/w:tbl>/gu
)
].map((match) => match[0]);
const contentTables = tables.filter((table) =>
/<w:tblStyle\s+w:val="Table"\s*\/>/u.test(table)
);
const fullWidthTableCount = contentTables.filter((table) =>
/<w:tblW\s+w:w="5000"\s+w:type="pct"\s*\/>/u.test(table)
).length;
const internalMarkerCount = countMatches(
documentXml,
/MD_TO_PDF_(?:CONTAINER|SECTION)_/gu
@@ -216,7 +224,9 @@ function inspectDocument(
bytes: content.byteLength,
page: pageOptions,
paragraphCount: countMatches(documentXml, /<w:p(?:\s|>)/gu),
tableCount: countMatches(documentXml, /<w:tbl(?:\s|>)/gu),
tableCount: tables.length,
contentTableCount: contentTables.length,
structuralTableCount: tables.length - contentTables.length,
drawingCount: countMatches(documentXml, /<w:drawing(?:\s|>)/gu),
explicitPageBreaks,
sectionCount: sections.length,
@@ -280,6 +290,7 @@ function createMediaResource(kind, ordinal, kindOrdinal) {
...(kind === "image"
? {}
: { caption: `${kind} 主题验收图 ${kindOrdinal}` }),
alignment: "center",
displayWidthPx: dimensions.width,
displayHeightPx: dimensions.height,
captureX: 0,
@@ -345,8 +356,8 @@ function readThemeTokenReport(themes) {
const tokensByTheme = new Map(
report.tokenSets.map((tokens) => {
assert(
tokens.slots?.length === 56,
`主题 ${tokens.themeId} 的真实令牌槽位不是 56`
tokens.slots?.length === DOCX_STYLE_SLOT_NAMES.length,
`主题 ${tokens.themeId} 的真实令牌槽位不是 ${DOCX_STYLE_SLOT_NAMES.length}`
);
assert(
/^[a-f0-9]{64}$/u.test(tokens.themeFingerprint ?? ""),
@@ -483,7 +494,7 @@ for (const result of results) {
`主题 ${result.id} 存在重复标题`
);
assert(
inspection.fullWidthTableCount === inspection.tableCount,
inspection.fullWidthTableCount === inspection.contentTableCount,
`主题 ${result.id} 存在非内容区全宽表格`
);
if (inspection.profile) {