Add PDF processing and multi-format document conversion
Features added: - PDF to image conversion with configurable DPI - Multi-page PDF processing with OCR - Export to Markdown, HTML, DOCX, and JSON formats - Automatic image extraction from PDFs - Formula and formatting preservation - Real-time progress tracking for multi-page documents Backend changes: - New /api/process-pdf endpoint for PDF processing - pdf_utils.py: PDF conversion and image extraction utilities - format_converter.py: Document format conversion (MD, HTML, DOCX) - Updated dependencies: PyMuPDF, img2pdf, python-docx, markdown Frontend changes: - File type toggle (Image OCR / PDF Processing) - PDFProcessor component with format selection - Updated ImageUpload to support both images and PDFs - Progress bars for multi-page processing - Download options for converted documents Documentation: - Updated README with PDF processing features - Added API documentation for /api/process-pdf endpoint - Added format conversion examples
This commit is contained in:
@@ -1,16 +1,18 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Sparkles, Zap, Loader2, Settings } from 'lucide-react'
|
||||
import { Sparkles, Zap, Loader2, Settings, Image as ImageIcon, FileText } from 'lucide-react'
|
||||
import ImageUpload from './components/ImageUpload'
|
||||
import ModeSelector from './components/ModeSelector'
|
||||
import ResultPanel from './components/ResultPanel'
|
||||
import AdvancedSettings from './components/AdvancedSettings'
|
||||
import PDFProcessor from './components/PDFProcessor'
|
||||
import axios from 'axios'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || '/api'
|
||||
|
||||
function App() {
|
||||
const [mode, setMode] = useState('plain_ocr')
|
||||
const [fileType, setFileType] = useState('image') // 'image' or 'pdf'
|
||||
const [image, setImage] = useState(null)
|
||||
const [imagePreview, setImagePreview] = useState(null)
|
||||
const [result, setResult] = useState(null)
|
||||
@@ -29,11 +31,23 @@ function App() {
|
||||
test_compress: false
|
||||
})
|
||||
|
||||
const handleFileTypeChange = useCallback((newType) => {
|
||||
// Clear current file when switching types
|
||||
setImage(null)
|
||||
if (imagePreview) {
|
||||
URL.revokeObjectURL(imagePreview)
|
||||
}
|
||||
setImagePreview(null)
|
||||
setError(null)
|
||||
setResult(null)
|
||||
setFileType(newType)
|
||||
}, [imagePreview])
|
||||
|
||||
const handleImageSelect = useCallback((file) => {
|
||||
if (file === null) {
|
||||
// Clear everything when removing image
|
||||
setImage(null)
|
||||
if (imagePreview) {
|
||||
if (imagePreview && fileType === 'image') {
|
||||
URL.revokeObjectURL(imagePreview)
|
||||
}
|
||||
setImagePreview(null)
|
||||
@@ -41,11 +55,16 @@ function App() {
|
||||
setResult(null)
|
||||
} else {
|
||||
setImage(file)
|
||||
setImagePreview(URL.createObjectURL(file))
|
||||
// Only create preview URL for images, not PDFs
|
||||
if (fileType === 'image') {
|
||||
setImagePreview(URL.createObjectURL(file))
|
||||
} else {
|
||||
setImagePreview(file) // Just store the file for PDFs
|
||||
}
|
||||
setError(null)
|
||||
setResult(null)
|
||||
}
|
||||
}, [imagePreview])
|
||||
}, [imagePreview, fileType])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!image) {
|
||||
@@ -177,9 +196,41 @@ function App() {
|
||||
transition={{ delay: 0.1 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* File Type Toggle */}
|
||||
<div className="glass p-4 rounded-2xl">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<motion.button
|
||||
onClick={() => handleFileTypeChange('image')}
|
||||
className={`p-3 rounded-xl text-sm font-medium transition-all flex items-center justify-center gap-2 ${
|
||||
fileType === 'image'
|
||||
? 'bg-gradient-to-r from-purple-600 to-cyan-600 text-white'
|
||||
: 'glass text-gray-400 hover:bg-white/5'
|
||||
}`}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<ImageIcon className="w-4 h-4" />
|
||||
Image OCR
|
||||
</motion.button>
|
||||
<motion.button
|
||||
onClick={() => handleFileTypeChange('pdf')}
|
||||
className={`p-3 rounded-xl text-sm font-medium transition-all flex items-center justify-center gap-2 ${
|
||||
fileType === 'pdf'
|
||||
? 'bg-gradient-to-r from-purple-600 to-cyan-600 text-white'
|
||||
: 'glass text-gray-400 hover:bg-white/5'
|
||||
}`}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
PDF Processing
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode Selector with integrated inputs */}
|
||||
<ModeSelector
|
||||
mode={mode}
|
||||
<ModeSelector
|
||||
mode={mode}
|
||||
onModeChange={setMode}
|
||||
prompt={prompt}
|
||||
onPromptChange={setPrompt}
|
||||
@@ -187,10 +238,11 @@ function App() {
|
||||
onFindTermChange={setFindTerm}
|
||||
/>
|
||||
|
||||
{/* Image Upload */}
|
||||
<ImageUpload
|
||||
{/* Image/PDF Upload */}
|
||||
<ImageUpload
|
||||
onImageSelect={handleImageSelect}
|
||||
preview={imagePreview}
|
||||
fileType={fileType}
|
||||
/>
|
||||
|
||||
{/* Advanced Settings Toggle */}
|
||||
@@ -226,40 +278,52 @@ function App() {
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Action Button */}
|
||||
<motion.button
|
||||
onClick={handleSubmit}
|
||||
disabled={!image || loading}
|
||||
className={`w-full relative overflow-hidden rounded-2xl p-[2px] ${
|
||||
!image || loading ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
whileHover={!loading && image ? { scale: 1.02 } : {}}
|
||||
whileTap={!loading && image ? { scale: 0.98 } : {}}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-purple-600 via-pink-600 to-cyan-600 animate-gradient" />
|
||||
<div className="relative bg-dark-100 px-8 py-4 rounded-2xl flex items-center justify-center gap-3">
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span className="font-semibold">Processing Magic...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap className="w-5 h-5" />
|
||||
<span className="font-semibold">Analyze Image</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.button>
|
||||
{/* Action Button / PDF Processor */}
|
||||
{fileType === 'pdf' ? (
|
||||
<PDFProcessor
|
||||
pdfFile={image}
|
||||
mode={mode}
|
||||
prompt={prompt}
|
||||
advancedSettings={advancedSettings}
|
||||
includeCaption={includeCaption}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<motion.button
|
||||
onClick={handleSubmit}
|
||||
disabled={!image || loading}
|
||||
className={`w-full relative overflow-hidden rounded-2xl p-[2px] ${
|
||||
!image || loading ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
whileHover={!loading && image ? { scale: 1.02 } : {}}
|
||||
whileTap={!loading && image ? { scale: 0.98 } : {}}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-purple-600 via-pink-600 to-cyan-600 animate-gradient" />
|
||||
<div className="relative bg-dark-100 px-8 py-4 rounded-2xl flex items-center justify-center gap-3">
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span className="font-semibold">Processing Magic...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap className="w-5 h-5" />
|
||||
<span className="font-semibold">Analyze Image</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.button>
|
||||
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="glass p-4 rounded-2xl border-red-500/50 bg-red-500/10"
|
||||
>
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
</motion.div>
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="glass p-4 rounded-2xl border-red-500/50 bg-red-500/10"
|
||||
>
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import { useCallback } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { Upload, Image as ImageIcon, X } from 'lucide-react'
|
||||
import { Upload, Image as ImageIcon, X, FileText } from 'lucide-react'
|
||||
|
||||
export default function ImageUpload({ onImageSelect, preview }) {
|
||||
export default function ImageUpload({ onImageSelect, preview, fileType = 'image' }) {
|
||||
const onDrop = useCallback((acceptedFiles) => {
|
||||
if (acceptedFiles?.[0]) {
|
||||
onImageSelect(acceptedFiles[0])
|
||||
}
|
||||
}, [onImageSelect])
|
||||
|
||||
const isPDF = fileType === 'pdf'
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
accept: isPDF ? {
|
||||
'application/pdf': ['.pdf']
|
||||
} : {
|
||||
'image/*': ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp']
|
||||
},
|
||||
multiple: false
|
||||
@@ -21,8 +25,14 @@ export default function ImageUpload({ onImageSelect, preview }) {
|
||||
return (
|
||||
<div className="glass p-6 rounded-2xl space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-gray-200">Upload Image</h3>
|
||||
<ImageIcon className="w-5 h-5 text-purple-400" />
|
||||
<h3 className="font-semibold text-gray-200">
|
||||
{isPDF ? 'Upload PDF' : 'Upload Image'}
|
||||
</h3>
|
||||
{isPDF ? (
|
||||
<FileText className="w-5 h-5 text-purple-400" />
|
||||
) : (
|
||||
<ImageIcon className="w-5 h-5 text-purple-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!preview ? (
|
||||
@@ -59,10 +69,18 @@ export default function ImageUpload({ onImageSelect, preview }) {
|
||||
|
||||
<div>
|
||||
<p className="text-lg font-medium text-gray-200">
|
||||
{isDragActive ? 'Drop it like it\'s hot! 🔥' : 'Drag & drop your image'}
|
||||
{isDragActive
|
||||
? 'Drop it like it\'s hot! 🔥'
|
||||
: isPDF
|
||||
? 'Drag & drop your PDF'
|
||||
: 'Drag & drop your image'
|
||||
}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
or click to browse • PNG, JPG, WEBP up to 10MB
|
||||
{isPDF
|
||||
? 'or click to browse • PDF files up to 100MB'
|
||||
: 'or click to browse • PNG, JPG, WEBP up to 10MB'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -73,11 +91,21 @@ export default function ImageUpload({ onImageSelect, preview }) {
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="relative group rounded-2xl overflow-hidden"
|
||||
>
|
||||
<img
|
||||
src={preview}
|
||||
alt="Preview"
|
||||
className="w-full rounded-2xl border border-white/10"
|
||||
/>
|
||||
{isPDF ? (
|
||||
<div className="flex items-center justify-center p-12 bg-white/5 border border-white/10 rounded-2xl">
|
||||
<div className="text-center">
|
||||
<FileText className="w-16 h-16 mx-auto mb-3 text-purple-400" />
|
||||
<p className="text-sm text-gray-300 font-medium">PDF Ready</p>
|
||||
<p className="text-xs text-gray-500 mt-1">{preview?.name || 'Document loaded'}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={preview}
|
||||
alt="Preview"
|
||||
className="w-full rounded-2xl border border-white/10"
|
||||
/>
|
||||
)}
|
||||
<div className="absolute top-3 right-3 flex gap-2">
|
||||
<motion.button
|
||||
onClick={(e) => {
|
||||
@@ -87,7 +115,7 @@ export default function ImageUpload({ onImageSelect, preview }) {
|
||||
className="bg-red-500/90 backdrop-blur-sm px-3 py-2 rounded-full opacity-100 hover:bg-red-600 transition-colors flex items-center gap-2 shadow-lg"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
title="Remove image"
|
||||
title={isPDF ? "Remove PDF" : "Remove image"}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">Remove</span>
|
||||
|
||||
233
frontend/src/components/PDFProcessor.jsx
Normal file
233
frontend/src/components/PDFProcessor.jsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { FileText, Download, Loader2, CheckCircle2, AlertCircle } from 'lucide-react'
|
||||
import axios from 'axios'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || '/api'
|
||||
|
||||
function PDFProcessor({ pdfFile, mode, prompt, advancedSettings, includeCaption }) {
|
||||
const [processing, setProcessing] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [result, setResult] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [outputFormat, setOutputFormat] = useState('markdown')
|
||||
|
||||
const formats = [
|
||||
{ value: 'markdown', label: 'Markdown', ext: 'md', icon: '📝' },
|
||||
{ value: 'html', label: 'HTML', ext: 'html', icon: '🌐' },
|
||||
{ value: 'docx', label: 'Word', ext: 'docx', icon: '📄' },
|
||||
{ value: 'json', label: 'JSON', ext: 'json', icon: '📊' }
|
||||
]
|
||||
|
||||
const handleProcess = useCallback(async () => {
|
||||
if (!pdfFile) return
|
||||
|
||||
setProcessing(true)
|
||||
setError(null)
|
||||
setProgress(0)
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('pdf_file', pdfFile)
|
||||
formData.append('mode', mode)
|
||||
formData.append('prompt', prompt)
|
||||
formData.append('output_format', outputFormat)
|
||||
formData.append('grounding', mode === 'find_ref')
|
||||
formData.append('include_caption', includeCaption)
|
||||
formData.append('extract_images', true)
|
||||
formData.append('dpi', 144)
|
||||
formData.append('base_size', advancedSettings.base_size)
|
||||
formData.append('image_size', advancedSettings.image_size)
|
||||
formData.append('crop_mode', advancedSettings.crop_mode)
|
||||
|
||||
const response = await axios.post(`${API_BASE}/process-pdf`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
responseType: outputFormat === 'json' ? 'json' : 'blob',
|
||||
onUploadProgress: (progressEvent) => {
|
||||
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total)
|
||||
setProgress(percentCompleted)
|
||||
}
|
||||
})
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
setResult(response.data)
|
||||
} else {
|
||||
// For file downloads (markdown, html, docx)
|
||||
const format = formats.find(f => f.value === outputFormat)
|
||||
const blob = new Blob([response.data], {
|
||||
type: response.headers['content-type']
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `ocr_result.${format.ext}`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
setResult({
|
||||
success: true,
|
||||
message: `Document downloaded as ${format.label}`,
|
||||
format: outputFormat
|
||||
})
|
||||
}
|
||||
|
||||
setProgress(100)
|
||||
} catch (err) {
|
||||
console.error('PDF processing error:', err)
|
||||
setError(err.response?.data?.detail || err.message || 'Failed to process PDF')
|
||||
} finally {
|
||||
setProcessing(false)
|
||||
}
|
||||
}, [pdfFile, mode, prompt, outputFormat, includeCaption, advancedSettings])
|
||||
|
||||
const handleDownloadJSON = useCallback(() => {
|
||||
if (!result || outputFormat !== 'json') return
|
||||
|
||||
const blob = new Blob([JSON.stringify(result, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'ocr_result.json'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}, [result, outputFormat])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Format Selector */}
|
||||
<div className="glass p-6 rounded-2xl space-y-3">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-3">
|
||||
Output Format
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{formats.map((format) => (
|
||||
<motion.button
|
||||
key={format.value}
|
||||
onClick={() => setOutputFormat(format.value)}
|
||||
className={`p-3 rounded-xl text-sm font-medium transition-all ${
|
||||
outputFormat === format.value
|
||||
? 'bg-gradient-to-r from-purple-600 to-cyan-600 text-white'
|
||||
: 'glass text-gray-400 hover:bg-white/5'
|
||||
}`}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<span className="mr-2">{format.icon}</span>
|
||||
{format.label}
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Process Button */}
|
||||
<motion.button
|
||||
onClick={handleProcess}
|
||||
disabled={!pdfFile || processing}
|
||||
className={`w-full relative overflow-hidden rounded-2xl p-[2px] ${
|
||||
!pdfFile || processing ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
whileHover={!processing && pdfFile ? { scale: 1.02 } : {}}
|
||||
whileTap={!processing && pdfFile ? { scale: 0.98 } : {}}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-purple-600 via-pink-600 to-cyan-600 animate-gradient" />
|
||||
<div className="relative bg-dark-100 px-8 py-4 rounded-2xl flex items-center justify-center gap-3">
|
||||
{processing ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span className="font-semibold">Processing PDF...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileText className="w-5 h-5" />
|
||||
<span className="font-semibold">Process PDF</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.button>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<AnimatePresence>
|
||||
{processing && progress > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="glass p-4 rounded-2xl"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-400">Processing...</span>
|
||||
<span className="text-sm font-medium text-purple-400">{progress}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-dark-200 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full bg-gradient-to-r from-purple-600 to-cyan-600"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Error Display */}
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="glass p-4 rounded-2xl border-red-500/50 bg-red-500/10 flex items-start gap-3"
|
||||
>
|
||||
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-400">Processing Failed</p>
|
||||
<p className="text-xs text-red-300 mt-1">{error}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Success Display */}
|
||||
<AnimatePresence>
|
||||
{result && !error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="glass p-6 rounded-2xl border-green-500/50 bg-green-500/10"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-green-400">
|
||||
{result.message || 'PDF processed successfully!'}
|
||||
</p>
|
||||
{outputFormat === 'json' && result.pages && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<p className="text-xs text-gray-400">
|
||||
Processed {result.total_pages} page{result.total_pages > 1 ? 's' : ''}
|
||||
</p>
|
||||
<motion.button
|
||||
onClick={handleDownloadJSON}
|
||||
className="glass px-4 py-2 rounded-xl text-sm font-medium hover:bg-white/5 transition-colors flex items-center gap-2"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Download JSON
|
||||
</motion.button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PDFProcessor
|
||||
Reference in New Issue
Block a user