feat: 完成 DOCX R4 元素级视觉门禁
建立 Chromium、Word 与 WPS 的可编辑内容、段落几何、页眉页脚、封面、媒体和逐页视觉比较链路。 支持封面独立分节、正文页码重启、主题页边距与横向纸张,并将自然分页差异降为诊断项。 修正代码块内边距和折叠表格单元格边框映射,14 套主题生产矩阵与六组布局视觉矩阵通过。
This commit is contained in:
@@ -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`);
|
||||
Reference in New Issue
Block a user