-
Histórico
+
+
Histórico
+ {history.length > 0 && (
+
+
+
+
+ )}
+
{history.length === 0 ? (
Nenhuma operação ainda
@@ -622,17 +824,28 @@ export const PDFManipulationPage: React.FC = () => {
{op.tipoOperacao.replace('_', ' ')}
-
- {op.status}
-
+
+
+ {op.status}
+
+
+
diff --git a/src/server/controllers/pdfController.ts b/src/server/controllers/pdfController.ts
index 61335a9..9671eaa 100644
--- a/src/server/controllers/pdfController.ts
+++ b/src/server/controllers/pdfController.ts
@@ -70,8 +70,9 @@ export class PDFController {
return;
}
- const quality = req.body.quality ? parseInt(req.body.quality) : 95;
+ const quality = req.body.quality ? parseInt(req.body.quality) : 50; // Padrão 50 para mais compressão
const maxSizeMB = req.body.maxSizeMB ? parseInt(req.body.maxSizeMB) : undefined;
+ const enableOCR = req.body.enableOCR === 'true' || req.body.enableOCR === true;
const arquivosOriginais: string[] = [];
const arquivosResultado: string[] = [];
@@ -99,7 +100,23 @@ export class PDFController {
arquivosOriginais.push(file.path);
tamanhoOriginal += BigInt(file.size);
- const result = await PDFService.compressPDF(file.path, undefined, {
+ let processedPath = file.path;
+
+ // Adiciona OCR se solicitado
+ if (enableOCR) {
+ try {
+ const ocrResult = await PDFService.addOCRLayerToPDF(file.path);
+ processedPath = ocrResult.outputPath;
+ // Remove arquivo temporário anterior
+ if (processedPath !== file.path) {
+ await fs.unlink(file.path).catch(() => {});
+ }
+ } catch (ocrError) {
+ console.warn('Erro ao adicionar OCR, continuando sem OCR:', ocrError);
+ }
+ }
+
+ const result = await PDFService.compressPDF(processedPath, undefined, {
quality,
maxSizeMB,
});
@@ -115,6 +132,10 @@ export class PDFController {
outputPath: result.outputPath,
});
+ // Remove o arquivo intermediário
+ if (processedPath !== file.path && processedPath !== result.outputPath) {
+ await fs.unlink(processedPath).catch(() => {});
+ }
// Remove o arquivo original
await fs.unlink(file.path).catch(() => {});
}
@@ -221,6 +242,16 @@ export class PDFController {
// Divide o PDF
const outputPaths = await PDFService.splitPDF(file.path, options);
+ // Cria ZIP se houver múltiplos arquivos
+ let zipPath: string | undefined;
+ if (outputPaths.length > 1) {
+ try {
+ zipPath = await PDFService.createZipFromFiles(outputPaths);
+ } catch (zipError) {
+ console.warn('Erro ao criar ZIP, disponibilizando arquivos individuais:', zipError);
+ }
+ }
+
// Calcula tamanho total dos arquivos resultantes
let tamanhoResultado = BigInt(0);
for (const outputPath of outputPaths) {
@@ -235,7 +266,7 @@ export class PDFController {
where: { id: operacaoId },
data: {
status: 'CONCLUIDO',
- arquivosResultado: outputPaths,
+ arquivosResultado: zipPath ? [zipPath, ...outputPaths] : outputPaths,
tamanhoResultado,
tempoProcessamento,
},
@@ -249,6 +280,7 @@ export class PDFController {
operacaoId,
filesCreated: outputPaths.length,
outputPaths,
+ zipPath,
processingTime: tempoProcessamento,
});
} catch (error) {
@@ -293,6 +325,7 @@ export class PDFController {
return;
}
+ const enableOCR = req.body.enableOCR === 'true' || req.body.enableOCR === true;
const filePaths = files.map((f) => f.path);
let tamanhoOriginal = BigInt(0);
@@ -316,11 +349,23 @@ export class PDFController {
operacaoId = operacao.id;
// Mescla os PDFs
- const outputPath = await PDFService.mergePDFs({
+ let outputPath = await PDFService.mergePDFs({
filePaths,
outputPath: path.join(process.cwd(), 'storage', 'pdf-output', `merged-${uuidv4()}.pdf`),
});
+ // Adiciona OCR se solicitado
+ if (enableOCR) {
+ try {
+ const ocrResult = await PDFService.addOCRLayerToPDF(outputPath);
+ // Remove arquivo anterior
+ await fs.unlink(outputPath).catch(() => {});
+ outputPath = ocrResult.outputPath;
+ } catch (ocrError) {
+ console.warn('Erro ao adicionar OCR ao PDF mesclado:', ocrError);
+ }
+ }
+
// Calcula tamanho do arquivo resultante
const stats = await fs.stat(outputPath);
const tamanhoResultado = BigInt(stats.size);
@@ -422,6 +467,16 @@ export class PDFController {
pages,
});
+ // Cria ZIP com todas as imagens
+ let zipPath: string | undefined;
+ if (outputPaths.length > 0) {
+ try {
+ zipPath = await PDFService.createZipFromFiles(outputPaths);
+ } catch (zipError) {
+ console.warn('Erro ao criar ZIP, disponibilizando imagens individuais:', zipError);
+ }
+ }
+
// Calcula tamanho total
let tamanhoResultado = BigInt(0);
for (const outputPath of outputPaths) {
@@ -436,7 +491,7 @@ export class PDFController {
where: { id: operacaoId },
data: {
status: 'CONCLUIDO',
- arquivosResultado: outputPaths,
+ arquivosResultado: zipPath ? [zipPath, ...outputPaths] : outputPaths,
tamanhoResultado,
tempoProcessamento,
},
@@ -450,6 +505,7 @@ export class PDFController {
operacaoId,
imagesCreated: outputPaths.length,
outputPaths,
+ zipPath,
processingTime: tempoProcessamento,
});
} catch (error) {
@@ -503,6 +559,7 @@ export class PDFController {
const fitToPage = req.body.fitToPage === 'true';
const pageSize = req.body.pageSize ? JSON.parse(req.body.pageSize) : undefined;
+ const enableOCR = req.body.enableOCR === 'true' || req.body.enableOCR === true;
// Cria registro da operação
const operacao = await prisma.operacaoPDF.create({
@@ -520,13 +577,25 @@ export class PDFController {
operacaoId = operacao.id;
// Converte para PDF
- const outputPath = await PDFService.imagesToPDF({
+ let outputPath = await PDFService.imagesToPDF({
imagePaths,
outputPath: path.join(process.cwd(), 'storage', 'pdf-output', `images-to-pdf-${uuidv4()}.pdf`),
fitToPage,
pageSize,
});
+ // Adiciona OCR se solicitado
+ if (enableOCR) {
+ try {
+ const ocrResult = await PDFService.addOCRLayerToPDF(outputPath);
+ // Remove arquivo anterior
+ await fs.unlink(outputPath).catch(() => {});
+ outputPath = ocrResult.outputPath;
+ } catch (ocrError) {
+ console.warn('Erro ao adicionar OCR ao PDF:', ocrError);
+ }
+ }
+
// Calcula tamanho do arquivo resultante
const stats = await fs.stat(outputPath);
const tamanhoResultado = BigInt(stats.size);
@@ -797,7 +866,37 @@ export class PDFController {
return;
}
- res.download(filePath, path.basename(filePath));
+ // Verifica se o arquivo existe
+ try {
+ await fs.access(filePath);
+ } catch {
+ res.status(404).json({ error: 'Arquivo não encontrado no sistema' });
+ return;
+ }
+
+ // Detecta o Content-Type correto baseado na extensão
+ const ext = path.extname(filePath).toLowerCase();
+ const mimeTypes: { [key: string]: string } = {
+ '.pdf': 'application/pdf',
+ '.png': 'image/png',
+ '.jpg': 'image/jpeg',
+ '.jpeg': 'image/jpeg',
+ '.webp': 'image/webp',
+ '.zip': 'application/zip',
+ };
+
+ const contentType = mimeTypes[ext] || 'application/octet-stream';
+ const fileName = path.basename(filePath);
+
+ console.log(`Download solicitado: ${fileName} (${contentType})`);
+
+ // Define o Content-Type correto
+ res.setHeader('Content-Type', contentType);
+ res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
+
+ // Garante caminho absoluto e envia o arquivo
+ const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
+ res.sendFile(absolutePath);
} catch (error) {
console.error('Erro ao fazer download:', error);
res.status(500).json({
@@ -806,4 +905,196 @@ export class PDFController {
});
}
}
+
+ /**
+ * Deleta uma operação específica e seus arquivos
+ */
+ static async deleteOperation(req: Request, res: Response): Promise {
+ try {
+ const { operacaoId } = req.params;
+ const userId = (req as any).user?.id;
+
+ if (!userId) {
+ res.status(401).json({ error: 'Usuário não autenticado' });
+ return;
+ }
+
+ const operacao = await prisma.operacaoPDF.findUnique({
+ where: { id: operacaoId },
+ });
+
+ if (!operacao) {
+ res.status(404).json({ error: 'Operação não encontrada' });
+ return;
+ }
+
+ if (operacao.usuarioId !== userId) {
+ res.status(403).json({ error: 'Acesso negado' });
+ return;
+ }
+
+ // Deleta arquivos físicos
+ const allFiles = [...operacao.arquivosOriginais, ...operacao.arquivosResultado];
+ for (const filePath of allFiles) {
+ try {
+ // Verifica se o arquivo existe antes de deletar
+ await fs.access(filePath);
+ await fs.unlink(filePath);
+ } catch (error: any) {
+ // Ignora erro se o arquivo não existe (já foi deletado)
+ if (error.code !== 'ENOENT') {
+ console.warn(`Erro ao deletar arquivo ${filePath}:`, error);
+ }
+ }
+ }
+
+ // Deleta registro do banco
+ await prisma.operacaoPDF.delete({
+ where: { id: operacaoId },
+ });
+
+ res.json({
+ success: true,
+ message: 'Operação deletada com sucesso',
+ });
+ } catch (error) {
+ console.error('Erro ao deletar operação:', error);
+ res.status(500).json({
+ error: 'Falha ao deletar operação',
+ message: error instanceof Error ? error.message : 'Erro desconhecido',
+ });
+ }
+ }
+
+ /**
+ * Limpa todo o histórico do usuário
+ */
+ static async clearHistory(req: Request, res: Response): Promise {
+ try {
+ const userId = (req as any).user?.id;
+
+ if (!userId) {
+ res.status(401).json({ error: 'Usuário não autenticado' });
+ return;
+ }
+
+ // Busca todas as operações do usuário
+ const operacoes = await prisma.operacaoPDF.findMany({
+ where: { usuarioId: userId },
+ });
+
+ let filesDeleted = 0;
+ let filesError = 0;
+
+ // Deleta todos os arquivos
+ for (const operacao of operacoes) {
+ const allFiles = [...operacao.arquivosOriginais, ...operacao.arquivosResultado];
+ for (const filePath of allFiles) {
+ try {
+ // Verifica se o arquivo existe antes de deletar
+ await fs.access(filePath);
+ await fs.unlink(filePath);
+ filesDeleted++;
+ } catch (error: any) {
+ // Ignora erro se o arquivo não existe
+ if (error.code !== 'ENOENT') {
+ filesError++;
+ console.warn(`Erro ao deletar arquivo ${filePath}:`, error);
+ }
+ }
+ }
+ }
+
+ // Deleta todos os registros
+ const deleted = await prisma.operacaoPDF.deleteMany({
+ where: { usuarioId: userId },
+ });
+
+ res.json({
+ success: true,
+ message: 'Histórico limpo com sucesso',
+ operacoesRemovidas: deleted.count,
+ arquivosDeletados: filesDeleted,
+ arquivosComErro: filesError,
+ });
+ } catch (error) {
+ console.error('Erro ao limpar histórico:', error);
+ res.status(500).json({
+ error: 'Falha ao limpar histórico',
+ message: error instanceof Error ? error.message : 'Erro desconhecido',
+ });
+ }
+ }
+
+ /**
+ * Limpa operações antigas (mais de X horas)
+ */
+ static async cleanupOldOperations(req: Request, res: Response): Promise {
+ try {
+ const userId = (req as any).user?.id;
+ const hours = parseInt(req.query.hours as string) || 24;
+
+ if (!userId) {
+ res.status(401).json({ error: 'Usuário não autenticado' });
+ return;
+ }
+
+ const cutoffDate = new Date(Date.now() - hours * 60 * 60 * 1000);
+
+ // Busca operações antigas
+ const operacoes = await prisma.operacaoPDF.findMany({
+ where: {
+ usuarioId: userId,
+ criadoEm: {
+ lt: cutoffDate,
+ },
+ },
+ });
+
+ let filesDeleted = 0;
+ let filesError = 0;
+
+ // Deleta arquivos
+ for (const operacao of operacoes) {
+ const allFiles = [...operacao.arquivosOriginais, ...operacao.arquivosResultado];
+ for (const filePath of allFiles) {
+ try {
+ // Verifica se o arquivo existe antes de deletar
+ await fs.access(filePath);
+ await fs.unlink(filePath);
+ filesDeleted++;
+ } catch (error: any) {
+ // Ignora erro se o arquivo não existe
+ if (error.code !== 'ENOENT') {
+ filesError++;
+ }
+ }
+ }
+ }
+
+ // Deleta registros
+ const deleted = await prisma.operacaoPDF.deleteMany({
+ where: {
+ usuarioId: userId,
+ criadoEm: {
+ lt: cutoffDate,
+ },
+ },
+ });
+
+ res.json({
+ success: true,
+ message: `Operações com mais de ${hours}h removidas`,
+ operacoesRemovidas: deleted.count,
+ arquivosDeletados: filesDeleted,
+ arquivosComErro: filesError,
+ });
+ } catch (error) {
+ console.error('Erro ao limpar operações antig as:', error);
+ res.status(500).json({
+ error: 'Falha ao limpar operações antigas',
+ message: error instanceof Error ? error.message : 'Erro desconhecido',
+ });
+ }
+ }
}
diff --git a/src/server/index.ts b/src/server/index.ts
index 9463539..b7fe752 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -1430,9 +1430,11 @@ app.use(
},
);
-app.listen(port, () => {
+// Aumentar timeout do servidor para operações longas (ex: PDF, OCR)
+const server = app.listen(port, () => {
console.log(`🚀 Servidor rodando em http://localhost:${port}`);
});
+server.timeout = 600000; // 10 minutos (600 segundos)
process.on("unhandledRejection", (err) => {
console.error("Erro não tratado:", err);
diff --git a/src/server/routes/pdf.ts b/src/server/routes/pdf.ts
index e80edee..3c42276 100644
--- a/src/server/routes/pdf.ts
+++ b/src/server/routes/pdf.ts
@@ -79,4 +79,25 @@ router.get(
PDFController.downloadFile
);
+// Deleta uma operação específica
+router.delete(
+ '/operation/:operacaoId',
+ authMiddleware,
+ PDFController.deleteOperation
+);
+
+// Limpa todo o histórico
+router.delete(
+ '/history/clear',
+ authMiddleware,
+ PDFController.clearHistory
+);
+
+// Limpa operações antigas
+router.delete(
+ '/history/cleanup',
+ authMiddleware,
+ PDFController.cleanupOldOperations
+);
+
export default router;
diff --git a/src/services/pdfService.ts b/src/services/pdfService.ts
index 08d5331..c162611 100644
--- a/src/services/pdfService.ts
+++ b/src/services/pdfService.ts
@@ -1,10 +1,12 @@
-import { PDFDocument } from 'pdf-lib';
+import { PDFDocument, rgb } 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';
+import archiver from 'archiver';
+import { createWriteStream } from 'fs';
/**
* Serviço completo de manipulação de PDFs
@@ -75,8 +77,8 @@ export class PDFService {
}
/**
- * Comprime um PDF mantendo a qualidade máxima possível
- * Usa otimização de streams e remoção de metadados desnecessários
+ * Comprime um PDF de forma agressiva para redução >60%
+ * Usa abordagem simplificada e eficiente
*/
static async compressPDF(
inputPath: string,
@@ -86,7 +88,7 @@ export class PDFService {
try {
await this.initialize();
- const { quality = 95 } = options;
+ const { quality = 50 } = options;
// Lê o PDF original
const pdfBytes = await fs.readFile(inputPath);
@@ -95,9 +97,10 @@ export class PDFService {
// Carrega o PDF
const pdfDoc = await PDFDocument.load(pdfBytes, {
updateMetadata: false,
+ ignoreEncryption: true,
});
- // Remove metadados desnecessários mas mantém metadados essenciais
+ // Remove TODOS os metadados
pdfDoc.setTitle('');
pdfDoc.setAuthor('');
pdfDoc.setSubject('');
@@ -105,43 +108,31 @@ export class PDFService {
pdfDoc.setProducer('');
pdfDoc.setCreator('');
- // Salva com compressão
- const compressedBytes = await pdfDoc.save({
- useObjectStreams: true, // Comprime streams de objetos
+ // Para compressão agressiva, reconstrói o PDF do zero
+ const newDoc = await PDFDocument.create();
+ const pageCount = pdfDoc.getPageCount();
+
+ // Copia cada página
+ const copiedPages = await newDoc.copyPages(pdfDoc, Array.from({ length: pageCount }, (_, i) => i));
+ copiedPages.forEach((page: any) => newDoc.addPage(page));
+
+ // Salva com máxima compressão
+ const compressedBytes = await newDoc.save({
+ useObjectStreams: true, // Compressão de streams
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);
+ // Salva o arquivo
+ await fs.writeFile(finalOutputPath, compressedBytes);
- const compressedSize = finalBytes.length;
+ const compressedSize = compressedBytes.length;
const reduction = ((originalSize - compressedSize) / originalSize) * 100;
return {
@@ -334,7 +325,9 @@ export class PDFService {
`page-${pageNum}-${uuidv4()}.${format}`
);
- if (format === 'jpg') {
+ console.log(`Convertendo página ${pageNum} para ${format}: ${optimizedPath}`);
+
+ if (format === 'jpg' || format === 'jpeg') {
await sharp(imagePath)
.jpeg({ quality, progressive: true })
.toFile(optimizedPath);
@@ -346,6 +339,20 @@ export class PDFService {
await sharp(imagePath)
.webp({ quality })
.toFile(optimizedPath);
+ } else {
+ // Formato não suportado, usa PNG como padrão
+ console.warn(`Formato ${format} não suportado, usando PNG`);
+ await sharp(imagePath)
+ .png({ quality: 95, progressive: true, compressionLevel: 9 })
+ .toFile(optimizedPath.replace(`.${format}`, '.png'));
+
+ // Atualiza o caminho otimizado para .png
+ const correctedPath = optimizedPath.replace(`.${format}`, '.png');
+ outputPaths.push(correctedPath);
+
+ // Remove o arquivo temporário
+ await fs.unlink(imagePath).catch(() => {});
+ continue;
}
// Remove o arquivo temporário
@@ -522,6 +529,124 @@ export class PDFService {
}
}
+ /**
+ * Cria um arquivo ZIP com múltiplos arquivos
+ */
+ static async createZipFromFiles(
+ filePaths: string[],
+ outputPath?: string
+ ): Promise {
+ try {
+ await this.initialize();
+
+ const finalOutputPath = outputPath || path.join(
+ this.OUTPUT_DIR,
+ `arquivos-${uuidv4()}.zip`
+ );
+
+ return new Promise((resolve, reject) => {
+ const output = createWriteStream(finalOutputPath);
+ const archive = archiver('zip', {
+ zlib: { level: 9 } // Máxima compressão
+ });
+
+ output.on('close', () => {
+ console.log(`ZIP criado: ${archive.pointer()} bytes`);
+ resolve(finalOutputPath);
+ });
+
+ archive.on('error', (err) => {
+ reject(err);
+ });
+
+ archive.pipe(output);
+
+ // Adiciona todos os arquivos ao ZIP
+ filePaths.forEach((filePath, index) => {
+ const ext = path.extname(filePath);
+
+ // Renomeia arquivos para sequência
+ const zipFileName = `arquivo-${index + 1}${ext}`;
+ archive.file(filePath, { name: zipFileName });
+ });
+
+ archive.finalize();
+ });
+ } catch (error) {
+ console.error('Erro ao criar ZIP:', error);
+ throw new Error(`Falha ao criar ZIP: ${error instanceof Error ? error.message : 'Erro desconhecido'}`);
+ }
+ }
+
+ /**
+ * Adiciona camada de OCR a um PDF (torna o PDF pesquisável)
+ */
+ static async addOCRLayerToPDF(
+ inputPath: string,
+ outputPath?: string,
+ language: string = 'por'
+ ): Promise<{ outputPath: string; confidence: number }> {
+ try {
+ await this.initialize();
+
+ // Primeiro, extrai o texto via OCR
+ const ocrResult = await this.performOCR(inputPath, { language, pages: 'all' });
+
+ // Carrega o PDF original
+ const pdfBytes = await fs.readFile(inputPath);
+ const pdfDoc = await PDFDocument.load(pdfBytes);
+
+ // Cria um novo PDF com camada de texto
+ const newDoc = await PDFDocument.create();
+ const pages = pdfDoc.getPages();
+
+ for (let i = 0; i < pages.length; i++) {
+ const [copiedPage] = await newDoc.copyPages(pdfDoc, [i]);
+ newDoc.addPage(copiedPage);
+
+ // Adiciona texto invisível sobre a página (camada OCR)
+ if (ocrResult.pages[i]) {
+ const newPage = newDoc.getPage(i);
+ const { height } = newPage.getSize();
+
+ // Desenha texto invisível (transparente) sobre a página
+ const lines = ocrResult.pages[i].text.split('\n');
+ let yPosition = height - 20;
+
+ lines.forEach((line) => {
+ if (line.trim()) {
+ newPage.drawText(line, {
+ x: 10,
+ y: yPosition,
+ size: 8,
+ color: rgb(0, 0, 0), // Texto preto mas será invisível
+ opacity: 0.01, // Quase invisível mas indexável
+ });
+ yPosition -= 12;
+ }
+ });
+ }
+ }
+
+ // Salva o PDF com camada OCR
+ const finalOutputPath = outputPath || path.join(
+ this.OUTPUT_DIR,
+ `ocr-${uuidv4()}.pdf`
+ );
+
+ const newPdfBytes = await newDoc.save();
+ await fs.writeFile(finalOutputPath, newPdfBytes);
+
+ return {
+ outputPath: finalOutputPath,
+ confidence: ocrResult.confidence,
+ };
+ } catch (error) {
+ console.error('Erro ao adicionar camada OCR:', error);
+ throw new Error(`Falha ao adicionar OCR: ${error instanceof Error ? error.message : 'Erro desconhecido'}`);
+ }
+ }
+
/**
* Limpa arquivos temporários antigos
*/
diff --git a/vite.config.ts b/vite.config.ts
index 05eefe2..e4fe097 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -17,6 +17,8 @@ export default defineConfig({
target: 'http://localhost:5000',
changeOrigin: true,
secure: false,
+ timeout: 600000, // 10 minutos para operações longas (PDF, OCR)
+ proxyTimeout: 600000,
}
},
hmr: {