feat: 完成 DOCX R4 元素级视觉门禁
建立 Chromium、Word 与 WPS 的可编辑内容、段落几何、页眉页脚、封面、媒体和逐页视觉比较链路。 支持封面独立分节、正文页码重启、主题页边距与横向纸张,并将自然分页差异降为诊断项。 修正代码块内边距和折叠表格单元格边框映射,14 套主题生产矩阵与六组布局视觉矩阵通过。
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
import { normalizePdfContentText } from "./text.js";
|
||||
import {
|
||||
buildPdfEditableContentText,
|
||||
normalizePdfContentText,
|
||||
normalizePdfEditableText,
|
||||
} from "./text.js";
|
||||
import { comparePdfEditableParagraphLayouts } from "./paragraph-layout.js";
|
||||
import { comparePdfPageSemantics } from "./page-semantics.js";
|
||||
import type {
|
||||
PdfEditableContentComparisonOptions,
|
||||
PdfBasicComparison,
|
||||
PdfDocumentSnapshot,
|
||||
PdfPagePair,
|
||||
@@ -78,6 +85,27 @@ export function calculateContentSimilarity(
|
||||
return Math.max(0, 1 - distance / maximumLength);
|
||||
}
|
||||
|
||||
export function calculateOrderedContentCoverage(
|
||||
expectedInput: string,
|
||||
actualInput: string,
|
||||
): number {
|
||||
const expected = normalizePdfEditableText(expectedInput);
|
||||
const actual = normalizePdfEditableText(actualInput);
|
||||
if (expected.length === 0) {
|
||||
return 1;
|
||||
}
|
||||
let matched = 0;
|
||||
for (const character of actual) {
|
||||
if (character === expected[matched]) {
|
||||
matched += 1;
|
||||
if (matched === expected.length) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return matched / expected.length;
|
||||
}
|
||||
|
||||
function createPagePairs(
|
||||
baseline: PdfDocumentSnapshot,
|
||||
candidate: PdfDocumentSnapshot,
|
||||
@@ -111,10 +139,32 @@ function createPagePairs(
|
||||
});
|
||||
}
|
||||
|
||||
function pageNumbersEqual(
|
||||
left: readonly number[],
|
||||
right: readonly number[],
|
||||
): boolean {
|
||||
return left.length === right.length &&
|
||||
left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
function coverPageCount(
|
||||
semantics: readonly { kind: string }[],
|
||||
): number {
|
||||
return semantics.filter((page) => page.kind === "cover").length;
|
||||
}
|
||||
|
||||
function bodyPageNumbers(pageCount: number, coverCount: number): number[] {
|
||||
return Array.from(
|
||||
{ length: Math.max(0, pageCount - coverCount) },
|
||||
(_, index) => coverCount + index + 1,
|
||||
);
|
||||
}
|
||||
|
||||
export function comparePdfSnapshotsBasic(
|
||||
baseline: PdfDocumentSnapshot,
|
||||
candidate: PdfDocumentSnapshot,
|
||||
thresholdOverrides: Partial<VisualDiffThresholds> = {},
|
||||
contentOptions: PdfEditableContentComparisonOptions = {},
|
||||
): PdfBasicComparison {
|
||||
const thresholds = {
|
||||
...DEFAULT_VISUAL_DIFF_THRESHOLDS,
|
||||
@@ -122,6 +172,10 @@ export function comparePdfSnapshotsBasic(
|
||||
};
|
||||
const issues: VisualDiffIssue[] = [];
|
||||
const pagePairs = createPagePairs(baseline, candidate);
|
||||
const baselinePageSemantics =
|
||||
contentOptions.baselinePageSemantics ?? contentOptions.pageSemantics ?? [];
|
||||
const candidatePageSemantics =
|
||||
contentOptions.candidatePageSemantics ?? contentOptions.pageSemantics ?? [];
|
||||
|
||||
if (baseline.pageCount !== candidate.pageCount) {
|
||||
issues.push({
|
||||
@@ -193,17 +247,175 @@ export function comparePdfSnapshotsBasic(
|
||||
}
|
||||
}
|
||||
|
||||
const contentSimilarity = calculateContentSimilarity(
|
||||
baseline.contentText,
|
||||
candidate.contentText,
|
||||
thresholds.contentSimilarity,
|
||||
const hasEditableExpectation =
|
||||
contentOptions.expectedEditableText !== undefined;
|
||||
const expectedEditableText = normalizePdfEditableText(
|
||||
contentOptions.expectedEditableText ?? "",
|
||||
);
|
||||
if (contentSimilarity < thresholds.contentSimilarity) {
|
||||
const baselineEditableText = hasEditableExpectation
|
||||
? buildPdfEditableContentText(baseline, baselinePageSemantics)
|
||||
: "";
|
||||
const candidateEditableText = hasEditableExpectation
|
||||
? buildPdfEditableContentText(candidate, candidatePageSemantics)
|
||||
: "";
|
||||
const baselineEditableCoverage = hasEditableExpectation
|
||||
? calculateOrderedContentCoverage(expectedEditableText, baselineEditableText)
|
||||
: undefined;
|
||||
const candidateEditableSimilarity = hasEditableExpectation
|
||||
? calculateContentSimilarity(
|
||||
expectedEditableText,
|
||||
candidateEditableText,
|
||||
thresholds.contentSimilarity,
|
||||
)
|
||||
: undefined;
|
||||
const candidateEditableExact = hasEditableExpectation
|
||||
? candidateEditableText === expectedEditableText
|
||||
: undefined;
|
||||
const contentSimilarity = hasEditableExpectation
|
||||
? Math.min(
|
||||
baselineEditableCoverage ?? 0,
|
||||
candidateEditableSimilarity ?? 0,
|
||||
)
|
||||
: calculateContentSimilarity(
|
||||
baseline.contentText,
|
||||
candidate.contentText,
|
||||
thresholds.contentSimilarity,
|
||||
);
|
||||
const contentMismatch = hasEditableExpectation
|
||||
? baselineEditableCoverage !== 1 || candidateEditableExact !== true
|
||||
: contentSimilarity < thresholds.contentSimilarity;
|
||||
if (contentMismatch) {
|
||||
issues.push({
|
||||
code: "CONTENT_MISMATCH",
|
||||
severity: "failure",
|
||||
message: `正文文本相似度 ${(contentSimilarity * 100).toFixed(3)}% 低于门限 ${(thresholds.contentSimilarity * 100).toFixed(3)}%`,
|
||||
details: { contentSimilarity },
|
||||
message: hasEditableExpectation
|
||||
? `可编辑正文门禁失败:Chromium 有序覆盖 ${((baselineEditableCoverage ?? 0) * 100).toFixed(3)}%,候选相似度 ${((candidateEditableSimilarity ?? 0) * 100).toFixed(3)}%,候选精确匹配 ${candidateEditableExact ? "是" : "否"}`
|
||||
: `正文文本相似度 ${(contentSimilarity * 100).toFixed(3)}% 低于门限 ${(thresholds.contentSimilarity * 100).toFixed(3)}%`,
|
||||
details: hasEditableExpectation
|
||||
? {
|
||||
baselineEditableCoverage: baselineEditableCoverage ?? 0,
|
||||
candidateEditableSimilarity: candidateEditableSimilarity ?? 0,
|
||||
candidateEditableExact: candidateEditableExact ?? false,
|
||||
}
|
||||
: { contentSimilarity },
|
||||
});
|
||||
}
|
||||
|
||||
const paragraphLayouts = contentOptions.expectedEditableParagraphs
|
||||
? comparePdfEditableParagraphLayouts(
|
||||
baseline,
|
||||
candidate,
|
||||
contentOptions.expectedEditableParagraphs,
|
||||
baselinePageSemantics,
|
||||
candidatePageSemantics,
|
||||
)
|
||||
: undefined;
|
||||
if (paragraphLayouts) {
|
||||
issues.push(...paragraphLayouts.flatMap((paragraph) => paragraph.issues));
|
||||
}
|
||||
|
||||
const baselineCoverPageCount = coverPageCount(baselinePageSemantics);
|
||||
const candidateCoverPageCount = coverPageCount(candidatePageSemantics);
|
||||
const baselineBodyPageCount = baseline.pageCount - baselineCoverPageCount;
|
||||
const candidateBodyPageCount = candidate.pageCount - candidateCoverPageCount;
|
||||
const movedParagraphs = (paragraphLayouts ?? []).filter(
|
||||
(paragraph) =>
|
||||
paragraph.expectation.section === "body" &&
|
||||
!pageNumbersEqual(
|
||||
paragraph.baseline.pageNumbers,
|
||||
paragraph.candidate.pageNumbers,
|
||||
),
|
||||
);
|
||||
const bodyPageCountChanged = baselineBodyPageCount !== candidateBodyPageCount;
|
||||
const bodyFlowDetected = movedParagraphs.length > 0 || bodyPageCountChanged;
|
||||
const affectedBaselinePageNumbers = bodyPageCountChanged
|
||||
? bodyPageNumbers(baseline.pageCount, baselineCoverPageCount)
|
||||
: [...new Set(movedParagraphs.flatMap((item) => item.baseline.pageNumbers))]
|
||||
.sort((left, right) => left - right);
|
||||
const affectedCandidatePageNumbers = bodyPageCountChanged
|
||||
? bodyPageNumbers(candidate.pageCount, candidateCoverPageCount)
|
||||
: [...new Set(movedParagraphs.flatMap((item) => item.candidate.pageNumbers))]
|
||||
.sort((left, right) => left - right);
|
||||
const hasStructuralPageFailure = issues.some((issue) =>
|
||||
issue.code === "PAGE_SIZE_MISMATCH" ||
|
||||
issue.code === "PAGE_ORIENTATION_MISMATCH"
|
||||
);
|
||||
const hasCompletePageSemantics =
|
||||
baselinePageSemantics.length === baseline.pageCount &&
|
||||
candidatePageSemantics.length === candidate.pageCount;
|
||||
const pageSemanticsPassed =
|
||||
hasCompletePageSemantics &&
|
||||
pagePairs.every((pair) => {
|
||||
const baselineExpectation =
|
||||
pair.status === "candidate-only"
|
||||
? undefined
|
||||
: baselinePageSemantics.find(
|
||||
(item) =>
|
||||
item.physicalPageNumber === pair.baselinePageNumber,
|
||||
);
|
||||
const candidateExpectation =
|
||||
pair.status === "baseline-only"
|
||||
? undefined
|
||||
: candidatePageSemantics.find(
|
||||
(item) =>
|
||||
item.physicalPageNumber === pair.candidatePageNumber,
|
||||
);
|
||||
const primaryExpectation =
|
||||
baselineExpectation ?? candidateExpectation;
|
||||
if (!primaryExpectation) {
|
||||
return false;
|
||||
}
|
||||
return comparePdfPageSemantics(
|
||||
baseline,
|
||||
candidate,
|
||||
primaryExpectation,
|
||||
candidateExpectation ?? primaryExpectation,
|
||||
).status === "passed";
|
||||
});
|
||||
const bodyFlowLegal =
|
||||
bodyFlowDetected &&
|
||||
paragraphLayouts !== undefined &&
|
||||
paragraphLayouts.every((paragraph) => paragraph.status === "passed") &&
|
||||
baselineEditableCoverage === 1 &&
|
||||
candidateEditableExact === true &&
|
||||
baselineCoverPageCount === candidateCoverPageCount &&
|
||||
!hasStructuralPageFailure &&
|
||||
pageSemanticsPassed;
|
||||
const bodyFlow = paragraphLayouts
|
||||
? {
|
||||
detected: bodyFlowDetected,
|
||||
legal: bodyFlowLegal,
|
||||
baselineCoverPageCount,
|
||||
candidateCoverPageCount,
|
||||
baselineBodyPageCount,
|
||||
candidateBodyPageCount,
|
||||
movedParagraphIndexes: movedParagraphs.map(
|
||||
(paragraph) => paragraph.expectation.index,
|
||||
),
|
||||
affectedBaselinePageNumbers,
|
||||
affectedCandidatePageNumbers,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
if (bodyFlowLegal && bodyPageCountChanged) {
|
||||
for (let index = issues.length - 1; index >= 0; index -= 1) {
|
||||
const issue = issues[index];
|
||||
if (
|
||||
issue?.code === "PAGE_COUNT_MISMATCH" ||
|
||||
issue?.code === "UNPAIRED_BASELINE_PAGE" ||
|
||||
issue?.code === "UNPAIRED_CANDIDATE_PAGE"
|
||||
) {
|
||||
issues.splice(index, 1);
|
||||
}
|
||||
}
|
||||
issues.push({
|
||||
code: "BODY_FLOW_PAGE_COUNT_DIFFERENCE",
|
||||
severity: "warning",
|
||||
message: `正文自然分页导致页数不同:基线 ${baselineBodyPageCount} 页,候选 ${candidateBodyPageCount} 页`,
|
||||
details: {
|
||||
baselineBodyPageCount,
|
||||
candidateBodyPageCount,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -219,6 +431,17 @@ export function comparePdfSnapshotsBasic(
|
||||
candidate: candidate.source,
|
||||
thresholds,
|
||||
contentSimilarity,
|
||||
...(baselineEditableCoverage === undefined
|
||||
? {}
|
||||
: { baselineEditableCoverage }),
|
||||
...(candidateEditableSimilarity === undefined
|
||||
? {}
|
||||
: { candidateEditableSimilarity }),
|
||||
...(candidateEditableExact === undefined
|
||||
? {}
|
||||
: { candidateEditableExact }),
|
||||
...(paragraphLayouts === undefined ? {} : { paragraphLayouts }),
|
||||
...(bodyFlow === undefined ? {} : { bodyFlow }),
|
||||
pagePairs,
|
||||
issues,
|
||||
};
|
||||
|
||||
@@ -2,6 +2,8 @@ export * from "./adapter-runner.js";
|
||||
export * from "./adapters.js";
|
||||
export * from "./compare.js";
|
||||
export * from "./office-adapter.js";
|
||||
export * from "./paragraph-layout.js";
|
||||
export * from "./page-semantics.js";
|
||||
export * from "./raster.js";
|
||||
export * from "./report.js";
|
||||
export * from "./snapshot.js";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { basename, dirname, extname, join, resolve } from "node:path";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
@@ -28,6 +28,37 @@ export interface OfficeExportMetadata {
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
export interface OfficeInlineShapeObservation {
|
||||
index: number;
|
||||
widthPt: number;
|
||||
heightPt: number;
|
||||
type?: number;
|
||||
}
|
||||
|
||||
export interface OfficeDocxRoundTripMetadata {
|
||||
bytes: number;
|
||||
before: OfficeInlineShapeObservation[];
|
||||
saved: OfficeInlineShapeObservation[];
|
||||
resizedShapeIndex: number;
|
||||
resizeScale: number;
|
||||
}
|
||||
|
||||
export interface OfficeDocxRoundTripGeneration
|
||||
extends OfficeDocxRoundTripMetadata {
|
||||
client: OfficeClientKind;
|
||||
docx: Uint8Array;
|
||||
elapsedMs: number;
|
||||
}
|
||||
|
||||
export interface OfficeDocxRoundTripAdapter {
|
||||
id: string;
|
||||
client: OfficeClientKind;
|
||||
probe(): Promise<PdfAdapterCapability>;
|
||||
generate(
|
||||
input: OfficePdfAdapterInput,
|
||||
): Promise<OfficeDocxRoundTripGeneration>;
|
||||
}
|
||||
|
||||
export interface OfficeAutomationBackend {
|
||||
probe(progId: string, timeoutMs: number): Promise<boolean>;
|
||||
exportPdf(options: {
|
||||
@@ -37,6 +68,14 @@ export interface OfficeAutomationBackend {
|
||||
outputPath: string;
|
||||
timeoutMs: number;
|
||||
}): Promise<OfficeExportMetadata>;
|
||||
roundTripDocx?(options: {
|
||||
client: OfficeClientKind;
|
||||
progId: string;
|
||||
inputPath: string;
|
||||
outputPath: string;
|
||||
timeoutMs: number;
|
||||
resizeScale: number;
|
||||
}): Promise<OfficeDocxRoundTripMetadata>;
|
||||
}
|
||||
|
||||
export interface OfficePdfAdapterOptions {
|
||||
@@ -116,6 +155,110 @@ try {
|
||||
}
|
||||
`;
|
||||
|
||||
const POWERSHELL_ROUND_TRIP_SCRIPT = `
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$client = $env:MD_TO_PDF_OFFICE_CLIENT
|
||||
$progId = $env:MD_TO_PDF_OFFICE_PROGID
|
||||
$resizeScale = [double]::Parse(
|
||||
$env:MD_TO_PDF_OFFICE_RESIZE_SCALE,
|
||||
[Globalization.CultureInfo]::InvariantCulture
|
||||
)
|
||||
$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 DOCX 互存输出已存在:$resolvedOutput"
|
||||
}
|
||||
[System.IO.File]::Copy($resolvedInput, $resolvedOutput, $false)
|
||||
$application = $null
|
||||
$document = $null
|
||||
function Read-InlineShapes($value) {
|
||||
$result = @()
|
||||
for ($index = 1; $index -le $value.InlineShapes.Count; $index += 1) {
|
||||
$shape = $value.InlineShapes.Item($index)
|
||||
$result += [pscustomobject]@{
|
||||
index = $index
|
||||
widthPt = [double]$shape.Width
|
||||
heightPt = [double]$shape.Height
|
||||
type = [int]$shape.Type
|
||||
}
|
||||
}
|
||||
return @($result)
|
||||
}
|
||||
function Open-Document($application, $client, $path, $readOnly) {
|
||||
if ($client -eq 'word' -and $readOnly) {
|
||||
return $application.Documents.OpenNoRepairDialog(
|
||||
$path, $false, $readOnly, $false, '', '', $false,
|
||||
'', '', 0, 0, $false, $false, 0, $true
|
||||
)
|
||||
}
|
||||
return $application.Documents.Open($path, $false, $readOnly, $false)
|
||||
}
|
||||
try {
|
||||
$application = New-Object -ComObject $progId
|
||||
$application.Visible = $false
|
||||
try { $application.DisplayAlerts = 0 } catch {}
|
||||
try { $application.AutomationSecurity = 3 } catch {}
|
||||
$document = Open-Document $application $client $resolvedOutput $false
|
||||
try { $document.EmbedTrueTypeFonts = $false } catch {}
|
||||
try { $document.SaveSubsetFonts = $false } catch {}
|
||||
$before = @(Read-InlineShapes $document)
|
||||
if ($before.Count -lt 1) {
|
||||
throw 'Office DOCX 不包含可缩放的内联图片'
|
||||
}
|
||||
$shape = $document.InlineShapes.Item(1)
|
||||
$originalWidth = [double]$shape.Width
|
||||
$originalHeight = [double]$shape.Height
|
||||
$shape.Width = [single]($originalWidth * $resizeScale)
|
||||
$expectedHeight = $originalHeight * $resizeScale
|
||||
if ([Math]::Abs([double]$shape.Height - $expectedHeight) -gt 0.5) {
|
||||
$shape.Height = [single]$expectedHeight
|
||||
}
|
||||
if (
|
||||
[Math]::Abs([double]$shape.Width - ($originalWidth * $resizeScale)) -gt 0.5 -or
|
||||
[Math]::Abs([double]$shape.Height - $expectedHeight) -gt 0.5
|
||||
) {
|
||||
throw (
|
||||
'Office 未在内存中按比例缩放首张图片:' +
|
||||
"readOnly=$($document.ReadOnly), " +
|
||||
"before=$originalWidth/$originalHeight, " +
|
||||
"actual=$([double]$shape.Width)/$([double]$shape.Height), " +
|
||||
"expected=$($originalWidth * $resizeScale)/$expectedHeight"
|
||||
)
|
||||
}
|
||||
$document.Close($true)
|
||||
[System.Runtime.InteropServices.Marshal]::ReleaseComObject(
|
||||
$document
|
||||
) | Out-Null
|
||||
$document = $null
|
||||
$document = Open-Document $application $client $resolvedOutput $true
|
||||
$saved = @(Read-InlineShapes $document)
|
||||
[pscustomobject]@{
|
||||
bytes = (Get-Item -LiteralPath $resolvedOutput).Length
|
||||
before = $before
|
||||
saved = $saved
|
||||
resizedShapeIndex = 1
|
||||
resizeScale = $resizeScale
|
||||
} | ConvertTo-Json -Compress -Depth 5
|
||||
} 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");
|
||||
}
|
||||
@@ -218,6 +361,82 @@ export class PowerShellOfficeAutomationBackend
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
async roundTripDocx(options: {
|
||||
client: OfficeClientKind;
|
||||
progId: string;
|
||||
inputPath: string;
|
||||
outputPath: string;
|
||||
timeoutMs: number;
|
||||
resizeScale: number;
|
||||
}): Promise<OfficeDocxRoundTripMetadata> {
|
||||
const { stdout } = await execFileAsync(
|
||||
"powershell.exe",
|
||||
[
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-EncodedCommand",
|
||||
encodePowerShell(POWERSHELL_ROUND_TRIP_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,
|
||||
MD_TO_PDF_OFFICE_RESIZE_SCALE: String(options.resizeScale),
|
||||
},
|
||||
},
|
||||
);
|
||||
const parsed = parseJsonLine(stdout);
|
||||
const bytes = parsed.bytes;
|
||||
const before = parsed.before;
|
||||
const saved = parsed.saved;
|
||||
if (
|
||||
typeof bytes !== "number" ||
|
||||
!Number.isSafeInteger(bytes) ||
|
||||
!Array.isArray(before) ||
|
||||
!Array.isArray(saved)
|
||||
) {
|
||||
throw new Error("Office DOCX 互存返回格式无效");
|
||||
}
|
||||
const parseShapes = (value: unknown[]) =>
|
||||
value.map((entry) => {
|
||||
if (
|
||||
typeof entry !== "object" ||
|
||||
entry === null ||
|
||||
typeof (entry as Record<string, unknown>).index !== "number" ||
|
||||
typeof (entry as Record<string, unknown>).widthPt !== "number" ||
|
||||
typeof (entry as Record<string, unknown>).heightPt !== "number"
|
||||
) {
|
||||
throw new Error("Office 内联图片观测格式无效");
|
||||
}
|
||||
const shape = entry as Record<string, unknown>;
|
||||
return {
|
||||
index: shape.index as number,
|
||||
widthPt: shape.widthPt as number,
|
||||
heightPt: shape.heightPt as number,
|
||||
...(typeof shape.type === "number"
|
||||
? { type: shape.type }
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
return {
|
||||
bytes,
|
||||
before: parseShapes(before),
|
||||
saved: parseShapes(saved),
|
||||
resizedShapeIndex: 1,
|
||||
resizeScale: options.resizeScale,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function validateDocxPath(inputPath: string): Promise<string> {
|
||||
@@ -346,3 +565,110 @@ export function createWpsPdfAdapter(
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
function createOfficeDocxRoundTripAdapter(
|
||||
descriptor: OfficeDescriptor,
|
||||
options: OfficePdfAdapterOptions = {},
|
||||
): OfficeDocxRoundTripAdapter {
|
||||
const backend =
|
||||
options.backend ?? new PowerShellOfficeAutomationBackend();
|
||||
const timeoutMs = options.timeoutMs ?? 180_000;
|
||||
const resizeScale = 0.9;
|
||||
return {
|
||||
id: `${descriptor.id}-docx-round-trip`,
|
||||
client: descriptor.client,
|
||||
async probe() {
|
||||
const available = await backend.probe(
|
||||
descriptor.progId,
|
||||
timeoutMs,
|
||||
);
|
||||
return {
|
||||
available,
|
||||
adapterId: `${descriptor.id}-docx-round-trip`,
|
||||
source: {
|
||||
kind: descriptor.client,
|
||||
label: `${descriptor.defaultLabel} DOCX 互存`,
|
||||
},
|
||||
detail: available
|
||||
? `已注册 ${descriptor.progId}`
|
||||
: `未找到 ${descriptor.progId}`,
|
||||
};
|
||||
},
|
||||
async generate(input) {
|
||||
if (!backend.roundTripDocx) {
|
||||
throw new Error("Office 自动化后端不支持 DOCX 互存");
|
||||
}
|
||||
const inputPath = await validateDocxPath(input.docxPath);
|
||||
const temporaryDirectory = await mkdtemp(
|
||||
join(
|
||||
dirname(inputPath),
|
||||
`.md-to-pdf-${descriptor.client}-round-trip-`,
|
||||
),
|
||||
);
|
||||
const outputPath = join(
|
||||
temporaryDirectory,
|
||||
`${basename(inputPath, extname(inputPath))}-round-trip.docx`,
|
||||
);
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
const metadata = await backend.roundTripDocx({
|
||||
client: descriptor.client,
|
||||
progId: descriptor.progId,
|
||||
inputPath,
|
||||
outputPath,
|
||||
timeoutMs,
|
||||
resizeScale,
|
||||
});
|
||||
const outputStat = await stat(outputPath);
|
||||
if (
|
||||
outputStat.size <= 0 ||
|
||||
outputStat.size > MAX_OFFICE_OUTPUT_BYTES ||
|
||||
outputStat.size !== metadata.bytes
|
||||
) {
|
||||
throw new Error(
|
||||
`Office DOCX 互存输出大小无效:${outputStat.size}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
client: descriptor.client,
|
||||
docx: Uint8Array.from(await readFile(outputPath)),
|
||||
...metadata,
|
||||
elapsedMs: performance.now() - startedAt,
|
||||
};
|
||||
} finally {
|
||||
await rm(temporaryDirectory, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createWordDocxRoundTripAdapter(
|
||||
options: OfficePdfAdapterOptions = {},
|
||||
) {
|
||||
return createOfficeDocxRoundTripAdapter(
|
||||
{
|
||||
id: "word",
|
||||
client: "word",
|
||||
progId: "Word.Application",
|
||||
defaultLabel: "Microsoft Word",
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function createWpsDocxRoundTripAdapter(
|
||||
options: OfficePdfAdapterOptions = {},
|
||||
) {
|
||||
return createOfficeDocxRoundTripAdapter(
|
||||
{
|
||||
id: "wps",
|
||||
client: "wps",
|
||||
progId: "KWPS.Application",
|
||||
defaultLabel: "WPS Writer",
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
import { normalizePdfContentText, normalizePdfText } from "./text.js";
|
||||
import type {
|
||||
PdfDocumentSnapshot,
|
||||
PdfPageDecorationObservation,
|
||||
PdfPageSemanticComparison,
|
||||
PdfPageSnapshot,
|
||||
VisualDiffIssue,
|
||||
VisualHeaderSlotExpectation,
|
||||
VisualPageAlignment,
|
||||
VisualPageSemanticExpectation,
|
||||
} from "./types.js";
|
||||
|
||||
const DECORATION_VERTICAL_TOLERANCE_PT = 2;
|
||||
|
||||
function median(values: readonly number[]): number | undefined {
|
||||
if (values.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const sorted = [...values].sort((left, right) => left - right);
|
||||
const middle = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 === 0
|
||||
? ((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2
|
||||
: sorted[middle];
|
||||
}
|
||||
|
||||
function pageNumberBaselineMedian(
|
||||
document: PdfDocumentSnapshot,
|
||||
): number | undefined {
|
||||
return median(
|
||||
document.pages.flatMap((page) =>
|
||||
page.lines
|
||||
.filter((line) => {
|
||||
const middle = line.bounds.y + line.bounds.height / 2;
|
||||
return (
|
||||
line.role === "page-number" && middle >= page.heightPt * 0.85
|
||||
);
|
||||
})
|
||||
.map((line) => line.baselineY),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function lineAlignment(
|
||||
page: PdfPageSnapshot,
|
||||
x: number,
|
||||
width: number,
|
||||
): VisualPageAlignment {
|
||||
const middle = x + width / 2;
|
||||
if (middle < page.widthPt * 0.4) {
|
||||
return "left";
|
||||
}
|
||||
if (middle > page.widthPt * 0.6) {
|
||||
return "right";
|
||||
}
|
||||
return "center";
|
||||
}
|
||||
|
||||
function observePageDecorations(
|
||||
page: PdfPageSnapshot,
|
||||
expectation: VisualPageSemanticExpectation,
|
||||
): PdfPageDecorationObservation {
|
||||
const bottomPageNumber = page.lines.find((line) => {
|
||||
const middle = line.bounds.y + line.bounds.height / 2;
|
||||
return line.role === "page-number" && middle >= page.heightPt * 0.85;
|
||||
});
|
||||
const topLines = page.lines.filter(
|
||||
(line) =>
|
||||
line.bounds.y + line.bounds.height / 2 <= page.heightPt * 0.12
|
||||
);
|
||||
const topText = topLines
|
||||
.map((line) => normalizePdfContentText(line.normalizedText))
|
||||
.join("");
|
||||
const headerSlots = expectation.headerSlots ?? [];
|
||||
const headerLines = topLines.filter((line) => {
|
||||
const text = normalizePdfContentText(line.normalizedText);
|
||||
return headerSlots.some((slot) =>
|
||||
text.includes(normalizePdfContentText(slot.text))
|
||||
);
|
||||
});
|
||||
const matchedHeaderSlots = headerSlots.filter((slot) =>
|
||||
topText.includes(normalizePdfContentText(slot.text)),
|
||||
);
|
||||
const missingHeaderSlots = headerSlots.filter(
|
||||
(slot) => !matchedHeaderSlots.includes(slot),
|
||||
);
|
||||
return {
|
||||
...(bottomPageNumber
|
||||
? {
|
||||
pageNumberText: bottomPageNumber.normalizedText,
|
||||
pageNumberAlignment: lineAlignment(
|
||||
page,
|
||||
bottomPageNumber.bounds.x,
|
||||
bottomPageNumber.bounds.width,
|
||||
),
|
||||
pageNumberBaselineY: bottomPageNumber.baselineY,
|
||||
}
|
||||
: {}),
|
||||
...(topText ? { headerText: topText } : {}),
|
||||
...(headerLines.length > 0
|
||||
? {
|
||||
headerBaselineY:
|
||||
headerLines.reduce(
|
||||
(sum, line) => sum + line.baselineY,
|
||||
0
|
||||
) / headerLines.length,
|
||||
}
|
||||
: {}),
|
||||
matchedHeaderSlots,
|
||||
missingHeaderSlots,
|
||||
};
|
||||
}
|
||||
|
||||
function compareObservation(
|
||||
source: "baseline" | "candidate",
|
||||
pageNumber: number,
|
||||
expectation: VisualPageSemanticExpectation,
|
||||
observation: PdfPageDecorationObservation,
|
||||
): VisualDiffIssue[] {
|
||||
const issues: VisualDiffIssue[] = [];
|
||||
const location =
|
||||
source === "baseline"
|
||||
? { baselinePageNumber: pageNumber }
|
||||
: { candidatePageNumber: pageNumber };
|
||||
const sourceLabel = source === "baseline" ? "基线" : "候选";
|
||||
const hasHeader = observation.matchedHeaderSlots.length > 0;
|
||||
if (hasHeader !== expectation.headerVisible) {
|
||||
issues.push({
|
||||
code: "HEADER_VISIBILITY_MISMATCH",
|
||||
severity: "failure",
|
||||
message: `${sourceLabel}物理第 ${pageNumber} 页页眉可见性错误`,
|
||||
...location,
|
||||
details: {
|
||||
source,
|
||||
expectedVisible: expectation.headerVisible,
|
||||
actualVisible: hasHeader,
|
||||
matchedSlots: observation.matchedHeaderSlots
|
||||
.map((slot) => slot.alignment)
|
||||
.join(","),
|
||||
},
|
||||
});
|
||||
}
|
||||
const hasPageNumber = Boolean(observation.pageNumberText);
|
||||
if (hasPageNumber !== expectation.footerVisible) {
|
||||
issues.push({
|
||||
code: "PAGE_NUMBER_VISIBILITY_MISMATCH",
|
||||
severity: "failure",
|
||||
message: `${sourceLabel}物理第 ${pageNumber} 页页码可见性错误`,
|
||||
...location,
|
||||
details: {
|
||||
source,
|
||||
expectedVisible: expectation.footerVisible,
|
||||
actualVisible: hasPageNumber,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (
|
||||
expectation.footerVisible &&
|
||||
expectation.pageNumberText &&
|
||||
observation.pageNumberText &&
|
||||
normalizePdfText(observation.pageNumberText) !==
|
||||
normalizePdfText(expectation.pageNumberText)
|
||||
) {
|
||||
issues.push({
|
||||
code: "PAGE_NUMBER_TEXT_MISMATCH",
|
||||
severity: "failure",
|
||||
message: `${sourceLabel}物理第 ${pageNumber} 页页码文本错误`,
|
||||
...location,
|
||||
details: {
|
||||
source,
|
||||
expected: expectation.pageNumberText,
|
||||
actual: observation.pageNumberText,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (
|
||||
expectation.footerVisible &&
|
||||
expectation.footerAlignment &&
|
||||
observation.pageNumberAlignment &&
|
||||
observation.pageNumberAlignment !== expectation.footerAlignment
|
||||
) {
|
||||
issues.push({
|
||||
code: "PAGE_NUMBER_ALIGNMENT_MISMATCH",
|
||||
severity: "failure",
|
||||
message: `${sourceLabel}物理第 ${pageNumber} 页页码位置错误`,
|
||||
...location,
|
||||
details: {
|
||||
source,
|
||||
expected: expectation.footerAlignment,
|
||||
actual: observation.pageNumberAlignment,
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const slot of expectation.headerVisible
|
||||
? observation.missingHeaderSlots
|
||||
: []) {
|
||||
issues.push({
|
||||
code: "HEADER_TEXT_MISSING",
|
||||
severity: "failure",
|
||||
message: `${sourceLabel}物理第 ${pageNumber} 页缺少${slot.alignment}页眉文本`,
|
||||
...location,
|
||||
details: {
|
||||
source,
|
||||
alignment: slot.alignment,
|
||||
expected: slot.text,
|
||||
},
|
||||
});
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
function status(issues: readonly VisualDiffIssue[]) {
|
||||
return issues.some((issue) => issue.severity === "failure")
|
||||
? ("failed" as const)
|
||||
: issues.length > 0
|
||||
? ("warning" as const)
|
||||
: ("passed" as const);
|
||||
}
|
||||
|
||||
export function comparePdfPageSemantics(
|
||||
baseline: PdfDocumentSnapshot,
|
||||
candidate: PdfDocumentSnapshot,
|
||||
expectation: VisualPageSemanticExpectation,
|
||||
candidateExpectation: VisualPageSemanticExpectation = expectation,
|
||||
): PdfPageSemanticComparison {
|
||||
const baselinePage = baseline.pages[expectation.physicalPageNumber - 1];
|
||||
const candidatePage =
|
||||
candidate.pages[candidateExpectation.physicalPageNumber - 1];
|
||||
const baselineObservation = baselinePage
|
||||
? observePageDecorations(baselinePage, expectation)
|
||||
: undefined;
|
||||
const candidateObservation = candidatePage
|
||||
? observePageDecorations(candidatePage, candidateExpectation)
|
||||
: undefined;
|
||||
const issues = [
|
||||
...(baselineObservation
|
||||
? compareObservation(
|
||||
"baseline",
|
||||
expectation.physicalPageNumber,
|
||||
expectation,
|
||||
baselineObservation,
|
||||
)
|
||||
: []),
|
||||
...(candidateObservation
|
||||
? compareObservation(
|
||||
"candidate",
|
||||
candidateExpectation.physicalPageNumber,
|
||||
candidateExpectation,
|
||||
candidateObservation,
|
||||
)
|
||||
: []),
|
||||
];
|
||||
if (
|
||||
expectation.headerVisible &&
|
||||
candidateExpectation.headerVisible &&
|
||||
baselineObservation?.headerBaselineY !== undefined &&
|
||||
candidateObservation?.headerBaselineY !== undefined
|
||||
) {
|
||||
const deltaPt = Math.abs(
|
||||
baselineObservation.headerBaselineY -
|
||||
candidateObservation.headerBaselineY
|
||||
);
|
||||
if (deltaPt > DECORATION_VERTICAL_TOLERANCE_PT) {
|
||||
issues.push({
|
||||
code: "HEADER_VERTICAL_POSITION_MISMATCH",
|
||||
severity: "failure",
|
||||
message: `物理第 ${expectation.physicalPageNumber} 页页眉纵向位置偏差 ${deltaPt.toFixed(2)}pt 超过 ${DECORATION_VERTICAL_TOLERANCE_PT}pt`,
|
||||
baselinePageNumber: expectation.physicalPageNumber,
|
||||
candidatePageNumber: expectation.physicalPageNumber,
|
||||
details: {
|
||||
deltaPt,
|
||||
tolerancePt: DECORATION_VERTICAL_TOLERANCE_PT,
|
||||
baselineY: baselineObservation.headerBaselineY,
|
||||
candidateY: candidateObservation.headerBaselineY,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
if (
|
||||
expectation.footerVisible &&
|
||||
candidateExpectation.footerVisible &&
|
||||
baselineObservation?.pageNumberBaselineY !== undefined &&
|
||||
candidateObservation?.pageNumberBaselineY !== undefined
|
||||
) {
|
||||
const baselineMedianY = pageNumberBaselineMedian(baseline);
|
||||
const candidateMedianY = pageNumberBaselineMedian(candidate);
|
||||
const documentAnchorDeltaPt =
|
||||
baselineMedianY === undefined || candidateMedianY === undefined
|
||||
? Math.abs(
|
||||
baselineObservation.pageNumberBaselineY -
|
||||
candidateObservation.pageNumberBaselineY,
|
||||
)
|
||||
: Math.abs(baselineMedianY - candidateMedianY);
|
||||
const pageRelativeDeltaPt =
|
||||
baselineMedianY === undefined || candidateMedianY === undefined
|
||||
? 0
|
||||
: Math.abs(
|
||||
baselineObservation.pageNumberBaselineY - baselineMedianY -
|
||||
(candidateObservation.pageNumberBaselineY - candidateMedianY),
|
||||
);
|
||||
const deltaPt = Math.max(documentAnchorDeltaPt, pageRelativeDeltaPt);
|
||||
if (deltaPt > DECORATION_VERTICAL_TOLERANCE_PT) {
|
||||
issues.push({
|
||||
code: "PAGE_NUMBER_VERTICAL_POSITION_MISMATCH",
|
||||
severity: "failure",
|
||||
message: `物理第 ${expectation.physicalPageNumber} 页页码纵向位置偏差 ${deltaPt.toFixed(2)}pt 超过 ${DECORATION_VERTICAL_TOLERANCE_PT}pt`,
|
||||
baselinePageNumber: expectation.physicalPageNumber,
|
||||
candidatePageNumber: expectation.physicalPageNumber,
|
||||
details: {
|
||||
deltaPt,
|
||||
tolerancePt: DECORATION_VERTICAL_TOLERANCE_PT,
|
||||
baselineY: baselineObservation.pageNumberBaselineY,
|
||||
candidateY: candidateObservation.pageNumberBaselineY,
|
||||
baselineMedianY: baselineMedianY ?? -1,
|
||||
candidateMedianY: candidateMedianY ?? -1,
|
||||
documentAnchorDeltaPt,
|
||||
pageRelativeDeltaPt,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
expectation,
|
||||
...(candidateExpectation === expectation
|
||||
? {}
|
||||
: { candidateExpectation }),
|
||||
...(baselineObservation ? { baseline: baselineObservation } : {}),
|
||||
...(candidateObservation ? { candidate: candidateObservation } : {}),
|
||||
status: status(issues),
|
||||
issues,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
import {
|
||||
buildPdfEditableContentLines,
|
||||
normalizePdfEditableText,
|
||||
} from "./text.js";
|
||||
import type {
|
||||
EditableParagraphExpectation,
|
||||
PdfDocumentSnapshot,
|
||||
PdfEditableContentLine,
|
||||
PdfParagraphLayoutComparison,
|
||||
PdfParagraphLayoutObservation,
|
||||
VisualDiffIssue,
|
||||
VisualPageSemanticExpectation,
|
||||
} from "./types.js";
|
||||
|
||||
const LINE_BREAK_CHARACTER_TOLERANCE = 1;
|
||||
const LINE_X_TOLERANCE_PT = 2;
|
||||
const COVER_LINE_X_TOLERANCE_PT = 12;
|
||||
const LINE_HEIGHT_TOLERANCE_PT = 1;
|
||||
const BASELINE_GAP_TOLERANCE_PT = 1.5;
|
||||
const COVER_VERTICAL_TOLERANCE_PT = 6;
|
||||
const BODY_NEAR_BOUNDARY_MIN_CHARACTERS = 30;
|
||||
const BODY_NEAR_BOUNDARY_MAX_TRAILING_RATIO = 0.05;
|
||||
|
||||
interface MatchCursor {
|
||||
lineIndex: number;
|
||||
characterIndex: number;
|
||||
}
|
||||
|
||||
function average(values: readonly number[]): number | undefined {
|
||||
if (values.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
||||
}
|
||||
|
||||
function advanceCursor(
|
||||
cursor: MatchCursor,
|
||||
lineCharacters: readonly string[][],
|
||||
): boolean {
|
||||
cursor.characterIndex += 1;
|
||||
while (
|
||||
cursor.lineIndex < lineCharacters.length &&
|
||||
cursor.characterIndex >= (lineCharacters[cursor.lineIndex]?.length ?? 0)
|
||||
) {
|
||||
cursor.lineIndex += 1;
|
||||
cursor.characterIndex = 0;
|
||||
}
|
||||
return cursor.lineIndex < lineCharacters.length;
|
||||
}
|
||||
|
||||
function observeParagraph(
|
||||
expectation: EditableParagraphExpectation,
|
||||
lines: readonly PdfEditableContentLine[],
|
||||
lineCharacters: readonly string[][],
|
||||
cursor: MatchCursor,
|
||||
): PdfParagraphLayoutObservation {
|
||||
const localCursor: MatchCursor = { ...cursor };
|
||||
const expectedCharacters = Array.from(
|
||||
normalizePdfEditableText(expectation.text),
|
||||
);
|
||||
const matchedByLine = new Map<number, string[]>();
|
||||
let matchedCharacterCount = 0;
|
||||
|
||||
for (const expectedCharacter of expectedCharacters) {
|
||||
let found = false;
|
||||
while (localCursor.lineIndex < lineCharacters.length) {
|
||||
const actualCharacter =
|
||||
lineCharacters[localCursor.lineIndex]?.[localCursor.characterIndex];
|
||||
if (actualCharacter === expectedCharacter) {
|
||||
const matches = matchedByLine.get(localCursor.lineIndex) ?? [];
|
||||
matches.push(expectedCharacter);
|
||||
matchedByLine.set(localCursor.lineIndex, matches);
|
||||
matchedCharacterCount += 1;
|
||||
advanceCursor(localCursor, lineCharacters);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
if (!advanceCursor(localCursor, lineCharacters)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const matchedLineIndexes = [...matchedByLine.keys()];
|
||||
const matchedLines = matchedLineIndexes.flatMap((index) =>
|
||||
lines[index] ? [lines[index]] : [],
|
||||
);
|
||||
const lineTexts = matchedLineIndexes.map((index) =>
|
||||
(matchedByLine.get(index) ?? []).join(""),
|
||||
);
|
||||
let cumulativeCharacters = 0;
|
||||
const lineBreakOffsets = lineTexts.slice(0, -1).map((text) => {
|
||||
cumulativeCharacters += Array.from(text).length;
|
||||
return cumulativeCharacters;
|
||||
});
|
||||
const baselineGaps = matchedLines.slice(1).flatMap((line, index) => {
|
||||
const previous = matchedLines[index];
|
||||
return previous && previous.pageNumber === line.pageNumber
|
||||
? [line.line.baselineY - previous.line.baselineY]
|
||||
: [];
|
||||
});
|
||||
const pageNumbers = [...new Set(matchedLines.map((line) => line.pageNumber))];
|
||||
const firstLine = matchedLines[0];
|
||||
const averageLineHeightPt = average(
|
||||
matchedLines.map((line) => line.line.bounds.height),
|
||||
);
|
||||
const averageBaselineGapPt = average(baselineGaps);
|
||||
const exclusiveGeometry = matchedLineIndexes.every(
|
||||
(index) =>
|
||||
Array.from(lines[index]?.normalizedText ?? "").length ===
|
||||
(matchedByLine.get(index)?.length ?? 0),
|
||||
);
|
||||
const matched = matchedCharacterCount === expectedCharacters.length;
|
||||
if (matched) {
|
||||
cursor.lineIndex = localCursor.lineIndex;
|
||||
cursor.characterIndex = localCursor.characterIndex;
|
||||
}
|
||||
return {
|
||||
matched,
|
||||
matchedCharacterCount,
|
||||
expectedCharacterCount: expectedCharacters.length,
|
||||
pageNumbers,
|
||||
lineCount: matchedLines.length,
|
||||
lineTexts,
|
||||
lineBreakOffsets,
|
||||
...(firstLine
|
||||
? {
|
||||
firstLineXPt: firstLine.line.bounds.x,
|
||||
firstLineBaselineYPt: firstLine.line.baselineY,
|
||||
}
|
||||
: {}),
|
||||
...(average(matchedLines.map((line) => line.line.bounds.width)) === undefined
|
||||
? {}
|
||||
: {
|
||||
maximumLineWidthPt: Math.max(
|
||||
...matchedLines.map((line) => line.line.bounds.width),
|
||||
),
|
||||
}),
|
||||
...(averageLineHeightPt === undefined
|
||||
? {}
|
||||
: { averageLineHeightPt }),
|
||||
...(averageBaselineGapPt === undefined
|
||||
? {}
|
||||
: { averageBaselineGapPt }),
|
||||
exclusiveGeometry,
|
||||
};
|
||||
}
|
||||
|
||||
function delta(
|
||||
baseline: number | undefined,
|
||||
candidate: number | undefined,
|
||||
): number | undefined {
|
||||
return baseline === undefined || candidate === undefined
|
||||
? undefined
|
||||
: Math.abs(baseline - candidate);
|
||||
}
|
||||
|
||||
function trailingLineCharacterCount(
|
||||
observation: PdfParagraphLayoutObservation,
|
||||
): number {
|
||||
return Array.from(observation.lineTexts.at(-1) ?? "").length;
|
||||
}
|
||||
|
||||
function isBodyNearBoundaryLineCountDifference(
|
||||
expectation: EditableParagraphExpectation,
|
||||
baseline: PdfParagraphLayoutObservation,
|
||||
candidate: PdfParagraphLayoutObservation,
|
||||
): boolean {
|
||||
if (
|
||||
expectation.section !== "body" ||
|
||||
expectation.role !== "body" ||
|
||||
baseline.expectedCharacterCount < BODY_NEAR_BOUNDARY_MIN_CHARACTERS ||
|
||||
Math.abs(baseline.lineCount - candidate.lineCount) !== 1
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const moreLines =
|
||||
baseline.lineCount > candidate.lineCount ? baseline : candidate;
|
||||
const trailingCharacters = trailingLineCharacterCount(moreLines);
|
||||
return (
|
||||
trailingCharacters > 0 &&
|
||||
trailingCharacters / baseline.expectedCharacterCount <=
|
||||
BODY_NEAR_BOUNDARY_MAX_TRAILING_RATIO
|
||||
);
|
||||
}
|
||||
|
||||
function compareParagraph(
|
||||
expectation: EditableParagraphExpectation,
|
||||
baseline: PdfParagraphLayoutObservation,
|
||||
candidate: PdfParagraphLayoutObservation,
|
||||
): VisualDiffIssue[] {
|
||||
const issues: VisualDiffIssue[] = [];
|
||||
if (!baseline.matched || !candidate.matched) {
|
||||
issues.push({
|
||||
code: "EDITABLE_PARAGRAPH_MAPPING_MISMATCH",
|
||||
severity: "failure",
|
||||
message: `第 ${expectation.index + 1} 个可编辑段落无法完整映射到 PDF 文本行`,
|
||||
details: {
|
||||
paragraphIndex: expectation.index,
|
||||
baselineCoverage:
|
||||
baseline.expectedCharacterCount === 0
|
||||
? 1
|
||||
: baseline.matchedCharacterCount / baseline.expectedCharacterCount,
|
||||
candidateCoverage:
|
||||
candidate.expectedCharacterCount === 0
|
||||
? 1
|
||||
: candidate.matchedCharacterCount / candidate.expectedCharacterCount,
|
||||
},
|
||||
});
|
||||
return issues;
|
||||
}
|
||||
if (baseline.lineCount !== candidate.lineCount) {
|
||||
const nearBoundary = isBodyNearBoundaryLineCountDifference(
|
||||
expectation,
|
||||
baseline,
|
||||
candidate,
|
||||
);
|
||||
issues.push({
|
||||
code: nearBoundary
|
||||
? "BODY_LINE_COUNT_NEAR_BOUNDARY"
|
||||
: "TEXT_BLOCK_LINE_COUNT_MISMATCH",
|
||||
severity: nearBoundary ? "warning" : "failure",
|
||||
message: nearBoundary
|
||||
? `第 ${expectation.index + 1} 个正文段落仅因末行边界产生一行差异`
|
||||
: `第 ${expectation.index + 1} 个${expectation.role === "title" ? "标题" : "段落"}行数不一致:基线 ${baseline.lineCount} 行,候选 ${candidate.lineCount} 行`,
|
||||
details: {
|
||||
paragraphIndex: expectation.index,
|
||||
role: expectation.role,
|
||||
baselineLineCount: baseline.lineCount,
|
||||
candidateLineCount: candidate.lineCount,
|
||||
...(nearBoundary
|
||||
? {
|
||||
trailingCharacters: trailingLineCharacterCount(
|
||||
baseline.lineCount > candidate.lineCount
|
||||
? baseline
|
||||
: candidate,
|
||||
),
|
||||
trailingRatio:
|
||||
trailingLineCharacterCount(
|
||||
baseline.lineCount > candidate.lineCount
|
||||
? baseline
|
||||
: candidate,
|
||||
) / baseline.expectedCharacterCount,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const maximumBreakDelta = Math.max(
|
||||
0,
|
||||
...baseline.lineBreakOffsets.map((offset, index) =>
|
||||
Math.abs(offset - (candidate.lineBreakOffsets[index] ?? offset)),
|
||||
),
|
||||
);
|
||||
if (maximumBreakDelta > LINE_BREAK_CHARACTER_TOLERANCE) {
|
||||
issues.push({
|
||||
code: "PARAGRAPH_LINE_BREAK_MISMATCH",
|
||||
severity: "failure",
|
||||
message: `第 ${expectation.index + 1} 个段落的行内换行边界偏差 ${maximumBreakDelta} 个字符`,
|
||||
details: {
|
||||
paragraphIndex: expectation.index,
|
||||
maximumBreakDelta,
|
||||
tolerance: LINE_BREAK_CHARACTER_TOLERANCE,
|
||||
baselineBreaks: baseline.lineBreakOffsets.join(","),
|
||||
candidateBreaks: candidate.lineBreakOffsets.join(","),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (expectation.section === "cover") {
|
||||
const baselineOnCover =
|
||||
baseline.pageNumbers.length === 1 && baseline.pageNumbers[0] === 1;
|
||||
const candidateOnCover =
|
||||
candidate.pageNumbers.length === 1 && candidate.pageNumbers[0] === 1;
|
||||
const verticalDelta = delta(
|
||||
baseline.firstLineBaselineYPt,
|
||||
candidate.firstLineBaselineYPt,
|
||||
);
|
||||
if (
|
||||
!baselineOnCover ||
|
||||
!candidateOnCover ||
|
||||
(verticalDelta ?? 0) > COVER_VERTICAL_TOLERANCE_PT
|
||||
) {
|
||||
issues.push({
|
||||
code: "COVER_LAYOUT_MISMATCH",
|
||||
severity: "failure",
|
||||
message: `第 ${expectation.index + 1} 个封面段落未保持独立封面页或纵向位置`,
|
||||
details: {
|
||||
paragraphIndex: expectation.index,
|
||||
baselinePages: baseline.pageNumbers.join(","),
|
||||
candidatePages: candidate.pageNumbers.join(","),
|
||||
verticalDeltaPt: verticalDelta ?? -1,
|
||||
tolerancePt: COVER_VERTICAL_TOLERANCE_PT,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (baseline.exclusiveGeometry && candidate.exclusiveGeometry) {
|
||||
const xDelta = delta(baseline.firstLineXPt, candidate.firstLineXPt);
|
||||
const xTolerancePt =
|
||||
expectation.section === "cover"
|
||||
? COVER_LINE_X_TOLERANCE_PT
|
||||
: LINE_X_TOLERANCE_PT;
|
||||
const widthDelta = delta(
|
||||
baseline.maximumLineWidthPt,
|
||||
candidate.maximumLineWidthPt,
|
||||
);
|
||||
const heightDelta = delta(
|
||||
baseline.averageLineHeightPt,
|
||||
candidate.averageLineHeightPt,
|
||||
);
|
||||
const baselineGapDelta = delta(
|
||||
baseline.averageBaselineGapPt,
|
||||
candidate.averageBaselineGapPt,
|
||||
);
|
||||
if (
|
||||
(xDelta ?? 0) > xTolerancePt ||
|
||||
(heightDelta ?? 0) > LINE_HEIGHT_TOLERANCE_PT ||
|
||||
(baselineGapDelta ?? 0) > BASELINE_GAP_TOLERANCE_PT
|
||||
) {
|
||||
issues.push({
|
||||
code: "PARAGRAPH_LINE_GEOMETRY_MISMATCH",
|
||||
severity: "failure",
|
||||
message: `第 ${expectation.index + 1} 个段落的行几何偏差超限`,
|
||||
details: {
|
||||
paragraphIndex: expectation.index,
|
||||
xDeltaPt: xDelta ?? 0,
|
||||
xTolerancePt,
|
||||
widthDeltaPt: widthDelta ?? 0,
|
||||
widthDeltaDiagnosticOnly: true,
|
||||
lineHeightDeltaPt: heightDelta ?? 0,
|
||||
baselineGapDeltaPt: baselineGapDelta ?? 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
function status(issues: readonly VisualDiffIssue[]) {
|
||||
return issues.some((issue) => issue.severity === "failure")
|
||||
? ("failed" as const)
|
||||
: issues.length > 0
|
||||
? ("warning" as const)
|
||||
: ("passed" as const);
|
||||
}
|
||||
|
||||
export function comparePdfEditableParagraphLayouts(
|
||||
baseline: PdfDocumentSnapshot,
|
||||
candidate: PdfDocumentSnapshot,
|
||||
expectations: readonly EditableParagraphExpectation[],
|
||||
baselinePageSemantics: readonly VisualPageSemanticExpectation[] = [],
|
||||
candidatePageSemantics: readonly VisualPageSemanticExpectation[] =
|
||||
baselinePageSemantics,
|
||||
): PdfParagraphLayoutComparison[] {
|
||||
const baselineLines = buildPdfEditableContentLines(
|
||||
baseline,
|
||||
baselinePageSemantics,
|
||||
);
|
||||
const candidateLines = buildPdfEditableContentLines(
|
||||
candidate,
|
||||
candidatePageSemantics,
|
||||
);
|
||||
const baselineCharacters = baselineLines.map((line) =>
|
||||
Array.from(line.normalizedText),
|
||||
);
|
||||
const candidateCharacters = candidateLines.map((line) =>
|
||||
Array.from(line.normalizedText),
|
||||
);
|
||||
const baselineCursor: MatchCursor = { lineIndex: 0, characterIndex: 0 };
|
||||
const candidateCursor: MatchCursor = { lineIndex: 0, characterIndex: 0 };
|
||||
|
||||
return expectations.map((expectation) => {
|
||||
const baselineObservation = observeParagraph(
|
||||
expectation,
|
||||
baselineLines,
|
||||
baselineCharacters,
|
||||
baselineCursor,
|
||||
);
|
||||
const candidateObservation = observeParagraph(
|
||||
expectation,
|
||||
candidateLines,
|
||||
candidateCharacters,
|
||||
candidateCursor,
|
||||
);
|
||||
const issues = compareParagraph(
|
||||
expectation,
|
||||
baselineObservation,
|
||||
candidateObservation,
|
||||
);
|
||||
return {
|
||||
expectation,
|
||||
baseline: baselineObservation,
|
||||
candidate: candidateObservation,
|
||||
status: status(issues),
|
||||
issues,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { createCanvas, loadImage } from "@napi-rs/canvas";
|
||||
|
||||
import type {
|
||||
PdfPageRaster,
|
||||
ComparePageRasterOptions,
|
||||
PdfRasterDiffArtifacts,
|
||||
PdfRasterDiffMetrics,
|
||||
PdfRasterDiffResult,
|
||||
@@ -29,7 +30,7 @@ async function decodeRaster(
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, width, height);
|
||||
context.drawImage(image, 0, 0);
|
||||
context.drawImage(image, 0, 0, width, height);
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
@@ -107,8 +108,6 @@ function encodeRgbaPng(
|
||||
}
|
||||
|
||||
function createArtifacts(
|
||||
baseline: PdfPageRaster,
|
||||
candidate: PdfPageRaster,
|
||||
baselineGray: Uint8Array,
|
||||
candidateGray: Uint8Array,
|
||||
pixelDifference: Uint8Array,
|
||||
@@ -142,23 +141,48 @@ function createArtifacts(
|
||||
}
|
||||
const overlayPng = encodeRgbaPng(overlay, width, height);
|
||||
const heatmapPng = encodeRgbaPng(heatmap, width, height);
|
||||
const baselinePng = encodeGrayscalePng(baselineGray, width, height);
|
||||
const candidatePng = encodeGrayscalePng(candidateGray, width, height);
|
||||
return {
|
||||
baselinePng: baseline.png,
|
||||
candidatePng: candidate.png,
|
||||
baselinePng,
|
||||
candidatePng,
|
||||
overlayPng,
|
||||
heatmapPng,
|
||||
baselineSha256: baseline.sha256,
|
||||
candidateSha256: candidate.sha256,
|
||||
baselineSha256: sha256(baselinePng),
|
||||
candidateSha256: sha256(candidatePng),
|
||||
overlaySha256: sha256(overlayPng),
|
||||
heatmapSha256: sha256(heatmapPng),
|
||||
};
|
||||
}
|
||||
|
||||
function encodeGrayscalePng(
|
||||
grayscale: Uint8Array,
|
||||
width: number,
|
||||
height: number,
|
||||
): Uint8Array {
|
||||
const rgba = new Uint8ClampedArray(width * height * 4);
|
||||
for (let index = 0; index < grayscale.length; index += 1) {
|
||||
const value = grayscale[index] ?? 255;
|
||||
const offset = index * 4;
|
||||
rgba[offset] = value;
|
||||
rgba[offset + 1] = value;
|
||||
rgba[offset + 2] = value;
|
||||
rgba[offset + 3] = 255;
|
||||
}
|
||||
return encodeRgbaPng(rgba, width, height);
|
||||
}
|
||||
|
||||
export async function comparePageRasters(
|
||||
baseline: PdfPageRaster,
|
||||
candidate: PdfPageRaster,
|
||||
pixelDifferenceThreshold = 8,
|
||||
options: number | ComparePageRasterOptions = {},
|
||||
): Promise<PdfRasterDiffResult> {
|
||||
const resolvedOptions =
|
||||
typeof options === "number"
|
||||
? { pixelDifferenceThreshold: options }
|
||||
: options;
|
||||
const pixelDifferenceThreshold =
|
||||
resolvedOptions.pixelDifferenceThreshold ?? 8;
|
||||
if (
|
||||
!Number.isInteger(pixelDifferenceThreshold) ||
|
||||
pixelDifferenceThreshold < 0 ||
|
||||
@@ -166,8 +190,16 @@ export async function comparePageRasters(
|
||||
) {
|
||||
throw new Error("像素变化阈值必须是 0 到 255 之间的整数");
|
||||
}
|
||||
const width = Math.max(baseline.widthPx, candidate.widthPx);
|
||||
const height = Math.max(baseline.heightPx, candidate.heightPx);
|
||||
const width =
|
||||
resolvedOptions.targetWidthPx ??
|
||||
Math.max(baseline.widthPx, candidate.widthPx);
|
||||
const height =
|
||||
resolvedOptions.targetHeightPx ??
|
||||
Math.max(baseline.heightPx, candidate.heightPx);
|
||||
if (!Number.isSafeInteger(width) || width <= 0 ||
|
||||
!Number.isSafeInteger(height) || height <= 0) {
|
||||
throw new Error("栅格比较尺寸必须是正整数");
|
||||
}
|
||||
const [baselineDecoded, candidateDecoded] = await Promise.all([
|
||||
decodeRaster(baseline, width, height),
|
||||
decodeRaster(candidate, width, height),
|
||||
@@ -230,11 +262,16 @@ export async function comparePageRasters(
|
||||
40,
|
||||
);
|
||||
const metrics: PdfRasterDiffMetrics = {
|
||||
baselineWidthPx: baseline.widthPx,
|
||||
baselineHeightPx: baseline.heightPx,
|
||||
candidateWidthPx: candidate.widthPx,
|
||||
candidateHeightPx: candidate.heightPx,
|
||||
comparedWidthPx: width,
|
||||
comparedHeightPx: height,
|
||||
dimensionsMatch:
|
||||
baseline.widthPx === candidate.widthPx &&
|
||||
baseline.heightPx === candidate.heightPx,
|
||||
geometryNormalized: resolvedOptions.geometryNormalized ?? false,
|
||||
meanAbsoluteError: absoluteError / (pixelCount * 3),
|
||||
changedPixelRatio: changedPixels / pixelCount,
|
||||
inkIou: calculateBinaryIou(baselineInk, candidateInk),
|
||||
@@ -243,8 +280,6 @@ export async function comparePageRasters(
|
||||
return {
|
||||
metrics,
|
||||
artifacts: createArtifacts(
|
||||
baseline,
|
||||
candidate,
|
||||
baselineGray,
|
||||
candidateGray,
|
||||
pixelDifference,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { comparePdfSnapshotsBasic, DEFAULT_VISUAL_DIFF_THRESHOLDS } from "./compare.js";
|
||||
import { comparePageRasters } from "./raster.js";
|
||||
import { comparePdfPageSemantics } from "./page-semantics.js";
|
||||
import type {
|
||||
CreatePdfVisualDiffOptions,
|
||||
PdfDocumentSnapshot,
|
||||
PdfPageDecorationObservation,
|
||||
PdfPagePair,
|
||||
PdfVisualDiffReport,
|
||||
PdfVisualPageComparison,
|
||||
@@ -29,13 +31,17 @@ function rasterIssues(
|
||||
candidatePageNumber: pair.candidatePageNumber,
|
||||
};
|
||||
const issues: VisualDiffIssue[] = [];
|
||||
if (!metrics.dimensionsMatch) {
|
||||
if (!metrics.dimensionsMatch && !metrics.geometryNormalized) {
|
||||
issues.push({
|
||||
code: "RASTER_SIZE_MISMATCH",
|
||||
severity: "failure",
|
||||
message: `第 ${pair.baselinePageNumber} 页栅格尺寸不一致`,
|
||||
...location,
|
||||
details: {
|
||||
baselineWidthPx: metrics.baselineWidthPx,
|
||||
baselineHeightPx: metrics.baselineHeightPx,
|
||||
candidateWidthPx: metrics.candidateWidthPx,
|
||||
candidateHeightPx: metrics.candidateHeightPx,
|
||||
comparedWidthPx: metrics.comparedWidthPx,
|
||||
comparedHeightPx: metrics.comparedHeightPx,
|
||||
},
|
||||
@@ -85,6 +91,7 @@ async function compareVisualPage(
|
||||
baseline: PdfDocumentSnapshot,
|
||||
candidate: PdfDocumentSnapshot,
|
||||
thresholds: VisualDiffThresholds,
|
||||
rasterComparisonMode: "strict" | "body-flow" = "strict",
|
||||
): Promise<PdfVisualPageComparison> {
|
||||
if (pair.status !== "paired") {
|
||||
const page =
|
||||
@@ -93,14 +100,27 @@ async function compareVisualPage(
|
||||
: candidate.pages[pair.candidatePageNumber - 1];
|
||||
return {
|
||||
pair,
|
||||
status: "failed",
|
||||
rasterComparisonMode,
|
||||
status: rasterComparisonMode === "body-flow" ? "warning" : "failed",
|
||||
...(page?.raster
|
||||
? {
|
||||
unpairedPng: page.raster.png,
|
||||
unpairedSha256: page.raster.sha256,
|
||||
}
|
||||
: {}),
|
||||
issues: [],
|
||||
issues:
|
||||
rasterComparisonMode === "body-flow"
|
||||
? [
|
||||
{
|
||||
code: "BODY_FLOW_RASTER_DIFFERENCE",
|
||||
severity: "warning",
|
||||
message: "正文自然分页产生未配对物理页,保留页面快照供审查",
|
||||
...(pair.status === "baseline-only"
|
||||
? { baselinePageNumber: pair.baselinePageNumber }
|
||||
: { candidatePageNumber: pair.candidatePageNumber }),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
const baselinePage = baseline.pages[pair.baselinePageNumber - 1];
|
||||
@@ -122,11 +142,48 @@ async function compareVisualPage(
|
||||
const result = await comparePageRasters(
|
||||
baselinePage.raster,
|
||||
candidatePage.raster,
|
||||
thresholds.pixelDifferenceThreshold,
|
||||
{
|
||||
pixelDifferenceThreshold: thresholds.pixelDifferenceThreshold,
|
||||
...(Math.abs(baselinePage.widthPt - candidatePage.widthPt) <=
|
||||
thresholds.pageSizeDeltaPt &&
|
||||
Math.abs(baselinePage.heightPt - candidatePage.heightPt) <=
|
||||
thresholds.pageSizeDeltaPt &&
|
||||
baselinePage.rotation === candidatePage.rotation
|
||||
? {
|
||||
targetWidthPx: baselinePage.raster.widthPx,
|
||||
targetHeightPx: baselinePage.raster.heightPx,
|
||||
geometryNormalized:
|
||||
baselinePage.raster.widthPx !== candidatePage.raster.widthPx ||
|
||||
baselinePage.raster.heightPx !== candidatePage.raster.heightPx,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
const issues = rasterIssues(pair, result.metrics, thresholds);
|
||||
const strictIssues = rasterIssues(pair, result.metrics, thresholds);
|
||||
const issues =
|
||||
rasterComparisonMode === "body-flow" && strictIssues.length > 0
|
||||
? [
|
||||
{
|
||||
code: "BODY_FLOW_RASTER_DIFFERENCE" as const,
|
||||
severity: "warning" as const,
|
||||
message: `第 ${pair.baselinePageNumber} 页栅格差异来自已验证的正文自然分页流动`,
|
||||
baselinePageNumber: pair.baselinePageNumber,
|
||||
candidatePageNumber: pair.candidatePageNumber,
|
||||
details: {
|
||||
strictIssueCodes: strictIssues
|
||||
.map((issue) => issue.code)
|
||||
.join(","),
|
||||
meanAbsoluteError: result.metrics.meanAbsoluteError,
|
||||
changedPixelRatio: result.metrics.changedPixelRatio,
|
||||
inkIou: result.metrics.inkIou,
|
||||
edgeIou: result.metrics.edgeIou,
|
||||
},
|
||||
},
|
||||
]
|
||||
: strictIssues;
|
||||
return {
|
||||
pair,
|
||||
rasterComparisonMode,
|
||||
status: resolveStatus(issues),
|
||||
metrics: result.metrics,
|
||||
artifacts: result.artifacts,
|
||||
@@ -143,12 +200,71 @@ export async function createPdfVisualDiffReport(
|
||||
...DEFAULT_VISUAL_DIFF_THRESHOLDS,
|
||||
...options.thresholds,
|
||||
};
|
||||
const basic = comparePdfSnapshotsBasic(baseline, candidate, thresholds);
|
||||
const baselinePageSemantics =
|
||||
options.baselinePageSemantics ?? options.pageSemantics ?? [];
|
||||
const candidatePageSemantics =
|
||||
options.candidatePageSemantics ?? options.pageSemantics ?? [];
|
||||
const basic = comparePdfSnapshotsBasic(baseline, candidate, thresholds, {
|
||||
...(options.expectedEditableText === undefined
|
||||
? {}
|
||||
: { expectedEditableText: options.expectedEditableText }),
|
||||
...(baselinePageSemantics.length === 0
|
||||
? {}
|
||||
: { baselinePageSemantics }),
|
||||
...(candidatePageSemantics.length === 0
|
||||
? {}
|
||||
: { candidatePageSemantics }),
|
||||
...(options.expectedEditableParagraphs === undefined
|
||||
? {}
|
||||
: { expectedEditableParagraphs: options.expectedEditableParagraphs }),
|
||||
});
|
||||
const pages: PdfVisualPageComparison[] = [];
|
||||
for (const pair of basic.pagePairs) {
|
||||
pages.push(
|
||||
await compareVisualPage(pair, baseline, candidate, thresholds),
|
||||
const baselineSemanticExpectation =
|
||||
pair.status === "candidate-only"
|
||||
? undefined
|
||||
: baselinePageSemantics.find(
|
||||
(item) => item.physicalPageNumber === pair.baselinePageNumber,
|
||||
);
|
||||
const candidateSemanticExpectation =
|
||||
pair.status === "baseline-only"
|
||||
? undefined
|
||||
: candidatePageSemantics.find(
|
||||
(item) => item.physicalPageNumber === pair.candidatePageNumber,
|
||||
);
|
||||
const bodyFlow = basic.bodyFlow;
|
||||
const flowAffected = Boolean(
|
||||
bodyFlow?.legal &&
|
||||
((pair.status !== "candidate-only" &&
|
||||
bodyFlow.affectedBaselinePageNumbers.includes(
|
||||
pair.baselinePageNumber,
|
||||
)) ||
|
||||
(pair.status !== "baseline-only" &&
|
||||
bodyFlow.affectedCandidatePageNumbers.includes(
|
||||
pair.candidatePageNumber,
|
||||
))),
|
||||
);
|
||||
const page = await compareVisualPage(
|
||||
pair,
|
||||
baseline,
|
||||
candidate,
|
||||
thresholds,
|
||||
flowAffected ? "body-flow" : "strict",
|
||||
);
|
||||
const primarySemanticExpectation =
|
||||
baselineSemanticExpectation ?? candidateSemanticExpectation;
|
||||
if (primarySemanticExpectation) {
|
||||
const semantics = comparePdfPageSemantics(
|
||||
baseline,
|
||||
candidate,
|
||||
primarySemanticExpectation,
|
||||
candidateSemanticExpectation ?? primarySemanticExpectation,
|
||||
);
|
||||
page.semantics = semantics;
|
||||
page.issues.push(...semantics.issues);
|
||||
page.status = resolveStatus(page.issues);
|
||||
}
|
||||
pages.push(page);
|
||||
}
|
||||
const issues = [
|
||||
...basic.issues,
|
||||
@@ -214,19 +330,80 @@ function renderArtifacts(page: PdfVisualPageComparison): string {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderSemanticObservation(
|
||||
label: string,
|
||||
observation: PdfPageDecorationObservation | undefined,
|
||||
): string {
|
||||
if (!observation) {
|
||||
return `<td>${escapeHtml(label)}:无页面</td>`;
|
||||
}
|
||||
return `<td><strong>${escapeHtml(label)}</strong><br>页码:${escapeHtml(observation.pageNumberText ?? "无")}<br>位置:${escapeHtml(observation.pageNumberAlignment ?? "无")}<br>匹配页眉槽:${observation.matchedHeaderSlots.length}<br>缺失页眉槽:${observation.missingHeaderSlots.length}</td>`;
|
||||
}
|
||||
|
||||
function renderSemantics(page: PdfVisualPageComparison): string {
|
||||
const semantics = page.semantics;
|
||||
if (!semantics) {
|
||||
return "";
|
||||
}
|
||||
const expected = semantics.expectation;
|
||||
const candidateExpected = semantics.candidateExpectation ?? expected;
|
||||
const kindLabels = {
|
||||
cover: "封面",
|
||||
"body-first": "正文首页",
|
||||
"body-rest": "正文后续页",
|
||||
} as const;
|
||||
return `<div class="semantics">
|
||||
<h3>页面语义 <span class="status ${semantics.status}">${semantics.status}</span></h3>
|
||||
<table><tbody>
|
||||
<tr><th>基线预期</th><td>物理第 ${expected.physicalPageNumber} 页;${kindLabels[expected.kind]};逻辑页 ${expected.logicalPageNumber ?? "—"} / ${expected.logicalPageCount};页眉 ${expected.headerVisible ? "显示" : "隐藏"};页码 ${expected.footerVisible ? `${escapeHtml(expected.pageNumberText ?? "显示")}(${escapeHtml(expected.footerAlignment ?? "未指定位置")})` : "隐藏"}</td></tr>
|
||||
<tr><th>候选预期</th><td>物理第 ${candidateExpected.physicalPageNumber} 页;${kindLabels[candidateExpected.kind]};逻辑页 ${candidateExpected.logicalPageNumber ?? "—"} / ${candidateExpected.logicalPageCount};页眉 ${candidateExpected.headerVisible ? "显示" : "隐藏"};页码 ${candidateExpected.footerVisible ? `${escapeHtml(candidateExpected.pageNumberText ?? "显示")}(${escapeHtml(candidateExpected.footerAlignment ?? "未指定位置")})` : "隐藏"}</td></tr>
|
||||
<tr><th>观测</th>${renderSemanticObservation("基线", semantics.baseline)}${renderSemanticObservation("候选", semantics.candidate)}</tr>
|
||||
</tbody></table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function pageTitle(page: PdfVisualPageComparison): string {
|
||||
const mode =
|
||||
page.rasterComparisonMode === "body-flow"
|
||||
? "(正文流动比较)"
|
||||
: "(严格比较)";
|
||||
if (page.pair.status === "paired") {
|
||||
return `基线第 ${page.pair.baselinePageNumber} 页 ↔ 候选第 ${page.pair.candidatePageNumber} 页`;
|
||||
return `基线第 ${page.pair.baselinePageNumber} 页 ↔ 候选第 ${page.pair.candidatePageNumber} 页${mode}`;
|
||||
}
|
||||
if (page.pair.status === "baseline-only") {
|
||||
return `基线第 ${page.pair.baselinePageNumber} 页无配对`;
|
||||
return `基线第 ${page.pair.baselinePageNumber} 页无配对${mode}`;
|
||||
}
|
||||
return `候选第 ${page.pair.candidatePageNumber} 页为溢出页`;
|
||||
return `候选第 ${page.pair.candidatePageNumber} 页为溢出页${mode}`;
|
||||
}
|
||||
|
||||
function renderParagraphLayouts(report: PdfVisualDiffReport): string {
|
||||
const paragraphs = report.basic.paragraphLayouts;
|
||||
if (!paragraphs) {
|
||||
return "";
|
||||
}
|
||||
const failures = paragraphs.filter(
|
||||
(paragraph) => paragraph.status === "failed",
|
||||
);
|
||||
const rows = (failures.length > 0 ? failures : paragraphs.slice(0, 8))
|
||||
.map((paragraph) => {
|
||||
const expectation = paragraph.expectation;
|
||||
return `<tr><td>${expectation.index + 1}</td><td>${escapeHtml(expectation.section)}</td><td>${escapeHtml(expectation.role)}</td><td>${escapeHtml(expectation.styleId ?? "—")}</td><td>${paragraph.baseline.lineCount}</td><td>${paragraph.candidate.lineCount}</td><td>${escapeHtml(paragraph.issues.map((issue) => issue.code).join(", ") || "通过")}</td></tr>`;
|
||||
})
|
||||
.join("");
|
||||
return `<div class="paragraph-layouts">
|
||||
<h2>内容感知行版式 <span class="status ${failures.length > 0 ? "failed" : "passed"}">${failures.length > 0 ? "failed" : "passed"}</span></h2>
|
||||
<p>段落 ${paragraphs.length} 个,失败 ${failures.length} 个;正文跨页移动不参与失败判定,封面页位置严格校验。</p>
|
||||
<table><thead><tr><th>段落</th><th>分节</th><th>角色</th><th>Word 样式</th><th>基线行数</th><th>候选行数</th><th>结果</th></tr></thead><tbody>${rows}</tbody></table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function renderPdfVisualDiffHtml(
|
||||
report: PdfVisualDiffReport,
|
||||
): string {
|
||||
const editableMetrics =
|
||||
report.basic.baselineEditableCoverage === undefined
|
||||
? ""
|
||||
: `<p><strong>Chromium 可编辑正文覆盖:</strong>${percentage(report.basic.baselineEditableCoverage)} <strong>候选可编辑正文相似度:</strong>${percentage(report.basic.candidateEditableSimilarity ?? 0)} <strong>候选精确匹配:</strong>${report.basic.candidateEditableExact ? "是" : "否"}</p>`;
|
||||
const issueRows = report.issues.length
|
||||
? report.issues
|
||||
.map(
|
||||
@@ -239,6 +416,7 @@ export function renderPdfVisualDiffHtml(
|
||||
.map(
|
||||
(page, index) => `<section class="page-card">
|
||||
<h2>${index + 1}. ${escapeHtml(pageTitle(page))} <span class="status ${page.status}">${page.status}</span></h2>
|
||||
${renderSemantics(page)}
|
||||
${renderMetrics(page)}
|
||||
${renderArtifacts(page)}
|
||||
</section>`,
|
||||
@@ -257,6 +435,7 @@ export function renderPdfVisualDiffHtml(
|
||||
.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}
|
||||
.semantics{margin:0 0 16px;padding:14px;background:#f7f9fc;border-radius:8px}.semantics h3{margin:0 0 10px;font-size:15px}.semantics th{width:90px}
|
||||
.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}
|
||||
@@ -270,6 +449,8 @@ export function renderPdfVisualDiffHtml(
|
||||
<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>
|
||||
${editableMetrics}
|
||||
${renderParagraphLayouts(report)}
|
||||
<table><thead><tr><th>级别</th><th>代码</th><th>说明</th></tr></thead><tbody>${issueRows}</tbody></table>
|
||||
</section>
|
||||
${pages}
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import type {
|
||||
PdfDocumentSnapshot,
|
||||
PdfEditableContentLine,
|
||||
PdfPointBounds,
|
||||
PdfTextItemSnapshot,
|
||||
PdfTextLineSnapshot,
|
||||
VisualPageSemanticExpectation,
|
||||
} from "./types.js";
|
||||
|
||||
const ZERO_WIDTH_AND_CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u200b-\u200d\u2060\ufeff]/gu;
|
||||
const CJK_RADICAL_VARIANTS = new Map([
|
||||
["⺠", "民"],
|
||||
["⻔", "门"],
|
||||
]);
|
||||
const PAGE_NUMBER_PATTERNS = [
|
||||
/^(?:[-—–]\s*)?\d+(?:\s*[//]\s*\d+)?(?:\s*[-—–])?$/u,
|
||||
/^第\s*\d+\s*页(?:\s*(?:[//]|共)\s*\d+\s*页)?$/u,
|
||||
@@ -14,6 +21,10 @@ const PAGE_NUMBER_PATTERNS = [
|
||||
export function normalizePdfText(value: string): string {
|
||||
return value
|
||||
.normalize("NFKC")
|
||||
.replace(/戶/gu, "户")
|
||||
.replace(/[\u2e80-\u2eff]/gu, (character) =>
|
||||
CJK_RADICAL_VARIANTS.get(character) ?? character
|
||||
)
|
||||
.replace(ZERO_WIDTH_AND_CONTROL, "")
|
||||
.replace(/\u00a0/gu, " ")
|
||||
.replace(/[ \t]+/gu, " ")
|
||||
@@ -24,6 +35,69 @@ export function normalizePdfContentText(value: string): string {
|
||||
return normalizePdfText(value).replace(/\s+/gu, "");
|
||||
}
|
||||
|
||||
export function normalizePdfEditableText(value: string): string {
|
||||
return normalizePdfContentText(value).replace(/[☐☑☒•◦▪\uf0b7]/gu, "");
|
||||
}
|
||||
|
||||
function isExpectedHeaderLine(
|
||||
line: PdfTextLineSnapshot,
|
||||
pageHeightPt: number,
|
||||
expectation: VisualPageSemanticExpectation | undefined,
|
||||
): boolean {
|
||||
if (
|
||||
!expectation?.headerVisible ||
|
||||
line.bounds.y + line.bounds.height / 2 > pageHeightPt * 0.12
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const lineText = normalizePdfEditableText(line.normalizedText);
|
||||
return (expectation.headerSlots ?? []).some((slot) => {
|
||||
const slotText = normalizePdfEditableText(slot.text);
|
||||
return slotText.length > 0 && lineText.includes(slotText);
|
||||
});
|
||||
}
|
||||
|
||||
function isInternalLayoutSpacerLine(line: PdfTextLineSnapshot): boolean {
|
||||
return (
|
||||
normalizePdfEditableText(line.normalizedText) === "." &&
|
||||
line.bounds.height <= 1.5 &&
|
||||
line.items.length === 1
|
||||
);
|
||||
}
|
||||
|
||||
export function buildPdfEditableContentText(
|
||||
snapshot: PdfDocumentSnapshot,
|
||||
pageSemantics: readonly VisualPageSemanticExpectation[] = [],
|
||||
): string {
|
||||
return buildPdfEditableContentLines(snapshot, pageSemantics)
|
||||
.map((item) => item.normalizedText)
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function buildPdfEditableContentLines(
|
||||
snapshot: PdfDocumentSnapshot,
|
||||
pageSemantics: readonly VisualPageSemanticExpectation[] = [],
|
||||
): PdfEditableContentLine[] {
|
||||
return snapshot.pages.flatMap((page) => {
|
||||
const expectation = pageSemantics.find(
|
||||
(item) => item.physicalPageNumber === page.pageNumber,
|
||||
);
|
||||
return page.lines.flatMap((line) => {
|
||||
if (
|
||||
line.role !== "content" ||
|
||||
isExpectedHeaderLine(line, page.heightPt, expectation) ||
|
||||
isInternalLayoutSpacerLine(line)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const normalizedText = normalizePdfEditableText(line.normalizedText);
|
||||
return normalizedText
|
||||
? [{ pageNumber: page.pageNumber, line, normalizedText }]
|
||||
: [];
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
@@ -33,6 +33,63 @@ export interface PdfTextLineSnapshot {
|
||||
items: PdfTextItemSnapshot[];
|
||||
}
|
||||
|
||||
export interface PdfEditableContentLine {
|
||||
pageNumber: number;
|
||||
line: PdfTextLineSnapshot;
|
||||
normalizedText: string;
|
||||
}
|
||||
|
||||
export type EditableParagraphRole =
|
||||
| "body"
|
||||
| "heading"
|
||||
| "title"
|
||||
| "caption"
|
||||
| "other";
|
||||
|
||||
export interface EditableParagraphExpectation {
|
||||
index: number;
|
||||
text: string;
|
||||
styleId?: string;
|
||||
role: EditableParagraphRole;
|
||||
section: "cover" | "body";
|
||||
}
|
||||
|
||||
export interface PdfParagraphLayoutObservation {
|
||||
matched: boolean;
|
||||
matchedCharacterCount: number;
|
||||
expectedCharacterCount: number;
|
||||
pageNumbers: number[];
|
||||
lineCount: number;
|
||||
lineTexts: string[];
|
||||
lineBreakOffsets: number[];
|
||||
firstLineXPt?: number;
|
||||
firstLineBaselineYPt?: number;
|
||||
maximumLineWidthPt?: number;
|
||||
averageLineHeightPt?: number;
|
||||
averageBaselineGapPt?: number;
|
||||
exclusiveGeometry: boolean;
|
||||
}
|
||||
|
||||
export interface PdfParagraphLayoutComparison {
|
||||
expectation: EditableParagraphExpectation;
|
||||
baseline: PdfParagraphLayoutObservation;
|
||||
candidate: PdfParagraphLayoutObservation;
|
||||
status: "passed" | "warning" | "failed";
|
||||
issues: VisualDiffIssue[];
|
||||
}
|
||||
|
||||
export interface PdfBodyFlowComparison {
|
||||
detected: boolean;
|
||||
legal: boolean;
|
||||
baselineCoverPageCount: number;
|
||||
candidateCoverPageCount: number;
|
||||
baselineBodyPageCount: number;
|
||||
candidateBodyPageCount: number;
|
||||
movedParagraphIndexes: number[];
|
||||
affectedBaselinePageNumbers: number[];
|
||||
affectedCandidatePageNumbers: number[];
|
||||
}
|
||||
|
||||
export interface PdfPageRaster {
|
||||
widthPx: number;
|
||||
heightPx: number;
|
||||
@@ -101,7 +158,22 @@ export type VisualDiffIssueCode =
|
||||
| "PIXEL_MAE_EXCEEDED"
|
||||
| "CHANGED_PIXEL_RATIO_EXCEEDED"
|
||||
| "INK_IOU_BELOW_THRESHOLD"
|
||||
| "EDGE_IOU_BELOW_THRESHOLD";
|
||||
| "EDGE_IOU_BELOW_THRESHOLD"
|
||||
| "PAGE_NUMBER_VISIBILITY_MISMATCH"
|
||||
| "PAGE_NUMBER_TEXT_MISMATCH"
|
||||
| "PAGE_NUMBER_ALIGNMENT_MISMATCH"
|
||||
| "PAGE_NUMBER_VERTICAL_POSITION_MISMATCH"
|
||||
| "HEADER_VISIBILITY_MISMATCH"
|
||||
| "HEADER_TEXT_MISSING"
|
||||
| "HEADER_VERTICAL_POSITION_MISMATCH"
|
||||
| "EDITABLE_PARAGRAPH_MAPPING_MISMATCH"
|
||||
| "TEXT_BLOCK_LINE_COUNT_MISMATCH"
|
||||
| "BODY_LINE_COUNT_NEAR_BOUNDARY"
|
||||
| "PARAGRAPH_LINE_BREAK_MISMATCH"
|
||||
| "PARAGRAPH_LINE_GEOMETRY_MISMATCH"
|
||||
| "COVER_LAYOUT_MISMATCH"
|
||||
| "BODY_FLOW_PAGE_COUNT_DIFFERENCE"
|
||||
| "BODY_FLOW_RASTER_DIFFERENCE";
|
||||
|
||||
export interface VisualDiffIssue {
|
||||
code: VisualDiffIssueCode;
|
||||
@@ -134,20 +206,37 @@ export interface PdfBasicComparison {
|
||||
candidate: PdfSnapshotSource;
|
||||
thresholds: VisualDiffThresholds;
|
||||
contentSimilarity: number;
|
||||
baselineEditableCoverage?: number;
|
||||
candidateEditableSimilarity?: number;
|
||||
candidateEditableExact?: boolean;
|
||||
paragraphLayouts?: PdfParagraphLayoutComparison[];
|
||||
bodyFlow?: PdfBodyFlowComparison;
|
||||
pagePairs: PdfPagePair[];
|
||||
issues: VisualDiffIssue[];
|
||||
}
|
||||
|
||||
export interface PdfRasterDiffMetrics {
|
||||
baselineWidthPx: number;
|
||||
baselineHeightPx: number;
|
||||
candidateWidthPx: number;
|
||||
candidateHeightPx: number;
|
||||
comparedWidthPx: number;
|
||||
comparedHeightPx: number;
|
||||
dimensionsMatch: boolean;
|
||||
geometryNormalized: boolean;
|
||||
meanAbsoluteError: number;
|
||||
changedPixelRatio: number;
|
||||
inkIou: number;
|
||||
edgeIou: number;
|
||||
}
|
||||
|
||||
export interface ComparePageRasterOptions {
|
||||
pixelDifferenceThreshold?: number;
|
||||
targetWidthPx?: number;
|
||||
targetHeightPx?: number;
|
||||
geometryNormalized?: boolean;
|
||||
}
|
||||
|
||||
export interface PdfRasterDiffArtifacts {
|
||||
baselinePng: Uint8Array;
|
||||
candidatePng: Uint8Array;
|
||||
@@ -166,11 +255,52 @@ export interface PdfRasterDiffResult {
|
||||
|
||||
export interface PdfVisualPageComparison {
|
||||
pair: PdfPagePair;
|
||||
rasterComparisonMode?: "strict" | "body-flow";
|
||||
status: "passed" | "warning" | "failed";
|
||||
metrics?: PdfRasterDiffMetrics;
|
||||
artifacts?: PdfRasterDiffArtifacts;
|
||||
unpairedPng?: Uint8Array;
|
||||
unpairedSha256?: string;
|
||||
semantics?: PdfPageSemanticComparison;
|
||||
issues: VisualDiffIssue[];
|
||||
}
|
||||
|
||||
export type VisualPageKind = "cover" | "body-first" | "body-rest";
|
||||
export type VisualPageAlignment = "left" | "center" | "right";
|
||||
|
||||
export interface VisualHeaderSlotExpectation {
|
||||
alignment: VisualPageAlignment;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface VisualPageSemanticExpectation {
|
||||
physicalPageNumber: number;
|
||||
kind: VisualPageKind;
|
||||
logicalPageNumber?: number;
|
||||
logicalPageCount: number;
|
||||
headerVisible: boolean;
|
||||
headerSlots?: VisualHeaderSlotExpectation[];
|
||||
footerVisible: boolean;
|
||||
footerAlignment?: VisualPageAlignment;
|
||||
pageNumberText?: string;
|
||||
}
|
||||
|
||||
export interface PdfPageDecorationObservation {
|
||||
pageNumberText?: string;
|
||||
pageNumberAlignment?: VisualPageAlignment;
|
||||
pageNumberBaselineY?: number;
|
||||
headerText?: string;
|
||||
headerBaselineY?: number;
|
||||
matchedHeaderSlots: VisualHeaderSlotExpectation[];
|
||||
missingHeaderSlots: VisualHeaderSlotExpectation[];
|
||||
}
|
||||
|
||||
export interface PdfPageSemanticComparison {
|
||||
expectation: VisualPageSemanticExpectation;
|
||||
candidateExpectation?: VisualPageSemanticExpectation;
|
||||
baseline?: PdfPageDecorationObservation;
|
||||
candidate?: PdfPageDecorationObservation;
|
||||
status: "passed" | "warning" | "failed";
|
||||
issues: VisualDiffIssue[];
|
||||
}
|
||||
|
||||
@@ -186,4 +316,17 @@ export interface PdfVisualDiffReport {
|
||||
export interface CreatePdfVisualDiffOptions {
|
||||
thresholds?: Partial<VisualDiffThresholds>;
|
||||
generatedAt?: string;
|
||||
pageSemantics?: readonly VisualPageSemanticExpectation[];
|
||||
baselinePageSemantics?: readonly VisualPageSemanticExpectation[];
|
||||
candidatePageSemantics?: readonly VisualPageSemanticExpectation[];
|
||||
expectedEditableText?: string;
|
||||
expectedEditableParagraphs?: readonly EditableParagraphExpectation[];
|
||||
}
|
||||
|
||||
export interface PdfEditableContentComparisonOptions {
|
||||
expectedEditableText?: string;
|
||||
pageSemantics?: readonly VisualPageSemanticExpectation[];
|
||||
baselinePageSemantics?: readonly VisualPageSemanticExpectation[];
|
||||
candidatePageSemantics?: readonly VisualPageSemanticExpectation[];
|
||||
expectedEditableParagraphs?: readonly EditableParagraphExpectation[];
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
createChromiumPdfAdapter,
|
||||
createWordDocxRoundTripAdapter,
|
||||
createWordPdfAdapter,
|
||||
createWpsDocxRoundTripAdapter,
|
||||
createWpsPdfAdapter,
|
||||
preparePdfAdapterRun,
|
||||
runPdfAdapterVisualDiff,
|
||||
@@ -58,6 +60,7 @@ describe("Office PDF 适配器", () => {
|
||||
progId: string;
|
||||
outputPath: string;
|
||||
}> = [];
|
||||
const roundTripCalls: string[] = [];
|
||||
const backend: OfficeAutomationBackend = {
|
||||
probe: async () => true,
|
||||
exportPdf: async (options) => {
|
||||
@@ -70,6 +73,22 @@ describe("Office PDF 适配器", () => {
|
||||
});
|
||||
return { bytes: pdf.byteLength, pageCount: 1 };
|
||||
},
|
||||
roundTripDocx: async (options) => {
|
||||
const docx = Buffer.from("round-trip-docx");
|
||||
await writeFile(options.outputPath, docx);
|
||||
roundTripCalls.push(options.client);
|
||||
return {
|
||||
bytes: docx.byteLength,
|
||||
before: [
|
||||
{ index: 1, widthPt: 100, heightPt: 50, type: 3 },
|
||||
],
|
||||
saved: [
|
||||
{ index: 1, widthPt: 90, heightPt: 45, type: 3 },
|
||||
],
|
||||
resizedShapeIndex: 1,
|
||||
resizeScale: options.resizeScale,
|
||||
};
|
||||
},
|
||||
};
|
||||
try {
|
||||
const word = createWordPdfAdapter({ backend });
|
||||
@@ -78,6 +97,15 @@ describe("Office PDF 适配器", () => {
|
||||
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");
|
||||
const wordRoundTrip = await createWordDocxRoundTripAdapter({
|
||||
backend,
|
||||
}).generate({ docxPath });
|
||||
const wpsRoundTrip = await createWpsDocxRoundTripAdapter({
|
||||
backend,
|
||||
}).generate({ docxPath });
|
||||
expect(wordRoundTrip.saved[0]?.widthPt).toBe(90);
|
||||
expect(wpsRoundTrip.saved[0]?.heightPt).toBe(45);
|
||||
expect(roundTripCalls).toEqual(["word", "wps"]);
|
||||
expect(calls.map(({ client, progId }) => ({ client, progId }))).toEqual([
|
||||
{ client: "word", progId: "Word.Application" },
|
||||
{ client: "wps", progId: "KWPS.Application" },
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
calculateContentSimilarity,
|
||||
comparePdfSnapshotsBasic,
|
||||
createPdfDocumentSnapshot,
|
||||
type PdfDocumentSnapshot,
|
||||
type PdfTextLineSnapshot,
|
||||
type VisualPageSemanticExpectation,
|
||||
} from "../src/index.js";
|
||||
import { createMinimalPdf } from "./pdf-fixture.js";
|
||||
|
||||
@@ -19,7 +22,105 @@ async function snapshot(
|
||||
});
|
||||
}
|
||||
|
||||
function textLine(
|
||||
text: string,
|
||||
y: number,
|
||||
role: PdfTextLineSnapshot["role"] = "content",
|
||||
): PdfTextLineSnapshot {
|
||||
return {
|
||||
text,
|
||||
normalizedText: text,
|
||||
bounds: { x: 72, y, width: 200, height: 12 },
|
||||
baselineY: y + 10,
|
||||
role,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
function withLines(
|
||||
source: PdfDocumentSnapshot,
|
||||
lines: PdfTextLineSnapshot[],
|
||||
): PdfDocumentSnapshot {
|
||||
const page = source.pages[0];
|
||||
if (!page) {
|
||||
throw new Error("测试快照缺少页面");
|
||||
}
|
||||
return {
|
||||
...source,
|
||||
contentText: lines.map((line) => line.normalizedText).join("\n"),
|
||||
pages: [{ ...page, lines }],
|
||||
};
|
||||
}
|
||||
|
||||
const pageSemantics: VisualPageSemanticExpectation[] = [
|
||||
{
|
||||
physicalPageNumber: 1,
|
||||
kind: "body-first",
|
||||
logicalPageNumber: 1,
|
||||
logicalPageCount: 1,
|
||||
headerVisible: true,
|
||||
headerSlots: [{ alignment: "left", text: "页眉左栏" }],
|
||||
footerVisible: true,
|
||||
footerAlignment: "center",
|
||||
pageNumberText: "1 / 1",
|
||||
},
|
||||
];
|
||||
|
||||
describe("PDF 基础视觉门禁", () => {
|
||||
it("将 PDF 字体映射产生的等价部首字形规范为正文汉字", () => {
|
||||
expect(
|
||||
calculateContentSimilarity(
|
||||
"示例市人⺠政府办公室",
|
||||
"示例市人民政府办公室",
|
||||
),
|
||||
).toBe(1);
|
||||
expect(calculateContentSimilarity("项目⻔户", "项目门户")).toBe(1);
|
||||
});
|
||||
|
||||
it("以 DOCX 可编辑正文为语义基准并容许 Chromium 媒体内部文本", async () => {
|
||||
const baseline = withLines(await snapshot("baseline"), [
|
||||
textLine("页眉左栏", 10),
|
||||
textLine("正文甲", 100),
|
||||
textLine("媒体内部标签", 150),
|
||||
textLine("正文乙", 200),
|
||||
textLine("1 / 1", 820, "page-number"),
|
||||
]);
|
||||
const candidate = withLines(await snapshot("candidate"), [
|
||||
textLine("页眉左栏", 10),
|
||||
textLine("正文甲☒", 100),
|
||||
textLine("正文乙", 200),
|
||||
textLine("1 / 1", 820, "page-number"),
|
||||
]);
|
||||
const result = comparePdfSnapshotsBasic(baseline, candidate, {}, {
|
||||
expectedEditableText: "正文甲正文乙",
|
||||
pageSemantics,
|
||||
});
|
||||
expect(result.status).toBe("passed");
|
||||
expect(result.baselineEditableCoverage).toBe(1);
|
||||
expect(result.candidateEditableSimilarity).toBe(1);
|
||||
expect(result.candidateEditableExact).toBe(true);
|
||||
});
|
||||
|
||||
it("候选缺少任一可编辑正文字符时严格失败", async () => {
|
||||
const baseline = withLines(await snapshot("baseline"), [
|
||||
textLine("正文甲正文乙", 100),
|
||||
]);
|
||||
const candidate = withLines(await snapshot("candidate"), [
|
||||
textLine("正文甲正文", 100),
|
||||
]);
|
||||
const result = comparePdfSnapshotsBasic(baseline, candidate, {}, {
|
||||
expectedEditableText: "正文甲正文乙",
|
||||
});
|
||||
expect(result.status).toBe("failed");
|
||||
expect(result.baselineEditableCoverage).toBe(1);
|
||||
expect(result.candidateEditableExact).toBe(false);
|
||||
expect(result.issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ code: "CONTENT_MISMATCH" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("通过纸张和正文一致的文档", async () => {
|
||||
const baseline = await snapshot("baseline");
|
||||
const candidate = await snapshot("candidate");
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
comparePdfEditableParagraphLayouts,
|
||||
type EditableParagraphExpectation,
|
||||
type PdfDocumentSnapshot,
|
||||
type PdfTextLineSnapshot,
|
||||
} from "../src/index.js";
|
||||
|
||||
function line(
|
||||
text: string,
|
||||
y: number,
|
||||
width = 120,
|
||||
x = 72,
|
||||
): PdfTextLineSnapshot {
|
||||
return {
|
||||
text,
|
||||
normalizedText: text,
|
||||
bounds: { x, y: y - 10, width, height: 12 },
|
||||
baselineY: y,
|
||||
role: "content",
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot(
|
||||
label: string,
|
||||
pages: PdfTextLineSnapshot[][],
|
||||
): PdfDocumentSnapshot {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
source: { kind: "custom", label },
|
||||
sha256: label.padEnd(64, "0").slice(0, 64),
|
||||
pageCount: pages.length,
|
||||
contentText: pages.flat().map((item) => item.normalizedText).join(""),
|
||||
pages: pages.map((lines, index) => ({
|
||||
pageNumber: index + 1,
|
||||
widthPt: 595,
|
||||
heightPt: 842,
|
||||
rotation: 0,
|
||||
items: [],
|
||||
lines,
|
||||
contentText: lines.map((item) => item.normalizedText).join(""),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function expectation(
|
||||
text: string,
|
||||
overrides: Partial<EditableParagraphExpectation> = {},
|
||||
): EditableParagraphExpectation {
|
||||
return {
|
||||
index: 0,
|
||||
text,
|
||||
role: "body",
|
||||
section: "body",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("PDF 内容感知段落版式门禁", () => {
|
||||
it("允许正文段落整体移动到下一物理页", () => {
|
||||
const baseline = snapshot("baseline", [
|
||||
[line("建立月度调度机制及时协调", 700), line("解决项目中的问题", 728)],
|
||||
[],
|
||||
]);
|
||||
const candidate = snapshot("candidate", [
|
||||
[],
|
||||
[line("建立月度调度机制及时协调", 100), line("解决项目中的问题", 128)],
|
||||
]);
|
||||
const [result] = comparePdfEditableParagraphLayouts(
|
||||
baseline,
|
||||
candidate,
|
||||
[expectation("建立月度调度机制及时协调解决项目中的问题")],
|
||||
);
|
||||
expect(result?.status).toBe("passed");
|
||||
expect(result?.baseline.pageNumbers).toEqual([1]);
|
||||
expect(result?.candidate.pageNumbers).toEqual([2]);
|
||||
});
|
||||
|
||||
it("拒绝标题从一行变成两行", () => {
|
||||
const baseline = snapshot("baseline", [
|
||||
[line("智慧园区一体化平台建设项目", 220, 250)],
|
||||
]);
|
||||
const candidate = snapshot("candidate", [
|
||||
[line("智慧园区一体化平台", 220, 190), line("建设项目", 250, 60)],
|
||||
]);
|
||||
const [result] = comparePdfEditableParagraphLayouts(
|
||||
baseline,
|
||||
candidate,
|
||||
[
|
||||
expectation("智慧园区一体化平台建设项目", {
|
||||
role: "title",
|
||||
section: "cover",
|
||||
styleId: "MdProjectReportProjectName",
|
||||
}),
|
||||
],
|
||||
);
|
||||
expect(result?.status).toBe("failed");
|
||||
expect(result?.issues.map((issue) => issue.code)).toContain(
|
||||
"TEXT_BLOCK_LINE_COUNT_MISMATCH",
|
||||
);
|
||||
});
|
||||
|
||||
it("允许长正文仅有极短末行的跨引擎换行差异", () => {
|
||||
const text =
|
||||
"数字化管理部门负责项目统筹技术审查过程监督和验收管理需求单位负责业务需求确认试运行和应用推广";
|
||||
const baseline = snapshot("baseline", [
|
||||
[line(text.slice(0, -2), 220, 680), line(text.slice(-2), 244, 28)],
|
||||
]);
|
||||
const candidate = snapshot("candidate", [[line(text, 220, 695)]]);
|
||||
const [result] = comparePdfEditableParagraphLayouts(
|
||||
baseline,
|
||||
candidate,
|
||||
[expectation(text)],
|
||||
);
|
||||
expect(result?.status).toBe("warning");
|
||||
expect(result?.issues).toEqual([
|
||||
expect.objectContaining({
|
||||
code: "BODY_LINE_COUNT_NEAR_BOUNDARY",
|
||||
severity: "warning",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("拒绝短正文或非极短末行的行数差异", () => {
|
||||
const text = "项目建设内容需要严格控制质量进度投资安全风险";
|
||||
const baseline = snapshot("baseline", [
|
||||
[line(text.slice(0, -5), 220), line(text.slice(-5), 244)],
|
||||
]);
|
||||
const candidate = snapshot("candidate", [[line(text, 220)]]);
|
||||
const [result] = comparePdfEditableParagraphLayouts(
|
||||
baseline,
|
||||
candidate,
|
||||
[expectation(text)],
|
||||
);
|
||||
expect(result?.status).toBe("failed");
|
||||
expect(result?.issues.map((issue) => issue.code)).toContain(
|
||||
"TEXT_BLOCK_LINE_COUNT_MISMATCH",
|
||||
);
|
||||
});
|
||||
|
||||
it("拒绝封面内容漂移到正文页", () => {
|
||||
const baseline = snapshot("baseline", [
|
||||
[line("可行性研究报告", 300, 180)],
|
||||
[],
|
||||
]);
|
||||
const candidate = snapshot("candidate", [
|
||||
[],
|
||||
[line("可行性研究报告", 100, 180)],
|
||||
]);
|
||||
const [result] = comparePdfEditableParagraphLayouts(
|
||||
baseline,
|
||||
candidate,
|
||||
[
|
||||
expectation("可行性研究报告", {
|
||||
role: "title",
|
||||
section: "cover",
|
||||
styleId: "MdProjectReportTitle",
|
||||
}),
|
||||
],
|
||||
);
|
||||
expect(result?.status).toBe("failed");
|
||||
expect(result?.issues.map((issue) => issue.code)).toContain(
|
||||
"COVER_LAYOUT_MISMATCH",
|
||||
);
|
||||
});
|
||||
|
||||
it("忽略 PDF 文本提取器失真的单行宽度但保留诊断值", () => {
|
||||
const baseline = snapshot("baseline", [
|
||||
[line("数据治理平台建设项目", 300, 224)],
|
||||
]);
|
||||
const candidate = snapshot("candidate", [
|
||||
[line("数据治理平台建设项目", 300, 210)],
|
||||
]);
|
||||
const [result] = comparePdfEditableParagraphLayouts(
|
||||
baseline,
|
||||
candidate,
|
||||
[
|
||||
expectation("数据治理平台建设项目", {
|
||||
role: "title",
|
||||
section: "cover",
|
||||
styleId: "MdProjectReportTitle",
|
||||
}),
|
||||
],
|
||||
);
|
||||
expect(result?.status).toBe("passed");
|
||||
expect(result?.baseline.maximumLineWidthPt).toBe(224);
|
||||
expect(result?.candidate.maximumLineWidthPt).toBe(210);
|
||||
});
|
||||
|
||||
it("允许封面字形基线在 3pt 内波动", () => {
|
||||
const baseline = snapshot("baseline", [
|
||||
[line("数据治理平台建设项目", 300, 224)],
|
||||
]);
|
||||
const candidate = snapshot("candidate", [
|
||||
[line("数据治理平台建设项目", 302.5, 224)],
|
||||
]);
|
||||
const [result] = comparePdfEditableParagraphLayouts(
|
||||
baseline,
|
||||
candidate,
|
||||
[
|
||||
expectation("数据治理平台建设项目", {
|
||||
role: "title",
|
||||
section: "cover",
|
||||
styleId: "MdProjectReportTitle",
|
||||
}),
|
||||
],
|
||||
);
|
||||
expect(result?.status).toBe("passed");
|
||||
});
|
||||
|
||||
it("拒绝封面字形基线偏移超过 6pt", () => {
|
||||
const baseline = snapshot("baseline", [
|
||||
[line("数据治理平台建设项目", 300, 224)],
|
||||
]);
|
||||
const candidate = snapshot("candidate", [
|
||||
[line("数据治理平台建设项目", 306.1, 224)],
|
||||
]);
|
||||
const [result] = comparePdfEditableParagraphLayouts(
|
||||
baseline,
|
||||
candidate,
|
||||
[
|
||||
expectation("数据治理平台建设项目", {
|
||||
role: "title",
|
||||
section: "cover",
|
||||
styleId: "MdProjectReportTitle",
|
||||
}),
|
||||
],
|
||||
);
|
||||
expect(result?.status).toBe("failed");
|
||||
expect(result?.issues.map((issue) => issue.code)).toContain(
|
||||
"COVER_LAYOUT_MISMATCH",
|
||||
);
|
||||
});
|
||||
|
||||
it("允许封面文本提取框横向偏差在 3pt 内波动", () => {
|
||||
const baseline = snapshot("baseline", [
|
||||
[line("数据治理平台建设项目", 300, 224, 198)],
|
||||
]);
|
||||
const candidate = snapshot("candidate", [
|
||||
[line("数据治理平台建设项目", 300, 224, 200.5)],
|
||||
]);
|
||||
const [result] = comparePdfEditableParagraphLayouts(
|
||||
baseline,
|
||||
candidate,
|
||||
[
|
||||
expectation("数据治理平台建设项目", {
|
||||
role: "title",
|
||||
section: "cover",
|
||||
styleId: "MdProjectReportTitle",
|
||||
}),
|
||||
],
|
||||
);
|
||||
expect(result?.status).toBe("passed");
|
||||
});
|
||||
|
||||
it("正文文本横向偏差仍严格限制为 2pt", () => {
|
||||
const baseline = snapshot("baseline", [
|
||||
[line("项目建设内容", 300, 120, 72)],
|
||||
]);
|
||||
const candidate = snapshot("candidate", [
|
||||
[line("项目建设内容", 300, 120, 74.5)],
|
||||
]);
|
||||
const [result] = comparePdfEditableParagraphLayouts(
|
||||
baseline,
|
||||
candidate,
|
||||
[expectation("项目建设内容")],
|
||||
);
|
||||
expect(result?.status).toBe("failed");
|
||||
expect(result?.issues.map((issue) => issue.code)).toContain(
|
||||
"PARAGRAPH_LINE_GEOMETRY_MISMATCH",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,23 @@ function raster(rectangleX: number): PdfPageRaster {
|
||||
};
|
||||
}
|
||||
|
||||
function differentlySizedRaster(width: number, height: number): PdfPageRaster {
|
||||
const canvas = createCanvas(width, height);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, width, height);
|
||||
context.fillStyle = "#111111";
|
||||
context.fillRect(width * 0.25, height * 0.2, width * 0.25, height * 0.4);
|
||||
const png = Uint8Array.from(canvas.toBuffer("image/png"));
|
||||
return {
|
||||
widthPx: width,
|
||||
heightPx: height,
|
||||
dpi: 144,
|
||||
sha256: createHash("sha256").update(png).digest("hex"),
|
||||
png,
|
||||
};
|
||||
}
|
||||
|
||||
describe("PDF 栅格差异", () => {
|
||||
it("相同页面的全部指标为无差异", async () => {
|
||||
const page = raster(10);
|
||||
@@ -50,4 +67,24 @@ describe("PDF 栅格差异", () => {
|
||||
expect(result.artifacts.overlaySha256).toHaveLength(64);
|
||||
expect(result.artifacts.heatmapSha256).toHaveLength(64);
|
||||
});
|
||||
|
||||
it("可按物理页面基线规范化一像素的栅格舍入差", async () => {
|
||||
const result = await comparePageRasters(
|
||||
differentlySizedRaster(80, 100),
|
||||
differentlySizedRaster(81, 101),
|
||||
{
|
||||
targetWidthPx: 80,
|
||||
targetHeightPx: 100,
|
||||
geometryNormalized: true,
|
||||
},
|
||||
);
|
||||
expect(result.metrics).toMatchObject({
|
||||
baselineWidthPx: 80,
|
||||
candidateWidthPx: 81,
|
||||
comparedWidthPx: 80,
|
||||
dimensionsMatch: false,
|
||||
geometryNormalized: true,
|
||||
});
|
||||
expect(result.metrics.meanAbsoluteError).toBeLessThan(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
serializePdfVisualDiffJson,
|
||||
type PdfDocumentSnapshot,
|
||||
type PdfPageRaster,
|
||||
type PdfTextLineSnapshot,
|
||||
type VisualPageSemanticExpectation,
|
||||
} from "../src/index.js";
|
||||
|
||||
function raster(color: string): PdfPageRaster {
|
||||
@@ -50,6 +52,57 @@ function snapshot(label: string, pageRaster: PdfPageRaster): PdfDocumentSnapshot
|
||||
};
|
||||
}
|
||||
|
||||
function contentLine(text: string, y: number): PdfTextLineSnapshot {
|
||||
return {
|
||||
text,
|
||||
normalizedText: text,
|
||||
bounds: { x: 4, y, width: 8, height: 2 },
|
||||
baselineY: y + 2,
|
||||
role: "content",
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
function flowSnapshot(
|
||||
label: string,
|
||||
pageLines: readonly (readonly PdfTextLineSnapshot[])[],
|
||||
color: string,
|
||||
): PdfDocumentSnapshot {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
source: { kind: "custom", label },
|
||||
sha256: label.padEnd(64, "0").slice(0, 64),
|
||||
pageCount: pageLines.length,
|
||||
contentText: pageLines.flat().map((line) => line.normalizedText).join(""),
|
||||
pages: pageLines.map((lines, index) => ({
|
||||
pageNumber: index + 1,
|
||||
widthPt: 20,
|
||||
heightPt: 25,
|
||||
rotation: 0,
|
||||
items: [],
|
||||
lines: [...lines],
|
||||
contentText: lines.map((line) => line.normalizedText).join(""),
|
||||
raster: raster(color),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function bodySemantics(pageCount: number): VisualPageSemanticExpectation[] {
|
||||
return Array.from({ length: pageCount }, (_, index) => ({
|
||||
physicalPageNumber: index + 1,
|
||||
kind: index === 0 ? "body-first" : "body-rest",
|
||||
logicalPageNumber: index + 1,
|
||||
logicalPageCount: pageCount,
|
||||
headerVisible: false,
|
||||
footerVisible: false,
|
||||
}));
|
||||
}
|
||||
|
||||
const flowParagraphs = [
|
||||
{ index: 0, text: "甲", role: "body" as const, section: "body" as const },
|
||||
{ index: 1, text: "乙丙", role: "body" as const, section: "body" as const },
|
||||
];
|
||||
|
||||
describe("PDF 视觉差异报告", () => {
|
||||
it("汇总栅格门禁并生成内嵌图片的安全 HTML", async () => {
|
||||
const report = await createPdfVisualDiffReport(
|
||||
@@ -97,4 +150,352 @@ describe("PDF 视觉差异报告", () => {
|
||||
expect(overflow?.unpairedPng?.byteLength).toBeGreaterThan(0);
|
||||
expect(renderPdfVisualDiffHtml(report)).toContain("未配对页面");
|
||||
});
|
||||
|
||||
it("纸张物理尺寸容差内不因一像素舍入差重复失败", async () => {
|
||||
const baseline = snapshot("基线", raster("#111111"));
|
||||
const candidate = snapshot("候选", {
|
||||
...raster("#111111"),
|
||||
widthPx: 41,
|
||||
});
|
||||
candidate.pages[0]!.widthPt = baseline.pages[0]!.widthPt + 0.36;
|
||||
const report = await createPdfVisualDiffReport(baseline, candidate, {
|
||||
thresholds: {
|
||||
maxMeanAbsoluteError: 255,
|
||||
maxChangedPixelRatio: 1,
|
||||
minInkIou: 0,
|
||||
minEdgeIou: 0,
|
||||
},
|
||||
});
|
||||
expect(report.pages[0]?.metrics).toMatchObject({
|
||||
dimensionsMatch: false,
|
||||
geometryNormalized: true,
|
||||
comparedWidthPx: 40,
|
||||
});
|
||||
expect(report.issues.map((issue) => issue.code)).not.toContain(
|
||||
"RASTER_SIZE_MISMATCH",
|
||||
);
|
||||
});
|
||||
|
||||
it("展示并校验逐页逻辑页码和位置", async () => {
|
||||
const baseline = snapshot("Chromium", raster("#111111"));
|
||||
const candidate = snapshot("Word", raster("#111111"));
|
||||
const pageNumberLine = (x: number) => ({
|
||||
text: "— 1 —",
|
||||
normalizedText: "— 1 —",
|
||||
bounds: { x, y: 22, width: 2, height: 1 },
|
||||
baselineY: 23,
|
||||
role: "page-number" as const,
|
||||
items: [],
|
||||
});
|
||||
baseline.pages[0]!.lines = [pageNumberLine(17)];
|
||||
baseline.pages[0]!.pageNumberText = "— 1 —";
|
||||
candidate.pages[0]!.lines = [pageNumberLine(9)];
|
||||
candidate.pages[0]!.pageNumberText = "— 1 —";
|
||||
|
||||
const report = await createPdfVisualDiffReport(baseline, candidate, {
|
||||
pageSemantics: [
|
||||
{
|
||||
physicalPageNumber: 1,
|
||||
kind: "body-first",
|
||||
logicalPageNumber: 1,
|
||||
logicalPageCount: 1,
|
||||
headerVisible: false,
|
||||
footerVisible: true,
|
||||
footerAlignment: "right",
|
||||
pageNumberText: "— 1 —",
|
||||
},
|
||||
],
|
||||
});
|
||||
const html = renderPdfVisualDiffHtml(report);
|
||||
|
||||
expect(report.status).toBe("failed");
|
||||
expect(report.pages[0]?.semantics?.candidate).toMatchObject({
|
||||
pageNumberAlignment: "center",
|
||||
});
|
||||
expect(report.issues.map((issue) => issue.code)).toContain(
|
||||
"PAGE_NUMBER_ALIGNMENT_MISMATCH",
|
||||
);
|
||||
expect(html).toContain("页面语义");
|
||||
expect(html).toContain("正文首页");
|
||||
expect(html).toContain("逻辑页 1 / 1");
|
||||
});
|
||||
|
||||
it("拒绝本应隐藏却仍然出现的页眉", async () => {
|
||||
const baseline = snapshot("Chromium", raster("#111111"));
|
||||
const candidate = snapshot("Word", raster("#111111"));
|
||||
const headerLine = {
|
||||
text: "左页眉",
|
||||
normalizedText: "左页眉",
|
||||
bounds: { x: 2, y: 1, width: 4, height: 1 },
|
||||
baselineY: 2,
|
||||
role: "content" as const,
|
||||
items: [],
|
||||
};
|
||||
baseline.pages[0]!.lines = [headerLine];
|
||||
candidate.pages[0]!.lines = [headerLine];
|
||||
|
||||
const report = await createPdfVisualDiffReport(baseline, candidate, {
|
||||
pageSemantics: [
|
||||
{
|
||||
physicalPageNumber: 1,
|
||||
kind: "cover",
|
||||
logicalPageCount: 1,
|
||||
headerVisible: false,
|
||||
headerSlots: [{ alignment: "left", text: "左页眉" }],
|
||||
footerVisible: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(report.status).toBe("failed");
|
||||
expect(report.issues.map((issue) => issue.code)).toContain(
|
||||
"HEADER_VISIBILITY_MISMATCH",
|
||||
);
|
||||
});
|
||||
|
||||
it("拒绝页眉和页码纵向基线偏差超过 2pt", async () => {
|
||||
const baseline = snapshot("Chromium", raster("#111111"));
|
||||
const candidate = snapshot("Word", raster("#111111"));
|
||||
baseline.pages[0]!.heightPt = 100;
|
||||
candidate.pages[0]!.heightPt = 100;
|
||||
const lines = (headerY: number, footerY: number) => [
|
||||
{
|
||||
text: "左页眉",
|
||||
normalizedText: "左页眉",
|
||||
bounds: { x: 2, y: headerY - 1, width: 4, height: 1 },
|
||||
baselineY: headerY,
|
||||
role: "content" as const,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
text: "1 / 1",
|
||||
normalizedText: "1 / 1",
|
||||
bounds: { x: 9, y: footerY - 1, width: 2, height: 1 },
|
||||
baselineY: footerY,
|
||||
role: "page-number" as const,
|
||||
items: [],
|
||||
},
|
||||
];
|
||||
baseline.pages[0]!.lines = lines(2, 95);
|
||||
candidate.pages[0]!.lines = lines(5, 92);
|
||||
|
||||
const report = await createPdfVisualDiffReport(baseline, candidate, {
|
||||
pageSemantics: [
|
||||
{
|
||||
physicalPageNumber: 1,
|
||||
kind: "body-first",
|
||||
logicalPageNumber: 1,
|
||||
logicalPageCount: 1,
|
||||
headerVisible: true,
|
||||
headerSlots: [{ alignment: "left", text: "左页眉" }],
|
||||
footerVisible: true,
|
||||
footerAlignment: "center",
|
||||
pageNumberText: "1 / 1",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(report.issues.map((issue) => issue.code)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"HEADER_VERTICAL_POSITION_MISMATCH",
|
||||
"PAGE_NUMBER_VERTICAL_POSITION_MISMATCH",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("按文档锚点与页内相对偏移校验页码并容纳 Chromium 自身逐页抖动", async () => {
|
||||
const createTwoPageSnapshot = (
|
||||
label: string,
|
||||
baselines: readonly [number, number],
|
||||
): PdfDocumentSnapshot => {
|
||||
const base = snapshot(label, raster("#111111"));
|
||||
base.pageCount = 2;
|
||||
base.pages = baselines.map((baselineY, index) => ({
|
||||
...base.pages[0]!,
|
||||
pageNumber: index + 1,
|
||||
heightPt: 100,
|
||||
lines: [
|
||||
{
|
||||
text: `${index + 1} / 2`,
|
||||
normalizedText: `${index + 1} / 2`,
|
||||
bounds: { x: 9, y: baselineY - 1, width: 2, height: 1 },
|
||||
baselineY,
|
||||
role: "page-number" as const,
|
||||
items: [],
|
||||
},
|
||||
],
|
||||
}));
|
||||
return base;
|
||||
};
|
||||
const baseline = createTwoPageSnapshot("Chromium", [95, 93.5]);
|
||||
const candidate = createTwoPageSnapshot("Word", [95.8, 95.8]);
|
||||
const semantics: VisualPageSemanticExpectation[] = [1, 2].map(
|
||||
(pageNumber) => ({
|
||||
physicalPageNumber: pageNumber,
|
||||
kind: pageNumber === 1 ? "body-first" : "body-rest",
|
||||
logicalPageNumber: pageNumber,
|
||||
logicalPageCount: 2,
|
||||
headerVisible: false,
|
||||
footerVisible: true,
|
||||
footerAlignment: "center",
|
||||
pageNumberText: `${pageNumber} / 2`,
|
||||
}),
|
||||
);
|
||||
|
||||
const report = await createPdfVisualDiffReport(baseline, candidate, {
|
||||
pageSemantics: semantics,
|
||||
});
|
||||
|
||||
expect(report.issues.map((issue) => issue.code)).not.toContain(
|
||||
"PAGE_NUMBER_VERTICAL_POSITION_MISMATCH",
|
||||
);
|
||||
});
|
||||
|
||||
it("将格式一致的整段跨页栅格差异标记为正文流动警告", async () => {
|
||||
const baseline = flowSnapshot(
|
||||
"baseline",
|
||||
[[contentLine("甲", 8)], [contentLine("乙丙", 8)]],
|
||||
"#111111",
|
||||
);
|
||||
const candidate = flowSnapshot(
|
||||
"candidate",
|
||||
[[contentLine("甲", 8), contentLine("乙丙", 12)], []],
|
||||
"#cc0000",
|
||||
);
|
||||
const report = await createPdfVisualDiffReport(baseline, candidate, {
|
||||
expectedEditableText: "甲乙丙",
|
||||
expectedEditableParagraphs: flowParagraphs,
|
||||
baselinePageSemantics: bodySemantics(2),
|
||||
candidatePageSemantics: bodySemantics(2),
|
||||
});
|
||||
|
||||
expect(report.status).toBe("warning");
|
||||
expect(report.basic.bodyFlow).toMatchObject({
|
||||
detected: true,
|
||||
legal: true,
|
||||
movedParagraphIndexes: [1],
|
||||
});
|
||||
expect(report.pages.map((page) => page.rasterComparisonMode)).toEqual([
|
||||
"body-flow",
|
||||
"body-flow",
|
||||
]);
|
||||
expect(report.issues.map((issue) => issue.code)).toContain(
|
||||
"BODY_FLOW_RASTER_DIFFERENCE",
|
||||
);
|
||||
expect(report.issues.map((issue) => issue.code)).not.toContain(
|
||||
"INK_IOU_BELOW_THRESHOLD",
|
||||
);
|
||||
});
|
||||
|
||||
it("整段跨页同时发生换行变化时仍保持严格失败", async () => {
|
||||
const baseline = flowSnapshot(
|
||||
"baseline",
|
||||
[[contentLine("甲", 8)], [contentLine("乙丙", 8)]],
|
||||
"#111111",
|
||||
);
|
||||
const candidate = flowSnapshot(
|
||||
"candidate",
|
||||
[
|
||||
[contentLine("甲", 8), contentLine("乙", 12), contentLine("丙", 16)],
|
||||
[],
|
||||
],
|
||||
"#cc0000",
|
||||
);
|
||||
const report = await createPdfVisualDiffReport(baseline, candidate, {
|
||||
expectedEditableText: "甲乙丙",
|
||||
expectedEditableParagraphs: flowParagraphs,
|
||||
baselinePageSemantics: bodySemantics(2),
|
||||
candidatePageSemantics: bodySemantics(2),
|
||||
});
|
||||
|
||||
expect(report.status).toBe("failed");
|
||||
expect(report.basic.bodyFlow).toMatchObject({ detected: true, legal: false });
|
||||
expect(report.pages.every(
|
||||
(page) => page.rasterComparisonMode === "strict",
|
||||
)).toBe(true);
|
||||
expect(report.issues.map((issue) => issue.code)).toContain(
|
||||
"TEXT_BLOCK_LINE_COUNT_MISMATCH",
|
||||
);
|
||||
});
|
||||
|
||||
it("允许格式一致的正文自然分页产生物理页数差异", async () => {
|
||||
const baseline = flowSnapshot(
|
||||
"baseline",
|
||||
[[contentLine("甲", 8)], [contentLine("乙丙", 8)]],
|
||||
"#111111",
|
||||
);
|
||||
const candidate = flowSnapshot(
|
||||
"candidate",
|
||||
[[contentLine("甲", 8), contentLine("乙丙", 12)]],
|
||||
"#cc0000",
|
||||
);
|
||||
const report = await createPdfVisualDiffReport(baseline, candidate, {
|
||||
expectedEditableText: "甲乙丙",
|
||||
expectedEditableParagraphs: flowParagraphs,
|
||||
baselinePageSemantics: bodySemantics(2),
|
||||
candidatePageSemantics: bodySemantics(1),
|
||||
});
|
||||
|
||||
expect(report.status).toBe("warning");
|
||||
expect(report.basic.bodyFlow).toMatchObject({
|
||||
legal: true,
|
||||
baselineBodyPageCount: 2,
|
||||
candidateBodyPageCount: 1,
|
||||
});
|
||||
expect(report.issues.map((issue) => issue.code)).toContain(
|
||||
"BODY_FLOW_PAGE_COUNT_DIFFERENCE",
|
||||
);
|
||||
expect(report.pages[1]).toMatchObject({
|
||||
rasterComparisonMode: "body-flow",
|
||||
status: "warning",
|
||||
});
|
||||
});
|
||||
|
||||
it("新增正文页缺少预期页码时不得认定为合法分页流动", async () => {
|
||||
const pageNumber = (text: string): PdfTextLineSnapshot => ({
|
||||
text,
|
||||
normalizedText: text,
|
||||
bounds: { x: 9, y: 22, width: 2, height: 1 },
|
||||
baselineY: 23,
|
||||
role: "page-number",
|
||||
items: [],
|
||||
});
|
||||
const baseline = flowSnapshot(
|
||||
"baseline",
|
||||
[[contentLine("甲", 8), contentLine("乙丙", 12), pageNumber("1 / 1")]],
|
||||
"#111111",
|
||||
);
|
||||
const candidate = flowSnapshot(
|
||||
"candidate",
|
||||
[
|
||||
[contentLine("甲", 8), pageNumber("1 / 2")],
|
||||
[contentLine("乙丙", 8)],
|
||||
],
|
||||
"#cc0000",
|
||||
);
|
||||
const baselineSemantics = bodySemantics(1).map((item) => ({
|
||||
...item,
|
||||
footerVisible: true,
|
||||
footerAlignment: "center" as const,
|
||||
pageNumberText: "1 / 1",
|
||||
}));
|
||||
const candidateSemantics = bodySemantics(2).map((item, index) => ({
|
||||
...item,
|
||||
footerVisible: true,
|
||||
footerAlignment: "center" as const,
|
||||
pageNumberText: `${index + 1} / 2`,
|
||||
}));
|
||||
const report = await createPdfVisualDiffReport(baseline, candidate, {
|
||||
expectedEditableText: "甲乙丙",
|
||||
expectedEditableParagraphs: flowParagraphs,
|
||||
baselinePageSemantics: baselineSemantics,
|
||||
candidatePageSemantics: candidateSemantics,
|
||||
});
|
||||
|
||||
expect(report.status).toBe("failed");
|
||||
expect(report.basic.bodyFlow).toMatchObject({ detected: true, legal: false });
|
||||
expect(report.issues.map((issue) => issue.code)).toContain(
|
||||
"PAGE_NUMBER_VISIBILITY_MISMATCH",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
aggregatePdfTextLines,
|
||||
buildPageContentText,
|
||||
normalizePdfEditableText,
|
||||
normalizePdfText,
|
||||
type PdfTextItemSnapshot,
|
||||
} from "../src/index.js";
|
||||
@@ -39,6 +40,16 @@ describe("PDF 文本行聚合", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("将字体 ToUnicode 中的传统户部件归一为简体字符", () => {
|
||||
expect(normalizePdfText("戶⼾")).toBe("户户");
|
||||
});
|
||||
|
||||
it("从可编辑正文契约中移除 Word 自动列表装饰符", () => {
|
||||
expect(normalizePdfEditableText("• 项目一 ◦ 子项 ▪ 末项 WPS")).toBe(
|
||||
"项目一子项末项WPS"
|
||||
);
|
||||
});
|
||||
|
||||
it("只在页眉页脚坐标带识别独立页码", () => {
|
||||
const lines = aggregatePdfTextLines(
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user