feat: 完成 DOCX 视觉门禁与字体兼容映射

This commit is contained in:
SkyJourney
2026-07-31 20:13:19 +08:00
parent 10bc62cee4
commit e5da699ea3
32 changed files with 3015 additions and 17 deletions
@@ -0,0 +1,28 @@
{
"name": "@md-to-pdf/document-visual-diff",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
},
"devDependencies": {
"@napi-rs/canvas": "1.0.2",
"@types/node": "^24.10.1",
"pdfjs-dist": "6.1.200",
"vitest": "^4.1.10"
}
}
@@ -0,0 +1,82 @@
import {
type PdfAdapterGeneration,
type PdfArtifactAdapter,
} from "./adapters.js";
import { createPdfVisualDiffReport } from "./report.js";
import { createPdfDocumentSnapshot } from "./snapshot.js";
import type {
CreatePdfSnapshotOptions,
CreatePdfVisualDiffOptions,
PdfDocumentSnapshot,
PdfVisualDiffReport,
} from "./types.js";
export interface PreparedPdfAdapterRun {
id: string;
generate(): Promise<PdfAdapterGeneration>;
}
export interface PdfAdapterVisualDiffResult {
baseline: PdfAdapterGeneration;
baselineSnapshot: PdfDocumentSnapshot;
candidates: Array<{
generation: PdfAdapterGeneration;
snapshot: PdfDocumentSnapshot;
report: PdfVisualDiffReport;
}>;
}
export interface RunPdfAdapterVisualDiffOptions {
baseline: PreparedPdfAdapterRun;
candidates: PreparedPdfAdapterRun[];
snapshot?: Omit<CreatePdfSnapshotOptions, "source">;
report?: CreatePdfVisualDiffOptions;
}
export function preparePdfAdapterRun<TInput>(
adapter: PdfArtifactAdapter<TInput>,
input: TInput,
): PreparedPdfAdapterRun {
return {
id: adapter.id,
generate: () => adapter.generate(input),
};
}
export async function runPdfAdapterVisualDiff(
options: RunPdfAdapterVisualDiffOptions,
): Promise<PdfAdapterVisualDiffResult> {
if (options.candidates.length === 0) {
throw new Error("视觉差异编排至少需要一个候选适配器");
}
const baseline = await options.baseline.generate();
const baselineSnapshot = await createPdfDocumentSnapshot(
baseline.pdf,
{
...options.snapshot,
source: baseline.source,
},
);
const candidates: PdfAdapterVisualDiffResult["candidates"] = [];
for (const candidateRun of options.candidates) {
const generation = await candidateRun.generate();
const snapshot = await createPdfDocumentSnapshot(
generation.pdf,
{
...options.snapshot,
source: generation.source,
},
);
const report = await createPdfVisualDiffReport(
baselineSnapshot,
snapshot,
options.report,
);
candidates.push({ generation, snapshot, report });
}
return {
baseline,
baselineSnapshot,
candidates,
};
}
@@ -0,0 +1,90 @@
import { performance } from "node:perf_hooks";
import type { PdfSnapshotSource } from "./types.js";
export interface PdfAdapterCapability {
available: boolean;
adapterId: string;
source: PdfSnapshotSource;
detail?: string;
}
export interface PdfAdapterGeneration {
source: PdfSnapshotSource;
pdf: Uint8Array;
pageCount?: number;
elapsedMs: number;
diagnostics: string[];
}
export interface PdfArtifactAdapter<TInput> {
readonly id: string;
readonly source: PdfSnapshotSource;
probe(): Promise<PdfAdapterCapability>;
generate(input: TInput): Promise<PdfAdapterGeneration>;
}
export type ChromiumPdfProducerResult =
| Uint8Array
| {
pdf: Uint8Array;
pageCount?: number;
diagnostics?: string[];
};
export interface ChromiumPdfAdapterOptions<TInput> {
label?: string;
fileName?: string;
generate: (input: TInput) => Promise<ChromiumPdfProducerResult>;
}
export function assertPdfBytes(
pdf: Uint8Array,
label = "PDF",
): void {
if (!(pdf instanceof Uint8Array) || pdf.byteLength < 8) {
throw new Error(`${label} 内容无效或过短`);
}
const signature = new TextDecoder("ascii").decode(pdf.subarray(0, 5));
if (signature !== "%PDF-") {
throw new Error(`${label} 缺少有效的 PDF 文件签名`);
}
}
export function createChromiumPdfAdapter<TInput>(
options: ChromiumPdfAdapterOptions<TInput>,
): PdfArtifactAdapter<TInput> {
const source: PdfSnapshotSource = {
kind: "chromium",
label: options.label ?? "Chromium",
...(options.fileName ? { fileName: options.fileName } : {}),
};
return {
id: "chromium",
source,
async probe() {
return {
available: true,
adapterId: "chromium",
source,
detail: "由调用方提供现有 Chromium PDF 生成链",
};
},
async generate(input) {
const startedAt = performance.now();
const produced = await options.generate(input);
const result =
produced instanceof Uint8Array ? { pdf: produced } : produced;
assertPdfBytes(result.pdf, source.label);
return {
source,
pdf: result.pdf,
...(result.pageCount === undefined
? {}
: { pageCount: result.pageCount }),
elapsedMs: performance.now() - startedAt,
diagnostics: result.diagnostics ?? [],
};
},
};
}
@@ -0,0 +1,225 @@
import { normalizePdfContentText } from "./text.js";
import type {
PdfBasicComparison,
PdfDocumentSnapshot,
PdfPagePair,
VisualDiffIssue,
VisualDiffThresholds,
} from "./types.js";
export const DEFAULT_VISUAL_DIFF_THRESHOLDS: VisualDiffThresholds = {
pageSizeDeltaPt: 0.5,
contentSimilarity: 0.999,
strictPageCount: true,
pixelDifferenceThreshold: 8,
maxMeanAbsoluteError: 8,
maxChangedPixelRatio: 0.05,
minInkIou: 0.75,
minEdgeIou: 0.6,
};
function cappedLevenshteinDistance(
left: string,
right: string,
cap: number,
): number {
if (left === right) {
return 0;
}
if (Math.abs(left.length - right.length) > cap) {
return cap + 1;
}
let previous = new Int32Array(right.length + 1);
let current = new Int32Array(right.length + 1);
for (let column = 0; column <= right.length; column += 1) {
previous[column] = column;
}
for (let row = 1; row <= left.length; row += 1) {
current.fill(cap + 1);
current[0] = row;
const start = Math.max(1, row - cap);
const end = Math.min(right.length, row + cap);
let rowMinimum = cap + 1;
for (let column = start; column <= end; column += 1) {
const substitutionCost =
left.charCodeAt(row - 1) === right.charCodeAt(column - 1) ? 0 : 1;
const value = Math.min(
(previous[column] ?? cap + 1) + 1,
(current[column - 1] ?? cap + 1) + 1,
(previous[column - 1] ?? cap + 1) + substitutionCost,
);
current[column] = value;
rowMinimum = Math.min(rowMinimum, value);
}
if (rowMinimum > cap) {
return cap + 1;
}
[previous, current] = [current, previous];
}
return Math.min(previous[right.length] ?? cap + 1, cap + 1);
}
export function calculateContentSimilarity(
leftInput: string,
rightInput: string,
requiredSimilarity = DEFAULT_VISUAL_DIFF_THRESHOLDS.contentSimilarity,
): number {
const left = normalizePdfContentText(leftInput);
const right = normalizePdfContentText(rightInput);
const maximumLength = Math.max(left.length, right.length);
if (maximumLength === 0) {
return 1;
}
const cap = Math.max(
1,
Math.ceil(maximumLength * (1 - requiredSimilarity)),
);
const distance = cappedLevenshteinDistance(left, right, cap);
return Math.max(0, 1 - distance / maximumLength);
}
function createPagePairs(
baseline: PdfDocumentSnapshot,
candidate: PdfDocumentSnapshot,
): PdfPagePair[] {
const pairCount = Math.max(baseline.pageCount, candidate.pageCount);
return Array.from({ length: pairCount }, (_, index) => {
const baselinePageNumber =
index < baseline.pageCount ? index + 1 : undefined;
const candidatePageNumber =
index < candidate.pageCount ? index + 1 : undefined;
if (baselinePageNumber === undefined) {
if (candidatePageNumber === undefined) {
throw new Error("页面配对状态无效");
}
return {
candidatePageNumber,
status: "candidate-only" as const,
};
}
if (candidatePageNumber === undefined) {
return {
baselinePageNumber,
status: "baseline-only" as const,
};
}
return {
baselinePageNumber,
candidatePageNumber,
status: "paired" as const,
};
});
}
export function comparePdfSnapshotsBasic(
baseline: PdfDocumentSnapshot,
candidate: PdfDocumentSnapshot,
thresholdOverrides: Partial<VisualDiffThresholds> = {},
): PdfBasicComparison {
const thresholds = {
...DEFAULT_VISUAL_DIFF_THRESHOLDS,
...thresholdOverrides,
};
const issues: VisualDiffIssue[] = [];
const pagePairs = createPagePairs(baseline, candidate);
if (baseline.pageCount !== candidate.pageCount) {
issues.push({
code: "PAGE_COUNT_MISMATCH",
severity: thresholds.strictPageCount ? "failure" : "warning",
message: `页数不一致:基线 ${baseline.pageCount} 页,候选 ${candidate.pageCount}`,
details: {
baselinePageCount: baseline.pageCount,
candidatePageCount: candidate.pageCount,
},
});
}
for (const pair of pagePairs) {
if (pair.status === "baseline-only") {
issues.push({
code: "UNPAIRED_BASELINE_PAGE",
severity: "failure",
message: `基线第 ${pair.baselinePageNumber} 页没有候选配对页`,
baselinePageNumber: pair.baselinePageNumber,
});
continue;
}
if (pair.status === "candidate-only") {
issues.push({
code: "UNPAIRED_CANDIDATE_PAGE",
severity: "failure",
message: `候选第 ${pair.candidatePageNumber} 页为溢出页`,
candidatePageNumber: pair.candidatePageNumber,
});
continue;
}
const baselinePage = baseline.pages[pair.baselinePageNumber - 1];
const candidatePage = candidate.pages[pair.candidatePageNumber - 1];
if (!baselinePage || !candidatePage) {
continue;
}
const widthDelta = Math.abs(
baselinePage.widthPt - candidatePage.widthPt,
);
const heightDelta = Math.abs(
baselinePage.heightPt - candidatePage.heightPt,
);
if (
widthDelta > thresholds.pageSizeDeltaPt ||
heightDelta > thresholds.pageSizeDeltaPt
) {
issues.push({
code: "PAGE_SIZE_MISMATCH",
severity: "failure",
message: `${pair.baselinePageNumber} 页纸张尺寸偏差超限`,
baselinePageNumber: pair.baselinePageNumber,
candidatePageNumber: pair.candidatePageNumber,
details: { widthDeltaPt: widthDelta, heightDeltaPt: heightDelta },
});
}
const baselineLandscape =
baselinePage.widthPt > baselinePage.heightPt;
const candidateLandscape =
candidatePage.widthPt > candidatePage.heightPt;
if (baselineLandscape !== candidateLandscape) {
issues.push({
code: "PAGE_ORIENTATION_MISMATCH",
severity: "failure",
message: `${pair.baselinePageNumber} 页方向不一致`,
baselinePageNumber: pair.baselinePageNumber,
candidatePageNumber: pair.candidatePageNumber,
});
}
}
const contentSimilarity = calculateContentSimilarity(
baseline.contentText,
candidate.contentText,
thresholds.contentSimilarity,
);
if (contentSimilarity < thresholds.contentSimilarity) {
issues.push({
code: "CONTENT_MISMATCH",
severity: "failure",
message: `正文文本相似度 ${(contentSimilarity * 100).toFixed(3)}% 低于门限 ${(thresholds.contentSimilarity * 100).toFixed(3)}%`,
details: { contentSimilarity },
});
}
const status = issues.some((issue) => issue.severity === "failure")
? "failed"
: issues.length > 0
? "warning"
: "passed";
return {
schemaVersion: 1,
status,
baseline: baseline.source,
candidate: candidate.source,
thresholds,
contentSimilarity,
pagePairs,
issues,
};
}
@@ -0,0 +1,9 @@
export * from "./adapter-runner.js";
export * from "./adapters.js";
export * from "./compare.js";
export * from "./office-adapter.js";
export * from "./raster.js";
export * from "./report.js";
export * from "./snapshot.js";
export * from "./text.js";
export * from "./types.js";
@@ -0,0 +1,348 @@
import { execFile } from "node:child_process";
import { mkdtemp, readFile, realpath, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, extname, join, resolve } from "node:path";
import { performance } from "node:perf_hooks";
import { promisify } from "node:util";
import {
assertPdfBytes,
type PdfAdapterCapability,
type PdfAdapterGeneration,
type PdfArtifactAdapter,
} from "./adapters.js";
import type { PdfSnapshotSource } from "./types.js";
const execFileAsync = promisify(execFile);
const MAX_OFFICE_INPUT_BYTES = 250 * 1024 * 1024;
const MAX_OFFICE_OUTPUT_BYTES = 500 * 1024 * 1024;
export type OfficeClientKind = "word" | "wps";
export interface OfficePdfAdapterInput {
docxPath: string;
}
export interface OfficeExportMetadata {
pageCount?: number;
bytes: number;
}
export interface OfficeAutomationBackend {
probe(progId: string, timeoutMs: number): Promise<boolean>;
exportPdf(options: {
client: OfficeClientKind;
progId: string;
inputPath: string;
outputPath: string;
timeoutMs: number;
}): Promise<OfficeExportMetadata>;
}
export interface OfficePdfAdapterOptions {
label?: string;
timeoutMs?: number;
backend?: OfficeAutomationBackend;
}
interface OfficeDescriptor {
id: string;
client: OfficeClientKind;
progId: string;
defaultLabel: string;
}
const POWERSHELL_PROBE_SCRIPT = `
$ErrorActionPreference = 'Stop'
$progId = $env:MD_TO_PDF_OFFICE_PROGID
$type = [Type]::GetTypeFromProgID($progId, $false)
if ($null -eq $type) { exit 2 }
[pscustomobject]@{ available = $true; progId = $progId } |
ConvertTo-Json -Compress
`;
const POWERSHELL_EXPORT_SCRIPT = `
$ErrorActionPreference = 'Stop'
$client = $env:MD_TO_PDF_OFFICE_CLIENT
$progId = $env:MD_TO_PDF_OFFICE_PROGID
$resolvedInput = (Resolve-Path -LiteralPath $env:MD_TO_PDF_OFFICE_INPUT).Path
$resolvedOutput = [System.IO.Path]::GetFullPath($env:MD_TO_PDF_OFFICE_OUTPUT)
if ([System.IO.File]::Exists($resolvedOutput)) {
throw "Office PDF 输出已存在:$resolvedOutput"
}
$application = $null
$document = $null
try {
$application = New-Object -ComObject $progId
$application.Visible = $false
try { $application.DisplayAlerts = 0 } catch {}
if ($client -eq 'word') {
$application.AutomationSecurity = 3
$application.Options.SaveNormalPrompt = $false
$document = $application.Documents.OpenNoRepairDialog(
$resolvedInput, $false, $true, $false, '', '', $false,
'', '', 0, 0, $false, $false, 0, $true
)
} else {
try { $application.AutomationSecurity = 3 } catch {}
$document = $application.Documents.Open(
$resolvedInput, $false, $true, $false
)
}
$pageCount = $null
try { $pageCount = $document.ComputeStatistics(2) } catch {}
$document.ExportAsFixedFormat($resolvedOutput, 17)
[pscustomobject]@{
pages = $pageCount
bytes = (Get-Item -LiteralPath $resolvedOutput).Length
} | ConvertTo-Json -Compress
} finally {
if ($null -ne $document) {
try { $document.Close($false) } finally {
[System.Runtime.InteropServices.Marshal]::ReleaseComObject(
$document
) | Out-Null
}
}
if ($null -ne $application) {
try { $application.Quit() } finally {
[System.Runtime.InteropServices.Marshal]::ReleaseComObject(
$application
) | Out-Null
}
}
[GC]::Collect()
[GC]::WaitForPendingFinalizers()
}
`;
function encodePowerShell(script: string): string {
return Buffer.from(script, "utf16le").toString("base64");
}
function parseJsonLine(
stdout: string,
): Record<string, unknown> {
const line = stdout
.split(/\r?\n/u)
.map((entry) => entry.trim())
.filter(Boolean)
.at(-1);
if (!line) {
throw new Error("Office 自动化没有返回结果");
}
const parsed: unknown = JSON.parse(line);
if (typeof parsed !== "object" || parsed === null) {
throw new Error("Office 自动化返回格式无效");
}
return parsed as Record<string, unknown>;
}
export class PowerShellOfficeAutomationBackend
implements OfficeAutomationBackend
{
async probe(progId: string, timeoutMs: number): Promise<boolean> {
if (process.platform !== "win32") {
return false;
}
try {
await execFileAsync(
"powershell.exe",
[
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
encodePowerShell(POWERSHELL_PROBE_SCRIPT),
],
{
timeout: Math.min(timeoutMs, 15_000),
windowsHide: true,
encoding: "utf8",
env: {
...process.env,
MD_TO_PDF_OFFICE_PROGID: progId,
},
},
);
return true;
} catch {
return false;
}
}
async exportPdf(options: {
client: OfficeClientKind;
progId: string;
inputPath: string;
outputPath: string;
timeoutMs: number;
}): Promise<OfficeExportMetadata> {
const { stdout } = await execFileAsync(
"powershell.exe",
[
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
encodePowerShell(POWERSHELL_EXPORT_SCRIPT),
],
{
timeout: options.timeoutMs,
windowsHide: true,
encoding: "utf8",
maxBuffer: 1024 * 1024,
env: {
...process.env,
MD_TO_PDF_OFFICE_CLIENT: options.client,
MD_TO_PDF_OFFICE_PROGID: options.progId,
MD_TO_PDF_OFFICE_INPUT: options.inputPath,
MD_TO_PDF_OFFICE_OUTPUT: options.outputPath,
},
},
);
const parsed = parseJsonLine(stdout);
const bytes = parsed.bytes;
const pages = parsed.pages;
if (typeof bytes !== "number" || !Number.isSafeInteger(bytes)) {
throw new Error("Office 自动化未返回有效的 PDF 字节数");
}
return {
bytes,
...(typeof pages === "number" && Number.isSafeInteger(pages)
? { pageCount: pages }
: {}),
};
}
}
async function validateDocxPath(inputPath: string): Promise<string> {
if (extname(inputPath).toLowerCase() !== ".docx") {
throw new Error("Office PDF 适配器只接受 .docx 文件");
}
const resolvedPath = await realpath(resolve(inputPath));
const inputStat = await stat(resolvedPath);
if (!inputStat.isFile()) {
throw new Error("Office PDF 输入必须是普通文件");
}
if (
inputStat.size <= 0 ||
inputStat.size > MAX_OFFICE_INPUT_BYTES
) {
throw new Error(
`Office PDF 输入大小超限:${inputStat.size}`,
);
}
return resolvedPath;
}
function createOfficePdfAdapter(
descriptor: OfficeDescriptor,
options: OfficePdfAdapterOptions = {},
): PdfArtifactAdapter<OfficePdfAdapterInput> {
const backend =
options.backend ?? new PowerShellOfficeAutomationBackend();
const timeoutMs = options.timeoutMs ?? 180_000;
const source: PdfSnapshotSource = {
kind: descriptor.client,
label: options.label ?? descriptor.defaultLabel,
};
return {
id: descriptor.id,
source,
async probe(): Promise<PdfAdapterCapability> {
const available = await backend.probe(
descriptor.progId,
timeoutMs,
);
return {
available,
adapterId: descriptor.id,
source,
detail: available
? `已注册 ${descriptor.progId}`
: `未找到 ${descriptor.progId}`,
};
},
async generate(
input: OfficePdfAdapterInput,
): Promise<PdfAdapterGeneration> {
const inputPath = await validateDocxPath(input.docxPath);
const temporaryDirectory = await mkdtemp(
join(tmpdir(), `md-to-pdf-${descriptor.client}-`),
);
const outputPath = join(
temporaryDirectory,
`${basename(inputPath, extname(inputPath))}.pdf`,
);
const startedAt = performance.now();
try {
const metadata = await backend.exportPdf({
client: descriptor.client,
progId: descriptor.progId,
inputPath,
outputPath,
timeoutMs,
});
const outputStat = await stat(outputPath);
if (
outputStat.size <= 0 ||
outputStat.size > MAX_OFFICE_OUTPUT_BYTES ||
outputStat.size !== metadata.bytes
) {
throw new Error(
`Office PDF 输出大小无效:${outputStat.size}`,
);
}
const pdf = Uint8Array.from(await readFile(outputPath));
assertPdfBytes(pdf, source.label);
return {
source,
pdf,
...(metadata.pageCount === undefined
? {}
: { pageCount: metadata.pageCount }),
elapsedMs: performance.now() - startedAt,
diagnostics: [],
};
} finally {
await rm(temporaryDirectory, {
recursive: true,
force: true,
});
}
},
};
}
export function createWordPdfAdapter(
options: OfficePdfAdapterOptions = {},
): PdfArtifactAdapter<OfficePdfAdapterInput> {
return createOfficePdfAdapter(
{
id: "word",
client: "word",
progId: "Word.Application",
defaultLabel: "Microsoft Word",
},
options,
);
}
export function createWpsPdfAdapter(
options: OfficePdfAdapterOptions = {},
): PdfArtifactAdapter<OfficePdfAdapterInput> {
return createOfficePdfAdapter(
{
id: "wps",
client: "wps",
progId: "KWPS.Application",
defaultLabel: "WPS Writer",
},
options,
);
}
+255
View File
@@ -0,0 +1,255 @@
import { createHash } from "node:crypto";
import { createCanvas, loadImage } from "@napi-rs/canvas";
import type {
PdfPageRaster,
PdfRasterDiffArtifacts,
PdfRasterDiffMetrics,
PdfRasterDiffResult,
} from "./types.js";
interface DecodedRaster {
width: number;
height: number;
rgba: Uint8ClampedArray;
}
function sha256(bytes: Uint8Array): string {
return createHash("sha256").update(bytes).digest("hex");
}
async function decodeRaster(
raster: PdfPageRaster,
width: number,
height: number,
): Promise<DecodedRaster> {
const image = await loadImage(Buffer.from(raster.png));
const canvas = createCanvas(width, height);
const context = canvas.getContext("2d");
context.fillStyle = "#ffffff";
context.fillRect(0, 0, width, height);
context.drawImage(image, 0, 0);
return {
width,
height,
rgba: context.getImageData(0, 0, width, height).data,
};
}
function luminance(
rgba: Uint8ClampedArray,
offset: number,
): number {
const red = rgba[offset] ?? 255;
const green = rgba[offset + 1] ?? 255;
const blue = rgba[offset + 2] ?? 255;
return (77 * red + 150 * green + 29 * blue) >> 8;
}
function calculateBinaryIou(
left: Uint8Array,
right: Uint8Array,
): number {
let intersection = 0;
let union = 0;
for (let index = 0; index < left.length; index += 1) {
const leftValue = left[index] === 1;
const rightValue = right[index] === 1;
if (leftValue || rightValue) {
union += 1;
if (leftValue && rightValue) {
intersection += 1;
}
}
}
return union === 0 ? 1 : intersection / union;
}
function createEdgeMask(
grayscale: Uint8Array,
width: number,
height: number,
threshold: number,
): Uint8Array {
const edges = new Uint8Array(width * height);
for (let y = 1; y < height - 1; y += 1) {
const rowOffset = y * width;
for (let x = 1; x < width - 1; x += 1) {
const index = rowOffset + x;
const horizontal = Math.abs(
(grayscale[index + 1] ?? 255) -
(grayscale[index - 1] ?? 255),
);
const vertical = Math.abs(
(grayscale[index + width] ?? 255) -
(grayscale[index - width] ?? 255),
);
if (horizontal + vertical >= threshold) {
edges[index] = 1;
}
}
}
return edges;
}
function encodeRgbaPng(
rgba: Uint8ClampedArray,
width: number,
height: number,
): Uint8Array {
const canvas = createCanvas(width, height);
const context = canvas.getContext("2d");
const imageData = context.createImageData(width, height);
imageData.data.set(rgba);
context.putImageData(imageData, 0, 0);
return Uint8Array.from(canvas.toBuffer("image/png"));
}
function createArtifacts(
baseline: PdfPageRaster,
candidate: PdfPageRaster,
baselineGray: Uint8Array,
candidateGray: Uint8Array,
pixelDifference: Uint8Array,
width: number,
height: number,
): PdfRasterDiffArtifacts {
const overlay = new Uint8ClampedArray(width * height * 4);
const heatmap = new Uint8ClampedArray(width * height * 4);
for (let pixelIndex = 0; pixelIndex < width * height; pixelIndex += 1) {
const outputOffset = pixelIndex * 4;
const baselineValue = baselineGray[pixelIndex] ?? 255;
const candidateValue = candidateGray[pixelIndex] ?? 255;
const difference = pixelDifference[pixelIndex] ?? 0;
overlay[outputOffset] = baselineValue;
overlay[outputOffset + 1] = candidateValue;
overlay[outputOffset + 2] = candidateValue;
overlay[outputOffset + 3] = 255;
if (difference === 0) {
heatmap[outputOffset] = 255;
heatmap[outputOffset + 1] = 255;
heatmap[outputOffset + 2] = 255;
} else {
const intensity = Math.min(255, difference * 4);
heatmap[outputOffset] = 255;
heatmap[outputOffset + 1] = 255 - intensity;
heatmap[outputOffset + 2] = Math.max(0, 160 - intensity);
}
heatmap[outputOffset + 3] = 255;
}
const overlayPng = encodeRgbaPng(overlay, width, height);
const heatmapPng = encodeRgbaPng(heatmap, width, height);
return {
baselinePng: baseline.png,
candidatePng: candidate.png,
overlayPng,
heatmapPng,
baselineSha256: baseline.sha256,
candidateSha256: candidate.sha256,
overlaySha256: sha256(overlayPng),
heatmapSha256: sha256(heatmapPng),
};
}
export async function comparePageRasters(
baseline: PdfPageRaster,
candidate: PdfPageRaster,
pixelDifferenceThreshold = 8,
): Promise<PdfRasterDiffResult> {
if (
!Number.isInteger(pixelDifferenceThreshold) ||
pixelDifferenceThreshold < 0 ||
pixelDifferenceThreshold > 255
) {
throw new Error("像素变化阈值必须是 0 到 255 之间的整数");
}
const width = Math.max(baseline.widthPx, candidate.widthPx);
const height = Math.max(baseline.heightPx, candidate.heightPx);
const [baselineDecoded, candidateDecoded] = await Promise.all([
decodeRaster(baseline, width, height),
decodeRaster(candidate, width, height),
]);
const pixelCount = width * height;
const baselineGray = new Uint8Array(pixelCount);
const candidateGray = new Uint8Array(pixelCount);
const pixelDifference = new Uint8Array(pixelCount);
const baselineInk = new Uint8Array(pixelCount);
const candidateInk = new Uint8Array(pixelCount);
let absoluteError = 0;
let changedPixels = 0;
for (let pixelIndex = 0; pixelIndex < pixelCount; pixelIndex += 1) {
const offset = pixelIndex * 4;
const redDifference = Math.abs(
(baselineDecoded.rgba[offset] ?? 255) -
(candidateDecoded.rgba[offset] ?? 255),
);
const greenDifference = Math.abs(
(baselineDecoded.rgba[offset + 1] ?? 255) -
(candidateDecoded.rgba[offset + 1] ?? 255),
);
const blueDifference = Math.abs(
(baselineDecoded.rgba[offset + 2] ?? 255) -
(candidateDecoded.rgba[offset + 2] ?? 255),
);
absoluteError += redDifference + greenDifference + blueDifference;
const maximumDifference = Math.max(
redDifference,
greenDifference,
blueDifference,
);
pixelDifference[pixelIndex] = maximumDifference;
if (maximumDifference > pixelDifferenceThreshold) {
changedPixels += 1;
}
const baselineValue = luminance(baselineDecoded.rgba, offset);
const candidateValue = luminance(candidateDecoded.rgba, offset);
baselineGray[pixelIndex] = baselineValue;
candidateGray[pixelIndex] = candidateValue;
if (baselineValue < 245) {
baselineInk[pixelIndex] = 1;
}
if (candidateValue < 245) {
candidateInk[pixelIndex] = 1;
}
}
const baselineEdges = createEdgeMask(
baselineGray,
width,
height,
40,
);
const candidateEdges = createEdgeMask(
candidateGray,
width,
height,
40,
);
const metrics: PdfRasterDiffMetrics = {
comparedWidthPx: width,
comparedHeightPx: height,
dimensionsMatch:
baseline.widthPx === candidate.widthPx &&
baseline.heightPx === candidate.heightPx,
meanAbsoluteError: absoluteError / (pixelCount * 3),
changedPixelRatio: changedPixels / pixelCount,
inkIou: calculateBinaryIou(baselineInk, candidateInk),
edgeIou: calculateBinaryIou(baselineEdges, candidateEdges),
};
return {
metrics,
artifacts: createArtifacts(
baseline,
candidate,
baselineGray,
candidateGray,
pixelDifference,
width,
height,
),
};
}
+296
View File
@@ -0,0 +1,296 @@
import { comparePdfSnapshotsBasic, DEFAULT_VISUAL_DIFF_THRESHOLDS } from "./compare.js";
import { comparePageRasters } from "./raster.js";
import type {
CreatePdfVisualDiffOptions,
PdfDocumentSnapshot,
PdfPagePair,
PdfVisualDiffReport,
PdfVisualPageComparison,
VisualDiffIssue,
VisualDiffThresholds,
} from "./types.js";
function resolveStatus(
issues: readonly VisualDiffIssue[],
): "passed" | "warning" | "failed" {
if (issues.some((issue) => issue.severity === "failure")) {
return "failed";
}
return issues.length > 0 ? "warning" : "passed";
}
function rasterIssues(
pair: Extract<PdfPagePair, { status: "paired" }>,
metrics: NonNullable<PdfVisualPageComparison["metrics"]>,
thresholds: VisualDiffThresholds,
): VisualDiffIssue[] {
const location = {
baselinePageNumber: pair.baselinePageNumber,
candidatePageNumber: pair.candidatePageNumber,
};
const issues: VisualDiffIssue[] = [];
if (!metrics.dimensionsMatch) {
issues.push({
code: "RASTER_SIZE_MISMATCH",
severity: "failure",
message: `${pair.baselinePageNumber} 页栅格尺寸不一致`,
...location,
details: {
comparedWidthPx: metrics.comparedWidthPx,
comparedHeightPx: metrics.comparedHeightPx,
},
});
}
if (metrics.meanAbsoluteError > thresholds.maxMeanAbsoluteError) {
issues.push({
code: "PIXEL_MAE_EXCEEDED",
severity: "failure",
message: `${pair.baselinePageNumber} 页平均像素误差 ${metrics.meanAbsoluteError.toFixed(3)} 超过 ${thresholds.maxMeanAbsoluteError}`,
...location,
details: { meanAbsoluteError: metrics.meanAbsoluteError },
});
}
if (metrics.changedPixelRatio > thresholds.maxChangedPixelRatio) {
issues.push({
code: "CHANGED_PIXEL_RATIO_EXCEEDED",
severity: "failure",
message: `${pair.baselinePageNumber} 页变化像素率 ${(metrics.changedPixelRatio * 100).toFixed(3)}% 超过 ${(thresholds.maxChangedPixelRatio * 100).toFixed(3)}%`,
...location,
details: { changedPixelRatio: metrics.changedPixelRatio },
});
}
if (metrics.inkIou < thresholds.minInkIou) {
issues.push({
code: "INK_IOU_BELOW_THRESHOLD",
severity: "failure",
message: `${pair.baselinePageNumber} 页墨迹 IoU ${metrics.inkIou.toFixed(4)} 低于 ${thresholds.minInkIou}`,
...location,
details: { inkIou: metrics.inkIou },
});
}
if (metrics.edgeIou < thresholds.minEdgeIou) {
issues.push({
code: "EDGE_IOU_BELOW_THRESHOLD",
severity: "failure",
message: `${pair.baselinePageNumber} 页边缘 IoU ${metrics.edgeIou.toFixed(4)} 低于 ${thresholds.minEdgeIou}`,
...location,
details: { edgeIou: metrics.edgeIou },
});
}
return issues;
}
async function compareVisualPage(
pair: PdfPagePair,
baseline: PdfDocumentSnapshot,
candidate: PdfDocumentSnapshot,
thresholds: VisualDiffThresholds,
): Promise<PdfVisualPageComparison> {
if (pair.status !== "paired") {
const page =
pair.status === "baseline-only"
? baseline.pages[pair.baselinePageNumber - 1]
: candidate.pages[pair.candidatePageNumber - 1];
return {
pair,
status: "failed",
...(page?.raster
? {
unpairedPng: page.raster.png,
unpairedSha256: page.raster.sha256,
}
: {}),
issues: [],
};
}
const baselinePage = baseline.pages[pair.baselinePageNumber - 1];
const candidatePage = candidate.pages[pair.candidatePageNumber - 1];
if (!baselinePage?.raster || !candidatePage?.raster) {
const issue: VisualDiffIssue = {
code: "RASTER_MISSING",
severity: "failure",
message: `${pair.baselinePageNumber} 页缺少栅格快照`,
baselinePageNumber: pair.baselinePageNumber,
candidatePageNumber: pair.candidatePageNumber,
};
return {
pair,
status: "failed",
issues: [issue],
};
}
const result = await comparePageRasters(
baselinePage.raster,
candidatePage.raster,
thresholds.pixelDifferenceThreshold,
);
const issues = rasterIssues(pair, result.metrics, thresholds);
return {
pair,
status: resolveStatus(issues),
metrics: result.metrics,
artifacts: result.artifacts,
issues,
};
}
export async function createPdfVisualDiffReport(
baseline: PdfDocumentSnapshot,
candidate: PdfDocumentSnapshot,
options: CreatePdfVisualDiffOptions = {},
): Promise<PdfVisualDiffReport> {
const thresholds = {
...DEFAULT_VISUAL_DIFF_THRESHOLDS,
...options.thresholds,
};
const basic = comparePdfSnapshotsBasic(baseline, candidate, thresholds);
const pages: PdfVisualPageComparison[] = [];
for (const pair of basic.pagePairs) {
pages.push(
await compareVisualPage(pair, baseline, candidate, thresholds),
);
}
const issues = [
...basic.issues,
...pages.flatMap((page) => page.issues),
];
return {
schemaVersion: 1,
generatedAt: options.generatedAt ?? new Date().toISOString(),
status: resolveStatus(issues),
basic,
pages,
issues,
};
}
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function pngDataUri(bytes: Uint8Array): string {
return `data:image/png;base64,${Buffer.from(bytes).toString("base64")}`;
}
function percentage(value: number): string {
return `${(value * 100).toFixed(3)}%`;
}
function renderMetrics(page: PdfVisualPageComparison): string {
if (!page.metrics) {
return '<p class="muted">无可比较的成对栅格。</p>';
}
const metrics = page.metrics;
return `<div class="metrics">
<div><span>MAE</span><strong>${metrics.meanAbsoluteError.toFixed(3)}</strong></div>
<div><span>变化像素</span><strong>${percentage(metrics.changedPixelRatio)}</strong></div>
<div><span>墨迹 IoU</span><strong>${metrics.inkIou.toFixed(4)}</strong></div>
<div><span>边缘 IoU</span><strong>${metrics.edgeIou.toFixed(4)}</strong></div>
</div>`;
}
function renderArtifacts(page: PdfVisualPageComparison): string {
if (page.unpairedPng) {
return `<div class="unpaired">
<figure><figcaption>未配对页面</figcaption><img src="${pngDataUri(page.unpairedPng)}" alt="未配对页面"></figure>
</div>`;
}
const artifacts = page.artifacts;
if (!artifacts) {
return "";
}
return `<div class="side-by-side">
<figure><figcaption>基线</figcaption><img src="${pngDataUri(artifacts.baselinePng)}" alt="基线页面"></figure>
<figure><figcaption>候选</figcaption><img src="${pngDataUri(artifacts.candidatePng)}" alt="候选页面"></figure>
</div>
<div class="side-by-side">
<figure><figcaption>红/青叠加</figcaption><img src="${pngDataUri(artifacts.overlayPng)}" alt="叠加差异"></figure>
<figure><figcaption>差异热力图</figcaption><img src="${pngDataUri(artifacts.heatmapPng)}" alt="差异热力图"></figure>
</div>`;
}
function pageTitle(page: PdfVisualPageComparison): string {
if (page.pair.status === "paired") {
return `基线第 ${page.pair.baselinePageNumber} 页 ↔ 候选第 ${page.pair.candidatePageNumber}`;
}
if (page.pair.status === "baseline-only") {
return `基线第 ${page.pair.baselinePageNumber} 页无配对`;
}
return `候选第 ${page.pair.candidatePageNumber} 页为溢出页`;
}
export function renderPdfVisualDiffHtml(
report: PdfVisualDiffReport,
): string {
const issueRows = report.issues.length
? report.issues
.map(
(issue) =>
`<tr><td>${escapeHtml(issue.severity)}</td><td>${escapeHtml(issue.code)}</td><td>${escapeHtml(issue.message)}</td></tr>`,
)
.join("")
: '<tr><td colspan="3">没有发现门禁问题。</td></tr>';
const pages = report.pages
.map(
(page, index) => `<section class="page-card">
<h2>${index + 1}. ${escapeHtml(pageTitle(page))} <span class="status ${page.status}">${page.status}</span></h2>
${renderMetrics(page)}
${renderArtifacts(page)}
</section>`,
)
.join("");
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>PDF 视觉差异报告</title>
<style>
:root{font-family:Inter,"Microsoft YaHei",sans-serif;color:#172033;background:#f3f5f8}
*{box-sizing:border-box}body{margin:0}main{max-width:1500px;margin:auto;padding:28px}
h1,h2{margin:0 0 16px}h2{font-size:18px}.summary,.page-card{background:#fff;border:1px solid #dfe4ec;border-radius:12px;padding:20px;margin-bottom:20px;box-shadow:0 3px 12px #1720330d}
.status{display:inline-block;border-radius:999px;padding:4px 10px;font-size:12px;text-transform:uppercase}
.passed{background:#dff7e8;color:#126636}.warning{background:#fff1c7;color:#805d00}.failed{background:#ffe0e0;color:#9a1b1b}
.metrics{display:grid;grid-template-columns:repeat(4,minmax(120px,1fr));gap:10px;margin-bottom:16px}
.metrics div{padding:12px;background:#f7f9fc;border-radius:8px}.metrics span{display:block;color:#667085;font-size:12px}.metrics strong{font-size:20px}
.side-by-side{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px}.unpaired{max-width:720px;margin-top:16px}
figure{margin:0;min-width:0}figcaption{margin-bottom:8px;color:#475467;font-weight:600}
img{display:block;width:100%;height:auto;border:1px solid #d0d5dd;background:#fff}
table{width:100%;border-collapse:collapse}th,td{padding:9px;border-bottom:1px solid #e4e7ec;text-align:left;vertical-align:top}
.muted{color:#667085}@media(max-width:800px){main{padding:12px}.metrics,.side-by-side{grid-template-columns:1fr}}
</style>
</head>
<body><main>
<section class="summary">
<h1>PDF 视觉差异报告 <span class="status ${report.status}">${report.status}</span></h1>
<p><strong>基线:</strong>${escapeHtml(report.basic.baseline.label)} <strong>候选:</strong>${escapeHtml(report.basic.candidate.label)}</p>
<p><strong>生成时间:</strong>${escapeHtml(report.generatedAt)} <strong>正文相似度:</strong>${percentage(report.basic.contentSimilarity)}</p>
<table><thead><tr><th>级别</th><th>代码</th><th>说明</th></tr></thead><tbody>${issueRows}</tbody></table>
</section>
${pages}
</main></body></html>`;
}
export function serializePdfVisualDiffJson(
report: PdfVisualDiffReport,
indentation = 2,
): string {
return JSON.stringify(
report,
(key, value: unknown) => {
if (
key.endsWith("Png") &&
value instanceof Uint8Array
) {
return { byteLength: value.byteLength };
}
return value;
},
indentation,
);
}
@@ -0,0 +1,218 @@
import { createHash } from "node:crypto";
import { createCanvas } from "@napi-rs/canvas";
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
import { aggregatePdfTextLines, buildPageContentText, normalizePdfText } from "./text.js";
import type {
CreatePdfSnapshotOptions,
PdfDocumentSnapshot,
PdfPageSnapshot,
PdfSnapshotLimits,
PdfTextItemSnapshot,
} from "./types.js";
interface PdfJsTextItem {
str: string;
transform: number[];
width: number;
height: number;
fontName: string;
hasEOL: boolean;
}
export const DEFAULT_PDF_SNAPSHOT_LIMITS: PdfSnapshotLimits = {
maxPdfBytes: 100 * 1024 * 1024,
maxPages: 500,
maxPagePixels: 40_000_000,
maxTotalPixels: 500_000_000,
};
function sha256(bytes: Uint8Array): string {
return createHash("sha256").update(bytes).digest("hex");
}
function isTextItem(item: unknown): item is PdfJsTextItem {
return (
typeof item === "object" &&
item !== null &&
"str" in item &&
typeof item.str === "string" &&
"transform" in item &&
Array.isArray(item.transform) &&
item.transform.every((value) => typeof value === "number") &&
"width" in item &&
typeof item.width === "number" &&
"height" in item &&
typeof item.height === "number" &&
"fontName" in item &&
typeof item.fontName === "string" &&
"hasEOL" in item &&
typeof item.hasEOL === "boolean"
);
}
function createTextItemSnapshot(
item: PdfJsTextItem,
pageHeightPt: number,
): PdfTextItemSnapshot | undefined {
const normalizedText = normalizePdfText(item.str);
if (!normalizedText) {
return undefined;
}
const x = item.transform[4] ?? 0;
const baselineFromBottom = item.transform[5] ?? 0;
const height = Math.max(
Number.isFinite(item.height) ? Math.abs(item.height) : 0,
Math.hypot(item.transform[2] ?? 0, item.transform[3] ?? 0),
0.1,
);
const width = Math.max(
Number.isFinite(item.width) ? Math.abs(item.width) : 0,
0,
);
const baselineY = pageHeightPt - baselineFromBottom;
return {
text: item.str,
normalizedText,
bounds: {
x,
y: baselineY - height,
width,
height,
},
baselineY,
fontName: item.fontName,
hasEol: item.hasEOL,
};
}
function resolveLimits(
input: Partial<PdfSnapshotLimits> | undefined,
): PdfSnapshotLimits {
const limits = { ...DEFAULT_PDF_SNAPSHOT_LIMITS, ...input };
for (const [name, value] of Object.entries(limits)) {
if (!Number.isFinite(value) || value <= 0) {
throw new Error(`PDF 快照限制 ${name} 必须是正数`);
}
}
return limits;
}
export async function createPdfDocumentSnapshot(
pdfBytes: Uint8Array,
options: CreatePdfSnapshotOptions,
): Promise<PdfDocumentSnapshot> {
const limits = resolveLimits(options.limits);
if (pdfBytes.byteLength === 0) {
throw new Error("PDF 快照输入不能为空");
}
if (pdfBytes.byteLength > limits.maxPdfBytes) {
throw new Error(
`PDF 快照输入超过限制:${pdfBytes.byteLength} > ${limits.maxPdfBytes}`,
);
}
const dpi = options.dpi ?? 144;
if (!Number.isFinite(dpi) || dpi < 72 || dpi > 600) {
throw new Error("PDF 快照 DPI 必须位于 72 到 600 之间");
}
const includeRaster = options.includeRaster ?? true;
const loadingTask = getDocument({
data: Uint8Array.from(pdfBytes),
useSystemFonts: false,
verbosity: 0,
});
let totalPixels = 0;
try {
const document = await loadingTask.promise;
if (document.numPages > limits.maxPages) {
throw new Error(
`PDF 页数超过限制:${document.numPages} > ${limits.maxPages}`,
);
}
const pages: PdfPageSnapshot[] = [];
for (let pageNumber = 1; pageNumber <= document.numPages; pageNumber += 1) {
const page = await document.getPage(pageNumber);
const pointViewport = page.getViewport({ scale: 1 });
const textContent = await page.getTextContent({
disableNormalization: false,
includeMarkedContent: false,
});
const items = textContent.items.flatMap((item) => {
if (!isTextItem(item)) {
return [];
}
const snapshot = createTextItemSnapshot(item, pointViewport.height);
return snapshot ? [snapshot] : [];
});
const lines = aggregatePdfTextLines(items, pointViewport.height);
const pageSnapshot: PdfPageSnapshot = {
pageNumber,
widthPt: pointViewport.width,
heightPt: pointViewport.height,
rotation: pointViewport.rotation,
items,
lines,
contentText: buildPageContentText(lines),
};
const pageNumberLine = lines.find((line) => line.role === "page-number");
if (pageNumberLine) {
pageSnapshot.pageNumberText = pageNumberLine.normalizedText;
}
if (includeRaster) {
const renderViewport = page.getViewport({ scale: dpi / 72 });
const widthPx = Math.ceil(renderViewport.width);
const heightPx = Math.ceil(renderViewport.height);
const pagePixels = widthPx * heightPx;
if (pagePixels > limits.maxPagePixels) {
throw new Error(
`${pageNumber} 页像素数超过限制:${pagePixels} > ${limits.maxPagePixels}`,
);
}
totalPixels += pagePixels;
if (totalPixels > limits.maxTotalPixels) {
throw new Error(
`PDF 总像素数超过限制:${totalPixels} > ${limits.maxTotalPixels}`,
);
}
const canvas = createCanvas(widthPx, heightPx);
const context = canvas.getContext("2d");
context.fillStyle = "#ffffff";
context.fillRect(0, 0, widthPx, heightPx);
await page.render({
canvas: null,
canvasContext:
context as unknown as CanvasRenderingContext2D,
viewport: renderViewport,
background: "rgb(255,255,255)",
intent: "print",
}).promise;
const png = canvas.toBuffer("image/png");
pageSnapshot.raster = {
widthPx,
heightPx,
dpi,
sha256: sha256(png),
png: Uint8Array.from(png),
};
}
page.cleanup();
pages.push(pageSnapshot);
}
const contentText = pages
.map((page) => page.contentText)
.filter(Boolean)
.join("\n\f\n");
return {
schemaVersion: 1,
source: options.source,
sha256: sha256(pdfBytes),
pageCount: pages.length,
contentText,
pages,
};
} finally {
await loadingTask.destroy();
}
}
+158
View File
@@ -0,0 +1,158 @@
import type {
PdfPointBounds,
PdfTextItemSnapshot,
PdfTextLineSnapshot,
} from "./types.js";
const ZERO_WIDTH_AND_CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u200b-\u200d\u2060\ufeff]/gu;
const PAGE_NUMBER_PATTERNS = [
/^(?:[-]\s*)?\d+(?:\s*[/]\s*\d+)?(?:\s*[-])?$/u,
/^\s*\d+\s*(?:\s*(?:[/]|)\s*\d+\s*)?$/u,
/^\d+\s*(?:\s*(?:[/]|)\s*\d+\s*)?$/u,
];
export function normalizePdfText(value: string): string {
return value
.normalize("NFKC")
.replace(ZERO_WIDTH_AND_CONTROL, "")
.replace(/\u00a0/gu, " ")
.replace(/[ \t]+/gu, " ")
.trim();
}
export function normalizePdfContentText(value: string): string {
return normalizePdfText(value).replace(/\s+/gu, "");
}
function unionBounds(items: readonly PdfTextItemSnapshot[]): PdfPointBounds {
const left = Math.min(...items.map((item) => item.bounds.x));
const top = Math.min(...items.map((item) => item.bounds.y));
const right = Math.max(
...items.map((item) => item.bounds.x + item.bounds.width),
);
const bottom = Math.max(
...items.map((item) => item.bounds.y + item.bounds.height),
);
return {
x: left,
y: top,
width: Math.max(0, right - left),
height: Math.max(0, bottom - top),
};
}
function median(values: readonly number[]): number {
if (values.length === 0) {
return 0;
}
const sorted = [...values].sort((left, right) => left - right);
const middle = Math.floor(sorted.length / 2);
const value = sorted[middle] ?? 0;
if (sorted.length % 2 === 1) {
return value;
}
return ((sorted[middle - 1] ?? value) + value) / 2;
}
function joinLineItems(items: readonly PdfTextItemSnapshot[]): string {
let result = "";
let previous: PdfTextItemSnapshot | undefined;
for (const item of items) {
const text = item.normalizedText;
if (!text) {
continue;
}
if (previous && result) {
const gap =
item.bounds.x - (previous.bounds.x + previous.bounds.width);
const referenceHeight = Math.max(
1,
Math.min(previous.bounds.height, item.bounds.height),
);
if (
gap > referenceHeight * 0.24 &&
!result.endsWith(" ") &&
!text.startsWith(" ")
) {
result += " ";
}
}
result += text;
previous = item;
}
return normalizePdfText(result);
}
export function isPageNumberLine(
lineText: string,
bounds: PdfPointBounds,
pageHeightPt: number,
): boolean {
const normalized = normalizePdfText(lineText);
if (!PAGE_NUMBER_PATTERNS.some((pattern) => pattern.test(normalized))) {
return false;
}
const lineMiddle = bounds.y + bounds.height / 2;
const topBand = pageHeightPt * 0.08;
const bottomBand = pageHeightPt * 0.85;
return lineMiddle <= topBand || lineMiddle >= bottomBand;
}
export function aggregatePdfTextLines(
sourceItems: readonly PdfTextItemSnapshot[],
pageHeightPt: number,
): PdfTextLineSnapshot[] {
const items = sourceItems
.filter((item) => item.normalizedText.length > 0)
.sort(
(left, right) =>
left.bounds.y - right.bounds.y || left.bounds.x - right.bounds.x,
);
const typicalHeight = median(items.map((item) => item.bounds.height));
const baselineTolerance = Math.max(1.5, typicalHeight * 0.15);
const groups: PdfTextItemSnapshot[][] = [];
for (const item of items) {
const lastGroup = groups.at(-1);
const lastBaseline = lastGroup
? median(lastGroup.map((entry) => entry.baselineY))
: undefined;
if (
lastGroup &&
lastBaseline !== undefined &&
Math.abs(item.baselineY - lastBaseline) <= baselineTolerance
) {
lastGroup.push(item);
} else {
groups.push([item]);
}
}
return groups.map((group) => {
const sortedItems = [...group].sort(
(left, right) => left.bounds.x - right.bounds.x,
);
const bounds = unionBounds(sortedItems);
const normalizedText = joinLineItems(sortedItems);
return {
text: normalizedText,
normalizedText,
bounds,
baselineY: median(sortedItems.map((item) => item.baselineY)),
role: isPageNumberLine(normalizedText, bounds, pageHeightPt)
? "page-number"
: "content",
items: sortedItems,
};
});
}
export function buildPageContentText(
lines: readonly PdfTextLineSnapshot[],
): string {
return lines
.filter((line) => line.role === "content")
.map((line) => line.normalizedText)
.filter(Boolean)
.join("\n");
}
+189
View File
@@ -0,0 +1,189 @@
export type PdfSourceKind = "chromium" | "word" | "wps" | "custom";
export interface PdfSnapshotSource {
kind: PdfSourceKind;
label: string;
fileName?: string;
}
export interface PdfPointBounds {
x: number;
y: number;
width: number;
height: number;
}
export interface PdfTextItemSnapshot {
text: string;
normalizedText: string;
bounds: PdfPointBounds;
baselineY: number;
fontName?: string;
hasEol: boolean;
}
export type PdfTextLineRole = "content" | "page-number";
export interface PdfTextLineSnapshot {
text: string;
normalizedText: string;
bounds: PdfPointBounds;
baselineY: number;
role: PdfTextLineRole;
items: PdfTextItemSnapshot[];
}
export interface PdfPageRaster {
widthPx: number;
heightPx: number;
dpi: number;
sha256: string;
png: Uint8Array;
}
export interface PdfPageSnapshot {
pageNumber: number;
widthPt: number;
heightPt: number;
rotation: number;
items: PdfTextItemSnapshot[];
lines: PdfTextLineSnapshot[];
contentText: string;
pageNumberText?: string;
raster?: PdfPageRaster;
}
export interface PdfDocumentSnapshot {
schemaVersion: 1;
source: PdfSnapshotSource;
sha256: string;
pageCount: number;
contentText: string;
pages: PdfPageSnapshot[];
}
export interface PdfSnapshotLimits {
maxPdfBytes: number;
maxPages: number;
maxPagePixels: number;
maxTotalPixels: number;
}
export interface CreatePdfSnapshotOptions {
source: PdfSnapshotSource;
dpi?: number;
includeRaster?: boolean;
limits?: Partial<PdfSnapshotLimits>;
}
export interface VisualDiffThresholds {
pageSizeDeltaPt: number;
contentSimilarity: number;
strictPageCount: boolean;
pixelDifferenceThreshold: number;
maxMeanAbsoluteError: number;
maxChangedPixelRatio: number;
minInkIou: number;
minEdgeIou: number;
}
export type VisualDiffIssueSeverity = "warning" | "failure";
export type VisualDiffIssueCode =
| "PAGE_COUNT_MISMATCH"
| "UNPAIRED_BASELINE_PAGE"
| "UNPAIRED_CANDIDATE_PAGE"
| "PAGE_SIZE_MISMATCH"
| "PAGE_ORIENTATION_MISMATCH"
| "CONTENT_MISMATCH"
| "RASTER_MISSING"
| "RASTER_SIZE_MISMATCH"
| "PIXEL_MAE_EXCEEDED"
| "CHANGED_PIXEL_RATIO_EXCEEDED"
| "INK_IOU_BELOW_THRESHOLD"
| "EDGE_IOU_BELOW_THRESHOLD";
export interface VisualDiffIssue {
code: VisualDiffIssueCode;
severity: VisualDiffIssueSeverity;
message: string;
baselinePageNumber?: number;
candidatePageNumber?: number;
details?: Record<string, number | string | boolean>;
}
export type PdfPagePair =
| {
baselinePageNumber: number;
candidatePageNumber: number;
status: "paired";
}
| {
baselinePageNumber: number;
status: "baseline-only";
}
| {
candidatePageNumber: number;
status: "candidate-only";
};
export interface PdfBasicComparison {
schemaVersion: 1;
status: "passed" | "warning" | "failed";
baseline: PdfSnapshotSource;
candidate: PdfSnapshotSource;
thresholds: VisualDiffThresholds;
contentSimilarity: number;
pagePairs: PdfPagePair[];
issues: VisualDiffIssue[];
}
export interface PdfRasterDiffMetrics {
comparedWidthPx: number;
comparedHeightPx: number;
dimensionsMatch: boolean;
meanAbsoluteError: number;
changedPixelRatio: number;
inkIou: number;
edgeIou: number;
}
export interface PdfRasterDiffArtifacts {
baselinePng: Uint8Array;
candidatePng: Uint8Array;
overlayPng: Uint8Array;
heatmapPng: Uint8Array;
baselineSha256: string;
candidateSha256: string;
overlaySha256: string;
heatmapSha256: string;
}
export interface PdfRasterDiffResult {
metrics: PdfRasterDiffMetrics;
artifacts: PdfRasterDiffArtifacts;
}
export interface PdfVisualPageComparison {
pair: PdfPagePair;
status: "passed" | "warning" | "failed";
metrics?: PdfRasterDiffMetrics;
artifacts?: PdfRasterDiffArtifacts;
unpairedPng?: Uint8Array;
unpairedSha256?: string;
issues: VisualDiffIssue[];
}
export interface PdfVisualDiffReport {
schemaVersion: 1;
generatedAt: string;
status: "passed" | "warning" | "failed";
basic: PdfBasicComparison;
pages: PdfVisualPageComparison[];
issues: VisualDiffIssue[];
}
export interface CreatePdfVisualDiffOptions {
thresholds?: Partial<VisualDiffThresholds>;
generatedAt?: string;
}
@@ -0,0 +1,128 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import {
createChromiumPdfAdapter,
createWordPdfAdapter,
createWpsPdfAdapter,
preparePdfAdapterRun,
runPdfAdapterVisualDiff,
type OfficeAutomationBackend,
} from "../src/index.js";
import { createMinimalPdf } from "./pdf-fixture.js";
describe("PDF 产物适配器", () => {
it("Chromium 适配器复用调用方生成链并校验 PDF", async () => {
const generate = vi.fn(async () => ({
pdf: createMinimalPdf("Chromium"),
pageCount: 1,
diagnostics: ["ok"],
}));
const adapter = createChromiumPdfAdapter({
label: "统一 Chromium",
generate,
});
expect(await adapter.probe()).toMatchObject({
available: true,
adapterId: "chromium",
});
const result = await adapter.generate({ markdown: "# 标题" });
expect(generate).toHaveBeenCalledWith({ markdown: "# 标题" });
expect(result.source.kind).toBe("chromium");
expect(result.pageCount).toBe(1);
expect(result.diagnostics).toEqual(["ok"]);
});
it("拒绝生产者返回非 PDF 字节", async () => {
const adapter = createChromiumPdfAdapter({
generate: async () => new TextEncoder().encode("not a pdf"),
});
await expect(adapter.generate(undefined)).rejects.toThrow(
"PDF 文件签名",
);
});
});
describe("Office PDF 适配器", () => {
it("分别使用 Word 与 WPS ProgID 并清理临时输出", async () => {
const directory = await mkdtemp(
join(tmpdir(), "visual-diff-office-test-"),
);
const docxPath = join(directory, "fixture.docx");
await writeFile(docxPath, "fixture");
const calls: Array<{
client: string;
progId: string;
outputPath: string;
}> = [];
const backend: OfficeAutomationBackend = {
probe: async () => true,
exportPdf: async (options) => {
const pdf = createMinimalPdf(options.client);
await writeFile(options.outputPath, pdf);
calls.push({
client: options.client,
progId: options.progId,
outputPath: options.outputPath,
});
return { bytes: pdf.byteLength, pageCount: 1 };
},
};
try {
const word = createWordPdfAdapter({ backend });
const wps = createWpsPdfAdapter({ backend });
expect((await word.probe()).available).toBe(true);
expect((await wps.probe()).available).toBe(true);
expect((await word.generate({ docxPath })).source.kind).toBe("word");
expect((await wps.generate({ docxPath })).source.kind).toBe("wps");
expect(calls.map(({ client, progId }) => ({ client, progId }))).toEqual([
{ client: "word", progId: "Word.Application" },
{ client: "wps", progId: "KWPS.Application" },
]);
for (const call of calls) {
await expect(
import("node:fs/promises").then(({ stat }) =>
stat(call.outputPath),
),
).rejects.toThrow();
}
} finally {
await rm(directory, { recursive: true, force: true });
}
});
});
describe("PDF 适配器视觉编排", () => {
it("基线只生成一次并按顺序比较全部候选", async () => {
const order: string[] = [];
const adapter = (label: string) =>
createChromiumPdfAdapter({
label,
generate: async () => {
order.push(label);
return createMinimalPdf("same");
},
});
const result = await runPdfAdapterVisualDiff({
baseline: preparePdfAdapterRun(adapter("baseline"), undefined),
candidates: [
preparePdfAdapterRun(adapter("candidate-1"), undefined),
preparePdfAdapterRun(adapter("candidate-2"), undefined),
],
snapshot: { includeRaster: false },
});
expect(order).toEqual(["baseline", "candidate-1", "candidate-2"]);
expect(result.candidates).toHaveLength(2);
expect(
result.candidates.map(({ report }) => report.status),
).toEqual(["failed", "failed"]);
expect(
result.candidates.flatMap(({ report }) =>
report.issues.map((issue) => issue.code),
),
).toEqual(["RASTER_MISSING", "RASTER_MISSING"]);
});
});
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import {
comparePdfSnapshotsBasic,
createPdfDocumentSnapshot,
type PdfDocumentSnapshot,
} from "../src/index.js";
import { createMinimalPdf } from "./pdf-fixture.js";
async function snapshot(
label: string,
text = "Visual diff fixture",
width = 595,
height = 842,
): Promise<PdfDocumentSnapshot> {
return createPdfDocumentSnapshot(createMinimalPdf(text, width, height), {
source: { kind: "custom", label },
includeRaster: false,
});
}
describe("PDF 基础视觉门禁", () => {
it("通过纸张和正文一致的文档", async () => {
const baseline = await snapshot("baseline");
const candidate = await snapshot("candidate");
const result = comparePdfSnapshotsBasic(baseline, candidate);
expect(result.status).toBe("passed");
expect(result.contentSimilarity).toBe(1);
expect(result.issues).toEqual([]);
});
it("拒绝纸张尺寸和正文内容偏差", async () => {
const baseline = await snapshot("baseline");
const candidate = await snapshot("candidate", "Different content", 612);
const result = comparePdfSnapshotsBasic(baseline, candidate);
expect(result.status).toBe("failed");
expect(result.issues.map((issue) => issue.code)).toEqual(
expect.arrayContaining(["PAGE_SIZE_MISMATCH", "CONTENT_MISMATCH"]),
);
});
it("内容完整性比较不受重新换行影响", async () => {
const baseline = await snapshot("baseline", "same content");
const candidate = await snapshot("candidate", "samecontent");
const result = comparePdfSnapshotsBasic(baseline, candidate);
expect(result.contentSimilarity).toBe(1);
expect(result.status).toBe("passed");
});
it("页数不一致时仍保留溢出页配对记录", async () => {
const baseline = await snapshot("baseline");
const candidatePage = (await snapshot("candidate")).pages[0];
if (!candidatePage) {
throw new Error("测试快照缺少页面");
}
const candidate: PdfDocumentSnapshot = {
...(await snapshot("candidate")),
pageCount: 2,
pages: [
candidatePage,
{
...candidatePage,
pageNumber: 2,
contentText: "",
items: [],
lines: [],
},
],
};
const result = comparePdfSnapshotsBasic(baseline, candidate);
expect(result.pagePairs[1]).toEqual({
candidatePageNumber: 2,
status: "candidate-only",
});
expect(result.issues.map((issue) => issue.code)).toContain(
"UNPAIRED_CANDIDATE_PAGE",
);
});
});
@@ -0,0 +1,30 @@
export function createMinimalPdf(
text = "Visual diff fixture",
pageWidth = 595,
pageHeight = 842,
): Uint8Array {
const escapedText = text.replaceAll("\\", "\\\\").replaceAll("(", "\\(").replaceAll(")", "\\)");
const content = `BT /F1 18 Tf 72 ${pageHeight - 72} Td (${escapedText}) Tj ET`;
const objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pageWidth} ${pageHeight}] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>`,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
`<< /Length ${content.length} >>\nstream\n${content}\nendstream`,
];
let pdf = "%PDF-1.4\n";
const offsets = [0];
for (const [index, object] of objects.entries()) {
offsets.push(pdf.length);
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
}
const xrefOffset = pdf.length;
pdf += `xref\n0 ${objects.length + 1}\n`;
pdf += "0000000000 65535 f \n";
for (const offset of offsets.slice(1)) {
pdf += `${String(offset).padStart(10, "0")} 00000 n \n`;
}
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\n`;
pdf += `startxref\n${xrefOffset}\n%%EOF\n`;
return new TextEncoder().encode(pdf);
}
@@ -0,0 +1,53 @@
import { createHash } from "node:crypto";
import { createCanvas } from "@napi-rs/canvas";
import { describe, expect, it } from "vitest";
import {
comparePageRasters,
type PdfPageRaster,
} from "../src/index.js";
function raster(rectangleX: number): PdfPageRaster {
const canvas = createCanvas(80, 100);
const context = canvas.getContext("2d");
context.fillStyle = "#ffffff";
context.fillRect(0, 0, 80, 100);
context.fillStyle = "#111111";
context.fillRect(rectangleX, 20, 20, 40);
const png = Uint8Array.from(canvas.toBuffer("image/png"));
return {
widthPx: 80,
heightPx: 100,
dpi: 144,
sha256: createHash("sha256").update(png).digest("hex"),
png,
};
}
describe("PDF 栅格差异", () => {
it("相同页面的全部指标为无差异", async () => {
const page = raster(10);
const result = await comparePageRasters(page, page);
expect(result.metrics).toMatchObject({
dimensionsMatch: true,
meanAbsoluteError: 0,
changedPixelRatio: 0,
inkIou: 1,
edgeIou: 1,
});
expect(result.artifacts.overlayPng.subarray(0, 8)).toEqual(
new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]),
);
});
it("量化位移并生成稳定叠加图与热力图", async () => {
const result = await comparePageRasters(raster(10), raster(20));
expect(result.metrics.meanAbsoluteError).toBeGreaterThan(10);
expect(result.metrics.changedPixelRatio).toBeCloseTo(0.1, 2);
expect(result.metrics.inkIou).toBeCloseTo(1 / 3, 2);
expect(result.metrics.edgeIou).toBeLessThan(0.4);
expect(result.artifacts.overlaySha256).toHaveLength(64);
expect(result.artifacts.heatmapSha256).toHaveLength(64);
});
});
@@ -0,0 +1,100 @@
import { createHash } from "node:crypto";
import { createCanvas } from "@napi-rs/canvas";
import { describe, expect, it } from "vitest";
import {
createPdfVisualDiffReport,
renderPdfVisualDiffHtml,
serializePdfVisualDiffJson,
type PdfDocumentSnapshot,
type PdfPageRaster,
} from "../src/index.js";
function raster(color: string): PdfPageRaster {
const canvas = createCanvas(40, 50);
const context = canvas.getContext("2d");
context.fillStyle = "#ffffff";
context.fillRect(0, 0, 40, 50);
context.fillStyle = color;
context.fillRect(10, 10, 20, 20);
const png = Uint8Array.from(canvas.toBuffer("image/png"));
return {
widthPx: 40,
heightPx: 50,
dpi: 144,
sha256: createHash("sha256").update(png).digest("hex"),
png,
};
}
function snapshot(label: string, pageRaster: PdfPageRaster): PdfDocumentSnapshot {
return {
schemaVersion: 1,
source: { kind: "custom", label },
sha256: "a".repeat(64),
pageCount: 1,
contentText: "相同正文",
pages: [
{
pageNumber: 1,
widthPt: 20,
heightPt: 25,
rotation: 0,
items: [],
lines: [],
contentText: "相同正文",
raster: pageRaster,
},
],
};
}
describe("PDF 视觉差异报告", () => {
it("汇总栅格门禁并生成内嵌图片的安全 HTML", async () => {
const report = await createPdfVisualDiffReport(
snapshot("<基线>", raster("#111111")),
snapshot("候选", raster("#cc0000")),
{ generatedAt: "2026-07-31T00:00:00.000Z" },
);
const html = renderPdfVisualDiffHtml(report);
expect(report.status).toBe("failed");
expect(html).toContain("&lt;基线&gt;");
expect(html).toContain("data:image/png;base64,");
expect(html).toContain("差异热力图");
expect(html).not.toContain("<基线>");
});
it("JSON 只保留图片字节数和稳定摘要", async () => {
const page = raster("#111111");
const report = await createPdfVisualDiffReport(
snapshot("基线", page),
snapshot("候选", page),
);
const json = serializePdfVisualDiffJson(report);
expect(json).toContain('"overlaySha256"');
expect(json).toContain('"byteLength"');
expect(json).not.toContain('"0": 137');
});
it("在报告中展示未配对的溢出页面", async () => {
const baseline = snapshot("基线", raster("#111111"));
const candidatePage = snapshot("候选", raster("#111111"));
const candidate: PdfDocumentSnapshot = {
...candidatePage,
pageCount: 2,
pages: [
candidatePage.pages[0]!,
{
...candidatePage.pages[0]!,
pageNumber: 2,
},
],
};
const report = await createPdfVisualDiffReport(baseline, candidate);
const overflow = report.pages[1];
expect(overflow?.pair.status).toBe("candidate-only");
expect(overflow?.unpairedPng?.byteLength).toBeGreaterThan(0);
expect(renderPdfVisualDiffHtml(report)).toContain("未配对页面");
});
});
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { createPdfDocumentSnapshot } from "../src/index.js";
import { createMinimalPdf } from "./pdf-fixture.js";
describe("PDF.js 页面快照", () => {
it("从真实 PDF 同时提取纸张、文本和稳定 PNG", async () => {
const pdf = createMinimalPdf();
const first = await createPdfDocumentSnapshot(pdf, {
source: { kind: "custom", label: "fixture" },
dpi: 144,
});
const second = await createPdfDocumentSnapshot(pdf, {
source: { kind: "custom", label: "fixture" },
dpi: 144,
});
expect(first.pageCount).toBe(1);
expect(first.pages[0]?.widthPt).toBeCloseTo(595, 3);
expect(first.pages[0]?.heightPt).toBeCloseTo(842, 3);
expect(first.contentText).toContain("Visual diff fixture");
expect(first.pages[0]?.raster).toMatchObject({
widthPx: 1190,
heightPx: 1684,
dpi: 144,
});
expect(first.pages[0]?.raster?.png.byteLength).toBeGreaterThan(1000);
expect(first.pages[0]?.raster?.sha256).toBe(
second.pages[0]?.raster?.sha256,
);
});
it("允许关闭栅格输出以执行轻量结构门禁", async () => {
const snapshot = await createPdfDocumentSnapshot(createMinimalPdf(), {
source: { kind: "custom", label: "fixture" },
includeRaster: false,
});
expect(snapshot.pages[0]?.raster).toBeUndefined();
});
});
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import {
aggregatePdfTextLines,
buildPageContentText,
normalizePdfText,
type PdfTextItemSnapshot,
} from "../src/index.js";
function item(
text: string,
x: number,
y: number,
width = 20,
height = 10,
): PdfTextItemSnapshot {
return {
text,
normalizedText: normalizePdfText(text),
bounds: { x, y, width, height },
baselineY: y + height,
hasEol: false,
};
}
describe("PDF 文本行聚合", () => {
it("按坐标聚合中文文本并规范化兼容字符", () => {
const lines = aggregatePdfTextLines(
[
item("ABC", 10, 100, 30),
item("中文", 41, 100, 20),
item("第二行", 10, 120, 40),
],
842,
);
expect(lines.map((line) => line.normalizedText)).toEqual([
"ABC中文",
"第二行",
]);
});
it("只在页眉页脚坐标带识别独立页码", () => {
const lines = aggregatePdfTextLines(
[
item("章节 1", 72, 400, 50),
item("— 1 —", 280, 731, 35, 14),
],
842,
);
expect(lines.map((line) => line.role)).toEqual([
"content",
"page-number",
]);
expect(buildPageContentText(lines)).toBe("章节 1");
});
it("不会把页脚中的普通说明误判为页码", () => {
const lines = aggregatePdfTextLines(
[item("内部资料 1", 72, 800, 70)],
842,
);
expect(lines[0]?.role).toBe("content");
});
});
@@ -0,0 +1,19 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"types": [
"node"
]
},
"include": [
"src"
]
}