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
@@ -0,0 +1,519 @@
import { execFile } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { promisify } from "node:util";
import { DOMParser } from "@xmldom/xmldom";
import { unzipSync } from "fflate";
import {
createPdfDocumentSnapshot,
createPdfVisualDiffReport,
createWordPdfAdapter,
createWpsPdfAdapter,
renderPdfVisualDiffHtml,
serializePdfVisualDiffJson
} from "../packages/document-visual-diff/dist/index.js";
import {
formatPageNumber,
resolvePageNumberAlignment
} from "../packages/preview-engine/dist/index.js";
const execFileAsync = promisify(execFile);
const WORD_NAMESPACE =
"http://schemas.openxmlformats.org/wordprocessingml/2006/main";
const MATH_NAMESPACE =
"http://schemas.openxmlformats.org/officeDocument/2006/math";
const textDecoder = new TextDecoder();
const repositoryDirectory = path.resolve(import.meta.dirname, "..");
const outputDirectory = path.join(
repositoryDirectory,
"output",
"docx-page-decoration-gate"
);
const matrixScript = path.join(
repositoryDirectory,
"apps",
"server",
"scripts",
"verify-docx-r4-matrix.mjs"
);
const commonHeader = {
enabled: true,
height: "8mm",
showDivider: true,
fontSize: "3mm",
fontFamily: '"Microsoft YaHei", sans-serif',
color: "#44546a",
left: { enabled: true, content: "页眉左栏" },
center: { enabled: true, content: "页眉中栏" },
right: { enabled: true, content: "页眉右栏" }
};
const cases = [
{
id: "no-cover",
themeId: "typora-github",
coverPageCount: 0,
config: {
pageDecorationsMode: "custom",
header: { ...commonHeader, showOnFirstPage: false },
footer: {
enabled: true,
height: "8mm",
showDivider: true,
fontSize: "3mm",
fontFamily: '"Microsoft YaHei", sans-serif',
color: "#44546a",
alignment: "center",
format: "page-total",
startFrom: 1,
showOnFirstPage: true
}
}
},
{
id: "official",
themeId: "gov-red-standard",
coverPageCount: 0,
config: {
pageDecorationsMode: "custom",
header: { ...commonHeader, showOnFirstPage: true },
footer: {
enabled: true,
height: "7mm",
showDivider: false,
fontSize: "3mm",
fontFamily: '"FangSong", "SimSun", serif',
color: "#000000",
alignment: "outer",
format: "official-page",
startFrom: 1,
showOnFirstPage: false
}
}
},
{
id: "project-cover",
themeId: "formal-feasibility",
coverPageCount: 1,
config: {
pageDecorationsMode: "custom",
header: { ...commonHeader, showOnFirstPage: true },
footer: {
enabled: true,
height: "8mm",
showDivider: true,
fontSize: "3mm",
fontFamily: '"Microsoft YaHei", sans-serif',
color: "#44546a",
alignment: "outer",
format: "page-total",
startFrom: 1,
showOnFirstPage: true
}
}
}
];
const selectedCaseId =
process.env.MD_TO_PDF_PAGE_DECORATION_CASE?.trim() || undefined;
const selectedCases = selectedCaseId
? cases.filter((caseDefinition) => caseDefinition.id === selectedCaseId)
: cases;
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function extractDocxEditableText(docxPath) {
const entries = unzipSync(Uint8Array.from(fs.readFileSync(docxPath)));
const documentXml = entries["word/document.xml"];
assert(documentXml, `DOCX 缺少 word/document.xml${docxPath}`);
const document = new DOMParser().parseFromString(
textDecoder.decode(documentXml),
"application/xml"
);
return Array.from(document.getElementsByTagName("*")).flatMap((node) =>
node.localName === "t" &&
(node.namespaceURI === WORD_NAMESPACE || node.namespaceURI === MATH_NAMESPACE)
? [node.textContent ?? ""]
: []
).join("");
}
function paragraphStyleId(paragraph) {
const style = Array.from(paragraph.getElementsByTagName("*")).find(
(node) => node.localName === "pStyle" && node.namespaceURI === WORD_NAMESPACE
);
return style?.getAttribute("w:val") || style?.getAttribute("val") || undefined;
}
function paragraphRole(styleId) {
if (!styleId) {
return "body";
}
if (/^Heading[1-6]$/u.test(styleId)) {
return "heading";
}
if (
styleId === "Title" ||
/(?:Title|Masthead|ProjectName)$/u.test(styleId)
) {
return "title";
}
if (/Caption/u.test(styleId)) {
return "caption";
}
return "body";
}
function extractDocxEditableParagraphs(docxPath) {
const entries = unzipSync(Uint8Array.from(fs.readFileSync(docxPath)));
const documentXml = entries["word/document.xml"];
assert(documentXml, `DOCX 缺少 word/document.xml${docxPath}`);
const document = new DOMParser().parseFromString(
textDecoder.decode(documentXml),
"application/xml"
);
let section = "cover";
let hasCoverSection = false;
const paragraphs = [];
for (const paragraph of Array.from(document.getElementsByTagName("*")).filter(
(node) => node.localName === "p" && node.namespaceURI === WORD_NAMESPACE
)) {
const descendants = Array.from(paragraph.getElementsByTagName("*"));
const hasSectionBreak = descendants.some(
(node) => node.localName === "sectPr" && node.namespaceURI === WORD_NAMESPACE
);
if (hasSectionBreak) {
hasCoverSection = true;
section = "body";
continue;
}
const text = descendants.flatMap((node) =>
node.localName === "t" &&
(node.namespaceURI === WORD_NAMESPACE || node.namespaceURI === MATH_NAMESPACE)
? [node.textContent ?? ""]
: []
).join("");
if (!text) {
continue;
}
const styleId = paragraphStyleId(paragraph);
paragraphs.push({
index: paragraphs.length,
text,
...(styleId ? { styleId } : {}),
role: paragraphRole(styleId),
section
});
}
if (!hasCoverSection) {
for (const paragraph of paragraphs) {
paragraph.section = "body";
}
}
return paragraphs;
}
function buildPageSemantics(pageCount, coverPageCount, config) {
assert(
pageCount > coverPageCount,
`物理页数 ${pageCount} 必须大于封面页数 ${coverPageCount}`
);
const logicalPageCount = pageCount - coverPageCount;
const headerSlots = ["left", "center", "right"].flatMap((alignment) => {
const slot = config.header[alignment];
return config.header.enabled && slot.enabled && slot.content
? [{ alignment, text: slot.content }]
: [];
});
return Array.from({ length: pageCount }, (_, index) => {
const physicalPageNumber = index + 1;
if (physicalPageNumber <= coverPageCount) {
return {
physicalPageNumber,
kind: "cover",
logicalPageCount,
headerVisible: false,
headerSlots,
footerVisible: false
};
}
const bodyPageIndex = index - coverPageCount;
const isBodyFirst = bodyPageIndex === 0;
const headerVisible =
config.header.enabled &&
(!isBodyFirst || config.header.showOnFirstPage);
const footerVisible =
config.footer.enabled &&
(!isBodyFirst || config.footer.showOnFirstPage);
return {
physicalPageNumber,
kind: isBodyFirst ? "body-first" : "body-rest",
logicalPageNumber: config.footer.startFrom + bodyPageIndex,
logicalPageCount,
headerVisible,
headerSlots,
footerVisible,
...(footerVisible
? {
footerAlignment: resolvePageNumberAlignment(
config.footer,
bodyPageIndex
),
pageNumberText: formatPageNumber(
config.footer,
bodyPageIndex,
logicalPageCount
)
}
: {})
};
});
}
async function generateCase(caseDefinition) {
const caseDirectory = path.join(outputDirectory, caseDefinition.id);
fs.mkdirSync(caseDirectory, { recursive: true });
const { stdout } = await execFileAsync(process.execPath, [matrixScript], {
cwd: repositoryDirectory,
windowsHide: true,
timeout: 300_000,
maxBuffer: 16 * 1024 * 1024,
env: {
...process.env,
MD_TO_PDF_R4_THEME_ID: caseDefinition.themeId,
MD_TO_PDF_R4_OUTPUT_DIR: caseDirectory,
MD_TO_PDF_R4_EXPORT_CONFIG_JSON: JSON.stringify(caseDefinition.config)
}
});
const matrix = JSON.parse(stdout);
const result = matrix.results?.[0];
assert(result, `代表用例 ${caseDefinition.id} 没有生成矩阵结果`);
return { caseDirectory, result };
}
fs.mkdirSync(outputDirectory, { recursive: true });
assert(
selectedCases.length > 0,
`页装饰视觉门禁用例不存在:${selectedCaseId}`
);
const wordAdapter = createWordPdfAdapter({ timeoutMs: 240_000 });
const wordCapability = await wordAdapter.probe();
assert(wordCapability.available, `Microsoft Word 不可用:${wordCapability.detail}`);
const wpsAdapter = createWpsPdfAdapter({ timeoutMs: 240_000 });
const wpsCapability = await wpsAdapter.probe();
assert(wpsCapability.available, `WPS Writer 不可用:${wpsCapability.detail}`);
const summaries = [];
for (const caseDefinition of selectedCases) {
process.stderr.write(
`[page decoration gate] generating ${caseDefinition.id}\n`
);
const { caseDirectory, result } = await generateCase(caseDefinition);
const chromiumPdfPath = path.resolve(
repositoryDirectory,
result.pdf.outputFile
);
const docxPath = path.resolve(repositoryDirectory, result.docx.outputFile);
const expectedEditableText = extractDocxEditableText(docxPath);
const expectedEditableParagraphs = extractDocxEditableParagraphs(docxPath);
const chromiumPdf = Uint8Array.from(fs.readFileSync(chromiumPdfPath));
const wordGeneration = await wordAdapter.generate({ docxPath });
const wordPdfPath = path.join(caseDirectory, "word.pdf");
fs.writeFileSync(wordPdfPath, wordGeneration.pdf);
const baseline = await createPdfDocumentSnapshot(chromiumPdf, {
source: { kind: "chromium", label: `${caseDefinition.id} Chromium` },
dpi: 144
});
const candidate = await createPdfDocumentSnapshot(wordGeneration.pdf, {
source: { kind: "word", label: `${caseDefinition.id} Microsoft Word` },
dpi: 144
});
const baselinePageSemantics = buildPageSemantics(
baseline.pageCount,
caseDefinition.coverPageCount,
result.exportConfig
);
const wordPageSemantics = buildPageSemantics(
candidate.pageCount,
caseDefinition.coverPageCount,
result.exportConfig
);
const report = await createPdfVisualDiffReport(baseline, candidate, {
baselinePageSemantics,
candidatePageSemantics: wordPageSemantics,
expectedEditableText,
expectedEditableParagraphs
});
const wpsGeneration = await wpsAdapter.generate({ docxPath });
const wpsPdfPath = path.join(caseDirectory, "wps.pdf");
fs.writeFileSync(wpsPdfPath, wpsGeneration.pdf);
const wpsCandidate = await createPdfDocumentSnapshot(wpsGeneration.pdf, {
source: { kind: "wps", label: `${caseDefinition.id} WPS Writer` },
dpi: 144
});
const wpsPageSemantics = buildPageSemantics(
wpsCandidate.pageCount,
caseDefinition.coverPageCount,
result.exportConfig
);
const wpsReport = await createPdfVisualDiffReport(
baseline,
wpsCandidate,
{
baselinePageSemantics,
candidatePageSemantics: wpsPageSemantics,
expectedEditableText,
expectedEditableParagraphs
}
);
const rasterDirectory = path.join(caseDirectory, "raster-pages");
fs.mkdirSync(rasterDirectory, { recursive: true });
for (const page of baseline.pages) {
if (page.raster) {
fs.writeFileSync(
path.join(rasterDirectory, `chromium-${page.pageNumber}.png`),
page.raster.png
);
}
}
for (const page of candidate.pages) {
if (page.raster) {
fs.writeFileSync(
path.join(rasterDirectory, `word-${page.pageNumber}.png`),
page.raster.png
);
}
}
for (const page of wpsCandidate.pages) {
if (page.raster) {
fs.writeFileSync(
path.join(rasterDirectory, `wps-${page.pageNumber}.png`),
page.raster.png
);
}
}
for (const page of report.pages) {
const pageNumber = page.pair.baselinePageNumber ??
page.pair.candidatePageNumber;
if (!pageNumber || !page.artifacts) {
continue;
}
fs.writeFileSync(
path.join(rasterDirectory, `overlay-${pageNumber}.png`),
page.artifacts.overlayPng
);
fs.writeFileSync(
path.join(rasterDirectory, `heatmap-${pageNumber}.png`),
page.artifacts.heatmapPng
);
}
for (const page of wpsReport.pages) {
const pageNumber = page.pair.baselinePageNumber ??
page.pair.candidatePageNumber;
if (!pageNumber || !page.artifacts) {
continue;
}
fs.writeFileSync(
path.join(rasterDirectory, `wps-overlay-${pageNumber}.png`),
page.artifacts.overlayPng
);
fs.writeFileSync(
path.join(rasterDirectory, `wps-heatmap-${pageNumber}.png`),
page.artifacts.heatmapPng
);
}
const reportJsonPath = path.join(caseDirectory, "report.json");
const reportHtmlPath = path.join(caseDirectory, "report.html");
const wpsReportJsonPath = path.join(caseDirectory, "wps-report.json");
const wpsReportHtmlPath = path.join(caseDirectory, "wps-report.html");
fs.writeFileSync(
reportJsonPath,
`${serializePdfVisualDiffJson(report)}\n`,
"utf8"
);
fs.writeFileSync(reportHtmlPath, renderPdfVisualDiffHtml(report), "utf8");
fs.writeFileSync(
wpsReportJsonPath,
`${serializePdfVisualDiffJson(wpsReport)}\n`,
"utf8"
);
fs.writeFileSync(
wpsReportHtmlPath,
renderPdfVisualDiffHtml(wpsReport),
"utf8"
);
summaries.push({
id: caseDefinition.id,
themeId: caseDefinition.themeId,
status: report.status,
chromiumPages: baseline.pageCount,
wordPages: candidate.pageCount,
editableTextLength: expectedEditableText.length,
editableParagraphCount: expectedEditableParagraphs.length,
paragraphLayoutIssueCount: report.basic.paragraphLayouts
?.flatMap((paragraph) => paragraph.issues).length ?? 0,
bodyFlow: report.basic.bodyFlow,
baselineEditableCoverage: report.basic.baselineEditableCoverage,
wordEditableSimilarity: report.basic.candidateEditableSimilarity,
wordEditableExact: report.basic.candidateEditableExact,
semanticIssueCount: report.issues.filter((issue) =>
[
"PAGE_NUMBER_VISIBILITY_MISMATCH",
"PAGE_NUMBER_TEXT_MISMATCH",
"PAGE_NUMBER_ALIGNMENT_MISMATCH",
"PAGE_NUMBER_VERTICAL_POSITION_MISMATCH",
"HEADER_VISIBILITY_MISMATCH",
"HEADER_TEXT_MISSING",
"HEADER_VERTICAL_POSITION_MISMATCH"
].includes(issue.code)
).length,
issueCodes: [...new Set(report.issues.map((issue) => issue.code))],
wpsStatus: wpsReport.status,
wpsPages: wpsCandidate.pageCount,
wpsEditableSimilarity: wpsReport.basic.candidateEditableSimilarity,
wpsEditableExact: wpsReport.basic.candidateEditableExact,
wpsParagraphLayoutIssueCount: wpsReport.basic.paragraphLayouts
?.flatMap((paragraph) => paragraph.issues).length ?? 0,
wpsBodyFlow: wpsReport.basic.bodyFlow,
wpsSemanticIssueCount: wpsReport.issues.filter((issue) =>
[
"PAGE_NUMBER_VISIBILITY_MISMATCH",
"PAGE_NUMBER_TEXT_MISMATCH",
"PAGE_NUMBER_ALIGNMENT_MISMATCH",
"PAGE_NUMBER_VERTICAL_POSITION_MISMATCH",
"HEADER_VISIBILITY_MISMATCH",
"HEADER_TEXT_MISSING",
"HEADER_VERTICAL_POSITION_MISMATCH"
].includes(issue.code)
).length,
wpsIssueCodes: [
...new Set(wpsReport.issues.map((issue) => issue.code))
],
reportHtml: path.relative(repositoryDirectory, reportHtmlPath),
reportJson: path.relative(repositoryDirectory, reportJsonPath),
wpsReportHtml: path.relative(repositoryDirectory, wpsReportHtmlPath),
wpsReportJson: path.relative(repositoryDirectory, wpsReportJsonPath),
chromiumPdf: path.relative(repositoryDirectory, chromiumPdfPath),
wordPdf: path.relative(repositoryDirectory, wordPdfPath),
wpsPdf: path.relative(repositoryDirectory, wpsPdfPath),
docx: path.relative(repositoryDirectory, docxPath)
});
}
const summary = {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
selectedCaseId,
word: wordCapability,
wps: wpsCapability,
cases: summaries
};
const summaryPath = path.join(outputDirectory, "summary.json");
fs.writeFileSync(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, "utf8");
process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);