pdf: add new feature

Signed-off-by: Rodrigo Bertoldo de Sousa Martins <rodrigo.sousa.577@gmail.com>
This commit is contained in:
rodrigo committed 2026-02-27 17:32:18 -03:00
1 parent a63aaeb655
commit 61a1631519
14 files changed
+3091 -1

No files matched your search

+10
View File
@@ -50,6 +50,9 @@ import { DetalhesCalculo } from './components/calculos/DetalhesCalculo';
import { NovoPensaoAlimenticia } from './components/calculos/NovoPensaoAlimenticia';
import { DetalhePensaoAlimenticia } from './components/calculos/DetalhePensaoAlimenticia';
// Importar componente de manipulação de PDF
import { PDFManipulationPage } from './components/pdf/PDFManipulationPage';
const queryClient = new QueryClient();
const router = createBrowserRouter(
@@ -126,6 +129,13 @@ const router = createBrowserRouter(
<DetalhePensaoAlimenticia />
</ProtectedRoute>
} />
{/* Rota para Manipulação de PDF */}
<Route path="/pdf" element={
<ProtectedRoute>
<PDFManipulationPage />
</ProtectedRoute>
} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/login" />} />
+658
View File
@@ -0,0 +1,658 @@
import React, { useState, useCallback } from 'react';
import { useDropzone } from 'react-dropzone';
import axios from 'axios';
import { toast } from 'react-toastify';
import {
DocumentIcon,
CloudArrowUpIcon,
ArrowsPointingInIcon,
ScissorsIcon,
ArrowsUpDownIcon,
PhotoIcon,
DocumentTextIcon,
InformationCircleIcon,
ArrowDownTrayIcon,
ClockIcon,
} from '@heroicons/react/24/outline';
interface OperationResult {
operacaoId: string;
outputPaths?: string[];
outputPath?: string;
processingTime?: number;
reduction?: number;
filesCreated?: number;
text?: string;
confidence?: number;
}
type OperationType =
| 'compress'
| 'split'
| 'merge'
| 'pdfToImages'
| 'imagesToPDF'
| 'ocr'
| 'info';
export const PDFManipulationPage: React.FC = () => {
const [selectedOperation, setSelectedOperation] = useState<OperationType>('compress');
const [files, setFiles] = useState<File[]>([]);
const [processing, setProcessing] = useState(false);
const [result, setResult] = useState<OperationResult | null>(null);
const [history, setHistory] = useState<any[]>([]);
// Configurações para cada operação
const [compressQuality, setCompressQuality] = useState(95);
const [splitType, setSplitType] = useState<'pages' | 'range' | 'chunks'>('pages');
const [splitPages, setSplitPages] = useState<string>('1,2,3');
const [splitRanges, setSplitRanges] = useState<string>('1-5,6-10');
const [chunkSize, setChunkSize] = useState(5);
const [imageFormat, setImageFormat] = useState<'png' | 'jpg' | 'webp'>('png');
const [imageQuality, setImageQuality] = useState(95);
const [imageDensity, setImageDensity] = useState(300);
const [ocrLanguage, setOcrLanguage] = useState('por');
const onDrop = useCallback((acceptedFiles: File[]) => {
setFiles((prev) => [...prev, ...acceptedFiles]);
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
'application/pdf': ['.pdf'],
'image/*': ['.png', '.jpg', '.jpeg', '.webp'],
},
});
const removeFile = (index: number) => {
setFiles((prev) => prev.filter((_, i) => i !== index));
};
const clearFiles = () => {
setFiles([]);
setResult(null);
};
const getToken = () => {
return localStorage.getItem('token');
};
const processFiles = async () => {
if (files.length === 0) {
toast.error('Selecione pelo menos um arquivo');
return;
}
setProcessing(true);
setResult(null);
try {
const formData = new FormData();
files.forEach((file) => {
formData.append('files', file);
});
const token = getToken();
const config = {
headers: {
'Content-Type': 'multipart/form-data',
Authorization: `Bearer ${token}`,
},
};
let response;
switch (selectedOperation) {
case 'compress':
formData.append('quality', compressQuality.toString());
response = await axios.post('/api/pdf/compress', formData, config);
toast.success('PDFs comprimidos com sucesso!');
break;
case 'split':
formData.append('splitType', splitType);
if (splitType === 'pages') {
const pages = splitPages.split(',').map((p) => parseInt(p.trim()));
formData.append('pages', JSON.stringify(pages));
} else if (splitType === 'range') {
const ranges = splitRanges.split(',').map((r) => {
const [start, end] = r.trim().split('-').map(Number);
return { start, end };
});
formData.append('ranges', JSON.stringify(ranges));
} else {
formData.append('chunkSize', chunkSize.toString());
}
response = await axios.post('/api/pdf/split', formData, config);
toast.success('PDF dividido com sucesso!');
break;
case 'merge':
response = await axios.post('/api/pdf/merge', formData, config);
toast.success('PDFs mesclados com sucesso!');
break;
case 'pdfToImages':
formData.append('format', imageFormat);
formData.append('quality', imageQuality.toString());
formData.append('density', imageDensity.toString());
response = await axios.post('/api/pdf/pdf-to-images', formData, config);
toast.success('PDF convertido em imagens!');
break;
case 'imagesToPDF':
formData.append('fitToPage', 'true');
response = await axios.post('/api/pdf/images-to-pdf', formData, config);
toast.success('Imagens convertidas em PDF!');
break;
case 'ocr':
formData.append('language', ocrLanguage);
response = await axios.post('/api/pdf/ocr', formData, config);
toast.success('OCR realizado com sucesso!');
break;
case 'info':
response = await axios.post('/api/pdf/info', formData, config);
break;
}
setResult(response.data);
loadHistory();
} catch (error: any) {
console.error('Erro ao processar arquivos:', error);
toast.error(error.response?.data?.message || 'Erro ao processar arquivos');
} finally {
setProcessing(false);
}
};
const loadHistory = async () => {
try {
const token = getToken();
const response = await axios.get('/api/pdf/history', {
headers: { Authorization: `Bearer ${token}` },
});
setHistory(response.data.operacoes || []);
} catch (error) {
console.error('Erro ao carregar histórico:', error);
}
};
const downloadFile = async (operacaoId: string, fileIndex: number) => {
try {
const token = getToken();
const response = await axios.get(`/api/pdf/download/${operacaoId}/${fileIndex}`, {
headers: { Authorization: `Bearer ${token}` },
responseType: 'blob',
});
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `arquivo-${fileIndex}.pdf`);
document.body.appendChild(link);
link.click();
link.remove();
toast.success('Download iniciado!');
} catch (error) {
console.error('Erro ao fazer download:', error);
toast.error('Erro ao fazer download');
}
};
React.useEffect(() => {
loadHistory();
}, []);
const operations = [
{
id: 'compress' as OperationType,
name: 'Comprimir PDF',
icon: ArrowsPointingInIcon,
description: 'Reduza o tamanho mantendo a qualidade',
},
{
id: 'split' as OperationType,
name: 'Dividir PDF',
icon: ScissorsIcon,
description: 'Separe páginas específicas',
},
{
id: 'merge' as OperationType,
name: 'Mesclar PDFs',
icon: ArrowsUpDownIcon,
description: 'Combine múltiplos PDFs',
},
{
id: 'pdfToImages' as OperationType,
name: 'PDF para Imagens',
icon: PhotoIcon,
description: 'Converta páginas em imagens',
},
{
id: 'imagesToPDF' as OperationType,
name: 'Imagens para PDF',
icon: DocumentIcon,
description: 'Crie PDF a partir de imagens',
},
{
id: 'ocr' as OperationType,
name: 'OCR',
icon: DocumentTextIcon,
description: 'Extraia texto de PDFs',
},
{
id: 'info' as OperationType,
name: 'Informações',
icon: InformationCircleIcon,
description: 'Obtenha detalhes do PDF',
},
];
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
};
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">Manipulação de PDFs</h1>
<p className="mt-2 text-gray-600">
Ferramentas completas para trabalhar com arquivos PDF
</p>
</div>
{/* Seleção de operação */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-4 mb-8">
{operations.map((op) => (
<button
key={op.id}
onClick={() => {
setSelectedOperation(op.id);
clearFiles();
}}
className={`p-4 rounded-lg border-2 transition-all ${
selectedOperation === op.id
? 'border-blue-500 bg-blue-50 text-blue-700'
: 'border-gray-200 bg-white hover:border-gray-300'
}`}
>
<op.icon className="h-8 w-8 mx-auto mb-2" />
<div className="text-sm font-medium">{op.name}</div>
</button>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Área principal */}
<div className="lg:col-span-2 space-y-6">
{/* Upload de arquivos */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-semibold mb-4">Arquivos</h2>
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-lg p-12 text-center cursor-pointer transition-colors ${
isDragActive
? 'border-blue-500 bg-blue-50'
: 'border-gray-300 hover:border-gray-400'
}`}
>
<input {...getInputProps()} />
<CloudArrowUpIcon className="h-12 w-12 mx-auto text-gray-400 mb-4" />
{isDragActive ? (
<p className="text-blue-600">Solte os arquivos aqui...</p>
) : (
<div>
<p className="text-gray-600 mb-2">
Arraste e solte arquivos aqui, ou clique para selecionar
</p>
<p className="text-sm text-gray-500">
PDFs e imagens (PNG, JPG, WEBP)
</p>
</div>
)}
</div>
{/* Lista de arquivos */}
{files.length > 0 && (
<div className="mt-4">
<div className="flex justify-between items-center mb-2">
<h3 className="font-medium">
Arquivos selecionados ({files.length})
</h3>
<button
onClick={clearFiles}
className="text-sm text-red-600 hover:text-red-700"
>
Limpar todos
</button>
</div>
<div className="space-y-2">
{files.map((file, index) => (
<div
key={index}
className="flex items-center justify-between p-3 bg-gray-50 rounded"
>
<div className="flex items-center space-x-3">
<DocumentIcon className="h-5 w-5 text-gray-400" />
<div>
<p className="text-sm font-medium">{file.name}</p>
<p className="text-xs text-gray-500">
{formatBytes(file.size)}
</p>
</div>
</div>
<button
onClick={() => removeFile(index)}
className="text-red-600 hover:text-red-700"
>
Remover
</button>
</div>
))}
</div>
</div>
)}
</div>
{/* Configurações específicas */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-semibold mb-4">Configurações</h2>
{selectedOperation === 'compress' && (
<div>
<label className="block text-sm font-medium mb-2">
Qualidade ({compressQuality}%)
</label>
<input
type="range"
min="50"
max="100"
value={compressQuality}
onChange={(e) => setCompressQuality(parseInt(e.target.value))}
className="w-full"
/>
<p className="text-xs text-gray-500 mt-1">
95% recomendado para qualidade sem perda perceptível
</p>
</div>
)}
{selectedOperation === 'split' && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2">
Tipo de divisão
</label>
<select
value={splitType}
onChange={(e) => setSplitType(e.target.value as any)}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
>
<option value="pages">Páginas específicas</option>
<option value="range">Intervalos</option>
<option value="chunks">Chunks (partes iguais)</option>
</select>
</div>
{splitType === 'pages' && (
<div>
<label className="block text-sm font-medium mb-2">
Páginas (separadas por vírgula)
</label>
<input
type="text"
value={splitPages}
onChange={(e) => setSplitPages(e.target.value)}
placeholder="1,2,3,5,8"
className="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</div>
)}
{splitType === 'range' && (
<div>
<label className="block text-sm font-medium mb-2">
Intervalos (ex: 1-5,6-10)
</label>
<input
type="text"
value={splitRanges}
onChange={(e) => setSplitRanges(e.target.value)}
placeholder="1-5,6-10,11-15"
className="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</div>
)}
{splitType === 'chunks' && (
<div>
<label className="block text-sm font-medium mb-2">
Páginas por chunk
</label>
<input
type="number"
value={chunkSize}
onChange={(e) => setChunkSize(parseInt(e.target.value))}
min="1"
className="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</div>
)}
</div>
)}
{selectedOperation === 'pdfToImages' && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2">Formato</label>
<select
value={imageFormat}
onChange={(e) => setImageFormat(e.target.value as any)}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
>
<option value="png">PNG</option>
<option value="jpg">JPG</option>
<option value="webp">WEBP</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-2">
Qualidade ({imageQuality}%)
</label>
<input
type="range"
min="50"
max="100"
value={imageQuality}
onChange={(e) => setImageQuality(parseInt(e.target.value))}
className="w-full"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">
DPI ({imageDensity})
</label>
<input
type="range"
min="150"
max="600"
step="50"
value={imageDensity}
onChange={(e) => setImageDensity(parseInt(e.target.value))}
className="w-full"
/>
</div>
</div>
)}
{selectedOperation === 'ocr' && (
<div>
<label className="block text-sm font-medium mb-2">Idioma</label>
<select
value={ocrLanguage}
onChange={(e) => setOcrLanguage(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
>
<option value="por">Português</option>
<option value="eng">Inglês</option>
<option value="spa">Espanhol</option>
</select>
</div>
)}
<button
onClick={processFiles}
disabled={processing || files.length === 0}
className={`mt-6 w-full py-3 rounded-lg font-medium transition-colors ${
processing || files.length === 0
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'
}`}
>
{processing ? (
<span className="flex items-center justify-center">
<svg
className="animate-spin h-5 w-5 mr-2"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Processando...
</span>
) : (
'Processar'
)}
</button>
</div>
{/* Resultado */}
{result && (
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-semibold mb-4">Resultado</h2>
{selectedOperation === 'compress' && result.reduction !== undefined && (
<div className="space-y-2">
<p className="text-green-600 font-medium">
Redução de {result.reduction.toFixed(2)}%
</p>
<p className="text-sm text-gray-600">
Tempo de processamento: {result.processingTime}ms
</p>
</div>
)}
{(result.outputPaths || result.outputPath) && (
<div className="mt-4">
<p className="font-medium mb-2">Arquivos gerados:</p>
{result.outputPaths?.map((path, index) => (
<button
key={index}
onClick={() => downloadFile(result.operacaoId, index)}
className="flex items-center space-x-2 w-full p-2 hover:bg-gray-50 rounded"
>
<ArrowDownTrayIcon className="h-5 w-5 text-blue-600" />
<span className="text-sm">Arquivo {index + 1}</span>
</button>
))}
{result.outputPath && (
<button
onClick={() => downloadFile(result.operacaoId, 0)}
className="flex items-center space-x-2 w-full p-2 hover:bg-gray-50 rounded"
>
<ArrowDownTrayIcon className="h-5 w-5 text-blue-600" />
<span className="text-sm">Download</span>
</button>
)}
</div>
)}
{selectedOperation === 'ocr' && result.text && (
<div className="mt-4">
<p className="font-medium mb-2">Texto extraído:</p>
<div className="bg-gray-50 p-4 rounded max-h-64 overflow-y-auto">
<pre className="text-sm whitespace-pre-wrap">{result.text}</pre>
</div>
{result.confidence && (
<p className="text-sm text-gray-600 mt-2">
Confiança: {result.confidence.toFixed(2)}%
</p>
)}
</div>
)}
</div>
)}
</div>
{/* Histórico */}
<div className="lg:col-span-1">
<div className="bg-white rounded-lg shadow p-6 sticky top-8">
<h2 className="text-xl font-semibold mb-4">Histórico</h2>
{history.length === 0 ? (
<p className="text-gray-500 text-sm">Nenhuma operação ainda</p>
) : (
<div className="space-y-3 max-h-96 overflow-y-auto">
{history.map((op) => (
<div key={op.id} className="border-b pb-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{op.tipoOperacao.replace('_', ' ')}
</span>
<span
className={`text-xs px-2 py-1 rounded ${
op.status === 'CONCLUIDO'
? 'bg-green-100 text-green-800'
: op.status === 'ERRO'
? 'bg-red-100 text-red-800'
: 'bg-yellow-100 text-yellow-800'
}`}
>
{op.status}
</span>
</div>
<div className="flex items-center text-xs text-gray-500 mt-1">
<ClockIcon className="h-3 w-3 mr-1" />
{new Date(op.criadoEm).toLocaleString('pt-BR')}
</div>
{op.tempoProcessamento && (
<p className="text-xs text-gray-500 mt-1">
{op.tempoProcessamento}ms
</p>
)}
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
};
+588
View File
@@ -0,0 +1,588 @@
import { PDFDocument } from 'pdf-lib';
import sharp from 'sharp';
import { createWorker } from 'tesseract.js';
import { fromPath } from 'pdf2pic';
import fs from 'fs/promises';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
/**
* Serviço completo de manipulação de PDFs
* Implementa compressão sem perda, divisão, mesclagem, conversão e OCR
*/
interface CompressOptions {
quality?: number; // 0-100, padrão 95 para qualidade alta
maxSizeMB?: number;
}
interface SplitOptions {
splitType: 'pages' | 'range' | 'chunks';
pages?: number[]; // Páginas específicas para extrair
ranges?: Array<{ start: number; end: number }>; // Intervalos de páginas
chunkSize?: number; // Tamanho de cada chunk em páginas
}
interface MergeOptions {
filePaths: string[];
outputPath: string;
}
interface PDFToImageOptions {
format?: 'png' | 'jpg' | 'webp';
quality?: number;
density?: number; // DPI, padrão 300
pages?: number[] | 'all'; // Páginas específicas ou todas
}
interface ImageToPDFOptions {
imagePaths: string[];
outputPath: string;
pageSize?: { width: number; height: number };
fitToPage?: boolean;
}
interface OCROptions {
language?: string; // padrão 'por' (português)
pages?: number[] | 'all';
}
interface OCRResult {
text: string;
confidence: number;
pages: Array<{
pageNumber: number;
text: string;
confidence: number;
}>;
}
export class PDFService {
private static readonly TEMP_DIR = path.join(process.cwd(), 'storage', 'temp', 'pdf-ops');
private static readonly OUTPUT_DIR = path.join(process.cwd(), 'storage', 'pdf-output');
/**
* Inicializa os diretórios necessários
*/
static async initialize(): Promise<void> {
try {
await fs.mkdir(this.TEMP_DIR, { recursive: true });
await fs.mkdir(this.OUTPUT_DIR, { recursive: true });
} catch (error) {
console.error('Erro ao criar diretórios:', error);
throw new Error('Falha ao inicializar diretórios para operações de PDF');
}
}
/**
* Comprime um PDF mantendo a qualidade máxima possível
* Usa otimização de streams e remoção de metadados desnecessários
*/
static async compressPDF(
inputPath: string,
outputPath?: string,
options: CompressOptions = {}
): Promise<{ outputPath: string; originalSize: number; compressedSize: number; reduction: number }> {
try {
await this.initialize();
const { quality = 95 } = options;
// Lê o PDF original
const pdfBytes = await fs.readFile(inputPath);
const originalSize = pdfBytes.length;
// Carrega o PDF
const pdfDoc = await PDFDocument.load(pdfBytes, {
updateMetadata: false,
});
// Remove metadados desnecessários mas mantém metadados essenciais
pdfDoc.setTitle('');
pdfDoc.setAuthor('');
pdfDoc.setSubject('');
pdfDoc.setKeywords([]);
pdfDoc.setProducer('');
pdfDoc.setCreator('');
// Salva com compressão
const compressedBytes = await pdfDoc.save({
useObjectStreams: true, // Comprime streams de objetos
addDefaultPage: false,
objectsPerTick: 50,
});
// Se a compressão não foi suficiente, otimiza as imagens
let finalBytes = compressedBytes;
if (options.maxSizeMB && compressedBytes.length > options.maxSizeMB * 1024 * 1024) {
// Recarrega para otimizar imagens
const reloadedDoc = await PDFDocument.load(compressedBytes);
const pages = reloadedDoc.getPages();
// Processa cada página para otimizar imagens
for (const page of pages) {
// A otimização de imagens seria feita aqui
// Por limitações da pdf-lib, mantemos a qualidade original
}
finalBytes = await reloadedDoc.save({
useObjectStreams: true,
addDefaultPage: false,
});
}
// Define o caminho de saída
const finalOutputPath = outputPath || path.join(
this.OUTPUT_DIR,
`compressed-${uuidv4()}.pdf`
);
// Salva o arquivo comprimido
await fs.writeFile(finalOutputPath, finalBytes);
const compressedSize = finalBytes.length;
const reduction = ((originalSize - compressedSize) / originalSize) * 100;
return {
outputPath: finalOutputPath,
originalSize,
compressedSize,
reduction: Math.max(0, reduction),
};
} catch (error) {
console.error('Erro ao comprimir PDF:', error);
throw new Error(`Falha ao comprimir PDF: ${error instanceof Error ? error.message : 'Erro desconhecido'}`);
}
}
/**
* Divide um PDF em múltiplos arquivos
*/
static async splitPDF(
inputPath: string,
options: SplitOptions
): Promise<string[]> {
try {
await this.initialize();
const pdfBytes = await fs.readFile(inputPath);
const pdfDoc = await PDFDocument.load(pdfBytes);
const totalPages = pdfDoc.getPageCount();
const outputPaths: string[] = [];
if (options.splitType === 'pages' && options.pages) {
// Divide em páginas específicas
for (const pageNum of options.pages) {
if (pageNum < 1 || pageNum > totalPages) {
throw new Error(`Página ${pageNum} inválida. O PDF tem ${totalPages} páginas.`);
}
const newDoc = await PDFDocument.create();
const [copiedPage] = await newDoc.copyPages(pdfDoc, [pageNum - 1]);
newDoc.addPage(copiedPage);
const outputPath = path.join(this.OUTPUT_DIR, `split-page-${pageNum}-${uuidv4()}.pdf`);
const newPdfBytes = await newDoc.save();
await fs.writeFile(outputPath, newPdfBytes);
outputPaths.push(outputPath);
}
} else if (options.splitType === 'range' && options.ranges) {
// Divide em intervalos de páginas
for (let i = 0; i < options.ranges.length; i++) {
const { start, end } = options.ranges[i];
if (start < 1 || end > totalPages || start > end) {
throw new Error(`Intervalo inválido: ${start}-${end}. O PDF tem ${totalPages} páginas.`);
}
const newDoc = await PDFDocument.create();
const pageIndices = Array.from({ length: end - start + 1 }, (_, idx) => start - 1 + idx);
const copiedPages = await newDoc.copyPages(pdfDoc, pageIndices);
copiedPages.forEach((page) => newDoc.addPage(page));
const outputPath = path.join(this.OUTPUT_DIR, `split-range-${start}-${end}-${uuidv4()}.pdf`);
const newPdfBytes = await newDoc.save();
await fs.writeFile(outputPath, newPdfBytes);
outputPaths.push(outputPath);
}
} else if (options.splitType === 'chunks' && options.chunkSize) {
// Divide em chunks de tamanho fixo
const chunkSize = options.chunkSize;
const numChunks = Math.ceil(totalPages / chunkSize);
for (let i = 0; i < numChunks; i++) {
const startPage = i * chunkSize;
const endPage = Math.min((i + 1) * chunkSize, totalPages);
const newDoc = await PDFDocument.create();
const pageIndices = Array.from({ length: endPage - startPage }, (_, idx) => startPage + idx);
const copiedPages = await newDoc.copyPages(pdfDoc, pageIndices);
copiedPages.forEach((page) => newDoc.addPage(page));
const outputPath = path.join(this.OUTPUT_DIR, `split-chunk-${i + 1}-${uuidv4()}.pdf`);
const newPdfBytes = await newDoc.save();
await fs.writeFile(outputPath, newPdfBytes);
outputPaths.push(outputPath);
}
} else {
throw new Error('Opções de divisão inválidas');
}
return outputPaths;
} catch (error) {
console.error('Erro ao dividir PDF:', error);
throw new Error(`Falha ao dividir PDF: ${error instanceof Error ? error.message : 'Erro desconhecido'}`);
}
}
/**
* Mescla múltiplos PDFs em um único arquivo
*/
static async mergePDFs(options: MergeOptions): Promise<string> {
try {
await this.initialize();
if (!options.filePaths || options.filePaths.length === 0) {
throw new Error('Nenhum arquivo fornecido para mesclagem');
}
const mergedDoc = await PDFDocument.create();
// Mescla cada PDF
for (const filePath of options.filePaths) {
try {
const pdfBytes = await fs.readFile(filePath);
const pdfDoc = await PDFDocument.load(pdfBytes);
const copiedPages = await mergedDoc.copyPages(pdfDoc, pdfDoc.getPageIndices());
copiedPages.forEach((page) => mergedDoc.addPage(page));
} catch (error) {
console.error(`Erro ao processar arquivo ${filePath}:`, error);
throw new Error(`Falha ao processar arquivo ${path.basename(filePath)}`);
}
}
const outputPath = options.outputPath || path.join(
this.OUTPUT_DIR,
`merged-${uuidv4()}.pdf`
);
const mergedBytes = await mergedDoc.save();
await fs.writeFile(outputPath, mergedBytes);
return outputPath;
} catch (error) {
console.error('Erro ao mesclar PDFs:', error);
throw new Error(`Falha ao mesclar PDFs: ${error instanceof Error ? error.message : 'Erro desconhecido'}`);
}
}
/**
* Converte páginas de PDF em imagens
*/
static async pdfToImages(
inputPath: string,
options: PDFToImageOptions = {}
): Promise<string[]> {
try {
await this.initialize();
const {
format = 'png',
quality = 95,
density = 300,
pages = 'all',
} = options;
const pdfBytes = await fs.readFile(inputPath);
const pdfDoc = await PDFDocument.load(pdfBytes);
const totalPages = pdfDoc.getPageCount();
// Determina quais páginas converter
const pagesToConvert = pages === 'all'
? Array.from({ length: totalPages }, (_, i) => i + 1)
: pages;
const outputPaths: string[] = [];
// Configura o conversor
const converter = fromPath(inputPath, {
density,
saveFilename: uuidv4(),
savePath: this.OUTPUT_DIR,
format,
width: 2480, // A4 em 300 DPI
height: 3508,
});
// Converte cada página
for (const pageNum of pagesToConvert) {
if (pageNum < 1 || pageNum > totalPages) {
console.warn(`Página ${pageNum} ignorada - fora do intervalo`);
continue;
}
try {
const result = await converter(pageNum, { responseType: 'image' });
if (result && typeof result === 'object' && 'path' in result) {
const imagePath = result.path as string;
// Otimiza a imagem com sharp
const optimizedPath = path.join(
this.OUTPUT_DIR,
`page-${pageNum}-${uuidv4()}.${format}`
);
if (format === 'jpg') {
await sharp(imagePath)
.jpeg({ quality, progressive: true })
.toFile(optimizedPath);
} else if (format === 'png') {
await sharp(imagePath)
.png({ quality, progressive: true, compressionLevel: 9 })
.toFile(optimizedPath);
} else if (format === 'webp') {
await sharp(imagePath)
.webp({ quality })
.toFile(optimizedPath);
}
// Remove o arquivo temporário
await fs.unlink(imagePath).catch(() => {});
outputPaths.push(optimizedPath);
}
} catch (error) {
console.error(`Erro ao converter página ${pageNum}:`, error);
}
}
return outputPaths;
} catch (error) {
console.error('Erro ao converter PDF para imagens:', error);
throw new Error(`Falha ao converter PDF para imagens: ${error instanceof Error ? error.message : 'Erro desconhecido'}`);
}
}
/**
* Converte imagens em PDF
*/
static async imagesToPDF(options: ImageToPDFOptions): Promise<string> {
try {
await this.initialize();
if (!options.imagePaths || options.imagePaths.length === 0) {
throw new Error('Nenhuma imagem fornecida para conversão');
}
const pdfDoc = await PDFDocument.create();
for (const imagePath of options.imagePaths) {
try {
const imageBytes = await fs.readFile(imagePath);
const ext = path.extname(imagePath).toLowerCase();
let image;
if (ext === '.png') {
image = await pdfDoc.embedPng(imageBytes);
} else if (ext === '.jpg' || ext === '.jpeg') {
image = await pdfDoc.embedJpg(imageBytes);
} else {
// Converte outros formatos para PNG primeiro
const pngBuffer = await sharp(imageBytes).png().toBuffer();
image = await pdfDoc.embedPng(pngBuffer);
}
const { width, height } = image;
// Define o tamanho da página
let pageWidth = width;
let pageHeight = height;
if (options.pageSize) {
pageWidth = options.pageSize.width;
pageHeight = options.pageSize.height;
}
const page = pdfDoc.addPage([pageWidth, pageHeight]);
// Calcula dimensões para ajustar à página se necessário
if (options.fitToPage) {
const scale = Math.min(pageWidth / width, pageHeight / height);
const scaledWidth = width * scale;
const scaledHeight = height * scale;
const x = (pageWidth - scaledWidth) / 2;
const y = (pageHeight - scaledHeight) / 2;
page.drawImage(image, {
x,
y,
width: scaledWidth,
height: scaledHeight,
});
} else {
page.drawImage(image, {
x: 0,
y: 0,
width: pageWidth,
height: pageHeight,
});
}
} catch (error) {
console.error(`Erro ao processar imagem ${imagePath}:`, error);
throw new Error(`Falha ao processar imagem ${path.basename(imagePath)}`);
}
}
const outputPath = options.outputPath || path.join(
this.OUTPUT_DIR,
`images-to-pdf-${uuidv4()}.pdf`
);
const pdfBytes = await pdfDoc.save();
await fs.writeFile(outputPath, pdfBytes);
return outputPath;
} catch (error) {
console.error('Erro ao converter imagens para PDF:', error);
throw new Error(`Falha ao converter imagens para PDF: ${error instanceof Error ? error.message : 'Erro desconhecido'}`);
}
}
/**
* Realiza OCR em um PDF e extrai o texto
*/
static async performOCR(
inputPath: string,
options: OCROptions = {}
): Promise<OCRResult> {
try {
await this.initialize();
const { language = 'por', pages = 'all' } = options;
// Primeiro converte o PDF em imagens
const imageOptions: PDFToImageOptions = {
format: 'png',
density: 300,
pages,
};
const imagePaths = await this.pdfToImages(inputPath, imageOptions);
// Cria um worker do Tesseract
const worker = await createWorker(language);
const pageResults: Array<{
pageNumber: number;
text: string;
confidence: number;
}> = [];
let allText = '';
let totalConfidence = 0;
// Processa cada imagem
for (let i = 0; i < imagePaths.length; i++) {
try {
const imagePath = imagePaths[i];
const { data } = await worker.recognize(imagePath);
pageResults.push({
pageNumber: i + 1,
text: data.text,
confidence: data.confidence,
});
allText += data.text + '\n\n';
totalConfidence += data.confidence;
// Remove a imagem temporária
await fs.unlink(imagePath).catch(() => {});
} catch (error) {
console.error(`Erro ao processar OCR na página ${i + 1}:`, error);
}
}
await worker.terminate();
const averageConfidence = pageResults.length > 0
? totalConfidence / pageResults.length
: 0;
return {
text: allText.trim(),
confidence: averageConfidence,
pages: pageResults,
};
} catch (error) {
console.error('Erro ao realizar OCR:', error);
throw new Error(`Falha ao realizar OCR: ${error instanceof Error ? error.message : 'Erro desconhecido'}`);
}
}
/**
* Limpa arquivos temporários antigos
*/
static async cleanupOldFiles(olderThanHours: number = 24): Promise<void> {
try {
const dirs = [this.TEMP_DIR, this.OUTPUT_DIR];
const now = Date.now();
const maxAge = olderThanHours * 60 * 60 * 1000;
for (const dir of dirs) {
try {
const files = await fs.readdir(dir);
for (const file of files) {
const filePath = path.join(dir, file);
const stats = await fs.stat(filePath);
if (now - stats.mtimeMs > maxAge) {
await fs.unlink(filePath);
console.log(`Arquivo removido: ${filePath}`);
}
}
} catch (error) {
console.error(`Erro ao limpar diretório ${dir}:`, error);
}
}
} catch (error) {
console.error('Erro ao limpar arquivos antigos:', error);
}
}
/**
* Obtém informações sobre um PDF
*/
static async getPDFInfo(filePath: string): Promise<{
pages: number;
size: number;
title?: string;
author?: string;
creator?: string;
producer?: string;
creationDate?: Date;
modificationDate?: Date;
}> {
try {
const pdfBytes = await fs.readFile(filePath);
const pdfDoc = await PDFDocument.load(pdfBytes);
return {
pages: pdfDoc.getPageCount(),
size: pdfBytes.length,
title: pdfDoc.getTitle(),
author: pdfDoc.getAuthor(),
creator: pdfDoc.getCreator(),
producer: pdfDoc.getProducer(),
creationDate: pdfDoc.getCreationDate(),
modificationDate: pdfDoc.getModificationDate(),
};
} catch (error) {
console.error('Erro ao obter informações do PDF:', error);
throw new Error(`Falha ao obter informações do PDF: ${error instanceof Error ? error.message : 'Erro desconhecido'}`);
}
}
}