feat: 完成 DOCX 通用结构映射与严格收口
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user