modcomms/frontend/components/ProofPreview.tsx
michael c1030ee292 Add PDF rasterization support for reliable preview and analysis
PDFs are now converted to PNG images at 200 DPI before being sent to
Gemini for analysis. This fixes the unreliable iframe-based PDF preview
and ensures all pages are properly analyzed.

- Add PyMuPDF dependency for PDF rasterization
- Create pdf_service.py with rasterize() and get_page_count()
- Update agent interfaces to accept list of images for multi-page support
- Add analyze_with_images() to Gemini service for multi-image analysis
- Return rasterized PDF pages via WebSocket for frontend display
- Add page navigation UI for multi-page PDFs in preview components

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 09:36:56 -06:00

143 lines
5.3 KiB
TypeScript
Executable file

import React, { useState } from 'react';
import { DocumentIcon } from './icons/DocumentIcon';
import type { PDFPage } from '../types';
interface ProofPreviewProps {
file?: File | null;
previewUrl: string | null;
fileName?: string;
pdfPages?: PDFPage[];
}
export const ProofPreview: React.FC<ProofPreviewProps> = ({ file, previewUrl, fileName, pdfPages }) => {
const [currentPage, setCurrentPage] = useState(1);
if (!previewUrl && (!pdfPages || pdfPages.length === 0)) {
return null;
}
const getMimeType = (): string => {
if (file?.type) return file.type;
if (previewUrl?.startsWith('data:')) {
const match = previewUrl.match(/data:([a-zA-Z0-9]+\/[a-zA-Z0-9-.+]+);/);
if (match && match[1]) {
return match[1];
}
}
return 'application/octet-stream'; // Fallback
};
const fileType = getMimeType();
const displayName = fileName || file?.name || 'Proof Preview';
// Check if we have rasterized PDF pages to display
const hasPdfPages = pdfPages && pdfPages.length > 0;
const totalPages = pdfPages?.length || 0;
const handlePrevPage = () => {
setCurrentPage(prev => Math.max(1, prev - 1));
};
const handleNextPage = () => {
setCurrentPage(prev => Math.min(totalPages, prev + 1));
};
const renderPdfPages = () => {
if (!pdfPages || pdfPages.length === 0) return null;
const currentPdfPage = pdfPages[currentPage - 1];
return (
<div className="flex flex-col">
<img
src={currentPdfPage.data_url}
alt={`${displayName} - Page ${currentPage}`}
className="w-full rounded-lg shadow-2xl object-contain border border-gray-200 bg-white p-2"
style={{ maxHeight: 'calc(100vh - 12rem)' }}
/>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-4 mt-4 p-2 bg-white rounded-lg shadow border border-gray-200">
<button
onClick={handlePrevPage}
disabled={currentPage === 1}
className="px-3 py-1.5 text-sm font-medium rounded-md bg-gray-100 hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Previous
</button>
<span className="text-sm text-gray-600">
Page {currentPage} of {totalPages}
</span>
<button
onClick={handleNextPage}
disabled={currentPage === totalPages}
className="px-3 py-1.5 text-sm font-medium rounded-md bg-gray-100 hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
)}
</div>
);
};
const renderPreview = () => {
// If we have rasterized PDF pages, use those
if (hasPdfPages) {
return renderPdfPages();
}
if (fileType.startsWith('image/')) {
return (
<img
src={previewUrl!}
alt={displayName}
className="w-full rounded-lg shadow-2xl object-contain border border-gray-200 bg-white p-2"
style={{ maxHeight: 'calc(100vh - 9rem)' }}
/>
);
}
if (fileType === 'video/mp4') {
return (
<video
src={previewUrl!}
controls
className="w-full rounded-lg shadow-2xl object-contain border border-gray-200 bg-white p-2"
style={{ maxHeight: 'calc(100vh - 9rem)' }}
>
Your browser does not support the video tag.
</video>
);
}
if (fileType === 'application/pdf') {
// Fallback to iframe if no rasterized pages available
return (
<iframe
src={`${previewUrl}#view=fitH`}
title={displayName}
className="w-full h-[calc(100vh-9rem)] rounded-lg shadow-2xl border border-gray-200 bg-white"
/>
);
}
// Fallback for other file types
return (
<div
className="w-full rounded-lg shadow-2xl border border-gray-200 bg-white p-8 flex flex-col items-center justify-center text-center"
style={{ minHeight: '300px', maxHeight: 'calc(100vh - 9rem)' }}
>
<DocumentIcon className="h-20 w-20 text-gray-400 mb-4" />
<p className="text-lg font-semibold text-brand-dark-blue break-all">{displayName}</p>
<p className="text-sm text-gray-500">{fileType}</p>
<p className="text-sm text-gray-500 mt-2">No preview available for this file type.</p>
</div>
);
};
return (
<div className="sticky top-8">
{renderPreview()}
</div>
);
};