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
+93
View File
@@ -0,0 +1,93 @@
import fs from "node:fs";
import { serialize } from "node:v8";
import { createCanvas, loadImage } from "@napi-rs/canvas";
import { createPdfDocumentSnapshot } from "../packages/document-visual-diff/dist/index.js";
const [pdfPath, outputPath, sourceJson, dpiText, markersJson] =
process.argv.slice(2);
if (!pdfPath || !outputPath || !sourceJson) {
throw new Error("缺少 PDF 快照 worker 参数");
}
const source = JSON.parse(sourceJson);
const snapshot = await createPdfDocumentSnapshot(fs.readFileSync(pdfPath), {
source,
dpi: Number(dpiText || 144)
});
const markerDefinitions = JSON.parse(markersJson || "[]");
const markers = [];
for (const definition of markerDefinitions) {
const target = {
r: Number.parseInt(definition.color.slice(1, 3), 16),
g: Number.parseInt(definition.color.slice(3, 5), 16),
b: Number.parseInt(definition.color.slice(5, 7), 16)
};
let best;
for (const page of snapshot.pages) {
if (!page.raster) {
continue;
}
const image = await loadImage(Buffer.from(page.raster.png));
const canvas = createCanvas(page.raster.widthPx, page.raster.heightPx);
const context = canvas.getContext("2d");
context.drawImage(image, 0, 0);
const pixels = context.getImageData(
0,
0,
page.raster.widthPx,
page.raster.heightPx
).data;
let count = 0;
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxX = -1;
let maxY = -1;
for (let offset = 0; offset < pixels.length; offset += 4) {
if (
Math.abs(pixels[offset] - target.r) <= 12 &&
Math.abs(pixels[offset + 1] - target.g) <= 12 &&
Math.abs(pixels[offset + 2] - target.b) <= 12 &&
pixels[offset + 3] >= 200
) {
const pixelIndex = offset / 4;
const x = pixelIndex % page.raster.widthPx;
const y = Math.floor(pixelIndex / page.raster.widthPx);
count += 1;
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
}
}
if (!best || count > best.count) {
best = {
pageNumber: page.pageNumber,
pageWidthPt: page.widthPt,
dpi: page.raster.dpi,
count,
minX,
minY,
maxX,
maxY
};
}
}
if (!best || best.count < 100) {
throw new Error(`${source.label} 未定位 ${definition.label}`);
}
const scale = 72 / best.dpi;
markers.push({
id: definition.id,
label: definition.label,
pageNumber: best.pageNumber,
pixelCount: best.count,
xPt: best.minX * scale,
yPt: best.minY * scale,
widthPt: (best.maxX - best.minX + 1) * scale,
heightPt: (best.maxY - best.minY + 1) * scale,
centerXPt: ((best.minX + best.maxX + 1) / 2) * scale,
pageWidthPt: best.pageWidthPt
});
}
fs.writeFileSync(outputPath, serialize({ snapshot, markers }));
@@ -0,0 +1,629 @@
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 decoder = new TextDecoder();
const repositoryDirectory = path.resolve(import.meta.dirname, "..");
const outputDirectory = path.join(
repositoryDirectory,
"output",
"docx-layout-visual-matrix"
);
const matrixScript = path.join(
repositoryDirectory,
"apps",
"server",
"scripts",
"verify-docx-r4-matrix.mjs"
);
const pageSizes = {
A4: { portrait: [595.276, 841.89], landscape: [841.89, 595.276] },
Letter: { portrait: [612, 792], landscape: [792, 612] }
};
const strictIssueCodes = new Set([
"PAGE_SIZE_MISMATCH",
"PAGE_ORIENTATION_MISMATCH",
"CONTENT_MISMATCH",
"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",
"EDITABLE_PARAGRAPH_MAPPING_MISMATCH",
"TEXT_BLOCK_LINE_COUNT_MISMATCH",
"PARAGRAPH_LINE_BREAK_MISMATCH",
"PARAGRAPH_LINE_GEOMETRY_MISMATCH",
"COVER_LAYOUT_MISMATCH"
]);
const cases = [
{
id: "official-theme-a4-portrait",
themeId: "gov-red-standard",
coverPageCount: 0,
paper: { format: "A4", orientation: "portrait" },
config: {}
},
{
id: "official-asymmetric-a4-portrait",
themeId: "gov-red-standard",
coverPageCount: 0,
paper: { format: "A4", orientation: "portrait" },
config: {
paper: {
format: "A4",
orientation: "portrait",
marginMode: "custom",
margins: {
top: "18mm",
right: "14mm",
bottom: "30mm",
left: "32mm"
}
}
}
},
{
id: "project-cover-custom-a4-portrait",
themeId: "formal-feasibility",
coverPageCount: 1,
paper: { format: "A4", orientation: "portrait" },
config: {
paper: {
format: "A4",
orientation: "portrait",
marginMode: "custom",
margins: {
top: "20mm",
right: "18mm",
bottom: "25mm",
left: "30mm"
}
}
}
},
{
id: "tender-cover-custom-letter-landscape",
themeId: "tender-business-blue",
coverPageCount: 1,
paper: { format: "Letter", orientation: "landscape" },
config: {
paper: {
format: "Letter",
orientation: "landscape",
marginMode: "custom",
margins: {
top: "16mm",
right: "18mm",
bottom: "20mm",
left: "24mm"
}
}
}
},
{
id: "formal-custom-a4-landscape",
themeId: "formal-regulation",
coverPageCount: 0,
paper: { format: "A4", orientation: "landscape" },
config: {
paper: {
format: "A4",
orientation: "landscape",
marginMode: "custom",
margins: {
top: "15mm",
right: "22mm",
bottom: "18mm",
left: "25mm"
}
}
}
},
{
id: "general-compact-letter-portrait",
themeId: "typora-github",
coverPageCount: 0,
paper: { format: "Letter", orientation: "portrait" },
config: {
paper: {
format: "Letter",
orientation: "portrait",
marginMode: "custom",
margins: {
top: "12.7mm",
right: "12.7mm",
bottom: "12.7mm",
left: "12.7mm"
}
}
}
}
];
const selectedCaseId =
process.env.MD_TO_PDF_LAYOUT_VISUAL_CASE?.trim() || undefined;
const selectedCases = selectedCaseId
? cases.filter((entry) => entry.id === selectedCaseId)
: cases;
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
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 isInternalLayoutSpacerParagraph(descendants, text) {
if (text !== ".") {
return false;
}
const attributeValue = (node, name) =>
node.getAttribute(`w:${name}`) || node.getAttribute(name) || undefined;
return (
descendants.some(
(node) =>
node.localName === "sz" &&
node.namespaceURI === WORD_NAMESPACE &&
attributeValue(node, "val") === "2"
) &&
descendants.some(
(node) =>
node.localName === "spacing" &&
node.namespaceURI === WORD_NAMESPACE &&
attributeValue(node, "lineRule") === "exact"
)
);
}
function extractEditableContract(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(
decoder.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 || isInternalLayoutSpacerParagraph(descendants, 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 {
text: paragraphs.map((paragraph) => paragraph.text).join(""),
paragraphs
};
}
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: 360_000,
maxBuffer: 24 * 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 result = JSON.parse(stdout).results?.[0];
assert(result, `视觉矩阵用例 ${caseDefinition.id} 没有生成产物`);
return { caseDirectory, result };
}
function expectedPageSize(caseDefinition) {
const dimensions =
pageSizes[caseDefinition.paper.format]?.[caseDefinition.paper.orientation];
assert(dimensions, `未知纸张配置:${JSON.stringify(caseDefinition.paper)}`);
return dimensions;
}
function pageGeometryFailures(snapshot, caseDefinition, label) {
const [expectedWidth, expectedHeight] = expectedPageSize(caseDefinition);
return snapshot.pages.flatMap((page) => {
const widthDelta = Math.abs(page.widthPt - expectedWidth);
const heightDelta = Math.abs(page.heightPt - expectedHeight);
return widthDelta <= 0.5 && heightDelta <= 0.5
? []
: [
`${label}${page.pageNumber} 页纸张尺寸为 ` +
`${page.widthPt.toFixed(3)}×${page.heightPt.toFixed(3)} pt` +
`期望 ${expectedWidth.toFixed(3)}×${expectedHeight.toFixed(3)} pt`
];
});
}
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
)
}
: {})
};
});
}
function reportGateFailures(report, label) {
const failures = report.issues
.filter((issue) => strictIssueCodes.has(issue.code))
.map((issue) => `${label}: ${issue.code} - ${issue.message}`);
if (report.basic.baselineEditableCoverage !== 1) {
failures.push(
`${label}: Chromium 可编辑正文有序覆盖率不是 100%`
);
}
if (report.basic.candidateEditableExact !== true) {
failures.push(`${label}: Office 可编辑正文未精确匹配`);
}
if (report.basic.bodyFlow?.detected && !report.basic.bodyFlow.legal) {
failures.push(`${label}: 分页差异未满足合法正文流条件`);
}
return failures;
}
function reportSummary(report) {
const paragraphIssues = report.basic.paragraphLayouts?.flatMap(
(paragraph) => paragraph.issues
) ?? [];
return {
status: report.status,
issueCodes: [...new Set(report.issues.map((issue) => issue.code))],
strictIssueCodes: [
...new Set(
report.issues
.filter((issue) => strictIssueCodes.has(issue.code))
.map((issue) => issue.code)
)
],
editableExact: report.basic.candidateEditableExact,
paragraphIssueCount: paragraphIssues.length,
bodyFlow: report.basic.bodyFlow
};
}
function writeSnapshotPages(directory, prefix, snapshot) {
for (const page of snapshot.pages) {
if (page.raster) {
fs.writeFileSync(
path.join(directory, `${prefix}-${page.pageNumber}.png`),
page.raster.png
);
}
}
}
function writeReportArtifacts(directory, prefix, report) {
fs.writeFileSync(
path.join(directory, `${prefix}-report.json`),
`${serializePdfVisualDiffJson(report)}\n`,
"utf8"
);
fs.writeFileSync(
path.join(directory, `${prefix}-report.html`),
renderPdfVisualDiffHtml(report),
"utf8"
);
for (const page of report.pages) {
const pageNumber =
page.pair.baselinePageNumber ?? page.pair.candidatePageNumber;
if (!pageNumber || !page.artifacts) {
continue;
}
fs.writeFileSync(
path.join(directory, `${prefix}-overlay-${pageNumber}.png`),
page.artifacts.overlayPng
);
fs.writeFileSync(
path.join(directory, `${prefix}-heatmap-${pageNumber}.png`),
page.artifacts.heatmapPng
);
}
}
fs.mkdirSync(outputDirectory, { recursive: true });
assert(
selectedCases.length > 0,
`视觉矩阵用例不存在:${selectedCaseId}`
);
const wordAdapter = createWordPdfAdapter({ timeoutMs: 240_000 });
const wpsAdapter = createWpsPdfAdapter({ timeoutMs: 240_000 });
const wordCapability = await wordAdapter.probe();
const wpsCapability = await wpsAdapter.probe();
assert(wordCapability.available, `Microsoft Word 不可用:${wordCapability.detail}`);
assert(wpsCapability.available, `WPS Writer 不可用:${wpsCapability.detail}`);
const summaries = [];
const gateFailures = [];
for (const caseDefinition of selectedCases) {
process.stderr.write(`[layout visual matrix] ${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 editable = extractEditableContract(docxPath);
const wordGeneration = await wordAdapter.generate({ docxPath });
const wpsGeneration = await wpsAdapter.generate({ docxPath });
const wordPdfPath = path.join(caseDirectory, "word.pdf");
const wpsPdfPath = path.join(caseDirectory, "wps.pdf");
fs.writeFileSync(wordPdfPath, wordGeneration.pdf);
fs.writeFileSync(wpsPdfPath, wpsGeneration.pdf);
const chromium = await createPdfDocumentSnapshot(
fs.readFileSync(chromiumPdfPath),
{
source: {
kind: "chromium",
label: `${caseDefinition.id} Chromium`,
fileName: path.basename(chromiumPdfPath)
},
dpi: 144
}
);
const word = await createPdfDocumentSnapshot(wordGeneration.pdf, {
source: {
kind: "word",
label: `${caseDefinition.id} Microsoft Word`,
fileName: path.basename(wordPdfPath)
},
dpi: 144
});
const wps = await createPdfDocumentSnapshot(wpsGeneration.pdf, {
source: {
kind: "wps",
label: `${caseDefinition.id} WPS Writer`,
fileName: path.basename(wpsPdfPath)
},
dpi: 144
});
const chromiumPageSemantics = buildPageSemantics(
chromium.pageCount,
caseDefinition.coverPageCount,
result.exportConfig
);
const wordPageSemantics = buildPageSemantics(
word.pageCount,
caseDefinition.coverPageCount,
result.exportConfig
);
const wpsPageSemantics = buildPageSemantics(
wps.pageCount,
caseDefinition.coverPageCount,
result.exportConfig
);
const reportOptions = {
thresholds: { strictPageCount: false },
expectedEditableText: editable.text,
expectedEditableParagraphs: editable.paragraphs
};
const wordReport = await createPdfVisualDiffReport(
chromium,
word,
{
...reportOptions,
baselinePageSemantics: chromiumPageSemantics,
candidatePageSemantics: wordPageSemantics
}
);
const wpsReport = await createPdfVisualDiffReport(
chromium,
wps,
{
...reportOptions,
baselinePageSemantics: chromiumPageSemantics,
candidatePageSemantics: wpsPageSemantics
}
);
const officeReport = await createPdfVisualDiffReport(word, wps, {
...reportOptions,
expectedEditableText: editable.text,
baselinePageSemantics: wordPageSemantics,
candidatePageSemantics: wpsPageSemantics
});
const rasterDirectory = path.join(caseDirectory, "raster-pages");
fs.rmSync(rasterDirectory, { recursive: true, force: true });
fs.mkdirSync(rasterDirectory, { recursive: true });
writeSnapshotPages(rasterDirectory, "chromium", chromium);
writeSnapshotPages(rasterDirectory, "word", word);
writeSnapshotPages(rasterDirectory, "wps", wps);
writeReportArtifacts(rasterDirectory, "word", wordReport);
writeReportArtifacts(rasterDirectory, "wps", wpsReport);
writeReportArtifacts(rasterDirectory, "office", officeReport);
const currentFailures = [
...pageGeometryFailures(chromium, caseDefinition, "Chromium"),
...pageGeometryFailures(word, caseDefinition, "Microsoft Word"),
...pageGeometryFailures(wps, caseDefinition, "WPS Writer"),
...reportGateFailures(wordReport, "Chromium/Word"),
...reportGateFailures(wpsReport, "Chromium/WPS"),
...reportGateFailures(officeReport, "Word/WPS")
];
gateFailures.push(
...currentFailures.map((failure) => `${caseDefinition.id}: ${failure}`)
);
summaries.push({
id: caseDefinition.id,
themeId: caseDefinition.themeId,
paper: caseDefinition.paper,
exportConfig: result.exportConfig,
coverPageCount: caseDefinition.coverPageCount,
pages: {
chromium: chromium.pageCount,
word: word.pageCount,
wps: wps.pageCount
},
editableParagraphCount: editable.paragraphs.length,
reports: {
word: reportSummary(wordReport),
wps: reportSummary(wpsReport),
office: reportSummary(officeReport)
},
gateFailures: currentFailures,
files: {
chromiumPdf: path.relative(repositoryDirectory, chromiumPdfPath),
docx: path.relative(repositoryDirectory, docxPath),
wordPdf: path.relative(repositoryDirectory, wordPdfPath),
wpsPdf: path.relative(repositoryDirectory, wpsPdfPath),
rasterDirectory: path.relative(repositoryDirectory, rasterDirectory)
}
});
}
const summary = {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
selectedCaseId,
word: wordCapability,
wps: wpsCapability,
caseCount: summaries.length,
gatePassed: gateFailures.length === 0,
gateFailures,
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`);
assert(
gateFailures.length === 0,
`布局视觉矩阵存在 ${gateFailures.length} 个严格门禁问题,详见 ${path.relative(
repositoryDirectory,
summaryPath
)}`
);
+761
View File
@@ -0,0 +1,761 @@
import fs from "node:fs";
import { execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import net from "node:net";
import path from "node:path";
import { deserialize } from "node:v8";
import { createCanvas } from "@napi-rs/canvas";
import { DOMParser } from "@xmldom/xmldom";
import { unzipSync } from "fflate";
import { defaultExportConfig } from "../packages/core/dist/index.js";
import {
createPdfVisualDiffReport,
createWordDocxRoundTripAdapter,
createWordPdfAdapter,
createWpsDocxRoundTripAdapter,
createWpsPdfAdapter,
renderPdfVisualDiffHtml,
serializePdfVisualDiffJson
} from "../packages/document-visual-diff/dist/index.js";
import { buildApp } from "../apps/server/dist/app.js";
import { createDocxMediaEngine } from "../apps/server/dist/docx-media-engine.js";
import { createPdfGenerator } from "../apps/server/dist/pdf-engine.js";
const repositoryDirectory = path.resolve(import.meta.dirname, "..");
const fixturePath = path.join(
repositoryDirectory,
"packages",
"docx-engine",
"fixtures",
"docx-media-visual.md"
);
const webDirectory = path.join(
repositoryDirectory,
"apps",
"web",
"dist"
);
const outputDirectory = path.join(
repositoryDirectory,
"output",
"docx-media-gate"
);
const snapshotWorkerPath = path.join(
repositoryDirectory,
"scripts",
"create-pdf-snapshot-artifact.mjs"
);
const rasterDirectory = path.join(outputDirectory, "raster-pages");
const decoder = new TextDecoder();
const WORD_NAMESPACE =
"http://schemas.openxmlformats.org/wordprocessingml/2006/main";
const MATH_NAMESPACE =
"http://schemas.openxmlformats.org/officeDocument/2006/math";
const contentTypes = new Map([
[".css", "text/css; charset=utf-8"],
[".html", "text/html; charset=utf-8"],
[".js", "text/javascript; charset=utf-8"],
[".mjs", "text/javascript; charset=utf-8"],
[".png", "image/png"],
[".svg", "image/svg+xml"],
[".woff", "font/woff"],
[".woff2", "font/woff2"]
]);
const markerDefinitions = [
{ id: "normal", label: "普通图片", color: "#7DD3FC" },
{ id: "wide", label: "宽图", color: "#FDBA74" },
{
id: "tall",
label: "高图",
color: "#86EFAC",
paginationScaleFlexible: true
},
{
id: "mermaid",
label: "Mermaid",
color: "#C4B5FD",
markerRatioTolerance: 0.08
},
{ id: "echarts", label: "ECharts", color: "#F9A8D4" }
];
const captionDefinitions = [
{ id: "normal", text: "普通图片:蓝色4:3" },
{ id: "wide", text: "宽图:橙色8:3" },
{ id: "tall", text: "高图:绿色3:8" },
{ id: "echarts", text: "ECharts紫色柱状图" }
];
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function createIsolatedPdfSnapshot(pdfPath, source) {
const snapshotPath = path.join(
outputDirectory,
`.snapshot-${randomUUID()}.bin`
);
try {
execFileSync(
process.execPath,
[
snapshotWorkerPath,
pdfPath,
snapshotPath,
JSON.stringify(source),
"144",
JSON.stringify(markerDefinitions)
],
{ cwd: repositoryDirectory, stdio: "inherit", timeout: 180_000 }
);
return deserialize(fs.readFileSync(snapshotPath));
} finally {
fs.rmSync(snapshotPath, { force: true });
}
}
function reservePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port =
typeof address === "object" && address ? address.port : undefined;
server.close((error) =>
error || !port
? reject(error ?? new Error("无法分配媒体验收端口"))
: resolve(port)
);
});
});
}
function resolveWebFile(relativePath) {
const resolved = path.resolve(webDirectory, relativePath);
const relative = path.relative(webDirectory, resolved);
assert(
relative !== ".." &&
!relative.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relative),
`Web 资源路径越界:${relativePath}`
);
return resolved;
}
function registerWebRuntime(app) {
app.get("/preview-frame.html", async (_request, reply) =>
reply
.type("text/html; charset=utf-8")
.send(fs.readFileSync(resolveWebFile("preview-frame.html")))
);
app.get("/assets/*", async (request, reply) => {
const relativePath = String(request.params["*"] ?? "");
const filePath = resolveWebFile(path.join("assets", relativePath));
assert(fs.statSync(filePath).isFile(), `Web 资源无效:${relativePath}`);
return reply
.type(
contentTypes.get(path.extname(filePath).toLowerCase()) ||
"application/octet-stream"
)
.send(fs.readFileSync(filePath));
});
}
function createMarkerPng(width, height, background, accent, label) {
const canvas = createCanvas(width, height);
const context = canvas.getContext("2d");
context.fillStyle = background;
context.fillRect(0, 0, width, height);
context.strokeStyle = accent;
context.lineWidth = Math.max(8, Math.round(Math.min(width, height) / 35));
context.strokeRect(
context.lineWidth / 2,
context.lineWidth / 2,
width - context.lineWidth,
height - context.lineWidth
);
context.globalAlpha = 0.22;
context.lineWidth = 2;
for (let index = 1; index < 8; index += 1) {
context.beginPath();
context.moveTo((width * index) / 8, 0);
context.lineTo((width * index) / 8, height);
context.stroke();
}
for (let index = 1; index < 6; index += 1) {
context.beginPath();
context.moveTo(0, (height * index) / 6);
context.lineTo(width, (height * index) / 6);
context.stroke();
}
context.globalAlpha = 1;
context.fillStyle = accent;
context.textAlign = "center";
context.textBaseline = "middle";
context.font = `bold ${Math.max(28, Math.round(Math.min(width, height) / 7))}px sans-serif`;
context.fillText(label, width / 2, height / 2);
return canvas.toBuffer("image/png");
}
function createResources() {
return [
{
path: "media/normal.png",
contentType: "image/png",
data: createMarkerPng(
640,
480,
"#7DD3FC",
"#1D4ED8",
"NORMAL 4:3"
).toString("base64")
},
{
path: "media/wide.png",
contentType: "image/png",
data: createMarkerPng(
1600,
600,
"#FDBA74",
"#C2410C",
"WIDE 8:3"
).toString("base64")
},
{
path: "media/tall.png",
contentType: "image/png",
data: createMarkerPng(
600,
1600,
"#86EFAC",
"#15803D",
"TALL 3:8"
).toString("base64")
}
];
}
async function requestArtifact(origin, route, payload, contentType) {
const response = await fetch(`${origin}${route}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload)
});
const content = new Uint8Array(await response.arrayBuffer());
assert(
response.ok,
`${route} 返回 ${response.status}${decoder.decode(content)}`
);
assert(
response.headers.get("content-type")?.includes(contentType),
`${route} MIME 无效`
);
assert(
Number(response.headers.get("x-echarts-error-count")) === 0 &&
Number(response.headers.get("x-mermaid-error-count")) === 0,
`${route} 图表渲染存在错误`
);
return content;
}
function extractEditableContract(docx) {
const entries = unzipSync(docx);
const documentXml = entries["word/document.xml"];
assert(documentXml, "DOCX 缺少 document.xml");
const document = new DOMParser().parseFromString(
decoder.decode(documentXml),
"application/xml"
);
const text = Array.from(document.getElementsByTagName("*")).flatMap(
(node) =>
node.localName === "t" &&
(node.namespaceURI === WORD_NAMESPACE ||
node.namespaceURI === MATH_NAMESPACE)
? [node.textContent ?? ""]
: []
).join("");
const paragraphs = Array.from(document.getElementsByTagName("*"))
.filter(
(node) =>
node.localName === "p" && node.namespaceURI === WORD_NAMESPACE
)
.flatMap((paragraph) => {
const descendants = Array.from(paragraph.getElementsByTagName("*"));
const paragraphText = descendants.flatMap((node) =>
node.localName === "t" &&
(node.namespaceURI === WORD_NAMESPACE ||
node.namespaceURI === MATH_NAMESPACE)
? [node.textContent ?? ""]
: []
).join("");
if (!paragraphText) {
return [];
}
const style = descendants.find(
(node) =>
node.localName === "pStyle" &&
node.namespaceURI === WORD_NAMESPACE
);
const styleId =
style?.getAttribute("w:val") || style?.getAttribute("val") || undefined;
return [
{
index: 0,
text: paragraphText,
...(styleId ? { styleId } : {}),
role:
styleId === "Title" || /Heading/u.test(styleId ?? "")
? "heading"
: /Caption/u.test(styleId ?? "")
? "caption"
: "body",
section: "body"
}
];
})
.map((paragraph, index) => ({ ...paragraph, index }));
return { text, paragraphs };
}
function readDrawingExtents(documentXml) {
return [...documentXml.matchAll(/<wp:extent cx="(\d+)" cy="(\d+)"/gu)].map(
(match) => ({ cx: Number(match[1]), cy: Number(match[2]) })
);
}
function inspectRoundTripDocx(docx, sourceDocx, label) {
const entries = unzipSync(docx);
const sourceEntries = unzipSync(sourceDocx);
const documentXml = decoder.decode(entries["word/document.xml"]);
const sourceDocumentXml = decoder.decode(
sourceEntries["word/document.xml"]
);
const relationshipsXml = decoder.decode(
entries["word/_rels/document.xml.rels"]
);
const drawingCount = documentXml.match(/<w:drawing(?:\s|>)/gu)?.length ?? 0;
const inlineCount = documentXml.match(/<wp:inline(?:\s|>)/gu)?.length ?? 0;
const anchorCount = documentXml.match(/<wp:anchor(?:\s|>)/gu)?.length ?? 0;
const pngRelationships = [
...relationshipsXml.matchAll(
/Type="[^"]*\/image"[^>]*Target="([^"]+)"/gu
)
].filter((match) => match[1]?.toLowerCase().endsWith(".png"));
assert(drawingCount === 5, `${label} Drawing 数量为 ${drawingCount}`);
assert(inlineCount === 5 && anchorCount === 0, `${label} 出现浮动图片`);
assert(pngRelationships.length === 5, `${label} 未保留五个 PNG 关系`);
assert(!documentXml.includes("mdtp-media:"), `${label} 残留内部标记`);
assert(!documentXml.includes("<a:srcRect"), `${label} 出现图片裁切`);
const sourceExtents = readDrawingExtents(sourceDocumentXml);
const savedExtents = readDrawingExtents(documentXml);
assert(
sourceExtents.length === 5 && savedExtents.length === 5,
`${label} 图片尺寸记录数量无效`
);
let maxUntargetedDriftPt = 0;
for (let index = 0; index < sourceExtents.length; index += 1) {
const expectedScale = index === 0 ? 0.9 : 1;
const expectedCx = sourceExtents[index].cx * expectedScale;
const expectedCy = sourceExtents[index].cy * expectedScale;
const driftPt = Math.max(
Math.abs(savedExtents[index].cx - expectedCx) / 12700,
Math.abs(savedExtents[index].cy - expectedCy) / 12700
);
assert(
driftPt <= 1,
`${label} 图片 ${index + 1} OOXML 尺寸漂移 ${driftPt.toFixed(3)} pt`
);
if (index > 0) {
maxUntargetedDriftPt = Math.max(maxUntargetedDriftPt, driftPt);
}
}
return {
drawingCount,
inlineCount,
anchorCount,
pngRelationshipCount: 5,
maxUntargetedDriftPt
};
}
function assertRoundTrip(result, label) {
assert(result.before.length === 5, `${label} 打开时图片数量无效`);
assert(result.saved.length === 5, `${label} 保存后图片数量无效`);
const before = result.before[0];
const saved = result.saved[0];
assert(before && saved, `${label} 缺少第一张图片尺寸`);
const widthScale = saved.widthPt / before.widthPt;
const heightScale = saved.heightPt / before.heightPt;
assert(
Math.abs(widthScale - 0.9) <= 0.02 &&
Math.abs(heightScale - 0.9) <= 0.02,
`${label} 未按比例保存缩放:${widthScale}/${heightScale}`
);
return { widthScale, heightScale };
}
function compareMarkers(baseline, candidate, label, options = {}) {
return baseline.map((expected) => {
const actual = candidate.find((entry) => entry.id === expected.id);
const definition = markerDefinitions.find(
(entry) => entry.id === expected.id
);
assert(actual, `${label} 缺少 ${expected.label}`);
const widthDelta = Math.abs(actual.widthPt / expected.widthPt - 1);
const heightDelta = Math.abs(actual.heightPt / expected.heightPt - 1);
const expectedRatio = expected.widthPt / expected.heightPt;
const actualRatio = actual.widthPt / actual.heightPt;
const ratioDelta = Math.abs(actualRatio / expectedRatio - 1);
const expectedCenterOffset =
expected.centerXPt - expected.pageWidthPt / 2;
const actualCenterOffset = actual.centerXPt - actual.pageWidthPt / 2;
const centerDeltaPt = Math.abs(actualCenterOffset - expectedCenterOffset);
const physicalSizeRequired =
options.requirePhysicalSize === true ||
!definition?.paginationScaleFlexible;
const sizeTolerance = options.sizeTolerance ?? 0.08;
if (physicalSizeRequired) {
assert(
widthDelta <= sizeTolerance,
`${label} ${expected.label} 宽度偏差过大:` +
`${expected.widthPt}/${actual.widthPt} pt, ` +
`${(widthDelta * 100).toFixed(2)}%`
);
assert(
heightDelta <= sizeTolerance,
`${label} ${expected.label} 高度偏差过大:` +
`${expected.heightPt}/${actual.heightPt} pt, ` +
`${(heightDelta * 100).toFixed(2)}%`
);
}
const ratioTolerance = definition?.markerRatioTolerance ?? 0.04;
assert(
ratioDelta <= ratioTolerance,
`${label} ${expected.label} 比例偏差过大:` +
`${expectedRatio.toFixed(4)}/${actualRatio.toFixed(4)}, ` +
`${(ratioDelta * 100).toFixed(2)}%`
);
assert(centerDeltaPt <= 8, `${label} ${expected.label} 水平对齐偏差过大`);
return {
id: expected.id,
widthDelta,
heightDelta,
ratioDelta,
centerDeltaPt,
physicalSizeRequired,
baselinePage: expected.pageNumber,
candidatePage: actual.pageNumber
};
});
}
function inspectCaptionCenters(snapshot, label) {
return captionDefinitions.map((definition) => {
let observation;
for (const page of snapshot.pages) {
const line = page.lines.find(
(entry) => entry.normalizedText.replace(/\s+/gu, "") === definition.text
);
if (line) {
const centerXPt = line.bounds.x + line.bounds.width / 2;
observation = {
id: definition.id,
text: definition.text,
pageNumber: page.pageNumber,
centerXPt,
pageWidthPt: page.widthPt,
centerDeltaPt: Math.abs(centerXPt - page.widthPt / 2)
};
break;
}
}
assert(observation, `${label} 缺少题注:${definition.text}`);
assert(
observation.centerDeltaPt <= 8,
`${label} 题注未居中:${definition.text}` +
`${observation.centerDeltaPt.toFixed(2)} pt`
);
return observation;
});
}
function writeReportArtifacts(prefix, report, snapshot) {
fs.writeFileSync(
path.join(outputDirectory, `${prefix}-report.json`),
`${serializePdfVisualDiffJson(report)}\n`,
"utf8"
);
fs.writeFileSync(
path.join(outputDirectory, `${prefix}-report.html`),
renderPdfVisualDiffHtml(report),
"utf8"
);
for (const page of snapshot.pages) {
if (page.raster) {
fs.writeFileSync(
path.join(rasterDirectory, `${prefix}-${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, `${prefix}-overlay-${pageNumber}.png`),
page.artifacts.overlayPng
);
fs.writeFileSync(
path.join(rasterDirectory, `${prefix}-heatmap-${pageNumber}.png`),
page.artifacts.heatmapPng
);
}
}
fs.mkdirSync(outputDirectory, { recursive: true });
fs.mkdirSync(rasterDirectory, { recursive: true });
const markdown = fs.readFileSync(fixturePath, "utf8");
const exportConfig = {
...defaultExportConfig,
name: "DOCX 媒体视觉专项验收",
themeId: "typora-github",
pageDecorationsMode: "custom",
header: { ...defaultExportConfig.header, enabled: false },
footer: { ...defaultExportConfig.footer, enabled: false },
paper: {
...defaultExportConfig.paper,
format: "A4",
orientation: "portrait",
marginMode: "custom",
margins: {
top: "20mm",
right: "20mm",
bottom: "20mm",
left: "20mm"
}
}
};
const payload = {
markdown,
fileName: "docx-media-visual.md",
language: "zh-CN",
resources: createResources(),
exportConfig
};
const port = await reservePort();
const origin = `http://127.0.0.1:${port}`;
const pdfGenerator = createPdfGenerator({ renderOrigin: origin });
const docxMediaAdapter = createDocxMediaEngine({ renderOrigin: origin });
const app = buildApp({
logger: { level: "error" },
pdfGenerator,
docxMediaAdapter
});
registerWebRuntime(app);
let chromiumPdf;
let docx;
try {
await app.listen({ port, host: "127.0.0.1" });
chromiumPdf = await requestArtifact(
origin,
"/api/pdf",
payload,
"application/pdf"
);
docx = await requestArtifact(
origin,
"/api/docx",
payload,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
);
} finally {
await app.close();
}
const chromiumPdfPath = path.join(outputDirectory, "chromium.pdf");
const docxPath = path.join(outputDirectory, "source.docx");
fs.writeFileSync(chromiumPdfPath, chromiumPdf);
fs.writeFileSync(docxPath, docx);
const editableContract = extractEditableContract(docx);
const wordAdapter = createWordPdfAdapter({ timeoutMs: 240_000 });
const wpsAdapter = createWpsPdfAdapter({ timeoutMs: 240_000 });
const wordRoundTripAdapter = createWordDocxRoundTripAdapter({
timeoutMs: 240_000
});
const wpsRoundTripAdapter = createWpsDocxRoundTripAdapter({
timeoutMs: 240_000
});
const capabilities = {
word: await wordAdapter.probe(),
wps: await wpsAdapter.probe(),
wordRoundTrip: await wordRoundTripAdapter.probe(),
wpsRoundTrip: await wpsRoundTripAdapter.probe()
};
for (const [name, capability] of Object.entries(capabilities)) {
assert(capability.available, `${name} 不可用:${capability.detail}`);
}
const wordGeneration = await wordAdapter.generate({ docxPath });
const wpsGeneration = await wpsAdapter.generate({ docxPath });
const wordPdfPath = path.join(outputDirectory, "word.pdf");
const wpsPdfPath = path.join(outputDirectory, "wps.pdf");
fs.writeFileSync(wordPdfPath, wordGeneration.pdf);
fs.writeFileSync(wpsPdfPath, wpsGeneration.pdf);
assert(
Buffer.from(fs.readFileSync(wordPdfPath)).equals(
Buffer.from(wordGeneration.pdf)
),
"Word PDF 落盘字节与生成结果不一致"
);
assert(
Buffer.from(fs.readFileSync(wpsPdfPath)).equals(
Buffer.from(wpsGeneration.pdf)
),
"WPS PDF 落盘字节与生成结果不一致"
);
const wordRoundTrip = await wordRoundTripAdapter.generate({ docxPath });
const wpsRoundTrip = await wpsRoundTripAdapter.generate({ docxPath });
const wordRoundTripPath = path.join(outputDirectory, "word-round-trip.docx");
const wpsRoundTripPath = path.join(outputDirectory, "wps-round-trip.docx");
fs.writeFileSync(wordRoundTripPath, wordRoundTrip.docx);
fs.writeFileSync(wpsRoundTripPath, wpsRoundTrip.docx);
const roundTripScale = {
word: assertRoundTrip(wordRoundTrip, "Microsoft Word"),
wps: assertRoundTrip(wpsRoundTrip, "WPS Writer")
};
const roundTripStructure = {
word: inspectRoundTripDocx(wordRoundTrip.docx, docx, "Microsoft Word"),
wps: inspectRoundTripDocx(wpsRoundTrip.docx, docx, "WPS Writer")
};
const wordRoundTripPdf = await wordAdapter.generate({
docxPath: wordRoundTripPath
});
const wpsRoundTripPdf = await wpsAdapter.generate({
docxPath: wpsRoundTripPath
});
fs.writeFileSync(
path.join(outputDirectory, "word-round-trip.pdf"),
wordRoundTripPdf.pdf
);
fs.writeFileSync(
path.join(outputDirectory, "wps-round-trip.pdf"),
wpsRoundTripPdf.pdf
);
const baselineArtifact = createIsolatedPdfSnapshot(chromiumPdfPath, {
kind: "chromium",
label: "Chromium 媒体基线"
});
const wordArtifact = createIsolatedPdfSnapshot(wordPdfPath, {
kind: "word",
label: "Microsoft Word"
});
const wpsArtifact = createIsolatedPdfSnapshot(wpsPdfPath, {
kind: "wps",
label: "WPS Writer"
});
const baseline = baselineArtifact.snapshot;
const wordSnapshot = wordArtifact.snapshot;
const wpsSnapshot = wpsArtifact.snapshot;
const chromiumMarkers = baselineArtifact.markers;
const wordMarkers = wordArtifact.markers;
const wpsMarkers = wpsArtifact.markers;
const captionCenters = {
chromium: inspectCaptionCenters(baseline, "Chromium"),
word: inspectCaptionCenters(wordSnapshot, "Microsoft Word"),
wps: inspectCaptionCenters(wpsSnapshot, "WPS Writer")
};
const reportOptions = {
expectedEditableText: editableContract.text,
expectedEditableParagraphs: editableContract.paragraphs
};
const wordReport = await createPdfVisualDiffReport(
baseline,
wordSnapshot,
reportOptions
);
const wpsReport = await createPdfVisualDiffReport(
baseline,
wpsSnapshot,
reportOptions
);
assert(
wordReport.basic.candidateEditableExact,
"Word 导出 PDF 的可编辑文本不完整"
);
assert(
wpsReport.basic.candidateEditableExact,
"WPS 导出 PDF 的可编辑文本不完整"
);
const markerComparisons = {
word: compareMarkers(chromiumMarkers, wordMarkers, "Microsoft Word"),
wps: compareMarkers(chromiumMarkers, wpsMarkers, "WPS Writer"),
officeParity: compareMarkers(wordMarkers, wpsMarkers, "Word/WPS", {
requirePhysicalSize: true,
sizeTolerance: 0.04
})
};
writeReportArtifacts("chromium", wordReport, baseline);
writeReportArtifacts("word", wordReport, wordSnapshot);
writeReportArtifacts("wps", wpsReport, wpsSnapshot);
const summary = {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
fixture: path.relative(repositoryDirectory, fixturePath),
exportConfig,
capabilities,
pages: {
chromium: baseline.pageCount,
word: wordSnapshot.pageCount,
wps: wpsSnapshot.pageCount
},
reports: {
word: {
status: wordReport.status,
issueCodes: [...new Set(wordReport.issues.map((issue) => issue.code))],
editableExact: wordReport.basic.candidateEditableExact
},
wps: {
status: wpsReport.status,
issueCodes: [...new Set(wpsReport.issues.map((issue) => issue.code))],
editableExact: wpsReport.basic.candidateEditableExact
}
},
markers: {
chromium: chromiumMarkers,
word: wordMarkers,
wps: wpsMarkers,
comparisons: markerComparisons
},
captionCenters,
roundTrip: {
scale: roundTripScale,
structure: roundTripStructure,
word: {
bytes: wordRoundTrip.docx.byteLength,
before: wordRoundTrip.before,
saved: wordRoundTrip.saved
},
wps: {
bytes: wpsRoundTrip.docx.byteLength,
before: wpsRoundTrip.before,
saved: wpsRoundTrip.saved
}
}
};
fs.writeFileSync(
path.join(outputDirectory, "summary.json"),
`${JSON.stringify(summary, null, 2)}\n`,
"utf8"
);
process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
@@ -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`);