feat: 完成 DOCX 视觉门禁与字体兼容映射
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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("<基线>");
|
||||
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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user