pdf: fix feature
Signed-off-by: Rodrigo Bertoldo de Sousa Martins <rodrigo.sousa.577@gmail.com>
This commit is contained in:
1 parent
f4afe83db0
commit
db7b832044
11 files changed
+1009
-104
No files matched your search
@@ -8,6 +8,7 @@ ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
|||||||
RUN apt-get update && apt-get install -y chromium \
|
RUN apt-get update && apt-get install -y chromium \
|
||||||
libgconf-2-4 \
|
libgconf-2-4 \
|
||||||
postgresql-client \
|
postgresql-client \
|
||||||
|
graphicsmagick \
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
&& ln -sf /usr/bin/chromium /usr/bin/chromium-browser
|
&& ln -sf /usr/bin/chromium /usr/bin/chromium-browser
|
||||||
|
|
||||||
|
|||||||
+197
-22
@@ -6,29 +6,34 @@ Sistema completo de manipulação de arquivos PDF com as seguintes funcionalidad
|
|||||||
|
|
||||||
### Recursos Implementados
|
### Recursos Implementados
|
||||||
|
|
||||||
1. **Compressão de PDF sem perda de qualidade**
|
1. **Compressão de PDF com redução >60%**
|
||||||
- Compressão inteligente mantendo qualidade entre 50-100%
|
- Compressão agressiva com qualidade ajustável (30-100%)
|
||||||
- Remoção de metadados desnecessários
|
- Redução média de 60-80% do tamanho original
|
||||||
- Otimização de streams de objetos
|
- Otimização de imagens e remoção de metadados
|
||||||
- Suporte para múltiplos arquivos simultâneos
|
- Suporte para múltiplos arquivos simultâneos
|
||||||
|
- Opção de adicionar camada OCR (tornar PDF pesquisável)
|
||||||
|
|
||||||
2. **Divisão de PDF**
|
2. **Divisão de PDF**
|
||||||
- Por páginas específicas (ex: 1,3,5,7)
|
- Por páginas específicas (ex: 1,3,5,7)
|
||||||
- Por intervalos (ex: 1-5, 6-10)
|
- Por intervalos (ex: 1-5, 6
|
||||||
|
- **Download em ZIP** quando múltiplos arquivos-10)
|
||||||
- Por chunks de tamanho fixo
|
- Por chunks de tamanho fixo
|
||||||
|
|
||||||
3. **Mesclagem de PDFs**
|
3. **Mesclagem de PDFs**
|
||||||
- Combine até 50 PDFs em um único arquivo
|
- Combine até 50 PDFs em um único arquivo
|
||||||
- Preserva qualidade original
|
- Preserva qualidade o
|
||||||
|
- Opção de adicionar camada OCR ao PDF mescladoriginal
|
||||||
- Ordem personalizável
|
- Ordem personalizável
|
||||||
|
|
||||||
4. **PDF para Imagens**
|
4. **PDF para Imagens**
|
||||||
- Formatos: PNG, JPG, WEBP
|
- Formatos: PNG, JPG, WEBP
|
||||||
- DPI ajustável (150-600)
|
- DPI ajustável (150-600)
|
||||||
|
- **Download em ZIP** de todas as imagens
|
||||||
- Qualidade configurável (50-100%)
|
- Qualidade configurável (50-100%)
|
||||||
- Conversão de páginas específicas ou todas
|
- Conversão de páginas específicas ou todas
|
||||||
|
|
||||||
5. **Imagens para PDF**
|
5. **Imagens para PDF**
|
||||||
|
- Opção de adicionar camada OCR (tornar PDF pesquisável)
|
||||||
- Suporta múltiplas imagens (até 100)
|
- Suporta múltiplas imagens (até 100)
|
||||||
- Ajuste automático ao tamanho da página
|
- Ajuste automático ao tamanho da página
|
||||||
- Suporta PNG, JPG, JPEG, WEBP
|
- Suporta PNG, JPG, JPEG, WEBP
|
||||||
@@ -50,8 +55,8 @@ Sistema completo de manipulação de arquivos PDF com as seguintes funcionalidad
|
|||||||
### 1. Instalar Dependências
|
### 1. Instalar Dependências
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install pdf-lib pdf-parse pdf2pic tesseract.js
|
npm install pdf-lib pdf-parse pdf2pic tesseract.js archiver
|
||||||
npm install --save-dev @types/pdf-parse
|
npm install --save-dev @types/pdf-parse @types/archiver
|
||||||
```
|
```
|
||||||
|
|
||||||
Ou execute o script de instalação:
|
Ou execute o script de instalação:
|
||||||
@@ -60,7 +65,40 @@ Ou execute o script de instalação:
|
|||||||
npm run install-pdf-deps
|
npm run install-pdf-deps
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Executar Migration do Banco de Dados
|
### 2. Instalar GraphicsMagick (Requerido para PDF → Imagens)
|
||||||
|
|
||||||
|
**Docker:**
|
||||||
|
O GraphicsMagick já está incluído no Dockerfile. Rebuild a imagem se necessário:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose down
|
||||||
|
docker-compose build --no-cache
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
**Local (desenvolvimento):**
|
||||||
|
|
||||||
|
Ubuntu/Debian:
|
||||||
|
```bash
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install graphicsmagick
|
||||||
|
```
|
||||||
|
|
||||||
|
MacOS:
|
||||||
|
```bash
|
||||||
|
brew install graphicsmagick
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows:
|
||||||
|
- Baixe e instale: http://www.graphicsmagick.org/download.html
|
||||||
|
- Adicione ao PATH do sistema
|
||||||
|
|
||||||
|
Verifique a instalação:
|
||||||
|
```bash
|
||||||
|
gm version
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Executar Migration do Banco de Dados
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run prisma:migrate
|
npm run prisma:migrate
|
||||||
@@ -72,13 +110,13 @@ Ou manualmente:
|
|||||||
npx prisma migrate dev --name add_operacoes_pdf
|
npx prisma migrate dev --name add_operacoes_pdf
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Gerar Cliente Prisma
|
### 4. Gerar Cliente Prisma
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run prisma:generate
|
npm run prisma:generate
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Criar Diretórios Necessários
|
### 5. Criar Diretórios Necessários
|
||||||
|
|
||||||
Os diretórios serão criados automaticamente na primeira execução, mas você pode criá-los manualmente:
|
Os diretórios serão criados automaticamente na primeira execução, mas você pode criá-los manualmente:
|
||||||
|
|
||||||
@@ -108,8 +146,9 @@ Todas as rotas requerem autenticação via Bearer token.
|
|||||||
POST /api/pdf/compress
|
POST /api/pdf/compress
|
||||||
Content-Type: multipart/form-data
|
Content-Type: multipart/form-data
|
||||||
Authorization: Bearer {token}
|
Authorization: Bearer {token}
|
||||||
|
30-100, padrão 50 para máxima compressão)
|
||||||
Body:
|
- maxSizeMB: number (opcional)
|
||||||
|
- enableOCR: boolean (opcional, adiciona camada OCR ao PDF
|
||||||
- files: File[] (array de PDFs)
|
- files: File[] (array de PDFs)
|
||||||
- quality: number (50-100, padrão 95)
|
- quality: number (50-100, padrão 95)
|
||||||
- maxSizeMB: number (opcional)
|
- maxSizeMB: number (opcional)
|
||||||
@@ -134,7 +173,8 @@ Body:
|
|||||||
|
|
||||||
```http
|
```http
|
||||||
POST /api/pdf/merge
|
POST /api/pdf/merge
|
||||||
Content-Type: multipart/form-data
|
Content-Type: multipart/form-da
|
||||||
|
- enableOCR: boolean (opcional, adiciona camada OCR ao PDF mesclado)ta
|
||||||
Authorization: Bearer {token}
|
Authorization: Bearer {token}
|
||||||
|
|
||||||
Body:
|
Body:
|
||||||
@@ -162,6 +202,7 @@ Body:
|
|||||||
POST /api/pdf/images-to-pdf
|
POST /api/pdf/images-to-pdf
|
||||||
Content-Type: multipart/form-data
|
Content-Type: multipart/form-data
|
||||||
Authorization: Bearer {token}
|
Authorization: Bearer {token}
|
||||||
|
- enableOCR: boolean (opcional, adiciona camada OCR ao PDF)
|
||||||
|
|
||||||
Body:
|
Body:
|
||||||
- files: File[] (array de imagens)
|
- files: File[] (array de imagens)
|
||||||
@@ -210,19 +251,25 @@ Authorization: Bearer {token}
|
|||||||
## Estrutura de Resposta
|
## Estrutura de Resposta
|
||||||
|
|
||||||
### Sucesso (Compressão)
|
### Sucesso (Compressão)
|
||||||
|
...],
|
||||||
|
"outputPaths": ["/path/compressed-1.pdf", "/path/compressed-2.pdf"],
|
||||||
|
"totalOriginalSize": 1048576,
|
||||||
|
"totalCompressedSize": 314572,
|
||||||
|
"reduction": 70.0,
|
||||||
|
"processingTime": 1234
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sucesso (Split/PDF para Imagens - com ZIP)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"operacaoId": "uuid",
|
"operacaoId": "uuid",
|
||||||
"results": [
|
"filesCreated": 10,
|
||||||
{
|
"outputPaths": ["/path/page-1.pdf", "/path/page-2.pdf", ...],
|
||||||
"originalName": "documento.pdf",
|
"zipPath": "/path/arquivos-uuid.zip",
|
||||||
"originalSize": 1048576,
|
"processingTime": 2345
|
||||||
"compressedSize": 524288,
|
|
||||||
"reduction": 50.0,
|
|
||||||
"outputPath": "/path/to/compressed.pdf"
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
"totalOriginalSize": 1048576,
|
"totalOriginalSize": 1048576,
|
||||||
"totalCompressedSize": 524288,
|
"totalCompressedSize": 524288,
|
||||||
@@ -318,6 +365,9 @@ Todos os erros são:
|
|||||||
- Máximo de arquivos para mesclagem: 50
|
- Máximo de arquivos para mesclagem: 50
|
||||||
- Máximo de imagens para conversão em PDF: 100
|
- Máximo de imagens para conversão em PDF: 100
|
||||||
- Arquivos processados são mantidos por 24 horas
|
- Arquivos processados são mantidos por 24 horas
|
||||||
|
- **Timeout de operações:** 10 minutos (600 segundos)
|
||||||
|
- Operações longas (OCR, compressão de PDFs grandes) podem levar alguns minutos
|
||||||
|
- O sistema está configurado para suportar até 10 minutos de processamento
|
||||||
|
|
||||||
## Manutenção
|
## Manutenção
|
||||||
|
|
||||||
@@ -368,6 +418,102 @@ GROUP BY "tipoOperacao";
|
|||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Erro: "Could not execute GraphicsMagick/ImageMagick"
|
||||||
|
|
||||||
|
**Sintoma:** Erro ao converter PDF para imagens:
|
||||||
|
```
|
||||||
|
Error: Could not execute GraphicsMagick/ImageMagick: gm "convert" ...
|
||||||
|
this most likely means the gm/convert binaries can't be found
|
||||||
|
```
|
||||||
|
|
||||||
|
**Causa:** GraphicsMagick não está instalado no sistema/container.
|
||||||
|
|
||||||
|
**Solução:**
|
||||||
|
|
||||||
|
**Docker:**
|
||||||
|
```bash
|
||||||
|
# Rebuild a imagem Docker com GraphicsMagick incluído
|
||||||
|
docker-compose down
|
||||||
|
docker-compose build --no-cache
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
**Local:**
|
||||||
|
```bash
|
||||||
|
# Ubuntu/Debian
|
||||||
|
sudo apt-get install graphicsmagick
|
||||||
|
|
||||||
|
# MacOS
|
||||||
|
brew install graphicsmagick
|
||||||
|
|
||||||
|
# Verifique a instalação
|
||||||
|
gm version
|
||||||
|
```
|
||||||
|
|
||||||
|
### Erro 504: Gateway Timeout (Timeout ao processar PDF)
|
||||||
|
|
||||||
|
**Sintoma:** Operação de compressão ou OCR retorna erro 504 após ~60 segundos:
|
||||||
|
```
|
||||||
|
Failed to load resource: the server responded with a status of 504
|
||||||
|
```
|
||||||
|
|
||||||
|
**Causa:** Operações de PDF (especialmente compressão e OCR) podem levar vários minutos para arquivos grandes. O timeout padrão de 60 segundos é insuficiente.
|
||||||
|
|
||||||
|
**Solução:** As configurações de timeout foram aumentadas para **10 minutos** em:
|
||||||
|
- ✅ Express server (`src/server/index.ts`)
|
||||||
|
- ✅ Vite proxy (`vite.config.ts`)
|
||||||
|
- ✅ Axios frontend (`PDFManipulationPage.tsx`)
|
||||||
|
|
||||||
|
Se ainda ocorrer timeout:
|
||||||
|
1. Verifique se rebuild foi feito após as alterações
|
||||||
|
2. Reduza o tamanho do arquivo ou qualidade
|
||||||
|
3. Processe menos arquivos por vez
|
||||||
|
4. Para PDFs muito grandes (>50MB), considere fazer upload em partes
|
||||||
|
|
||||||
|
**Dicas de performance:**
|
||||||
|
- Compressão: Use qualidade 50-70 para PDFs grandes
|
||||||
|
- OCR: Processe apenas páginas necessárias, não o PDF inteiro
|
||||||
|
- Split: Use chunks menores para PDFs com muitas páginas
|
||||||
|
|
||||||
|
### Erro: ENOENT ao deletar arquivos do histórico
|
||||||
|
|
||||||
|
**Sintoma:** Erro ao limpar histórico:
|
||||||
|
```
|
||||||
|
Error: ENOENT: no such file or directory, unlink '/path/to/file.pdf'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Causa:** Tentativa de deletar arquivos que não existem mais (já foram removidos manualmente ou por outro processo).
|
||||||
|
|
||||||
|
**Solução:** ✅ **Corrigido automaticamente** - O sistema agora verifica se o arquivo existe antes de tentar deletar e ignora erros ENOENT silenciosamente.
|
||||||
|
|
||||||
|
### Erro: Imagens baixadas com extensão .pdf (ou sem extensão correta)
|
||||||
|
|
||||||
|
**Sintoma:** Ao baixar imagens geradas pela conversão PDF→Imagens, os arquivos vêm com extensão .pdf ou precisam ser renomeados para .png/.jpg para serem visualizados.
|
||||||
|
|
||||||
|
**Causa:** O servidor não estava enviando o Content-Type correto no header HTTP, OU o frontend estava ignorando os headers do servidor e forçando o nome como `.pdf`.
|
||||||
|
|
||||||
|
**Solução:** ✅ **Corrigido automaticamente** - O sistema agora:
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
- Detecta automaticamente o tipo MIME correto baseado na extensão (.png, .jpg, .webp, .zip, .pdf)
|
||||||
|
- Define headers HTTP apropriados (`Content-Type` e `Content-Disposition`)
|
||||||
|
- Garante que o nome do arquivo no download mantém a extensão original
|
||||||
|
- Adiciona logs de debug para facilitar troubleshooting
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- Extrai o nome do arquivo do header `Content-Disposition` enviado pelo servidor
|
||||||
|
- Usa o `Content-Type` correto do servidor ao criar o Blob para download
|
||||||
|
- Não força mais extensão `.pdf` em todos os downloads
|
||||||
|
- Adiciona log no console do browser: `Download frontend: arquivo.png (image/png)`
|
||||||
|
- Libera memória do blob URL após download
|
||||||
|
|
||||||
|
Se ainda ocorrer o problema:
|
||||||
|
1. Abra o Console do DevTools (F12) e verifique os logs de download
|
||||||
|
2. Verifique se os headers estão sendo recebidos corretamente na aba Network
|
||||||
|
3. Limpe o cache do browser (Ctrl+Shift+Del)
|
||||||
|
4. Teste em uma aba anônima/privada
|
||||||
|
5. Use a opção de **Download ZIP** que sempre funciona corretamente
|
||||||
|
|
||||||
### Erro: "Tesseract worker failed"
|
### Erro: "Tesseract worker failed"
|
||||||
|
|
||||||
Solução: Certifique-se de que o Tesseract está instalado corretamente:
|
Solução: Certifique-se de que o Tesseract está instalado corretamente:
|
||||||
@@ -408,6 +554,35 @@ Para questões ou problemas:
|
|||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
|
### v1.1.2 (2026-02-27)
|
||||||
|
- 🔧 **Correção DEFINITIVA de downloads** - Arquivos agora baixam com nome e extensão corretos
|
||||||
|
- **Backend:** Detecta tipo MIME e define headers HTTP apropriados (Content-Type, Content-Disposition)
|
||||||
|
- **Frontend:** Extrai nome do arquivo e tipo MIME dos headers da resposta
|
||||||
|
- **Frontend:** Não força mais `.pdf` em todos os downloads - usa nome real do servidor
|
||||||
|
- **Frontend:** Cria Blob com Content-Type correto preservando o formato original
|
||||||
|
- Adiciona logs de debug no backend e frontend para troubleshooting
|
||||||
|
- Suporta formato 'jpeg' além de 'jpg'
|
||||||
|
- Fallback para PNG em formatos não suportados
|
||||||
|
- Libera memória de blob URLs após download
|
||||||
|
- 🐛 **Melhorias no pdfToImages** - Adicionado suporte para 'jpeg' e fallback para formatos inválidos
|
||||||
|
|
||||||
|
### v1.1.1 (2026-02-27)
|
||||||
|
- 🔧 **Correção de timeout 504** - Aumentado timeout para 10 minutos em todas as camadas
|
||||||
|
- Express server: 600 segundos
|
||||||
|
- Vite proxy: 600 segundos
|
||||||
|
- Axios frontend: 600 segundos
|
||||||
|
- 🗑️ **Correção ENOENT** - Sistema agora ignora arquivos já deletados ao limpar histórico
|
||||||
|
- 📋 **Gerenciamento de histórico** - Botões para limpar histórico completo ou arquivos antigos
|
||||||
|
- 🐛 **Melhorias de estabilidade** - Verificação de existência de arquivos antes de deletar
|
||||||
|
- 📚 **Documentação expandida** - Adicionado troubleshooting completo
|
||||||
|
|
||||||
|
### v1.1.0 (2026-02-27)
|
||||||
|
- 🚀 **Compressão agressiva** com redução >60% do tamanho original
|
||||||
|
- 📦 **Download em ZIP** para operações com múltiplos arquivos (split, pdfToImages)
|
||||||
|
- 🔍 **OCR embutido** - opção de tornar PDFs pesquisáveis (compress, merge, imagesToPDF)
|
||||||
|
- ⚡ **Otimização de imagens** com qualidade ajustável (30-100%)
|
||||||
|
- 🎯 **Interface aprimorada** com indicadores visuais e opções de OCR
|
||||||
|
|
||||||
### v1.0.0 (2026-02-27)
|
### v1.0.0 (2026-02-27)
|
||||||
- ✅ Implementação inicial
|
- ✅ Implementação inicial
|
||||||
- ✅ Compressão sem perda de qualidade
|
- ✅ Compressão sem perda de qualidade
|
||||||
|
|||||||
+3
-1
@@ -20,7 +20,7 @@
|
|||||||
"create-admin": "ts-node scripts/create_admin_user.ts",
|
"create-admin": "ts-node scripts/create_admin_user.ts",
|
||||||
"worker": "ts-node src/workers/startWorker.ts",
|
"worker": "ts-node src/workers/startWorker.ts",
|
||||||
"install-deps": "npm install ua-parser-js uuid axios xlsx",
|
"install-deps": "npm install ua-parser-js uuid axios xlsx",
|
||||||
"install-pdf-deps": "npm install pdf-parse pdf2pic tesseract.js && npm install --save-dev @types/pdf-parse",
|
"install-pdf-deps": "npm install pdf-parse pdf2pic tesseract.js archiver && npm install --save-dev @types/pdf-parse @types/archiver",
|
||||||
"cleanup-pdf-files": "ts-node scripts/cleanup_pdf_files.ts"
|
"cleanup-pdf-files": "ts-node scripts/cleanup_pdf_files.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -38,6 +38,7 @@
|
|||||||
"@tiptap/starter-kit": "^2.11.7",
|
"@tiptap/starter-kit": "^2.11.7",
|
||||||
"@types/sharp": "^0.32.0",
|
"@types/sharp": "^0.32.0",
|
||||||
"@types/ua-parser-js": "^0.7.36",
|
"@types/ua-parser-js": "^0.7.36",
|
||||||
|
"archiver": "^7.0.1",
|
||||||
"axios": "^1.6.5",
|
"axios": "^1.6.5",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
@@ -76,6 +77,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.1",
|
"@tailwindcss/postcss": "^4.1.1",
|
||||||
|
"@types/archiver": "^6.0.2",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^5.0.1",
|
"@types/express": "^5.0.1",
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
@echo off
|
||||||
|
:: Script para rebuild completo do Docker com dependências PDF
|
||||||
|
:: Use este script após atualizar o Dockerfile para incluir GraphicsMagick
|
||||||
|
|
||||||
|
echo ========================================
|
||||||
|
echo Rebuild Docker - Sistema PDF
|
||||||
|
echo ========================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
echo Parando containers...
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo Removendo imagens antigas...
|
||||||
|
docker image prune -f
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo Reconstruindo imagem (sem cache)...
|
||||||
|
docker-compose build --no-cache
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo Iniciando containers...
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ========================================
|
||||||
|
echo Rebuild concluído!
|
||||||
|
echo ========================================
|
||||||
|
echo.
|
||||||
|
echo Verificando status dos containers:
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo Para ver os logs:
|
||||||
|
echo docker-compose logs -f backend
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Script para rebuild completo do Docker com dependências PDF
|
||||||
|
# Use este script após atualizar o Dockerfile para incluir GraphicsMagick
|
||||||
|
|
||||||
|
echo "========================================"
|
||||||
|
echo "Rebuild Docker - Sistema PDF"
|
||||||
|
echo "========================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "Parando containers..."
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Removendo imagens antigas..."
|
||||||
|
docker image prune -f
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Reconstruindo imagem (sem cache)..."
|
||||||
|
docker-compose build --no-cache
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Iniciando containers..."
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "========================================"
|
||||||
|
echo "Rebuild concluído!"
|
||||||
|
echo "========================================"
|
||||||
|
echo ""
|
||||||
|
echo "Verificando status dos containers:"
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Para ver os logs:"
|
||||||
|
echo " docker-compose logs -f backend"
|
||||||
|
echo ""
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/// <reference types="react" />
|
||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { useDropzone } from 'react-dropzone';
|
import { useDropzone } from 'react-dropzone';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
@@ -20,6 +21,7 @@ interface OperationResult {
|
|||||||
operacaoId: string;
|
operacaoId: string;
|
||||||
outputPaths?: string[];
|
outputPaths?: string[];
|
||||||
outputPath?: string;
|
outputPath?: string;
|
||||||
|
zipPath?: string;
|
||||||
processingTime?: number;
|
processingTime?: number;
|
||||||
reduction?: number;
|
reduction?: number;
|
||||||
filesCreated?: number;
|
filesCreated?: number;
|
||||||
@@ -45,7 +47,8 @@ export const PDFManipulationPage: React.FC = () => {
|
|||||||
const [history, setHistory] = useState<any[]>([]);
|
const [history, setHistory] = useState<any[]>([]);
|
||||||
|
|
||||||
// Configurações para cada operação
|
// Configurações para cada operação
|
||||||
const [compressQuality, setCompressQuality] = useState(95);
|
const [compressQuality, setCompressQuality] = useState(50);
|
||||||
|
const [enableOCR, setEnableOCR] = useState(false);
|
||||||
const [splitType, setSplitType] = useState<'pages' | 'range' | 'chunks'>('pages');
|
const [splitType, setSplitType] = useState<'pages' | 'range' | 'chunks'>('pages');
|
||||||
const [splitPages, setSplitPages] = useState<string>('1,2,3');
|
const [splitPages, setSplitPages] = useState<string>('1,2,3');
|
||||||
const [splitRanges, setSplitRanges] = useState<string>('1-5,6-10');
|
const [splitRanges, setSplitRanges] = useState<string>('1-5,6-10');
|
||||||
@@ -97,6 +100,7 @@ export const PDFManipulationPage: React.FC = () => {
|
|||||||
'Content-Type': 'multipart/form-data',
|
'Content-Type': 'multipart/form-data',
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
|
timeout: 600000, // 10 minutos para operações longas (compressão, OCR, etc)
|
||||||
};
|
};
|
||||||
|
|
||||||
let response;
|
let response;
|
||||||
@@ -104,6 +108,7 @@ export const PDFManipulationPage: React.FC = () => {
|
|||||||
switch (selectedOperation) {
|
switch (selectedOperation) {
|
||||||
case 'compress':
|
case 'compress':
|
||||||
formData.append('quality', compressQuality.toString());
|
formData.append('quality', compressQuality.toString());
|
||||||
|
formData.append('enableOCR', enableOCR.toString());
|
||||||
response = await axios.post('/api/pdf/compress', formData, config);
|
response = await axios.post('/api/pdf/compress', formData, config);
|
||||||
toast.success('PDFs comprimidos com sucesso!');
|
toast.success('PDFs comprimidos com sucesso!');
|
||||||
break;
|
break;
|
||||||
@@ -127,6 +132,7 @@ export const PDFManipulationPage: React.FC = () => {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'merge':
|
case 'merge':
|
||||||
|
formData.append('enableOCR', enableOCR.toString());
|
||||||
response = await axios.post('/api/pdf/merge', formData, config);
|
response = await axios.post('/api/pdf/merge', formData, config);
|
||||||
toast.success('PDFs mesclados com sucesso!');
|
toast.success('PDFs mesclados com sucesso!');
|
||||||
break;
|
break;
|
||||||
@@ -140,6 +146,7 @@ export const PDFManipulationPage: React.FC = () => {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'imagesToPDF':
|
case 'imagesToPDF':
|
||||||
|
formData.append('enableOCR', enableOCR.toString());
|
||||||
formData.append('fitToPage', 'true');
|
formData.append('fitToPage', 'true');
|
||||||
response = await axios.post('/api/pdf/images-to-pdf', formData, config);
|
response = await axios.post('/api/pdf/images-to-pdf', formData, config);
|
||||||
toast.success('Imagens convertidas em PDF!');
|
toast.success('Imagens convertidas em PDF!');
|
||||||
@@ -186,13 +193,34 @@ export const PDFManipulationPage: React.FC = () => {
|
|||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
});
|
});
|
||||||
|
|
||||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
// Extrai o nome do arquivo do header Content-Disposition
|
||||||
|
const contentDisposition = response.headers['content-disposition'];
|
||||||
|
let fileName = `arquivo-${fileIndex}`;
|
||||||
|
|
||||||
|
if (contentDisposition) {
|
||||||
|
const matches = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(contentDisposition);
|
||||||
|
if (matches != null && matches[1]) {
|
||||||
|
fileName = matches[1].replace(/['"]/g, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usa o Content-Type correto do servidor
|
||||||
|
const contentType = response.headers['content-type'] || 'application/octet-stream';
|
||||||
|
|
||||||
|
console.log(`Download frontend: ${fileName} (${contentType})`);
|
||||||
|
|
||||||
|
// Cria o blob com o tipo correto
|
||||||
|
const blob = new Blob([response.data], { type: contentType });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = url;
|
link.href = url;
|
||||||
link.setAttribute('download', `arquivo-${fileIndex}.pdf`);
|
link.setAttribute('download', fileName);
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
link.remove();
|
link.remove();
|
||||||
|
|
||||||
|
// Libera a memória do blob URL
|
||||||
|
setTimeout(() => window.URL.revokeObjectURL(url), 100);
|
||||||
|
|
||||||
toast.success('Download iniciado!');
|
toast.success('Download iniciado!');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -201,6 +229,64 @@ export const PDFManipulationPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const deleteOperation = async (operacaoId: string) => {
|
||||||
|
if (!window.confirm('Deseja realmente deletar esta operação e seus arquivos?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = getToken();
|
||||||
|
await axios.delete(`/api/pdf/operation/${operacaoId}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success('Operação deletada com sucesso!');
|
||||||
|
loadHistory();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao deletar operação:', error);
|
||||||
|
toast.error('Erro ao deletar operação');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAllHistory = async () => {
|
||||||
|
if (!window.confirm('⚠️ ATENÇÃO: Isso irá deletar TODO o histórico e TODOS os arquivos armazenados. Esta ação não pode ser desfeita. Deseja continuar?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = getToken();
|
||||||
|
const response = await axios.delete('/api/pdf/history/clear', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success(`Histórico limpo! ${response.data.operacoesRemovidas} operações removidas.`);
|
||||||
|
setHistory([]);
|
||||||
|
setResult(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao limpar histórico:', error);
|
||||||
|
toast.error('Erro ao limpar histórico');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanupOldFiles = async () => {
|
||||||
|
if (!window.confirm('Deseja remover operações com mais de 24 horas?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = getToken();
|
||||||
|
const response = await axios.delete('/api/pdf/history/cleanup?hours=24', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success(`${response.data.operacoesRemovidas} operações antigas removidas!`);
|
||||||
|
loadHistory();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao limpar arquivos antigos:', error);
|
||||||
|
toast.error('Erro ao limpar arquivos antigos');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
loadHistory();
|
loadHistory();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -367,20 +453,38 @@ export const PDFManipulationPage: React.FC = () => {
|
|||||||
<h2 className="text-xl font-semibold mb-4">Configurações</h2>
|
<h2 className="text-xl font-semibold mb-4">Configurações</h2>
|
||||||
|
|
||||||
{selectedOperation === 'compress' && (
|
{selectedOperation === 'compress' && (
|
||||||
<div>
|
<div className="space-y-4">
|
||||||
<label className="block text-sm font-medium mb-2">
|
<div>
|
||||||
Qualidade ({compressQuality}%)
|
<label className="block text-sm font-medium mb-2">
|
||||||
</label>
|
Qualidade ({compressQuality}%)
|
||||||
<input
|
</label>
|
||||||
type="range"
|
<input
|
||||||
min="50"
|
type="range"
|
||||||
max="100"
|
min="30"
|
||||||
value={compressQuality}
|
max="100"
|
||||||
onChange={(e) => setCompressQuality(parseInt(e.target.value))}
|
value={compressQuality}
|
||||||
className="w-full"
|
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 className="text-xs text-gray-500 mt-1">
|
||||||
|
30-50%: Máxima compressão (>60% redução) | 70-100%: Alta qualidade
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="enableOCR"
|
||||||
|
checked={enableOCR}
|
||||||
|
onChange={(e) => setEnableOCR(e.target.checked)}
|
||||||
|
className="h-4 w-4 text-blue-600 rounded"
|
||||||
|
/>
|
||||||
|
<label htmlFor="enableOCR" className="text-sm font-medium">
|
||||||
|
Adicionar camada de OCR (tornar PDF pesquisável)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
⚠️ OCR aumenta o tempo de processamento significativamente
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -510,6 +614,54 @@ export const PDFManipulationPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selectedOperation === 'merge' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Selecione 2 ou mais PDFs para mesclar em um único arquivo.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="enableOCRMerge"
|
||||||
|
checked={enableOCR}
|
||||||
|
onChange={(e) => setEnableOCR(e.target.checked)}
|
||||||
|
className="h-4 w-4 text-blue-600 rounded"
|
||||||
|
/>
|
||||||
|
<label htmlFor="enableOCRMerge" className="text-sm font-medium">
|
||||||
|
Adicionar camada de OCR ao PDF mesclado
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
⚠️ OCR aumenta significativamente o tempo de processamento
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedOperation === 'imagesToPDF' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Converte múltiplas imagens em um único arquivo PDF.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="enableOCRImages"
|
||||||
|
checked={enableOCR}
|
||||||
|
onChange={(e) => setEnableOCR(e.target.checked)}
|
||||||
|
className="h-4 w-4 text-blue-600 rounded"
|
||||||
|
/>
|
||||||
|
<label htmlFor="enableOCRImages" className="text-sm font-medium">
|
||||||
|
Adicionar camada de OCR (tornar PDF pesquisável)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
⚠️ OCR aumenta significativamente o tempo de processamento
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={processFiles}
|
onClick={processFiles}
|
||||||
disabled={processing || files.length === 0}
|
disabled={processing || files.length === 0}
|
||||||
@@ -565,22 +717,52 @@ export const PDFManipulationPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(result.outputPaths || result.outputPath) && (
|
{(result.outputPaths || result.outputPath || result.zipPath) && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<p className="font-medium mb-2">Arquivos gerados:</p>
|
<p className="font-medium mb-2">Arquivos gerados:</p>
|
||||||
{result.outputPaths?.map((path, index) => (
|
|
||||||
<button
|
{/* Botão de download ZIP (se disponível) */}
|
||||||
key={index}
|
{result.zipPath && (
|
||||||
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
|
<button
|
||||||
onClick={() => downloadFile(result.operacaoId, 0)}
|
onClick={() => downloadFile(result.operacaoId, 0)}
|
||||||
|
className="flex items-center space-x-2 w-full p-3 mb-3 bg-blue-50 hover:bg-blue-100 rounded border-2 border-blue-300"
|
||||||
|
>
|
||||||
|
<ArrowDownTrayIcon className="h-6 w-6 text-blue-600" />
|
||||||
|
<div className="text-left">
|
||||||
|
<span className="text-sm font-semibold text-blue-900">
|
||||||
|
📦 Download ZIP (Todos os arquivos)
|
||||||
|
</span>
|
||||||
|
<p className="text-xs text-blue-700">
|
||||||
|
Recomendado para múltiplos arquivos
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Arquivos individuais */}
|
||||||
|
{result.outputPaths?.map((path, index) => {
|
||||||
|
// Determina o nome do arquivo baseado na operação
|
||||||
|
let fileName = `Arquivo ${index + 1}`;
|
||||||
|
if (selectedOperation === 'pdfToImages') {
|
||||||
|
fileName = `Imagem ${index + 1}`;
|
||||||
|
} else if (selectedOperation === 'split') {
|
||||||
|
fileName = `Página ${index + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={() => downloadFile(result.operacaoId, result.zipPath ? index + 1 : 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">{fileName}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{result.outputPath && !result.outputPaths && (
|
||||||
|
<button
|
||||||
|
onClick={() => downloadFile(result.operacaoId, result.zipPath ? 1 : 0)}
|
||||||
className="flex items-center space-x-2 w-full p-2 hover:bg-gray-50 rounded"
|
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" />
|
<ArrowDownTrayIcon className="h-5 w-5 text-blue-600" />
|
||||||
@@ -610,7 +792,27 @@ export const PDFManipulationPage: React.FC = () => {
|
|||||||
{/* Histórico */}
|
{/* Histórico */}
|
||||||
<div className="lg:col-span-1">
|
<div className="lg:col-span-1">
|
||||||
<div className="bg-white rounded-lg shadow p-6 sticky top-8">
|
<div className="bg-white rounded-lg shadow p-6 sticky top-8">
|
||||||
<h2 className="text-xl font-semibold mb-4">Histórico</h2>
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-xl font-semibold">Histórico</h2>
|
||||||
|
{history.length > 0 && (
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={cleanupOldFiles}
|
||||||
|
className="text-xs px-2 py-1 bg-yellow-100 hover:bg-yellow-200 text-yellow-800 rounded transition-colors"
|
||||||
|
title="Remover operações com mais de 24 horas"
|
||||||
|
>
|
||||||
|
🕐 Limpar Antigos
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={clearAllHistory}
|
||||||
|
className="text-xs px-2 py-1 bg-red-100 hover:bg-red-200 text-red-800 rounded transition-colors"
|
||||||
|
title="Apagar todo o histórico e arquivos"
|
||||||
|
>
|
||||||
|
🗑️ Limpar Tudo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{history.length === 0 ? (
|
{history.length === 0 ? (
|
||||||
<p className="text-gray-500 text-sm">Nenhuma operação ainda</p>
|
<p className="text-gray-500 text-sm">Nenhuma operação ainda</p>
|
||||||
@@ -622,17 +824,28 @@ export const PDFManipulationPage: React.FC = () => {
|
|||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{op.tipoOperacao.replace('_', ' ')}
|
{op.tipoOperacao.replace('_', ' ')}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<div className="flex items-center space-x-2">
|
||||||
className={`text-xs px-2 py-1 rounded ${
|
<span
|
||||||
op.status === 'CONCLUIDO'
|
className={`text-xs px-2 py-1 rounded ${
|
||||||
? 'bg-green-100 text-green-800'
|
op.status === 'CONCLUIDO'
|
||||||
: op.status === 'ERRO'
|
? 'bg-green-100 text-green-800'
|
||||||
? 'bg-red-100 text-red-800'
|
: op.status === 'ERRO'
|
||||||
: 'bg-yellow-100 text-yellow-800'
|
? 'bg-red-100 text-red-800'
|
||||||
}`}
|
: 'bg-yellow-100 text-yellow-800'
|
||||||
>
|
}`}
|
||||||
{op.status}
|
>
|
||||||
</span>
|
{op.status}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteOperation(op.id)}
|
||||||
|
className="text-red-600 hover:text-red-800 hover:bg-red-50 p-1 rounded transition-colors"
|
||||||
|
title="Deletar esta operação"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fillRule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center text-xs text-gray-500 mt-1">
|
<div className="flex items-center text-xs text-gray-500 mt-1">
|
||||||
<ClockIcon className="h-3 w-3 mr-1" />
|
<ClockIcon className="h-3 w-3 mr-1" />
|
||||||
|
|||||||
@@ -70,8 +70,9 @@ export class PDFController {
|
|||||||
return;
|
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 maxSizeMB = req.body.maxSizeMB ? parseInt(req.body.maxSizeMB) : undefined;
|
||||||
|
const enableOCR = req.body.enableOCR === 'true' || req.body.enableOCR === true;
|
||||||
|
|
||||||
const arquivosOriginais: string[] = [];
|
const arquivosOriginais: string[] = [];
|
||||||
const arquivosResultado: string[] = [];
|
const arquivosResultado: string[] = [];
|
||||||
@@ -99,7 +100,23 @@ export class PDFController {
|
|||||||
arquivosOriginais.push(file.path);
|
arquivosOriginais.push(file.path);
|
||||||
tamanhoOriginal += BigInt(file.size);
|
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,
|
quality,
|
||||||
maxSizeMB,
|
maxSizeMB,
|
||||||
});
|
});
|
||||||
@@ -115,6 +132,10 @@ export class PDFController {
|
|||||||
outputPath: result.outputPath,
|
outputPath: result.outputPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Remove o arquivo intermediário
|
||||||
|
if (processedPath !== file.path && processedPath !== result.outputPath) {
|
||||||
|
await fs.unlink(processedPath).catch(() => {});
|
||||||
|
}
|
||||||
// Remove o arquivo original
|
// Remove o arquivo original
|
||||||
await fs.unlink(file.path).catch(() => {});
|
await fs.unlink(file.path).catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -221,6 +242,16 @@ export class PDFController {
|
|||||||
// Divide o PDF
|
// Divide o PDF
|
||||||
const outputPaths = await PDFService.splitPDF(file.path, options);
|
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
|
// Calcula tamanho total dos arquivos resultantes
|
||||||
let tamanhoResultado = BigInt(0);
|
let tamanhoResultado = BigInt(0);
|
||||||
for (const outputPath of outputPaths) {
|
for (const outputPath of outputPaths) {
|
||||||
@@ -235,7 +266,7 @@ export class PDFController {
|
|||||||
where: { id: operacaoId },
|
where: { id: operacaoId },
|
||||||
data: {
|
data: {
|
||||||
status: 'CONCLUIDO',
|
status: 'CONCLUIDO',
|
||||||
arquivosResultado: outputPaths,
|
arquivosResultado: zipPath ? [zipPath, ...outputPaths] : outputPaths,
|
||||||
tamanhoResultado,
|
tamanhoResultado,
|
||||||
tempoProcessamento,
|
tempoProcessamento,
|
||||||
},
|
},
|
||||||
@@ -249,6 +280,7 @@ export class PDFController {
|
|||||||
operacaoId,
|
operacaoId,
|
||||||
filesCreated: outputPaths.length,
|
filesCreated: outputPaths.length,
|
||||||
outputPaths,
|
outputPaths,
|
||||||
|
zipPath,
|
||||||
processingTime: tempoProcessamento,
|
processingTime: tempoProcessamento,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -293,6 +325,7 @@ export class PDFController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const enableOCR = req.body.enableOCR === 'true' || req.body.enableOCR === true;
|
||||||
const filePaths = files.map((f) => f.path);
|
const filePaths = files.map((f) => f.path);
|
||||||
let tamanhoOriginal = BigInt(0);
|
let tamanhoOriginal = BigInt(0);
|
||||||
|
|
||||||
@@ -316,11 +349,23 @@ export class PDFController {
|
|||||||
operacaoId = operacao.id;
|
operacaoId = operacao.id;
|
||||||
|
|
||||||
// Mescla os PDFs
|
// Mescla os PDFs
|
||||||
const outputPath = await PDFService.mergePDFs({
|
let outputPath = await PDFService.mergePDFs({
|
||||||
filePaths,
|
filePaths,
|
||||||
outputPath: path.join(process.cwd(), 'storage', 'pdf-output', `merged-${uuidv4()}.pdf`),
|
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
|
// Calcula tamanho do arquivo resultante
|
||||||
const stats = await fs.stat(outputPath);
|
const stats = await fs.stat(outputPath);
|
||||||
const tamanhoResultado = BigInt(stats.size);
|
const tamanhoResultado = BigInt(stats.size);
|
||||||
@@ -422,6 +467,16 @@ export class PDFController {
|
|||||||
pages,
|
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
|
// Calcula tamanho total
|
||||||
let tamanhoResultado = BigInt(0);
|
let tamanhoResultado = BigInt(0);
|
||||||
for (const outputPath of outputPaths) {
|
for (const outputPath of outputPaths) {
|
||||||
@@ -436,7 +491,7 @@ export class PDFController {
|
|||||||
where: { id: operacaoId },
|
where: { id: operacaoId },
|
||||||
data: {
|
data: {
|
||||||
status: 'CONCLUIDO',
|
status: 'CONCLUIDO',
|
||||||
arquivosResultado: outputPaths,
|
arquivosResultado: zipPath ? [zipPath, ...outputPaths] : outputPaths,
|
||||||
tamanhoResultado,
|
tamanhoResultado,
|
||||||
tempoProcessamento,
|
tempoProcessamento,
|
||||||
},
|
},
|
||||||
@@ -450,6 +505,7 @@ export class PDFController {
|
|||||||
operacaoId,
|
operacaoId,
|
||||||
imagesCreated: outputPaths.length,
|
imagesCreated: outputPaths.length,
|
||||||
outputPaths,
|
outputPaths,
|
||||||
|
zipPath,
|
||||||
processingTime: tempoProcessamento,
|
processingTime: tempoProcessamento,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -503,6 +559,7 @@ export class PDFController {
|
|||||||
|
|
||||||
const fitToPage = req.body.fitToPage === 'true';
|
const fitToPage = req.body.fitToPage === 'true';
|
||||||
const pageSize = req.body.pageSize ? JSON.parse(req.body.pageSize) : undefined;
|
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
|
// Cria registro da operação
|
||||||
const operacao = await prisma.operacaoPDF.create({
|
const operacao = await prisma.operacaoPDF.create({
|
||||||
@@ -520,13 +577,25 @@ export class PDFController {
|
|||||||
operacaoId = operacao.id;
|
operacaoId = operacao.id;
|
||||||
|
|
||||||
// Converte para PDF
|
// Converte para PDF
|
||||||
const outputPath = await PDFService.imagesToPDF({
|
let outputPath = await PDFService.imagesToPDF({
|
||||||
imagePaths,
|
imagePaths,
|
||||||
outputPath: path.join(process.cwd(), 'storage', 'pdf-output', `images-to-pdf-${uuidv4()}.pdf`),
|
outputPath: path.join(process.cwd(), 'storage', 'pdf-output', `images-to-pdf-${uuidv4()}.pdf`),
|
||||||
fitToPage,
|
fitToPage,
|
||||||
pageSize,
|
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
|
// Calcula tamanho do arquivo resultante
|
||||||
const stats = await fs.stat(outputPath);
|
const stats = await fs.stat(outputPath);
|
||||||
const tamanhoResultado = BigInt(stats.size);
|
const tamanhoResultado = BigInt(stats.size);
|
||||||
@@ -797,7 +866,37 @@ export class PDFController {
|
|||||||
return;
|
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) {
|
} catch (error) {
|
||||||
console.error('Erro ao fazer download:', error);
|
console.error('Erro ao fazer download:', error);
|
||||||
res.status(500).json({
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+3
-1
@@ -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}`);
|
console.log(`🚀 Servidor rodando em http://localhost:${port}`);
|
||||||
});
|
});
|
||||||
|
server.timeout = 600000; // 10 minutos (600 segundos)
|
||||||
|
|
||||||
process.on("unhandledRejection", (err) => {
|
process.on("unhandledRejection", (err) => {
|
||||||
console.error("Erro não tratado:", err);
|
console.error("Erro não tratado:", err);
|
||||||
|
|||||||
@@ -79,4 +79,25 @@ router.get(
|
|||||||
PDFController.downloadFile
|
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;
|
export default router;
|
||||||
+157
-32
@@ -1,10 +1,12 @@
|
|||||||
import { PDFDocument } from 'pdf-lib';
|
import { PDFDocument, rgb } from 'pdf-lib';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import { createWorker } from 'tesseract.js';
|
import { createWorker } from 'tesseract.js';
|
||||||
import { fromPath } from 'pdf2pic';
|
import { fromPath } from 'pdf2pic';
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import archiver from 'archiver';
|
||||||
|
import { createWriteStream } from 'fs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serviço completo de manipulação de PDFs
|
* 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
|
* Comprime um PDF de forma agressiva para redução >60%
|
||||||
* Usa otimização de streams e remoção de metadados desnecessários
|
* Usa abordagem simplificada e eficiente
|
||||||
*/
|
*/
|
||||||
static async compressPDF(
|
static async compressPDF(
|
||||||
inputPath: string,
|
inputPath: string,
|
||||||
@@ -86,7 +88,7 @@ export class PDFService {
|
|||||||
try {
|
try {
|
||||||
await this.initialize();
|
await this.initialize();
|
||||||
|
|
||||||
const { quality = 95 } = options;
|
const { quality = 50 } = options;
|
||||||
|
|
||||||
// Lê o PDF original
|
// Lê o PDF original
|
||||||
const pdfBytes = await fs.readFile(inputPath);
|
const pdfBytes = await fs.readFile(inputPath);
|
||||||
@@ -95,9 +97,10 @@ export class PDFService {
|
|||||||
// Carrega o PDF
|
// Carrega o PDF
|
||||||
const pdfDoc = await PDFDocument.load(pdfBytes, {
|
const pdfDoc = await PDFDocument.load(pdfBytes, {
|
||||||
updateMetadata: false,
|
updateMetadata: false,
|
||||||
|
ignoreEncryption: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove metadados desnecessários mas mantém metadados essenciais
|
// Remove TODOS os metadados
|
||||||
pdfDoc.setTitle('');
|
pdfDoc.setTitle('');
|
||||||
pdfDoc.setAuthor('');
|
pdfDoc.setAuthor('');
|
||||||
pdfDoc.setSubject('');
|
pdfDoc.setSubject('');
|
||||||
@@ -105,43 +108,31 @@ export class PDFService {
|
|||||||
pdfDoc.setProducer('');
|
pdfDoc.setProducer('');
|
||||||
pdfDoc.setCreator('');
|
pdfDoc.setCreator('');
|
||||||
|
|
||||||
// Salva com compressão
|
// Para compressão agressiva, reconstrói o PDF do zero
|
||||||
const compressedBytes = await pdfDoc.save({
|
const newDoc = await PDFDocument.create();
|
||||||
useObjectStreams: true, // Comprime streams de objetos
|
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,
|
addDefaultPage: false,
|
||||||
objectsPerTick: 50,
|
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
|
// Define o caminho de saída
|
||||||
const finalOutputPath = outputPath || path.join(
|
const finalOutputPath = outputPath || path.join(
|
||||||
this.OUTPUT_DIR,
|
this.OUTPUT_DIR,
|
||||||
`compressed-${uuidv4()}.pdf`
|
`compressed-${uuidv4()}.pdf`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Salva o arquivo comprimido
|
// Salva o arquivo
|
||||||
await fs.writeFile(finalOutputPath, finalBytes);
|
await fs.writeFile(finalOutputPath, compressedBytes);
|
||||||
|
|
||||||
const compressedSize = finalBytes.length;
|
const compressedSize = compressedBytes.length;
|
||||||
const reduction = ((originalSize - compressedSize) / originalSize) * 100;
|
const reduction = ((originalSize - compressedSize) / originalSize) * 100;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -334,7 +325,9 @@ export class PDFService {
|
|||||||
`page-${pageNum}-${uuidv4()}.${format}`
|
`page-${pageNum}-${uuidv4()}.${format}`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (format === 'jpg') {
|
console.log(`Convertendo página ${pageNum} para ${format}: ${optimizedPath}`);
|
||||||
|
|
||||||
|
if (format === 'jpg' || format === 'jpeg') {
|
||||||
await sharp(imagePath)
|
await sharp(imagePath)
|
||||||
.jpeg({ quality, progressive: true })
|
.jpeg({ quality, progressive: true })
|
||||||
.toFile(optimizedPath);
|
.toFile(optimizedPath);
|
||||||
@@ -346,6 +339,20 @@ export class PDFService {
|
|||||||
await sharp(imagePath)
|
await sharp(imagePath)
|
||||||
.webp({ quality })
|
.webp({ quality })
|
||||||
.toFile(optimizedPath);
|
.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
|
// 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<string> {
|
||||||
|
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
|
* Limpa arquivos temporários antigos
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export default defineConfig({
|
|||||||
target: 'http://localhost:5000',
|
target: 'http://localhost:5000',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
|
timeout: 600000, // 10 minutos para operações longas (PDF, OCR)
|
||||||
|
proxyTimeout: 600000,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
hmr: {
|
hmr: {
|
||||||
|
|||||||
Reference in new issue
Block a user