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