compare pdf:compare the two pdf

This commit is contained in:
saqib mir
2026-08-14 17:55:31 +05:30
parent e37c9392e1
commit 1217649a96
11 changed files with 2326 additions and 3 deletions
+87 -1
View File
@@ -27,7 +27,9 @@ import { ProtectModal } from './components/ProtectModal';
import type { ProtectModalState } from './components/ProtectModal';
import { UnlockModal } from './components/UnlockModal';
import type { UnlockModalState } from './components/UnlockModal';
import type { DocumentInfo, SearchResult, EditOperation, DocumentMetadata, FontInfo, OutlineItem, PDFPermissions } from './lib/gatewayService';
import { CompareModal } from './components/CompareModal';
import { CompareWorkspace } from './components/CompareWorkspace';
import type { DocumentInfo, SearchResult, EditOperation, DocumentMetadata, FontInfo, OutlineItem, PDFPermissions, CompareResponse, DocumentSummary } from './lib/gatewayService';
import { viewportRectToPdf } from './lib/coordinateMapping';
import type { Rect } from './lib/coordinateMapping';
@@ -118,6 +120,61 @@ function App() {
const [passwordPrompt, setPasswordPrompt] = useState<{ file: File; filename: string; error?: string } | null>(null);
const [protectModalState, setProtectModalState] = useState<ProtectModalState | null>(null);
const [unlockModalState, setUnlockModalState] = useState<UnlockModalState | null>(null);
const [compareModalOpen, setCompareModalOpen] = useState(false);
const [compareResult, setCompareResult] = useState<CompareResponse | null>(null);
const [compareDocA, setCompareDocA] = useState<DocumentSummary | null>(null);
const [compareDocB, setCompareDocB] = useState<DocumentSummary | null>(null);
const handleOpenCompareModal = () => {
if (!activeDoc) return;
setCompareDocA({
id: activeDoc.id,
filename: activeDoc.filename,
totalPages: activeDoc.totalPages,
sizeBytes: activeDoc.sizeBytes,
pageWidth: activeDoc.pageWidth || 612,
pageHeight: activeDoc.pageHeight || 792,
});
setCompareModalOpen(true);
};
const handleUploadCompareFile = async (file: File): Promise<DocumentSummary> => {
const doc = await gatewayService.uploadDocument(file);
setDocuments((prev) => [...prev, doc]);
return {
id: doc.id,
filename: doc.filename,
totalPages: doc.totalPages,
sizeBytes: doc.sizeBytes,
pageWidth: doc.pageWidth || 612,
pageHeight: doc.pageHeight || 792,
};
};
const handleStartCompare = async (docIdA: string, docIdB: string) => {
const res = await gatewayService.compareDocuments(docIdA, docIdB, true, 144);
setCompareResult(res);
const foundB = documents.find((d) => d.id === docIdB);
if (foundB) {
setCompareDocB({
id: foundB.id,
filename: foundB.filename,
totalPages: foundB.totalPages,
sizeBytes: foundB.sizeBytes,
pageWidth: foundB.pageWidth || 612,
pageHeight: foundB.pageHeight || 792,
});
} else {
setCompareDocB({
id: docIdB,
filename: 'Compared Document.pdf',
totalPages: res.summary.pagesB,
sizeBytes: 0,
pageWidth: 612,
pageHeight: 792,
});
}
};
const [isInspectorExpanded, setIsInspectorExpanded] = useState(false);
const [activeStamp, setActiveStamp] = useState<StampPreset | null>(null);
const [redactionMode, setRedactionMode] = useState<'area' | 'text'>('area');
@@ -1001,6 +1058,7 @@ function App() {
onPrint={(activeTool === 'create_pdf' || createPdfModalOpen) ? (() => creatorActions?.print?.()) : handlePrint}
onProtect={() => selectedDocId && setProtectModalState({ documentId: selectedDocId, filename: activeDoc?.filename || 'Document.pdf' })}
onUnlock={() => selectedDocId && setUnlockModalState({ documentId: selectedDocId, filename: activeDoc?.filename || 'Document.pdf' })}
onCompare={handleOpenCompareModal}
isEncrypted={activeDoc?.permissions?.isEncrypted ?? false}
canPrint={(activeTool === 'create_pdf' || createPdfModalOpen) ? true : can('canPrint')}
canExport={(activeTool === 'create_pdf' || createPdfModalOpen) ? true : can('canCopy')}
@@ -1351,6 +1409,34 @@ function App() {
onConfirm={handleUnlockSubmit}
onClose={() => setUnlockModalState(null)}
/>
<CompareModal
isOpen={compareModalOpen}
onClose={() => setCompareModalOpen(false)}
currentDoc={compareDocA}
availableDocs={documents.map((d) => ({
id: d.id,
filename: d.filename,
totalPages: d.totalPages,
sizeBytes: d.sizeBytes,
}))}
onUploadFile={handleUploadCompareFile}
onStartCompare={handleStartCompare}
/>
{compareResult && compareDocA && compareDocB && (
<CompareWorkspace
compareResult={compareResult}
docA={compareDocA}
docB={compareDocB}
onExit={() => {
setCompareResult(null);
setCompareDocA(null);
setCompareDocB(null);
}}
renderPageUrl={(docId, pageIdx, dpi) => gatewayService.getPageRenderUrl(docId, pageIdx, dpi)}
/>
)}
</div>
);
}
@@ -0,0 +1,137 @@
import React, { useState } from 'react';
import type { Difference, BoundingBox } from '../lib/gatewayService';
interface CompareDifferenceOverlayProps {
differences: Difference[];
currentPage: number;
docType: 'A' | 'B';
scale: number; // Scale factor from PDF points (72 DPI) to displayed CSS pixels (i.e. zoom)
selectedDiffId: string | null;
onSelectDifference: (diffId: string) => void;
}
export const CompareDifferenceOverlay: React.FC<CompareDifferenceOverlayProps> = ({
differences,
currentPage,
docType,
scale,
selectedDiffId,
onSelectDifference,
}) => {
const [hoveredDiffId, setHoveredDiffId] = useState<string | null>(null);
// Filter differences relevant to current page and document type
const pageDiffs = differences.filter((diff) => {
if (docType === 'A') {
return diff.pageA === currentPage;
}
return diff.pageB === currentPage;
});
const getBBoxForDoc = (diff: Difference): BoundingBox | null => {
if (docType === 'A') {
return diff.bboxA || diff.bboxB || null;
}
return diff.bboxB || diff.bboxA || null;
};
const getTypeStyle = (type: string, isSelected: boolean, isHovered: boolean) => {
if (type.includes('ADDED')) {
if (isSelected) return 'border-emerald-600 bg-emerald-500/30 ring-4 ring-emerald-500/40 shadow-lg z-30 animate-pulse';
if (isHovered) return 'border-emerald-600 bg-emerald-500/25 z-20';
return 'border-emerald-500 bg-emerald-500/15 z-10';
}
if (type.includes('REMOVED')) {
if (isSelected) return 'border-rose-600 bg-rose-500/30 ring-4 ring-rose-500/40 shadow-lg z-30 animate-pulse';
if (isHovered) return 'border-rose-600 bg-rose-500/25 z-20';
return 'border-rose-500 bg-rose-500/15 z-10';
}
if (type.includes('MODIFIED')) {
if (isSelected) return 'border-amber-600 bg-amber-500/30 ring-4 ring-amber-500/40 shadow-lg z-30 animate-pulse';
if (isHovered) return 'border-amber-600 bg-amber-500/25 z-20';
return 'border-amber-500 bg-amber-500/15 z-10';
}
if (type.includes('MOVE') || type.includes('RESIZE')) {
if (isSelected) return 'border-cyan-600 bg-cyan-500/30 ring-4 ring-cyan-500/40 shadow-lg z-30 animate-pulse';
if (isHovered) return 'border-cyan-600 bg-cyan-500/25 z-20';
return 'border-cyan-500 bg-cyan-500/15 z-10';
}
if (type.includes('FONT')) {
if (isSelected) return 'border-purple-600 bg-purple-500/30 ring-4 ring-purple-500/40 shadow-lg z-30 animate-pulse';
if (isHovered) return 'border-purple-600 bg-purple-500/25 z-20';
return 'border-purple-500 bg-purple-500/15 z-10';
}
if (type.includes('VISUAL')) {
if (isSelected) return 'border-blue-600 bg-blue-500/30 ring-4 ring-blue-500/40 shadow-lg z-30 animate-pulse';
if (isHovered) return 'border-blue-600 bg-blue-500/25 z-20';
return 'border-blue-500 bg-blue-500/15 z-10';
}
if (isSelected) return 'border-brand-primary bg-brand-primary/30 ring-4 ring-brand-primary/40 shadow-lg z-30 animate-pulse';
if (isHovered) return 'border-brand-primary bg-brand-primary/25 z-20';
return 'border-brand-primary bg-brand-primary/15 z-10';
};
const formatTypeName = (type: string) => {
return type.replace('_', ' ');
};
return (
<div className="absolute inset-0 pointer-events-none overflow-visible">
{pageDiffs.map((diff) => {
const bbox = getBBoxForDoc(diff);
if (!bbox) return null;
const isSelected = diff.id === selectedDiffId;
const isHovered = diff.id === hoveredDiffId;
const leftPx = bbox.x * scale;
const topPx = bbox.y * scale;
const widthPx = Math.max(6, bbox.width * scale);
const heightPx = Math.max(6, bbox.height * scale);
// Find index of diff in total differences list for display
const diffIndex = differences.findIndex((d) => d.id === diff.id) + 1;
return (
<div
key={diff.id}
onClick={(e) => {
e.stopPropagation();
onSelectDifference(diff.id);
}}
onMouseEnter={() => setHoveredDiffId(diff.id)}
onMouseLeave={() => setHoveredDiffId(null)}
className={`absolute border-2 rounded-[3px] transition-all cursor-pointer pointer-events-auto flex items-start justify-start ${getTypeStyle(
diff.type,
isSelected,
isHovered
)}`}
style={{
left: `${leftPx}px`,
top: `${topPx}px`,
width: `${widthPx}px`,
height: `${heightPx}px`,
}}
title={`${diff.type}: ${diff.details}`}
>
{/* Floating Info Pill for Selected or Hovered Difference */}
{(isSelected || isHovered) && (
<div
className={`absolute left-0 -top-7 px-2 py-0.5 rounded text-[10px] font-bold shadow-md whitespace-nowrap flex items-center gap-1 z-40 transition-transform ${
isSelected ? 'bg-gray-900 text-white border border-gray-700 scale-105' : 'bg-gray-800/90 text-gray-100'
}`}
style={{
transform: topPx < 28 ? 'translateY(28px)' : 'none',
}}
>
<span className="uppercase tracking-wider text-[9px] opacity-90">{formatTypeName(diff.type)}</span>
<span className="opacity-40"></span>
<span className="font-mono">#{diffIndex}</span>
</div>
)}
</div>
);
})}
</div>
);
};
+209
View File
@@ -0,0 +1,209 @@
import React, { useState } from 'react';
export interface DocumentSummary {
id: string;
filename: string;
totalPages: number;
sizeBytes: number;
}
interface CompareModalProps {
isOpen: boolean;
onClose: () => void;
currentDoc: DocumentSummary | null;
availableDocs: DocumentSummary[];
onUploadFile: (file: File) => Promise<DocumentSummary>;
onStartCompare: (docIdA: string, docIdB: string) => Promise<void>;
}
export const CompareModal: React.FC<CompareModalProps> = ({
isOpen,
onClose,
currentDoc,
availableDocs,
onUploadFile,
onStartCompare,
}) => {
const [selectedDocBId, setSelectedDocBId] = useState<string>('');
const [selectedDocB, setSelectedDocB] = useState<DocumentSummary | null>(null);
const [isUploading, setIsUploading] = useState<boolean>(false);
const [isComparing, setIsComparing] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
if (!isOpen || !currentDoc) return null;
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
const handleSelectDocB = (id: string) => {
setSelectedDocBId(id);
const found = availableDocs.find((d) => d.id === id) || null;
setSelectedDocB(found);
setError(null);
};
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsUploading(true);
setError(null);
try {
const uploaded = await onUploadFile(file);
setSelectedDocB(uploaded);
setSelectedDocBId(uploaded.id);
} catch (err: any) {
setError(err.message || 'Failed to upload document for comparison');
} finally {
setIsUploading(false);
}
};
const handleCompareSubmit = async () => {
if (!selectedDocBId || !selectedDocB) {
setError('Please select or upload a second PDF document to compare.');
return;
}
setIsComparing(true);
setError(null);
try {
await onStartCompare(currentDoc.id, selectedDocBId);
onClose();
} catch (err: any) {
setError(err.message || 'Failed to compare documents');
} finally {
setIsComparing(false);
}
};
const otherDocs = availableDocs.filter((d) => d.id !== currentDoc.id);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-[2px]">
<div className="w-full max-w-lg bg-bg-primary rounded-lg border border-border-primary shadow-2xl p-6 flex flex-col gap-5">
{/* Header */}
<div className="flex items-center justify-between pb-3 border-b border-border-secondary">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-brand-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<h2 className="text-base font-semibold text-text-primary">Compare PDFs</h2>
</div>
<button
onClick={onClose}
disabled={isComparing}
className="text-text-tertiary hover:text-text-primary text-sm p-1 rounded hover:bg-bg-tertiary transition-colors"
>
</button>
</div>
{/* Original Document Info */}
<div className="flex flex-col gap-2">
<label className="text-xs font-semibold uppercase tracking-wider text-text-tertiary">Original PDF (Document A)</label>
<div className="p-3 bg-bg-secondary rounded border border-border-secondary flex items-center justify-between">
<div className="flex items-center gap-3 overflow-hidden">
<div className="w-8 h-8 rounded bg-brand-primary/10 flex items-center justify-center text-brand-primary font-bold text-xs shrink-0">
PDF
</div>
<div className="flex flex-col truncate">
<span className="text-sm font-medium text-text-primary truncate">{currentDoc.filename}</span>
<span className="text-xs text-text-tertiary">{currentDoc.totalPages} pages {formatSize(currentDoc.sizeBytes)}</span>
</div>
</div>
<span className="text-xs font-medium px-2 py-0.5 rounded bg-brand-primary/10 text-brand-primary border border-brand-primary/20 shrink-0">Active</span>
</div>
</div>
{/* Compared Document Selection */}
<div className="flex flex-col gap-2">
<label className="text-xs font-semibold uppercase tracking-wider text-text-tertiary">Compared PDF (Document B)</label>
{otherDocs.length > 0 && (
<select
value={selectedDocBId}
onChange={(e) => handleSelectDocB(e.target.value)}
disabled={isComparing || isUploading}
className="w-full text-sm p-2.5 rounded bg-bg-secondary border border-border-primary text-text-primary focus:outline-none focus:border-brand-primary"
>
<option value="">-- Choose from open documents --</option>
{otherDocs.map((doc) => (
<option key={doc.id} value={doc.id}>
{doc.filename} ({doc.totalPages} pages, {formatSize(doc.sizeBytes)})
</option>
))}
</select>
)}
<div className="relative flex items-center justify-center">
<input
type="file"
accept=".pdf"
onChange={handleFileUpload}
disabled={isComparing || isUploading}
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full z-10"
id="compare-file-upload"
/>
<div className="w-full p-3 border-2 border-dashed border-border-primary hover:border-brand-primary rounded-md bg-bg-secondary/50 flex items-center justify-center gap-2 cursor-pointer transition-colors">
<svg className="w-4 h-4 text-brand-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
<span className="text-xs font-medium text-text-secondary">
{isUploading ? 'Uploading document...' : 'Upload PDF File for Comparison'}
</span>
</div>
</div>
{selectedDocB && (
<div className="p-3 bg-bg-secondary rounded border border-border-secondary flex items-center justify-between">
<div className="flex items-center gap-3 overflow-hidden">
<div className="w-8 h-8 rounded bg-brand-secondary/10 flex items-center justify-center text-brand-secondary font-bold text-xs shrink-0">
PDF
</div>
<div className="flex flex-col truncate">
<span className="text-sm font-medium text-text-primary truncate">{selectedDocB.filename}</span>
<span className="text-xs text-text-tertiary">{selectedDocB.totalPages} pages {formatSize(selectedDocB.sizeBytes)}</span>
</div>
</div>
<span className="text-xs font-medium px-2 py-0.5 rounded bg-emerald-500/10 text-emerald-600 border border-emerald-500/20 shrink-0">Selected</span>
</div>
)}
</div>
{/* Error message */}
{error && (
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded text-xs text-red-600 dark:text-red-400">
{error}
</div>
)}
{/* Footer actions */}
<div className="flex items-center justify-end gap-3 pt-3 border-t border-border-secondary">
<button
onClick={onClose}
disabled={isComparing}
className="px-4 py-2 text-xs font-medium text-text-secondary hover:text-text-primary hover:bg-bg-tertiary rounded transition-colors"
>
Cancel
</button>
<button
onClick={handleCompareSubmit}
disabled={!selectedDocB || isComparing || isUploading}
className="px-5 py-2 text-xs font-medium text-white bg-brand-primary hover:bg-brand-primary/90 rounded disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 transition-colors shadow-sm"
>
{isComparing ? (
<>
<div className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
<span>Comparing PDFs...</span>
</>
) : (
<span>Start Comparison</span>
)}
</button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,512 @@
import React, { useState, useEffect, useRef } from 'react';
import type { CompareResponse, Difference, DocumentSummary } from '../lib/gatewayService';
import { CompareDifferenceOverlay } from './CompareDifferenceOverlay';
interface CompareWorkspaceProps {
compareResult: CompareResponse;
docA: DocumentSummary;
docB: DocumentSummary;
onExit: () => void;
renderPageUrl: (docId: string, pageIndex: number, dpi: number) => string;
}
export const CompareWorkspace: React.FC<CompareWorkspaceProps> = ({
compareResult,
docA,
docB,
onExit,
renderPageUrl,
}) => {
const [viewMode, setViewMode] = useState<'side_by_side' | 'overlay' | 'differences'>('side_by_side');
const [currentPageIndex, setCurrentPageIndex] = useState<number>(0);
const [activeDiffIndex, setActiveDiffIndex] = useState<number>(0);
const [opacity, setOpacity] = useState<number>(50); // For Overlay mode (0 - 100)
const [zoom, setZoom] = useState<number>(0.9); // Initial 90% zoom for optimal side-by-side fit
const [activeTab, setActiveTab] = useState<'pages' | 'differences'>('differences');
const viewportRefA = useRef<HTMLDivElement>(null);
const viewportRefB = useRef<HTMLDivElement>(null);
const isSyncingScroll = useRef<boolean>(false);
const differences = compareResult.differences;
const summary = compareResult.summary;
const pageMap = compareResult.pageMap;
const totalDiffs = differences.length;
const currentDiff: Difference | null = totalDiffs > 0 ? differences[activeDiffIndex] : null;
// Standard PDF dimensions (612 pt x 792 pt = 8.5 in x 11 in)
const pageWidth = docA.pageWidth || 612;
const pageHeight = docA.pageHeight || 792;
// Auto-scroll viewport to center exact difference bounding box
const scrollToDifference = (diff: Difference) => {
const targetPage = diff.pageB !== null && diff.pageB !== undefined ? diff.pageB : (diff.pageA ?? 0);
setCurrentPageIndex(targetPage);
const bbox = diff.bboxB || diff.bboxA;
if (!bbox) return;
requestAnimationFrame(() => {
[viewportRefA.current, viewportRefB.current].forEach((container) => {
if (!container) return;
const targetX = bbox.x * zoom;
const targetY = bbox.y * zoom;
const targetW = bbox.width * zoom;
const targetH = bbox.height * zoom;
const scrollLeft = Math.max(0, targetX - container.clientWidth / 2 + targetW / 2);
const scrollTop = Math.max(0, targetY - container.clientHeight / 2 + targetH / 2);
container.scrollTo({
left: scrollLeft,
top: scrollTop,
behavior: 'smooth',
});
});
});
};
// Jump to specific difference index
const goToDiff = (index: number) => {
if (totalDiffs === 0) return;
const clampedIndex = (index + totalDiffs) % totalDiffs;
setActiveDiffIndex(clampedIndex);
const diff = differences[clampedIndex];
scrollToDifference(diff);
};
const handleSelectDiffId = (id: string) => {
const idx = differences.findIndex((d) => d.id === id);
if (idx !== -1) {
goToDiff(idx);
}
};
// Keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
if (e.key === 'ArrowRight' || e.key.toLowerCase() === 'n') {
e.preventDefault();
goToDiff(activeDiffIndex + 1);
} else if (e.key === 'ArrowLeft' || e.key.toLowerCase() === 'p') {
e.preventDefault();
goToDiff(activeDiffIndex - 1);
} else if (e.key === 'Escape') {
onExit();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [activeDiffIndex, totalDiffs]);
// Synchronized scrolling for Side-by-Side view
const handleScrollA = () => {
if (isSyncingScroll.current) return;
if (viewportRefA.current && viewportRefB.current) {
isSyncingScroll.current = true;
viewportRefB.current.scrollTop = viewportRefA.current.scrollTop;
viewportRefB.current.scrollLeft = viewportRefA.current.scrollLeft;
requestAnimationFrame(() => {
isSyncingScroll.current = false;
});
}
};
const handleScrollB = () => {
if (isSyncingScroll.current) return;
if (viewportRefA.current && viewportRefB.current) {
isSyncingScroll.current = true;
viewportRefA.current.scrollTop = viewportRefB.current.scrollTop;
viewportRefA.current.scrollLeft = viewportRefB.current.scrollLeft;
requestAnimationFrame(() => {
isSyncingScroll.current = false;
});
}
};
// Format type badges
const getBadgeStyle = (type: string) => {
if (type.includes('ADDED')) return 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20';
if (type.includes('REMOVED')) return 'bg-rose-500/10 text-rose-600 border-rose-500/20';
if (type.includes('MODIFIED')) return 'bg-amber-500/10 text-amber-600 border-amber-500/20';
if (type.includes('FONT')) return 'bg-purple-500/10 text-purple-600 border-purple-500/20';
if (type.includes('VISUAL')) return 'bg-blue-500/10 text-blue-600 border-blue-500/20';
return 'bg-gray-500/10 text-gray-600 border-gray-500/20';
};
const maxPages = Math.max(docA.totalPages, docB.totalPages);
return (
<div className="fixed inset-0 z-50 flex flex-col bg-bg-primary text-text-primary select-none">
{/* ── TOP NAVIGATION BAR ── */}
<header className="h-14 px-4 bg-bg-secondary border-b border-border-primary flex items-center justify-between shrink-0 gap-4">
{/* Document comparison title */}
<div className="flex items-center gap-3 min-w-0">
<div className="flex items-center gap-2 text-xs font-semibold text-text-primary truncate">
<span className="px-2 py-1 bg-brand-primary/10 text-brand-primary rounded font-mono text-[11px]">Compare</span>
<span className="truncate max-w-[180px]">{docA.filename}</span>
<span className="text-text-tertiary"></span>
<span className="truncate max-w-[180px]">{docB.filename}</span>
</div>
</div>
{/* View Mode Switcher */}
<div className="flex items-center bg-bg-tertiary p-1 rounded-md border border-border-secondary">
<button
onClick={() => setViewMode('side_by_side')}
className={`px-3 py-1 text-xs font-medium rounded transition-colors ${
viewMode === 'side_by_side' ? 'bg-bg-primary text-brand-primary shadow-sm' : 'text-text-secondary hover:text-text-primary'
}`}
>
Side by Side
</button>
<button
onClick={() => setViewMode('overlay')}
className={`px-3 py-1 text-xs font-medium rounded transition-colors ${
viewMode === 'overlay' ? 'bg-bg-primary text-brand-primary shadow-sm' : 'text-text-secondary hover:text-text-primary'
}`}
>
Overlay
</button>
<button
onClick={() => setViewMode('differences')}
className={`px-3 py-1 text-xs font-medium rounded transition-colors ${
viewMode === 'differences' ? 'bg-bg-primary text-brand-primary shadow-sm' : 'text-text-secondary hover:text-text-primary'
}`}
>
Differences Mode
</button>
</div>
{/* Controls: Zoom & Exit */}
<div className="flex items-center gap-3">
<div className="flex items-center bg-bg-tertiary rounded border border-border-secondary text-xs">
<button
onClick={() => setZoom((z) => Math.max(0.4, z - 0.1))}
className="px-2 py-1 hover:bg-bg-primary text-text-secondary hover:text-text-primary transition-colors"
title="Zoom Out"
>
</button>
<span className="px-2 text-text-tertiary font-mono">{Math.round(zoom * 100)}%</span>
<button
onClick={() => setZoom((z) => Math.min(2.5, z + 0.1))}
className="px-2 py-1 hover:bg-bg-primary text-text-secondary hover:text-text-primary transition-colors"
title="Zoom In"
>
+
</button>
</div>
<button
onClick={onExit}
className="px-3 py-1.5 text-xs font-medium text-text-secondary hover:text-text-primary hover:bg-bg-tertiary rounded border border-border-secondary transition-colors"
>
Exit Compare
</button>
</div>
</header>
{/* ── MAIN WORKSPACE AREA ── */}
<div className="flex-1 flex overflow-hidden">
{/* ── LEFT SIDEBAR (Differences & Pages) ── */}
<aside className="w-80 border-r border-border-primary bg-bg-secondary flex flex-col shrink-0">
{/* Sidebar Tabs */}
<div className="flex border-b border-border-secondary text-xs font-medium">
<button
onClick={() => setActiveTab('differences')}
className={`flex-1 py-2.5 text-center border-b-2 transition-colors ${
activeTab === 'differences'
? 'border-brand-primary text-brand-primary font-semibold'
: 'border-transparent text-text-tertiary hover:text-text-primary'
}`}
>
Differences ({totalDiffs})
</button>
<button
onClick={() => setActiveTab('pages')}
className={`flex-1 py-2.5 text-center border-b-2 transition-colors ${
activeTab === 'pages'
? 'border-brand-primary text-brand-primary font-semibold'
: 'border-transparent text-text-tertiary hover:text-text-primary'
}`}
>
Pages ({maxPages})
</button>
</div>
{/* Sidebar Content */}
<div className="flex-1 overflow-y-auto p-3 flex flex-col gap-2">
{activeTab === 'differences' ? (
totalDiffs === 0 ? (
<div className="p-6 text-center text-xs text-text-tertiary">
No differences detected between the two PDF documents.
</div>
) : (
differences.map((diff, idx) => {
const isSelected = idx === activeDiffIndex;
return (
<div
key={diff.id}
onClick={() => goToDiff(idx)}
className={`p-3 rounded border text-xs cursor-pointer transition-all ${
isSelected
? 'bg-brand-primary/10 border-brand-primary shadow-sm ring-1 ring-brand-primary'
: 'bg-bg-primary border-border-secondary hover:border-border-primary'
}`}
>
<div className="flex items-center justify-between gap-2 mb-1.5">
<span className={`px-1.5 py-0.5 rounded text-[10px] font-semibold border ${getBadgeStyle(diff.type)}`}>
{diff.type}
</span>
<span className="text-[11px] font-mono text-text-tertiary">
Page {(diff.pageB !== null && diff.pageB !== undefined ? diff.pageB : diff.pageA ?? 0) + 1}
</span>
</div>
<p className="text-text-secondary leading-snug">{diff.details}</p>
{diff.textA && diff.textB && (
<div className="mt-2 text-[11px] font-mono bg-bg-tertiary p-1.5 rounded space-y-0.5">
<div className="text-rose-600 truncate"> {diff.textA}</div>
<div className="text-emerald-600 truncate">+ {diff.textB}</div>
</div>
)}
</div>
);
})
)
) : (
pageMap.map((entry, idx) => {
const isCurrent = idx === currentPageIndex;
return (
<div
key={idx}
onClick={() => setCurrentPageIndex(idx)}
className={`p-2.5 rounded border text-xs flex items-center justify-between cursor-pointer transition-colors ${
isCurrent
? 'bg-brand-primary/10 border-brand-primary font-medium'
: 'bg-bg-primary border-border-secondary hover:border-border-primary'
}`}
>
<span className="text-text-primary">Page {idx + 1}</span>
<div className="flex items-center gap-2">
{entry.differencesCount > 0 ? (
<span className="px-2 py-0.5 rounded-full text-[10px] font-semibold bg-amber-500/10 text-amber-600 border border-amber-500/20">
{entry.differencesCount} {entry.differencesCount === 1 ? 'change' : 'changes'}
</span>
) : (
<span className="text-[10px] text-text-tertiary">No changes</span>
)}
</div>
</div>
);
})
)}
</div>
{/* Summary Box */}
<div className="p-3 bg-bg-tertiary border-t border-border-secondary text-[11px] text-text-tertiary space-y-1">
<div className="flex justify-between">
<span>Text Changes:</span>
<span className="font-mono text-text-secondary">{summary.textDifferences}</span>
</div>
<div className="flex justify-between">
<span>Layout Shifts:</span>
<span className="font-mono text-text-secondary">{summary.layoutDifferences}</span>
</div>
<div className="flex justify-between">
<span>Visual Differences:</span>
<span className="font-mono text-text-secondary">{summary.visualDifferences}</span>
</div>
<div className="flex justify-between font-medium text-text-primary pt-1 border-t border-border-secondary">
<span>Total Differences:</span>
<span className="font-mono text-brand-primary">{summary.totalDifferences}</span>
</div>
</div>
</aside>
{/* ── CENTRAL COMPARISON VIEWPORT ── */}
<div className="flex-1 flex flex-col bg-bg-tertiary overflow-hidden relative">
{/* Overlay controls if Overlay mode is active */}
{viewMode === 'overlay' && (
<div className="h-10 px-4 bg-bg-secondary border-b border-border-secondary flex items-center justify-between text-xs z-10 shrink-0">
<span className="text-text-tertiary font-medium">Document Layer Opacity:</span>
<div className="flex items-center gap-3 w-64">
<span className="text-[11px] font-mono text-text-tertiary">Doc A</span>
<input
type="range"
min="0"
max="100"
value={opacity}
onChange={(e) => setOpacity(parseInt(e.target.value, 10))}
className="flex-1 accent-brand-primary cursor-pointer"
/>
<span className="text-[11px] font-mono text-text-tertiary">Doc B</span>
</div>
</div>
)}
{/* Viewport Rendering Container */}
<div className="flex-1 flex overflow-hidden p-6 items-center justify-center">
{viewMode === 'side_by_side' && (
<div className="w-full h-full grid grid-cols-2 gap-6 items-center justify-center">
{/* Document A Viewport */}
<div
ref={viewportRefA}
onScroll={handleScrollA}
className="w-full h-full overflow-auto bg-bg-secondary rounded border border-border-primary flex flex-col items-center p-6 relative shadow-inner"
>
<span className="sticky top-0 bg-bg-primary/90 backdrop-blur border border-border-secondary px-2.5 py-1 rounded text-xs font-semibold text-text-secondary shadow-sm mb-4 z-20">
Original (Doc A) Page {currentPageIndex + 1}
</span>
{currentPageIndex < docA.totalPages ? (
<div
className="relative shadow-md rounded-sm bg-white border border-border-primary overflow-visible shrink-0"
style={{
width: `${pageWidth * zoom}px`,
height: `${pageHeight * zoom}px`,
}}
>
<img
src={renderPageUrl(docA.id, currentPageIndex, 144)}
alt={`Doc A Page ${currentPageIndex + 1}`}
className="w-full h-full block rounded-sm bg-white pointer-events-none"
/>
{/* Location-anchored Bounding Box Overlay for Doc A */}
<CompareDifferenceOverlay
differences={differences}
currentPage={currentPageIndex}
docType="A"
scale={zoom}
selectedDiffId={currentDiff?.id || null}
onSelectDifference={handleSelectDiffId}
/>
</div>
) : (
<div className="p-12 text-xs text-text-tertiary italic">Page does not exist in Document A</div>
)}
</div>
{/* Document B Viewport */}
<div
ref={viewportRefB}
onScroll={handleScrollB}
className="w-full h-full overflow-auto bg-bg-secondary rounded border border-border-primary flex flex-col items-center p-6 relative shadow-inner"
>
<span className="sticky top-0 bg-bg-primary/90 backdrop-blur border border-border-secondary px-2.5 py-1 rounded text-xs font-semibold text-text-secondary shadow-sm mb-4 z-20">
Revised (Doc B) Page {currentPageIndex + 1}
</span>
{currentPageIndex < docB.totalPages ? (
<div
className="relative shadow-md rounded-sm bg-white border border-border-primary overflow-visible shrink-0"
style={{
width: `${pageWidth * zoom}px`,
height: `${pageHeight * zoom}px`,
}}
>
<img
src={renderPageUrl(docB.id, currentPageIndex, 144)}
alt={`Doc B Page ${currentPageIndex + 1}`}
className="w-full h-full block rounded-sm bg-white pointer-events-none"
/>
{/* Location-anchored Bounding Box Overlay for Doc B */}
<CompareDifferenceOverlay
differences={differences}
currentPage={currentPageIndex}
docType="B"
scale={zoom}
selectedDiffId={currentDiff?.id || null}
onSelectDifference={handleSelectDiffId}
/>
</div>
) : (
<div className="p-12 text-xs text-text-tertiary italic">Page does not exist in Document B</div>
)}
</div>
</div>
)}
{(viewMode === 'overlay' || viewMode === 'differences') && (
<div
ref={viewportRefA}
className="w-full h-full overflow-auto bg-bg-secondary rounded border border-border-primary flex flex-col items-center p-6 relative shadow-inner"
>
<div
className="relative shadow-md rounded-sm bg-white border border-border-primary overflow-visible shrink-0"
style={{
width: `${pageWidth * zoom}px`,
height: `${pageHeight * zoom}px`,
}}
>
{/* Layer A */}
{currentPageIndex < docA.totalPages && (
<img
src={renderPageUrl(docA.id, currentPageIndex, 144)}
alt="Doc A Overlay"
className="w-full h-full block rounded-sm bg-white pointer-events-none"
style={{ opacity: viewMode === 'overlay' ? (100 - opacity) / 100 : 1 }}
/>
)}
{/* Layer B (Overlay mode) */}
{viewMode === 'overlay' && currentPageIndex < docB.totalPages && (
<img
src={renderPageUrl(docB.id, currentPageIndex, 144)}
alt="Doc B Overlay"
className="w-full h-full block rounded-sm absolute top-0 left-0 pointer-events-none"
style={{ opacity: opacity / 100, mixBlendMode: 'difference' }}
/>
)}
{/* Highlights in Overlay / Differences mode */}
<CompareDifferenceOverlay
differences={differences}
currentPage={currentPageIndex}
docType="B"
scale={zoom}
selectedDiffId={currentDiff?.id || null}
onSelectDifference={handleSelectDiffId}
/>
</div>
</div>
)}
</div>
{/* ── BOTTOM DIFFERENCE NAVIGATION TOOLBAR ── */}
<footer className="h-12 px-6 bg-bg-secondary border-t border-border-primary flex items-center justify-between shrink-0 text-xs">
<div className="flex items-center gap-3">
<button
onClick={() => goToDiff(activeDiffIndex - 1)}
disabled={totalDiffs === 0}
className="px-3 py-1.5 bg-bg-tertiary hover:bg-bg-primary border border-border-secondary rounded text-text-primary font-medium disabled:opacity-40 transition-colors"
>
Previous Difference
</button>
<span className="font-mono text-text-secondary font-medium">
{totalDiffs > 0 ? `Difference ${activeDiffIndex + 1} of ${totalDiffs}` : '0 Differences'}
</span>
<button
onClick={() => goToDiff(activeDiffIndex + 1)}
disabled={totalDiffs === 0}
className="px-3 py-1.5 bg-bg-tertiary hover:bg-bg-primary border border-border-secondary rounded text-text-primary font-medium disabled:opacity-40 transition-colors"
>
Next Difference
</button>
</div>
{currentDiff && (
<div className="flex items-center gap-2 text-text-secondary truncate max-w-lg">
<span className="font-semibold text-text-primary">Active Location:</span>
<span className="truncate">{currentDiff.details}</span>
</div>
)}
<div className="flex items-center gap-2">
<span className="text-text-tertiary font-mono text-[11px]">Shortcuts: / or J / K</span>
</div>
</footer>
</div>
</div>
</div>
);
};
+3 -1
View File
@@ -27,6 +27,7 @@ interface TopBarProps {
onPrint: () => void;
onProtect?: () => void;
onUnlock?: () => void;
onCompare?: () => void;
isEncrypted?: boolean;
onUpload: (file: File) => void;
onNewBlankPDF?: () => void;
@@ -44,7 +45,7 @@ const ZOOM_PRESETS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
export const TopBar: React.FC<TopBarProps> = ({
documentName, backendHealthy, engineReady, zoom, onZoomChange, onFitWidth,
currentPage, totalPages, onGoToPage, canUndo, canRedo, onUndo, onRedo,
isSaving, isDirtySaved, onRotate, onExport, onPrint, onProtect, onUnlock, isEncrypted, onUpload, onNewBlankPDF, onSave,
isSaving, isDirtySaved, onRotate, onExport, onPrint, onProtect, onUnlock, onCompare, isEncrypted, onUpload, onNewBlankPDF, onSave,
canPrint = true, canExport = true, canAssemble = true,
}) => {
const fileRef = useRef<HTMLInputElement>(null);
@@ -83,6 +84,7 @@ export const TopBar: React.FC<TopBarProps> = ({
<MenuItem icon={<UploadIcon size={16} />} onClick={() => fileRef.current?.click()}>Open PDF</MenuItem>
<MenuItem icon={<DownloadIcon size={16} />} onClick={onExport} disabled={!documentName || !canExport}>Export / Download</MenuItem>
<MenuItem icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><polyline points="6 9 6 2 18 2 18 9"></polyline><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path><rect x="6" y="14" width="12" height="8"></rect></svg>} onClick={onPrint} disabled={!documentName || !canPrint}>Print</MenuItem>
<MenuItem icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2"/><rect x="9" y="3" width="6" height="4" rx="1"/></svg>} onClick={() => onCompare?.()} disabled={!documentName}>Compare PDFs</MenuItem>
{isEncrypted ? (
<MenuItem icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M10 11V7a4 4 0 0 1 8 0v4"/></svg>} onClick={() => onUnlock?.()} disabled={!documentName}>Remove Password</MenuItem>
) : (
+77
View File
@@ -634,6 +634,10 @@ class GatewayService {
}
}
getPageRenderUrl(documentId: string, pageIndex: number, dpi: number = 144): string {
return `${this.baseUrl}/documents/${documentId}/pages/${pageIndex}/render?dpi=${dpi}`;
}
private renderCache = new Map<string, string>();
async renderPage(params: RenderParams): Promise<string> {
@@ -1101,6 +1105,79 @@ class GatewayService {
if (!response.ok) throw new Error(`Failed to fetch document bytes: ${response.statusText}`);
return response.arrayBuffer();
}
async compareDocuments(documentIdA: string, documentIdB: string, includeVisualDiff: boolean = true, dpi: number = 144): Promise<CompareResponse> {
const response = await fetch(`${this.baseUrl}/documents/compare`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ documentIdA, documentIdB, includeVisualDiff, dpi }),
});
if (response.status === 401) {
const err = await response.json().catch(() => ({ detail: 'Password required' }));
throw new PasswordError(err.detail || 'Password required');
}
if (!response.ok) {
const err = await response.json().catch(() => ({ detail: response.statusText }));
throw new Error(err.detail || `Comparison failed (${response.status})`);
}
return response.json();
}
}
export interface DocumentSummary {
id: string;
filename: string;
totalPages: number;
sizeBytes: number;
pageWidth?: number;
pageHeight?: number;
}
export interface BoundingBox {
x: number;
y: number;
width: number;
height: number;
}
export interface Difference {
id: string;
type: string;
pageA?: number | null;
pageB?: number | null;
bboxA?: BoundingBox | null;
bboxB?: BoundingBox | null;
textA?: string | null;
textB?: string | null;
details: string;
}
export interface PageMapEntry {
pageA?: number | null;
pageB?: number | null;
status: string;
differencesCount: number;
}
export interface CompareSummary {
pagesA: number;
pagesB: number;
pagesAdded: number;
pagesRemoved: number;
pagesModified: number;
totalDifferences: number;
textDifferences: number;
layoutDifferences: number;
annotationDifferences: number;
visualDifferences: number;
}
export interface CompareResponse {
documentIdA: string;
documentIdB: string;
summary: CompareSummary;
pageMap: PageMapEntry[];
differences: Difference[];
}
export const gatewayService = new GatewayService();
+2 -1
View File
@@ -5,7 +5,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app import __version__
from app.routers import documents, edits, health, info, internal, layout, ocr, render
from app.routers import compare, documents, edits, health, info, internal, layout, ocr, render
from app.services import ocr as ocr_service
from app.ai.services.font_service import FontRecognitionService
@@ -62,6 +62,7 @@ def create_app() -> FastAPI:
app.include_router(health.router)
app.include_router(info.router)
app.include_router(documents.router)
app.include_router(compare.router)
app.include_router(render.router)
app.include_router(render.compat_router)
app.include_router(edits.router)
+58
View File
@@ -0,0 +1,58 @@
from fastapi import APIRouter, HTTPException, status
from app.schemas.compare import CompareRequest, CompareResponse
from app.services import engine
from app.services.compare import compare_documents
from app.services.store import document_store
router = APIRouter(prefix="/documents", tags=["compare"])
@router.post("/compare", response_model=CompareResponse)
async def compare_documents_endpoint(req: CompareRequest) -> CompareResponse:
if not engine.is_available():
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Engine bridge (bindings/python) not yet available.",
)
docA_info = document_store.get_document(req.documentIdA)
if not docA_info:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Document A not found.",
)
docB_info = document_store.get_document(req.documentIdB)
if not docB_info:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Document B not found.",
)
permsA = docA_info.get("permissions") or {}
if permsA.get("isEncrypted", False) and not docA_info.get("password") and permsA.get("canCopy") is False:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Password required to open Document A.",
)
permsB = docB_info.get("permissions") or {}
if permsB.get("isEncrypted", False) and not docB_info.get("password") and permsB.get("canCopy") is False:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Password required to open Document B.",
)
try:
return compare_documents(
docA_info,
docB_info,
include_visual_diff=req.includeVisualDiff,
dpi=req.dpi,
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to compare documents: {e!s}",
)
+55
View File
@@ -0,0 +1,55 @@
from pydantic import BaseModel, Field
class BoundingBox(BaseModel):
x: float
y: float
width: float
height: float
class CompareRequest(BaseModel):
documentIdA: str
documentIdB: str
includeVisualDiff: bool = True
dpi: int = Field(144, ge=72, le=300)
class Difference(BaseModel):
id: str
type: str
pageA: int | None = None
pageB: int | None = None
bboxA: BoundingBox | None = None
bboxB: BoundingBox | None = None
textA: str | None = None
textB: str | None = None
details: str
class PageMapEntry(BaseModel):
pageA: int | None = None
pageB: int | None = None
status: str
differencesCount: int = 0
class CompareSummary(BaseModel):
pagesA: int
pagesB: int
pagesAdded: int = 0
pagesRemoved: int = 0
pagesModified: int = 0
totalDifferences: int = 0
textDifferences: int = 0
layoutDifferences: int = 0
annotationDifferences: int = 0
visualDifferences: int = 0
class CompareResponse(BaseModel):
documentIdA: str
documentIdB: str
summary: CompareSummary
pageMap: list[PageMapEntry] = Field(default_factory=list)
differences: list[Difference] = Field(default_factory=list)
+622
View File
@@ -0,0 +1,622 @@
import difflib
import math
import uuid
from typing import Any
from PIL import Image, ImageChops
from app.schemas.compare import (
BoundingBox,
CompareResponse,
CompareSummary,
Difference,
PageMapEntry,
)
def _bbox_distance(b1: dict[str, float], b2: dict[str, float]) -> float:
"""Euclidean distance between top-left corners of two bounding boxes."""
dx = b1["x"] - b2["x"]
dy = b1["y"] - b2["y"]
return math.sqrt(dx * dx + dy * dy)
def _bbox_overlap(b1: dict[str, float], b2: dict[str, float]) -> bool:
"""Check if two bounding boxes overlap or are adjacent."""
return not (
b1["x"] + b1["width"] < b2["x"]
or b2["x"] + b2["width"] < b1["x"]
or b1["y"] + b1["height"] < b2["y"]
or b2["y"] + b2["height"] < b1["y"]
)
def extract_text_runs(doc_instance: Any, page_index: int) -> list[dict[str, Any]]:
"""Extract flattened text runs from a page's PageModel."""
runs = []
try:
page = doc_instance.get_page(page_index)
model = page.extract_document_model()
for p in model.paragraphs:
for line in p.lines:
for run in line.runs:
txt = run.text.strip()
if not txt:
continue
runs.append(
{
"text": run.text,
"bbox": {
"x": round(run.x, 2),
"y": round(run.y, 2),
"width": round(run.w, 2),
"height": round(run.h, 2),
},
"fontName": run.fontName,
"fontSize": round(run.fontSize, 2),
"fontWeight": run.fontWeight,
"fontStyle": run.fontStyle,
"fillColor": run.fillColor,
}
)
except Exception:
pass
return runs
def extract_annotations_dict(doc_instance: Any, page_index: int) -> list[dict[str, Any]]:
"""Extract annotations from a page."""
annots = []
try:
page = doc_instance.get_page(page_index)
extracted = page.extract_annotations()
for a in extracted:
annots.append(
{
"id": a.id,
"type": a.type,
"bbox": {
"x": round(a.x, 2),
"y": round(a.y, 2),
"width": round(a.width, 2),
"height": round(a.height, 2),
},
"color": getattr(a, "color", ""),
"author": getattr(a, "author", ""),
"content": getattr(a, "content", ""),
}
)
except Exception:
pass
return annots
def compute_visual_page_differences(
docA_instance: Any,
docB_instance: Any,
page_index: int,
dpi: int = 144,
threshold: int = 20,
) -> list[Difference]:
"""Render page_index of docA and docB, compute pixel delta mask, group into merged bounding boxes."""
diffs: list[Difference] = []
try:
pA = docA_instance.get_page(page_index)
pB = docB_instance.get_page(page_index)
except Exception:
return diffs
imgA = None
imgB = None
try:
wA_px, hA_px, bytesA = pA.render_tile(dpi, 0.0, 0.0, pA.width, pA.height)
imgA = Image.frombytes("RGBA", (wA_px, hA_px), bytes(bytesA))
wB_px, hB_px, bytesB = pB.render_tile(dpi, 0.0, 0.0, pB.width, pB.height)
imgB = Image.frombytes("RGBA", (wB_px, hB_px), bytes(bytesB))
max_w = max(wA_px, wB_px)
max_h = max(hA_px, hB_px)
if (wA_px, hA_px) != (max_w, max_h):
canvasA = Image.new("RGBA", (max_w, max_h), (255, 255, 255, 255))
canvasA.paste(imgA, (0, 0))
imgA = canvasA
if (wB_px, hB_px) != (max_w, max_h):
canvasB = Image.new("RGBA", (max_w, max_h), (255, 255, 255, 255))
canvasB.paste(imgB, (0, 0))
imgB = canvasB
diff_img = ImageChops.difference(imgA, imgB)
diff_gray = diff_img.convert("L")
diff_mask = diff_gray.point(lambda p: 255 if p > threshold else 0)
extrema = diff_mask.getextrema()
if not extrema or extrema[1] == 0:
return diffs
# Grid binning & bounding box clustering
cell_size = max(8, int(16 * (dpi / 72.0)))
cols = (max_w + cell_size - 1) // cell_size
rows = (max_h + cell_size - 1) // cell_size
active_cells = set()
for r in range(rows):
for c in range(cols):
box = (c * cell_size, r * cell_size, min((c + 1) * cell_size, max_w), min((r + 1) * cell_size, max_h))
sub_img = diff_mask.crop(box)
if sub_img.getextrema()[1] > 0:
active_cells.add((r, c))
if not active_cells:
return diffs
# Merge connected active cells via BFS
visited = set()
for cell in active_cells:
if cell in visited:
continue
group = []
queue = [cell]
visited.add(cell)
while queue:
curr = queue.pop()
group.append(curr)
cr, cc = curr
for dr in (-1, 0, 1):
for dc in (-1, 0, 1):
if dr == 0 and dc == 0:
continue
neighbor = (cr + dr, cc + dc)
if neighbor in active_cells and neighbor not in visited:
visited.add(neighbor)
queue.append(neighbor)
min_r = min(c[0] for c in group)
max_r = max(c[0] for c in group)
min_c = min(c[1] for c in group)
max_c = max(c[1] for c in group)
px_x1 = min_c * cell_size
px_y1 = min_r * cell_size
px_x2 = min((max_c + 1) * cell_size, max_w)
px_y2 = min((max_r + 1) * cell_size, max_h)
scale_pt = 72.0 / float(dpi)
pt_x = round(px_x1 * scale_pt, 2)
pt_y = round(px_y1 * scale_pt, 2)
pt_w = round((px_x2 - px_x1) * scale_pt, 2)
pt_h = round((px_y2 - px_y1) * scale_pt, 2)
diff_id = f"diff-{uuid.uuid4().hex[:8]}"
diffs.append(
Difference(
id=diff_id,
type="VISUAL_CHANGED",
pageA=page_index,
pageB=page_index,
bboxA=BoundingBox(x=pt_x, y=pt_y, width=pt_w, height=pt_h),
bboxB=BoundingBox(x=pt_x, y=pt_y, width=pt_w, height=pt_h),
details=f"Visual content difference detected at ({pt_x}, {pt_y}, {pt_w}x{pt_h} pt)",
)
)
except Exception:
pass
return diffs
def compare_documents(
docA_info: dict[str, Any],
docB_info: dict[str, Any],
include_visual_diff: bool = True,
dpi: int = 144,
) -> CompareResponse:
docA = docA_info["doc_instance"]
docB = docB_info["doc_instance"]
pagesA = docA_info["totalPages"]
pagesB = docB_info["totalPages"]
differences: list[Difference] = []
page_map: list[PageMapEntry] = []
min_pages = min(pagesA, pagesB)
pages_added_cnt = 0
pages_removed_cnt = 0
pages_modified_cnt = 0
text_diff_cnt = 0
layout_diff_cnt = 0
annot_diff_cnt = 0
visual_diff_cnt = 0
# --- Step 1: Compare Aligned Pages ---
for i in range(min_pages):
page_diffs: list[Difference] = []
try:
pA = docA.get_page(i)
pB = docB.get_page(i)
wA, hA = pA.width, pA.height
wB, hB = pB.width, pB.height
# Check page size or rotation changes
if abs(wA - wB) > 0.5 or abs(hA - hB) > 0.5:
diff_id = f"diff-{uuid.uuid4().hex[:8]}"
d = Difference(
id=diff_id,
type="PAGE_MODIFIED",
pageA=i,
pageB=i,
details=f"Page dimensions changed from {wA:.1f}x{hA:.1f} pt to {wB:.1f}x{hB:.1f} pt",
)
page_diffs.append(d)
layout_diff_cnt += 1
except Exception:
pass
# --- Step 2: Compare Text & Layout on Page i ---
runsA = extract_text_runs(docA, i)
runsB = extract_text_runs(docB, i)
matched_A = set()
matched_B = set()
# Phase 2a: Spatial and exact text matching
for idxA, rA in enumerate(runsA):
for idxB, rB in enumerate(runsB):
if idxB in matched_B:
continue
if rA["text"] == rB["text"] and (
_bbox_overlap(rA["bbox"], rB["bbox"]) or _bbox_distance(rA["bbox"], rB["bbox"]) < 20.0
):
matched_A.add(idxA)
matched_B.add(idxB)
# Check movement
dist = _bbox_distance(rA["bbox"], rB["bbox"])
if dist > 2.0:
diff_id = f"diff-{uuid.uuid4().hex[:8]}"
page_diffs.append(
Difference(
id=diff_id,
type="TEXT_MOVED",
pageA=i,
pageB=i,
bboxA=BoundingBox(**rA["bbox"]),
bboxB=BoundingBox(**rB["bbox"]),
textA=rA["text"],
textB=rB["text"],
details=f"Text position shifted by {dist:.1f} pt",
)
)
layout_diff_cnt += 1
# Check resize
w_diff = abs(rA["bbox"]["width"] - rB["bbox"]["width"])
h_diff = abs(rA["bbox"]["height"] - rB["bbox"]["height"])
if w_diff > 5.0 or h_diff > 5.0:
diff_id = f"diff-{uuid.uuid4().hex[:8]}"
page_diffs.append(
Difference(
id=diff_id,
type="TEXT_RESIZED",
pageA=i,
pageB=i,
bboxA=BoundingBox(**rA["bbox"]),
bboxB=BoundingBox(**rB["bbox"]),
textA=rA["text"],
textB=rB["text"],
details=f"Text bounding box size changed from {rA['bbox']['width']}x{rA['bbox']['height']} to {rB['bbox']['width']}x{rB['bbox']['height']}",
)
)
layout_diff_cnt += 1
# Check font / styling
font_changes = []
if rA["fontName"] != rB["fontName"]:
font_changes.append(f"font family ({rA['fontName']} -> {rB['fontName']})")
if abs(rA["fontSize"] - rB["fontSize"]) > 0.5:
font_changes.append(f"font size ({rA['fontSize']} -> {rB['fontSize']} pt)")
if rA["fontWeight"] != rB["fontWeight"]:
font_changes.append(f"font weight ({rA['fontWeight']} -> {rB['fontWeight']})")
if rA["fontStyle"] != rB["fontStyle"]:
font_changes.append(f"font style ({rA['fontStyle']} -> {rB['fontStyle']})")
if rA["fillColor"] != rB["fillColor"]:
font_changes.append(f"color ({rA['fillColor']} -> {rB['fillColor']})")
if font_changes:
diff_id = f"diff-{uuid.uuid4().hex[:8]}"
page_diffs.append(
Difference(
id=diff_id,
type="FONT_CHANGED",
pageA=i,
pageB=i,
bboxA=BoundingBox(**rA["bbox"]),
bboxB=BoundingBox(**rB["bbox"]),
textA=rA["text"],
textB=rB["text"],
details=f"Font formatting changed: {', '.join(font_changes)}",
)
)
text_diff_cnt += 1
break
# Phase 2b: Diffing unmatched text sequences
unmatched_runsA = [r for idx, r in enumerate(runsA) if idx not in matched_A]
unmatched_runsB = [r for idx, r in enumerate(runsB) if idx not in matched_B]
textsA = [r["text"] for r in unmatched_runsA]
textsB = [r["text"] for r in unmatched_runsB]
matcher = difflib.SequenceMatcher(None, textsA, textsB)
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
if tag == "equal":
for k in range(i2 - i1):
rA = unmatched_runsA[i1 + k]
rB = unmatched_runsB[j1 + k]
dist = _bbox_distance(rA["bbox"], rB["bbox"])
if dist > 2.0:
diff_id = f"diff-{uuid.uuid4().hex[:8]}"
page_diffs.append(
Difference(
id=diff_id,
type="TEXT_MOVED",
pageA=i,
pageB=i,
bboxA=BoundingBox(**rA["bbox"]),
bboxB=BoundingBox(**rB["bbox"]),
textA=rA["text"],
textB=rB["text"],
details=f"Text position shifted by {dist:.1f} pt",
)
)
layout_diff_cnt += 1
elif tag == "replace":
subA = unmatched_runsA[i1:i2]
subB = unmatched_runsB[j1:j2]
used_subB = set()
for itemA in subA:
best_match_idx = None
min_dist = 60.0
for idxB_sub, itemB in enumerate(subB):
if idxB_sub in used_subB:
continue
dist = _bbox_distance(itemA["bbox"], itemB["bbox"])
if dist < min_dist:
min_dist = dist
best_match_idx = idxB_sub
diff_id = f"diff-{uuid.uuid4().hex[:8]}"
if best_match_idx is not None:
used_subB.add(best_match_idx)
itemB = subB[best_match_idx]
page_diffs.append(
Difference(
id=diff_id,
type="TEXT_MODIFIED",
pageA=i,
pageB=i,
bboxA=BoundingBox(**itemA["bbox"]),
bboxB=BoundingBox(**itemB["bbox"]),
textA=itemA["text"],
textB=itemB["text"],
details=f"Text changed from '{itemA['text']}' to '{itemB['text']}'",
)
)
text_diff_cnt += 1
else:
page_diffs.append(
Difference(
id=diff_id,
type="TEXT_REMOVED",
pageA=i,
pageB=i,
bboxA=BoundingBox(**itemA["bbox"]),
textA=itemA["text"],
details=f"Text removed: '{itemA['text']}'",
)
)
text_diff_cnt += 1
for idxB_sub, itemB in enumerate(subB):
if idxB_sub not in used_subB:
diff_id = f"diff-{uuid.uuid4().hex[:8]}"
page_diffs.append(
Difference(
id=diff_id,
type="TEXT_ADDED",
pageA=i,
pageB=i,
bboxB=BoundingBox(**itemB["bbox"]),
textB=itemB["text"],
details=f"Text added: '{itemB['text']}'",
)
)
text_diff_cnt += 1
elif tag == "delete":
for itemA in unmatched_runsA[i1:i2]:
diff_id = f"diff-{uuid.uuid4().hex[:8]}"
page_diffs.append(
Difference(
id=diff_id,
type="TEXT_REMOVED",
pageA=i,
pageB=i,
bboxA=BoundingBox(**itemA["bbox"]),
textA=itemA["text"],
details=f"Text removed: '{itemA['text']}'",
)
)
text_diff_cnt += 1
elif tag == "insert":
for itemB in unmatched_runsB[j1:j2]:
diff_id = f"diff-{uuid.uuid4().hex[:8]}"
page_diffs.append(
Difference(
id=diff_id,
type="TEXT_ADDED",
pageA=i,
pageB=i,
bboxB=BoundingBox(**itemB["bbox"]),
textB=itemB["text"],
details=f"Text added: '{itemB['text']}'",
)
)
text_diff_cnt += 1
# --- Step 3: Compare Annotations on Page i ---
annotsA = extract_annotations_dict(docA, i)
annotsB = extract_annotations_dict(docB, i)
matched_annots_B = set()
for aA in annotsA:
matched_aB_idx = None
for idxB, aB in enumerate(annotsB):
if idxB in matched_annots_B:
continue
if aA["type"] == aB["type"] and _bbox_distance(aA["bbox"], aB["bbox"]) < 20.0:
matched_aB_idx = idxB
break
diff_id = f"diff-{uuid.uuid4().hex[:8]}"
if matched_aB_idx is not None:
matched_annots_B.add(matched_aB_idx)
aB = annotsB[matched_aB_idx]
if aA["content"] != aB["content"] or aA["color"] != aB["color"]:
page_diffs.append(
Difference(
id=diff_id,
type="ANNOTATION_CHANGED",
pageA=i,
pageB=i,
bboxA=BoundingBox(**aA["bbox"]),
bboxB=BoundingBox(**aB["bbox"]),
textA=aA["content"],
textB=aB["content"],
details=f"Annotation ({aA['type']}) modified",
)
)
annot_diff_cnt += 1
else:
page_diffs.append(
Difference(
id=diff_id,
type="ANNOTATION_REMOVED",
pageA=i,
pageB=i,
bboxA=BoundingBox(**aA["bbox"]),
textA=aA["content"],
details=f"Annotation ({aA['type']}) removed",
)
)
annot_diff_cnt += 1
for idxB, aB in enumerate(annotsB):
if idxB not in matched_annots_B:
diff_id = f"diff-{uuid.uuid4().hex[:8]}"
page_diffs.append(
Difference(
id=diff_id,
type="ANNOTATION_ADDED",
pageA=i,
pageB=i,
bboxB=BoundingBox(**aB["bbox"]),
textB=aB["content"],
details=f"Annotation ({aB['type']}) added",
)
)
annot_diff_cnt += 1
# --- Step 4 (Phase 3B): Compute Visual Differences on Page i ---
if include_visual_diff:
v_diffs = compute_visual_page_differences(docA, docB, i, dpi=dpi)
page_diffs.extend(v_diffs)
visual_diff_cnt += len(v_diffs)
status = "identical" if len(page_diffs) == 0 else "modified"
if status == "modified":
pages_modified_cnt += 1
page_map.append(
PageMapEntry(
pageA=i,
pageB=i,
status=status,
differencesCount=len(page_diffs),
)
)
differences.extend(page_diffs)
# --- Step 5: Handle Excess Pages in Document B (PAGE_ADDED) ---
if pagesB > min_pages:
for j in range(min_pages, pagesB):
pages_added_cnt += 1
diff_id = f"diff-{uuid.uuid4().hex[:8]}"
diff = Difference(
id=diff_id,
type="PAGE_ADDED",
pageA=None,
pageB=j,
details=f"Page {j + 1} added to Document B",
)
differences.append(diff)
layout_diff_cnt += 1
page_map.append(
PageMapEntry(
pageA=None,
pageB=j,
status="added",
differencesCount=1,
)
)
# --- Step 6: Handle Excess Pages in Document A (PAGE_REMOVED) ---
if pagesA > min_pages:
for k in range(min_pages, pagesA):
pages_removed_cnt += 1
diff_id = f"diff-{uuid.uuid4().hex[:8]}"
diff = Difference(
id=diff_id,
type="PAGE_REMOVED",
pageA=k,
pageB=None,
details=f"Page {k + 1} removed from Document A",
)
differences.append(diff)
layout_diff_cnt += 1
page_map.append(
PageMapEntry(
pageA=k,
pageB=None,
status="removed",
differencesCount=1,
)
)
summary = CompareSummary(
pagesA=pagesA,
pagesB=pagesB,
pagesAdded=pages_added_cnt,
pagesRemoved=pages_removed_cnt,
pagesModified=pages_modified_cnt,
totalDifferences=len(differences),
textDifferences=text_diff_cnt,
layoutDifferences=layout_diff_cnt,
annotationDifferences=annot_diff_cnt,
visualDifferences=visual_diff_cnt,
)
return CompareResponse(
documentIdA=docA_info["id"],
documentIdB=docB_info["id"],
summary=summary,
pageMap=page_map,
differences=differences,
)
+564
View File
@@ -0,0 +1,564 @@
import time
import pytest
from fastapi.testclient import TestClient
from PIL import Image, ImageDraw
from app.main import app
from app.services.store import document_store
client = TestClient(app)
# --- Mock Primitives with PIL Tile Renderer ---
class MockRun:
def __init__(
self,
text: str,
x: float = 50.0,
y: float = 100.0,
w: float = 100.0,
h: float = 15.0,
fontName: str = "Helvetica",
fontSize: float = 12.0,
fontWeight: int = 400,
fontStyle: str = "normal",
fillColor: str = "#000000",
):
self.text = text
self.x = x
self.y = y
self.w = w
self.h = h
self.fontName = fontName
self.fontSize = fontSize
self.fontWeight = fontWeight
self.fontStyle = fontStyle
self.fillColor = fillColor
class MockLine:
def __init__(self, runs: list[MockRun]):
self.runs = runs
class MockParagraph:
def __init__(self, lines: list[MockLine]):
self.lines = lines
class MockPageModel:
def __init__(self, paragraphs: list[MockParagraph]):
self.paragraphs = paragraphs
class MockAnnotation:
def __init__(
self,
id: str,
type: str,
x: float,
y: float,
width: float,
height: float,
color: str = "",
author: str = "",
content: str = "",
):
self.id = id
self.type = type
self.x = x
self.y = y
self.width = width
self.height = height
self.color = color
self.author = author
self.content = content
class MockPage:
def __init__(
self,
width: float = 612.0,
height: float = 792.0,
paragraphs: list[MockParagraph] | None = None,
annotations: list[MockAnnotation] | None = None,
bg_color: tuple = (255, 255, 255),
custom_elements: list[dict] | None = None,
noise_level: int = 0,
):
self.width = width
self.height = height
self._paragraphs = paragraphs or []
self._annotations = annotations or []
self.bg_color = bg_color
self.custom_elements = custom_elements or []
self.noise_level = noise_level
def extract_document_model(self):
return MockPageModel(self._paragraphs)
def extract_annotations(self):
return self._annotations
def render_tile(self, dpi: int, xPt: float, yPt: float, wPt: float, hPt: float):
scale = dpi / 72.0
w_px = max(1, int(round(wPt * scale)))
h_px = max(1, int(round(hPt * scale)))
img = Image.new("RGBA", (w_px, h_px), self.bg_color + (255,))
draw = ImageDraw.Draw(img)
# Render custom elements (shapes, images, colors)
for elem in self.custom_elements:
kind = elem.get("type")
if kind == "rect":
rx = int(elem["x"] * scale)
ry = int(elem["y"] * scale)
rw = int(elem["w"] * scale)
rh = int(elem["h"] * scale)
fill = elem.get("color", "#ff0000")
draw.rectangle([rx, ry, rx + rw, ry + rh], fill=fill)
elif kind == "text":
tx = int(elem["x"] * scale)
ty = int(elem["y"] * scale)
draw.text((tx, ty), elem["text"], fill=elem.get("color", "#000000"))
# Render text runs
for p in self._paragraphs:
for line in p.lines:
for run in line.runs:
rx = int(run.x * scale)
ry = int(run.y * scale)
draw.text((rx, ry), run.text, fill=run.fillColor)
# Add minor subpixel noise if specified (for anti-aliasing noise test)
if self.noise_level > 0:
pixels = img.load()
for x in range(min(50, w_px)):
for y in range(min(50, h_px)):
r, g, b, a = pixels[x, y]
pixels[x, y] = (
min(255, r + self.noise_level),
min(255, g + self.noise_level),
min(255, b + self.noise_level),
a,
)
return w_px, h_px, img.tobytes()
class MockDocument:
def __init__(self, pages: list[MockPage]):
self.page_count = len(pages)
self._pages = pages
def get_page(self, idx: int):
return self._pages[idx]
def create_registered_doc(filename: str, pages: list[MockPage], password: str = "", permissions: dict | None = None) -> str:
mock_doc = MockDocument(pages)
info = document_store.add_document(
filename=filename,
bytes_data=b"%PDF-1.4 mock content",
doc_instance=mock_doc,
permissions=permissions,
password=password,
)
return info["id"]
# --- Phase 3A Integration Tests ---
def test_compare_identical_pdfs():
"""TEST 1: Comparing identical PDFs yields 0 differences."""
page = MockPage(paragraphs=[MockParagraph([MockLine([MockRun("Hello World")])])])
docA_id = create_registered_doc("docA.pdf", [page])
docB_id = create_registered_doc("docB.pdf", [page])
resp = client.post("/documents/compare", json={"documentIdA": docA_id, "documentIdB": docB_id, "includeVisualDiff": False})
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["summary"]["totalDifferences"] == 0
assert data["summary"]["pagesModified"] == 0
assert data["pageMap"][0]["status"] == "identical"
def test_compare_text_modification():
"""TEST 2: Text modification detected as TEXT_MODIFIED."""
pageA = MockPage(paragraphs=[MockParagraph([MockLine([MockRun("Total: $1,000", x=50.0, y=100.0)])])])
pageB = MockPage(paragraphs=[MockParagraph([MockLine([MockRun("Total: $1,250", x=50.0, y=100.0)])])])
docA_id = create_registered_doc("docA.pdf", [pageA])
docB_id = create_registered_doc("docB.pdf", [pageB])
resp = client.post("/documents/compare", json={"documentIdA": docA_id, "documentIdB": docB_id, "includeVisualDiff": False})
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["summary"]["textDifferences"] == 1
diff = data["differences"][0]
assert diff["type"] == "TEXT_MODIFIED"
assert diff["textA"] == "Total: $1,000"
assert diff["textB"] == "Total: $1,250"
def test_compare_text_addition():
"""TEST 3: Text addition detected as TEXT_ADDED."""
pageA = MockPage(paragraphs=[])
pageB = MockPage(paragraphs=[MockParagraph([MockLine([MockRun("Newly Added Paragraph", x=100.0, y=200.0)])])])
docA_id = create_registered_doc("docA.pdf", [pageA])
docB_id = create_registered_doc("docB.pdf", [pageB])
resp = client.post("/documents/compare", json={"documentIdA": docA_id, "documentIdB": docB_id, "includeVisualDiff": False})
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["summary"]["textDifferences"] == 1
diff = data["differences"][0]
assert diff["type"] == "TEXT_ADDED"
assert diff["textB"] == "Newly Added Paragraph"
def test_compare_text_deletion():
"""TEST 4: Text deletion detected as TEXT_REMOVED."""
pageA = MockPage(paragraphs=[MockParagraph([MockLine([MockRun("Text to be removed", x=100.0, y=200.0)])])])
pageB = MockPage(paragraphs=[])
docA_id = create_registered_doc("docA.pdf", [pageA])
docB_id = create_registered_doc("docB.pdf", [pageB])
resp = client.post("/documents/compare", json={"documentIdA": docA_id, "documentIdB": docB_id, "includeVisualDiff": False})
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["summary"]["textDifferences"] == 1
diff = data["differences"][0]
assert diff["type"] == "TEXT_REMOVED"
assert diff["textA"] == "Text to be removed"
def test_compare_text_movement():
"""TEST 5: Text movement detected as TEXT_MOVED."""
pageA = MockPage(paragraphs=[MockParagraph([MockLine([MockRun("Movable Content", x=50.0, y=50.0)])])])
pageB = MockPage(paragraphs=[MockParagraph([MockLine([MockRun("Movable Content", x=200.0, y=350.0)])])])
docA_id = create_registered_doc("docA.pdf", [pageA])
docB_id = create_registered_doc("docB.pdf", [pageB])
resp = client.post("/documents/compare", json={"documentIdA": docA_id, "documentIdB": docB_id, "includeVisualDiff": False})
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["summary"]["layoutDifferences"] == 1
diff = data["differences"][0]
assert diff["type"] == "TEXT_MOVED"
assert diff["textA"] == "Movable Content"
def test_compare_text_resize():
"""TEST 6: Text bounding box resize detected as TEXT_RESIZED."""
pageA = MockPage(paragraphs=[MockParagraph([MockLine([MockRun("Resizable Block", x=100.0, y=100.0, w=80.0, h=20.0)])])])
pageB = MockPage(paragraphs=[MockParagraph([MockLine([MockRun("Resizable Block", x=100.0, y=100.0, w=200.0, h=50.0)])])])
docA_id = create_registered_doc("docA.pdf", [pageA])
docB_id = create_registered_doc("docB.pdf", [pageB])
resp = client.post("/documents/compare", json={"documentIdA": docA_id, "documentIdB": docB_id, "includeVisualDiff": False})
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["summary"]["layoutDifferences"] == 1
diff = data["differences"][0]
assert diff["type"] == "TEXT_RESIZED"
def test_compare_font_weight_change():
"""TEST 7: Font style/weight/size change detected as FONT_CHANGED."""
pageA = MockPage(paragraphs=[MockParagraph([MockLine([MockRun("Title Text", x=100.0, y=100.0, fontName="Helvetica", fontSize=12.0, fontWeight=400)])])])
pageB = MockPage(paragraphs=[MockParagraph([MockLine([MockRun("Title Text", x=100.0, y=100.0, fontName="Helvetica-Bold", fontSize=18.0, fontWeight=700)])])])
docA_id = create_registered_doc("docA.pdf", [pageA])
docB_id = create_registered_doc("docB.pdf", [pageB])
resp = client.post("/documents/compare", json={"documentIdA": docA_id, "documentIdB": docB_id, "includeVisualDiff": False})
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["summary"]["textDifferences"] == 1
diff = data["differences"][0]
assert diff["type"] == "FONT_CHANGED"
def test_compare_page_added():
"""TEST 8: Page addition detected as PAGE_ADDED."""
page1 = MockPage()
page2 = MockPage()
docA_id = create_registered_doc("docA.pdf", [page1])
docB_id = create_registered_doc("docB.pdf", [page1, page2])
resp = client.post("/documents/compare", json={"documentIdA": docA_id, "documentIdB": docB_id, "includeVisualDiff": False})
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["summary"]["pagesAdded"] == 1
assert data["pageMap"][1]["status"] == "added"
diff = [d for d in data["differences"] if d["type"] == "PAGE_ADDED"][0]
assert diff["pageB"] == 1
def test_compare_page_removed():
"""TEST 9: Page removal detected as PAGE_REMOVED."""
page1 = MockPage()
page2 = MockPage()
docA_id = create_registered_doc("docA.pdf", [page1, page2])
docB_id = create_registered_doc("docB.pdf", [page1])
resp = client.post("/documents/compare", json={"documentIdA": docA_id, "documentIdB": docB_id, "includeVisualDiff": False})
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["summary"]["pagesRemoved"] == 1
assert data["pageMap"][1]["status"] == "removed"
diff = [d for d in data["differences"] if d["type"] == "PAGE_REMOVED"][0]
assert diff["pageA"] == 1
def test_compare_page_rotation_change():
"""TEST 10: Page size/rotation change detected as PAGE_MODIFIED."""
pageA = MockPage(width=612.0, height=792.0)
pageB = MockPage(width=792.0, height=612.0)
docA_id = create_registered_doc("docA.pdf", [pageA])
docB_id = create_registered_doc("docB.pdf", [pageB])
resp = client.post("/documents/compare", json={"documentIdA": docA_id, "documentIdB": docB_id, "includeVisualDiff": False})
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["summary"]["layoutDifferences"] == 1
diff = data["differences"][0]
assert diff["type"] == "PAGE_MODIFIED"
def test_compare_annotation_added():
"""TEST 11: Annotation addition detected as ANNOTATION_ADDED."""
pageA = MockPage(annotations=[])
pageB = MockPage(annotations=[MockAnnotation("a1", "comment", 100.0, 100.0, 20.0, 20.0, content="Review Note")])
docA_id = create_registered_doc("docA.pdf", [pageA])
docB_id = create_registered_doc("docB.pdf", [pageB])
resp = client.post("/documents/compare", json={"documentIdA": docA_id, "documentIdB": docB_id, "includeVisualDiff": False})
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["summary"]["annotationDifferences"] == 1
diff = data["differences"][0]
assert diff["type"] == "ANNOTATION_ADDED"
def test_compare_annotation_removed():
"""TEST 12: Annotation removal detected as ANNOTATION_REMOVED."""
pageA = MockPage(annotations=[MockAnnotation("a1", "comment", 100.0, 100.0, 20.0, 20.0, content="Review Note")])
pageB = MockPage(annotations=[])
docA_id = create_registered_doc("docA.pdf", [pageA])
docB_id = create_registered_doc("docB.pdf", [pageB])
resp = client.post("/documents/compare", json={"documentIdA": docA_id, "documentIdB": docB_id, "includeVisualDiff": False})
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["summary"]["annotationDifferences"] == 1
diff = data["differences"][0]
assert diff["type"] == "ANNOTATION_REMOVED"
def test_compare_nonexistent_document():
"""TEST 13: Nonexistent document ID returns 404."""
page = MockPage()
docA_id = create_registered_doc("docA.pdf", [page])
resp = client.post("/documents/compare", json={"documentIdA": docA_id, "documentIdB": "nonexistent-uuid"})
assert resp.status_code == 404
assert "Document B not found" in resp.json()["detail"]
def test_compare_unauthenticated_protected_pdf():
"""TEST 14: Unauthenticated protected PDF returns 401."""
page = MockPage()
docA_id = create_registered_doc("docA.pdf", [page])
prot_perms = {"isEncrypted": True, "canCopy": False, "ownerUnlocked": False}
docB_id = create_registered_doc("docB_prot.pdf", [page], password="", permissions=prot_perms)
resp = client.post("/documents/compare", json={"documentIdA": docA_id, "documentIdB": docB_id})
assert resp.status_code == 401
assert "Password required" in resp.json()["detail"]
def test_compare_different_page_counts():
"""TEST 15: Different page counts are handled correctly in pageMap and summary."""
p1 = MockPage()
p2 = MockPage()
p3 = MockPage()
docA_id = create_registered_doc("docA.pdf", [p1])
docB_id = create_registered_doc("docB.pdf", [p1, p2, p3])
resp = client.post("/documents/compare", json={"documentIdA": docA_id, "documentIdB": docB_id, "includeVisualDiff": False})
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["summary"]["pagesA"] == 1
assert data["summary"]["pagesB"] == 3
assert data["summary"]["pagesAdded"] == 2
assert len(data["pageMap"]) == 3
assert data["pageMap"][0]["status"] == "identical"
# --- Phase 3B Visual Comparison Tests ---
def test_compare_identical_pdfs_visual():
"""TEST 16 (Phase 3B): Identical PDFs yield 0 visual differences."""
page = MockPage(paragraphs=[MockParagraph([MockLine([MockRun("Static Text", x=50.0, y=50.0)])])])
docA_id = create_registered_doc("docA.pdf", [page])
docB_id = create_registered_doc("docB.pdf", [page])
resp = client.post("/documents/compare", json={"documentIdA": docA_id, "documentIdB": docB_id, "includeVisualDiff": True})
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["summary"]["visualDifferences"] == 0
def test_compare_changed_text_rendered_in_pdf():
"""TEST 17 (Phase 3B): Rendered text change generates VISUAL_CHANGED difference."""
pageA = MockPage(paragraphs=[MockParagraph([MockLine([MockRun("Sample Text A", x=50.0, y=50.0)])])])
pageB = MockPage(paragraphs=[MockParagraph([MockLine([MockRun("Sample Text B", x=50.0, y=50.0)])])])
docA_id = create_registered_doc("docA.pdf", [pageA])
docB_id = create_registered_doc("docB.pdf", [pageB])
resp = client.post("/documents/compare", json={"documentIdA": docA_id, "documentIdB": docB_id, "includeVisualDiff": True})
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["summary"]["visualDifferences"] > 0
types = [d["type"] for d in data["differences"]]
assert "VISUAL_CHANGED" in types
def test_compare_changed_image():
"""TEST 18 (Phase 3B): Added image element detected as VISUAL_CHANGED."""
pageA = MockPage(custom_elements=[])
pageB = MockPage(custom_elements=[{"type": "rect", "x": 100.0, "y": 100.0, "w": 150.0, "h": 100.0, "color": "#0000ff"}])
docA_id = create_registered_doc("docA.pdf", [pageA])
docB_id = create_registered_doc("docB.pdf", [pageB])
resp = client.post("/documents/compare", json={"documentIdA": docA_id, "documentIdB": docB_id, "includeVisualDiff": True})
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["summary"]["visualDifferences"] > 0
vis_diffs = [d for d in data["differences"] if d["type"] == "VISUAL_CHANGED"]
assert len(vis_diffs) > 0
assert vis_diffs[0]["bboxA"]["x"] > 0 or vis_diffs[0]["bboxB"]["x"] > 0
def test_compare_changed_color():
"""TEST 19 (Phase 3B): Changed graphic color detected as VISUAL_CHANGED."""
pageA = MockPage(custom_elements=[{"type": "rect", "x": 50.0, "y": 50.0, "w": 100.0, "h": 50.0, "color": "#ff0000"}])
pageB = MockPage(custom_elements=[{"type": "rect", "x": 50.0, "y": 50.0, "w": 100.0, "h": 50.0, "color": "#00ff00"}])
docA_id = create_registered_doc("docA.pdf", [pageA])
docB_id = create_registered_doc("docB.pdf", [pageB])
resp = client.post("/documents/compare", json={"documentIdA": docA_id, "documentIdB": docB_id, "includeVisualDiff": True})
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["summary"]["visualDifferences"] > 0
def test_compare_moved_visual_element():
"""TEST 20 (Phase 3B): Moved graphic element detected as VISUAL_CHANGED."""
pageA = MockPage(custom_elements=[{"type": "rect", "x": 50.0, "y": 50.0, "w": 80.0, "h": 40.0, "color": "#000000"}])
pageB = MockPage(custom_elements=[{"type": "rect", "x": 250.0, "y": 300.0, "w": 80.0, "h": 40.0, "color": "#000000"}])
docA_id = create_registered_doc("docA.pdf", [pageA])
docB_id = create_registered_doc("docB.pdf", [pageB])
resp = client.post("/documents/compare", json={"documentIdA": docA_id, "documentIdB": docB_id, "includeVisualDiff": True})
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["summary"]["visualDifferences"] > 0
def test_compare_scanned_image_only_pdf():
"""TEST 21 (Phase 3B): Scanned image-only PDF comparison detects visual differences."""
pageA = MockPage(paragraphs=[], custom_elements=[{"type": "text", "x": 100.0, "y": 100.0, "text": "Scanned Document Page 1"}])
pageB = MockPage(paragraphs=[], custom_elements=[{"type": "text", "x": 100.0, "y": 100.0, "text": "Scanned Document Page 1 Modified"}])
docA_id = create_registered_doc("docA.pdf", [pageA])
docB_id = create_registered_doc("docB.pdf", [pageB])
resp = client.post("/documents/compare", json={"documentIdA": docA_id, "documentIdB": docB_id, "includeVisualDiff": True})
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["summary"]["textDifferences"] == 0
assert data["summary"]["visualDifferences"] > 0
def test_compare_different_page_dimensions_visual():
"""TEST 22 (Phase 3B): Uneven page dimensions handled without error."""
pageA = MockPage(width=612.0, height=792.0)
pageB = MockPage(width=792.0, height=612.0)
docA_id = create_registered_doc("docA.pdf", [pageA])
docB_id = create_registered_doc("docB.pdf", [pageB])
resp = client.post("/documents/compare", json={"documentIdA": docA_id, "documentIdB": docB_id, "includeVisualDiff": True})
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["summary"]["pagesModified"] == 1
def test_compare_antialiasing_rendering_variation():
"""TEST 23 (Phase 3B): Minor subpixel noise filtered by thresholding."""
pageA = MockPage(paragraphs=[MockParagraph([MockLine([MockRun("Clean Text")])])], noise_level=0)
pageB = MockPage(paragraphs=[MockParagraph([MockLine([MockRun("Clean Text")])])], noise_level=5)
docA_id = create_registered_doc("docA.pdf", [pageA])
docB_id = create_registered_doc("docB.pdf", [pageB])
resp = client.post("/documents/compare", json={"documentIdA": docA_id, "documentIdB": docB_id, "includeVisualDiff": True})
assert resp.status_code == 200, resp.text
data = resp.json()
# Noise below threshold (5 <= 20) is ignored
assert data["summary"]["visualDifferences"] == 0
def test_compare_protected_authenticated_pdfs_visual():
"""TEST 24 (Phase 3B): Authenticated protected PDF visual comparison succeeds."""
page = MockPage(paragraphs=[MockParagraph([MockLine([MockRun("Secret Document")])])])
prot_perms = {"isEncrypted": True, "canCopy": True, "ownerUnlocked": True}
docA_id = create_registered_doc("docA.pdf", [page], password="Password123", permissions=prot_perms)
docB_id = create_registered_doc("docB.pdf", [page], password="Password123", permissions=prot_perms)
resp = client.post("/documents/compare", json={"documentIdA": docA_id, "documentIdB": docB_id, "includeVisualDiff": True})
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["summary"]["totalDifferences"] == 0
def test_compare_large_multipage_document_performance():
"""TEST 25 (Phase 3B): Multi-page document processed page-by-page efficiently."""
pages_a = [MockPage(paragraphs=[MockParagraph([MockLine([MockRun(f"Page {i}")])])]) for i in range(5)]
pages_b = [MockPage(paragraphs=[MockParagraph([MockLine([MockRun(f"Page {i}")])])]) for i in range(5)]
# Tweak page 3 in doc B
pages_b[3] = MockPage(custom_elements=[{"type": "rect", "x": 100.0, "y": 100.0, "w": 50.0, "h": 50.0}])
docA_id = create_registered_doc("multiA.pdf", pages_a)
docB_id = create_registered_doc("multiB.pdf", pages_b)
t0 = time.time()
resp = client.post("/documents/compare", json={"documentIdA": docA_id, "documentIdB": docB_id, "includeVisualDiff": True})
elapsed = time.time() - t0
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["summary"]["pagesA"] == 5
assert data["summary"]["pagesB"] == 5
assert elapsed < 5.0 # Processing 5 pages in <5 seconds