compare pdf:compare the two pdf
This commit is contained in:
+87
-1
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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}",
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user