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
@@ -25,7 +25,7 @@ try {
$word.DisplayAlerts = 0
$word.AutomationSecurity = 3
$word.Options.SaveNormalPrompt = $false
$document = $word.Documents.Open(
$document = $word.Documents.OpenNoRepairDialog(
$resolvedInput,
$false,
$true,
@@ -38,7 +38,7 @@ try {
0,
0,
$false,
$true,
$false,
0,
$true
)
@@ -11,6 +11,7 @@ import {
PandocRuntime,
inspectDocxAcceptance
} from "@md-to-pdf/docx-engine";
import { DOCX_STYLE_SLOT_NAMES } from "@md-to-pdf/docx-theme-engine";
const directory = path.dirname(fileURLToPath(import.meta.url));
const packageDirectory = path.resolve(directory, "..");
@@ -95,6 +96,23 @@ function createMedia() {
};
}
function createThemeTokens(theme) {
return {
schemaVersion: 1,
themeId: theme.id,
themeFingerprint: "c".repeat(64),
mode: "auto-with-overrides",
basePreset: theme.docxStyle?.preset ?? "general",
slots: DOCX_STYLE_SLOT_NAMES.map((slot) => ({
slot,
source: "preset-fallback",
confidence: "fallback",
style: { fontCandidates: [] }
})),
diagnostics: []
};
}
const technicalTheme = loadTheme("typora-like");
const officialTheme = loadTheme("gov-red-standard");
const commonExpectation = {
@@ -287,6 +305,7 @@ for (const variant of variants) {
language: "zh-CN",
exportConfig: variant.exportConfig,
theme: variant.theme,
themeTokens: createThemeTokens(variant.theme),
metadata: {
title: "v0.6.0 DOCX 自动验收",
author: "Markdown PDF 导出器研发组",
@@ -294,6 +313,14 @@ for (const variant of variants) {
keywords: ["DOCX", "Pandoc", "OOXML"],
language: "zh-CN"
},
semanticDocument: {
schemaVersion: 1,
titlePolicy: {
metadataTitle: "emit",
firstBodyHeading: "keep"
},
regions: []
},
media: createMedia()
});
const report = inspectDocxAcceptance(
@@ -9,6 +9,7 @@ import {
PandocDocxConverter,
PandocRuntime
} from "@md-to-pdf/docx-engine";
import { DOCX_STYLE_SLOT_NAMES } from "@md-to-pdf/docx-theme-engine";
import { unzipSync } from "fflate";
const directory = path.dirname(fileURLToPath(import.meta.url));
@@ -76,6 +77,20 @@ const runtime = new PandocRuntime(
: {}
);
const converter = new PandocDocxConverter({ runtime });
const themeTokens = {
schemaVersion: 1,
themeId: theme.id,
themeFingerprint: "c".repeat(64),
mode: "auto-with-overrides",
basePreset: theme.docxStyle?.preset ?? "general",
slots: DOCX_STYLE_SLOT_NAMES.map((slot) => ({
slot,
source: "preset-fallback",
confidence: "fallback",
style: { fontCandidates: [] }
})),
diagnostics: []
};
const conversionInput = {
markdown,
fileName: "Pandoc 媒体转换验收.md",
@@ -85,6 +100,7 @@ const conversionInput = {
themeId: theme.id
},
theme,
themeTokens,
metadata: {
title: "Pandoc 媒体转换验收",
author: "Markdown PDF 导出器研发组",
@@ -92,6 +108,14 @@ const conversionInput = {
keywords: [],
language: "zh-CN"
},
semanticDocument: {
schemaVersion: 1,
titlePolicy: {
metadataTitle: "emit",
firstBodyHeading: "keep"
},
regions: []
},
media: {
resources: [
resource("image", 1, { width: 384, height: 192 }),
@@ -24,6 +24,12 @@ const outputDirectory = path.join(
"output",
"docx-theme-matrix"
);
const themeTokenReportPath = path.join(
repositoryDirectory,
"output",
"docx-theme-styles",
"snapshots.json"
);
const decoder = new TextDecoder();
const placeholderPng = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
@@ -49,6 +55,26 @@ function countMatches(value, pattern) {
return value.match(pattern)?.length ?? 0;
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function countOccurrences(value, search) {
let count = 0;
let offset = 0;
while (offset <= value.length - search.length) {
const index = value.indexOf(search, offset);
if (index < 0) {
break;
}
count += 1;
offset = index + search.length;
}
return count;
}
function readThemeManifests() {
return fs
.readdirSync(themesDirectory, { withFileTypes: true })
@@ -90,7 +116,13 @@ function structuralValues(document) {
.map(String);
}
function inspectDocument(content, theme, metadata, exportConfig) {
function inspectDocument(
content,
theme,
metadata,
semanticDocument,
exportConfig
) {
const entries = unzipSync(content);
const documentXml = decoder.decode(entries["word/document.xml"]);
const stylesXml = decoder.decode(entries["word/styles.xml"]);
@@ -120,6 +152,37 @@ function inspectDocument(content, theme, metadata, exportConfig) {
documentXml,
/<w:br[^>]*w:type="page"/gu
);
const sections = [
...documentXml.matchAll(
/<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 internalMarkerCount = countMatches(
documentXml,
/MD_TO_PDF_(?:CONTAINER|SECTION)_/gu
);
const pandocTitleParagraphCount = countMatches(
documentXml,
/<w:pStyle\s+w:val="Title"\s*\/>/gu
);
const visibleMetadataTitleCount = countOccurrences(
bodyText,
metadata.title
);
const titlePolicyPassed =
semanticDocument.titlePolicy.metadataTitle === "suppress"
? pandocTitleParagraphCount === 0
: pandocTitleParagraphCount === 1;
const titleDeduplicationPassed =
semanticDocument.titlePolicy.metadataTitle === "suppress" &&
semanticDocument.titlePolicy.firstBodyHeading === "keep" &&
!metadata.document?.profile
? visibleMetadataTitleCount === 1
: titlePolicyPassed;
return {
bytes: content.byteLength,
@@ -128,6 +191,14 @@ function inspectDocument(content, theme, metadata, exportConfig) {
tableCount: countMatches(documentXml, /<w:tbl(?:\s|>)/gu),
drawingCount: countMatches(documentXml, /<w:drawing(?:\s|>)/gu),
explicitPageBreaks,
sectionCount: sections.length,
fullWidthTableCount,
internalMarkerCount,
pandocTitleParagraphCount,
visibleMetadataTitleCount,
titlePolicy: semanticDocument.titlePolicy,
titlePolicyPassed,
titleDeduplicationPassed,
headerPartCount: Object.keys(entries).filter((name) =>
/^word\/header\d+\.xml$/u.test(name)
).length,
@@ -138,6 +209,7 @@ function inspectDocument(content, theme, metadata, exportConfig) {
!headerXml.includes("<w:tbl") && !footerXml.includes("<w:tbl"),
pageField: footerXml.includes(" PAGE "),
totalPagesField: footerXml.includes(" NUMPAGES "),
sectionPagesField: footerXml.includes(" SECTIONPAGES "),
altChunkCount: countMatches(documentXml, /<w:altChunk(?:\s|>)/gu),
styles: {
normal: stylesXml.includes('w:styleId="Normal"'),
@@ -157,7 +229,11 @@ function inspectDocument(content, theme, metadata, exportConfig) {
expectsStandaloneCover,
standaloneCoverDetected:
expectsStandaloneCover &&
explicitPageBreaks > 0 &&
sections.length >= 2 &&
!sections[0]?.includes("headerReference") &&
!sections[0]?.includes("footerReference") &&
sections.at(-1)?.includes('w:pgNumType w:start="1"') ===
true &&
missingStructureValues.length === 0
};
}
@@ -220,6 +296,49 @@ function createMedia(markdown) {
totalBytes: placeholderPng.byteLength * resources.length
};
}
function readThemeTokenReport(themes) {
if (!fs.existsSync(themeTokenReportPath)) {
throw new Error(
`缺少本轮 Playwright 主题令牌报告:${path.relative(repositoryDirectory, themeTokenReportPath)}`
);
}
const report = JSON.parse(
fs.readFileSync(themeTokenReportPath, "utf8")
);
assert(
Array.isArray(report.tokenSets),
"Playwright 主题令牌报告缺少 tokenSets"
);
assert(
report.tokenSets.length === themes.length,
`真实主题令牌数量应为 ${themes.length},实际为 ${report.tokenSets.length}`
);
const tokensByTheme = new Map(
report.tokenSets.map((tokens) => {
assert(
tokens.slots?.length === 56,
`主题 ${tokens.themeId} 的真实令牌槽位不是 56`
);
assert(
/^[a-f0-9]{64}$/u.test(tokens.themeFingerprint ?? ""),
`主题 ${tokens.themeId} 的 CSS 指纹无效`
);
return [tokens.themeId, tokens];
})
);
for (const theme of themes) {
assert(
tokensByTheme.has(theme.id),
`真实主题令牌缺少 ${theme.id}`
);
}
return {
generatedAt: report.generatedAt,
chromiumVersion: report.chromiumVersion,
tokensByTheme
};
}
const runtime = new PandocRuntime(
process.env.DOCX_PANDOC_PATH?.trim()
? { configuredPath: process.env.DOCX_PANDOC_PATH.trim() }
@@ -238,6 +357,8 @@ if (capability.capability.detectedVersion !== DOCX_PANDOC_VERSION) {
fs.mkdirSync(outputDirectory, { recursive: true });
const converter = new PandocDocxConverter({ runtime });
const themes = readThemeManifests();
assert(themes.length === 14, `内置主题数量应为 14,实际为 ${themes.length}`);
const themeTokenReport = readThemeTokenReport(themes);
const results = [];
for (const theme of themes) {
@@ -248,6 +369,7 @@ for (const theme of themes) {
}
const markdown = fs.readFileSync(samplePath, "utf8");
const rendered = renderMarkdown(markdown, { language: "zh-CN" });
const themeTokens = themeTokenReport.tokensByTheme.get(theme.id);
const exportConfig = {
...defaultExportConfig,
name: `${theme.name} DOCX 主题验收`,
@@ -268,7 +390,9 @@ for (const theme of themes) {
language: rendered.metadata.language,
exportConfig,
theme,
themeTokens,
metadata: rendered.metadata,
semanticDocument: rendered.semanticDocument,
media: createMedia(markdown)
});
} catch (error) {
@@ -297,14 +421,72 @@ for (const theme of themes) {
conversion.docx,
theme,
rendered.metadata,
rendered.semanticDocument,
exportConfig
)
});
}
for (const result of results) {
const { inspection } = result;
assert(
Object.values(inspection.styles).every(Boolean),
`主题 ${result.id} 缺少标准 Word 样式`
);
assert(
inspection.nativeHeaderFooter,
`主题 ${result.id} 的页眉页脚包含表格模拟`
);
assert(
inspection.altChunkCount === 0,
`主题 ${result.id} 包含 altChunk`
);
assert(
inspection.internalMarkerCount === 0,
`主题 ${result.id} 残留内部结构标记`
);
assert(
inspection.titlePolicyPassed,
`主题 ${result.id} 的标题策略未生效`
);
assert(
inspection.titleDeduplicationPassed,
`主题 ${result.id} 存在重复标题`
);
assert(
inspection.fullWidthTableCount === inspection.tableCount,
`主题 ${result.id} 存在非内容区全宽表格`
);
if (inspection.profile) {
assert(
inspection.structureCoverage === 1,
`主题 ${result.id} 的结构字段覆盖率不是 100%`
);
}
if (inspection.expectsStandaloneCover) {
assert(
inspection.standaloneCoverDetected,
`主题 ${result.id} 未生成合格的独立封面分节`
);
assert(
inspection.sectionPagesField,
`主题 ${result.id} 未使用 SECTIONPAGES`
);
}
}
const report = {
generatedAt: new Date().toISOString(),
pandocVersion: capability.capability.detectedVersion,
themeTokens: {
source: path.relative(
repositoryDirectory,
themeTokenReportPath
),
generatedAt: themeTokenReport.generatedAt,
chromiumVersion: themeTokenReport.chromiumVersion,
tokenSetCount: themeTokenReport.tokensByTheme.size
},
themeCount: themes.length,
outputDirectory: path.relative(
repositoryDirectory,