Gabriel Ramos commited on
Commit
780413d
·
1 Parent(s): 72b25f8

feat: Docling Document Processor - Gradio + ZeroGPU

Browse files

- Interface para upload múltiplo (1-5 arquivos)
- Suporte a PDF, DOC, DOCX (até 50MB)
- Saída em JSON, Markdown ou ZIP (ambos)
- Aceleração GPU via @spaces.GPU
- Rate limiting, logging e validação robusta

README.md CHANGED
@@ -1,12 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
- title: Docling Processor
3
- emoji: 🏢
4
- colorFrom: yellow
5
- colorTo: pink
6
- sdk: gradio
7
- sdk_version: 6.0.2
8
- app_file: app.py
9
- pinned: false
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
+ # 📄 Docling Document Processor
2
+
3
+ Aplicação Gradio para processamento de documentos usando [Docling](https://github.com/docling-project/docling) com aceleração ZeroGPU.
4
+
5
+ ![Gradio](https://img.shields.io/badge/Gradio-4.44+-orange)
6
+ ![Python](https://img.shields.io/badge/Python-3.10+-blue)
7
+ ![License](https://img.shields.io/badge/License-MIT-green)
8
+
9
+ ## ✨ Recursos
10
+
11
+ - 🔍 **Extração inteligente** de texto, tabelas e metadados
12
+ - 🌐 **Detecção automática** de idioma
13
+ - 🚀 **Aceleração GPU** via ZeroGPU (Hugging Face Spaces)
14
+ - 📊 **Preserva estrutura** hierárquica do documento
15
+ - 📁 **Upload múltiplo** (1-5 arquivos simultâneos)
16
+ - 🔒 **Segurança** com validação de MIME type e sanitização
17
+
18
+ ## 📋 Formatos Suportados
19
+
20
+ | Entrada | Saída |
21
+ |---------|-------|
22
+ | PDF | JSON |
23
+ | DOC | Markdown |
24
+ | DOCX | ZIP (ambos) |
25
+
26
+ ## 🚀 Instalação Local
27
+
28
+ ### Pré-requisitos
29
+
30
+ - Python 3.10+
31
+ - [uv](https://docs.astral.sh/uv/) (recomendado) ou pip
32
+
33
+ ### Com uv
34
+
35
+ ```bash
36
+ # Clone o repositório
37
+ git clone https://huggingface.co/spaces/SEU_USUARIO/docling-processor
38
+ cd docling-processor
39
+
40
+ # Crie ambiente virtual e instale dependências
41
+ uv venv
42
+ source .venv/bin/activate # Linux/macOS
43
+ uv pip install -r requirements.txt
44
+
45
+ # Execute
46
+ python app.py
47
+ ```
48
+
49
+ ### Com pip
50
+
51
+ ```bash
52
+ python -m venv venv
53
+ source venv/bin/activate
54
+ pip install -r requirements.txt
55
+ python app.py
56
+ ```
57
+
58
+ Acesse: http://localhost:7860
59
+
60
+ ## 🌐 Deploy no Hugging Face Spaces
61
+
62
+ ### 1. Crie o Space
63
+
64
+ ```bash
65
+ # Login no Hugging Face
66
+ hf login
67
+
68
+ # Crie o Space (substitua SEU_USUARIO pelo seu username)
69
+ hf repo create SEU_USUARIO/docling-processor --repo-type space --space-sdk gradio
70
+ ```
71
+
72
+ ### 2. Configure ZeroGPU
73
+
74
+ No Hugging Face, vá em **Settings > Hardware** e selecione **ZeroGPU**.
75
+
76
+ ### 3. Push do código
77
+
78
+ ```bash
79
+ cd /caminho/para/docling_hf
80
+ git remote add space https://huggingface.co/spaces/SEU_USUARIO/docling-processor
81
+ git push space main
82
+ ```
83
+
84
+ ### 4. Verifique
85
+
86
+ Acesse: `https://huggingface.co/spaces/SEU_USUARIO/docling-processor`
87
+
88
+ ## 📂 Estrutura do Projeto
89
+
90
+ ```
91
+ docling_hf/
92
+ ├── app.py # Interface Gradio + ZeroGPU
93
+ ├── config.py # Configurações centralizadas
94
+ ├── requirements.txt # Dependências
95
+ ├── README.md # Esta documentação
96
+ ├── processors/ # Lógica de processamento
97
+ │ ├── docling_processor.py
98
+ │ ├── json_formatter.py
99
+ │ └── markdown_formatter.py
100
+ ├── utils/ # Utilitários
101
+ │ ├── validators.py
102
+ │ ├── file_handler.py
103
+ │ └── logger.py
104
+ ├── tests/ # Testes unitários
105
+ │ └── test_processors.py
106
+ └── logs/ # Arquivos de log
107
+ ```
108
+
109
+ ## 📤 Formatos de Saída
110
+
111
+ ### JSON
112
+
113
+ ```json
114
+ {
115
+ "arquivo": "documento.pdf",
116
+ "idioma": "pt",
117
+ "processado_em": "2024-01-15T10:30:00",
118
+ "metadados": {
119
+ "nome_arquivo": "documento.pdf",
120
+ "num_paginas": 5,
121
+ "num_tabelas": 2
122
+ },
123
+ "tabelas": [...],
124
+ "conteudo": {...}
125
+ }
126
+ ```
127
+
128
+ ### Markdown
129
+
130
+ ```markdown
131
+ # Título do Documento
132
+
133
+ **Autor:** João Silva
134
+ **Idioma:** Português
135
+ **Páginas:** 5
136
+
137
+ ---
138
+
139
+ ## Conteúdo
140
+
141
+ [Texto extraído do documento...]
142
+
143
  ---
144
+
145
+ ## Tabelas Extraídas
146
+
147
+ | Coluna 1 | Coluna 2 |
148
+ |----------|----------|
149
+ | Valor 1 | Valor 2 |
150
+ ```
151
+
152
+ ## ⚙️ Configuração
153
+
154
+ Edite `config.py` para personalizar:
155
+
156
+ | Variável | Padrão | Descrição |
157
+ |----------|--------|-----------|
158
+ | `MAX_FILE_SIZE_MB` | 50 | Limite por arquivo |
159
+ | `MAX_FILES_PER_SESSION` | 5 | Arquivos por upload |
160
+ | `PROCESSING_TIMEOUT_SECONDS` | 300 | Timeout de processamento |
161
+ | `RATE_LIMIT_REQUESTS` | 10 | Requisições por hora |
162
+
163
+ ## 🧪 Testes
164
+
165
+ ```bash
166
+ # Executar testes
167
+ python -m pytest tests/ -v
168
+
169
+ # Com cobertura
170
+ python -m pytest tests/ -v --cov=. --cov-report=html
171
+ ```
172
+
173
+ ## 🔧 Troubleshooting
174
+
175
+ ### ❌ "Arquivo muito grande"
176
+
177
+ Reduza o tamanho do PDF ou divida em partes menores.
178
+
179
+ ### ❌ "Tipo de arquivo inválido"
180
+
181
+ Verifique se o arquivo não está corrompido. O sistema valida o conteúdo real, não apenas a extensão.
182
+
183
+ ### ❌ "Timeout"
184
+
185
+ - Arquivos muito grandes ou complexos podem exceder o limite
186
+ - Tente processar menos arquivos por vez
187
+ - PDFs escaneados (OCR) levam mais tempo
188
+
189
+ ### ❌ "Rate limit excedido"
190
+
191
+ Aguarde 1 hora ou use uma conta diferente.
192
+
193
+ ## 📄 Licença
194
+
195
+ MIT License - veja [LICENSE](LICENSE) para detalhes.
196
+
197
+ ## 🤝 Contribuições
198
+
199
+ Contribuições são bem-vindas! Abra uma issue ou pull request.
200
+
201
  ---
202
 
203
+ Desenvolvido com ❤️ usando [Docling](https://github.com/docling-project/docling) e [Gradio](https://gradio.app)
app.py ADDED
@@ -0,0 +1,491 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Docling Document Processor - Aplicação Principal.
3
+
4
+ Este é o ponto de entrada da aplicação Gradio que permite
5
+ o upload e processamento de documentos usando Docling.
6
+
7
+ Recursos:
8
+ - Upload múltiplo (1-5 arquivos)
9
+ - Formatos: PDF, DOC, DOCX
10
+ - Saída: JSON, Markdown ou ambos (ZIP)
11
+ - Aceleração GPU via ZeroGPU
12
+ """
13
+
14
+ import os
15
+ import sys
16
+ import time
17
+ import traceback
18
+ from collections import defaultdict
19
+ from datetime import datetime, timedelta
20
+ from pathlib import Path
21
+ from typing import Optional
22
+
23
+ import gradio as gr
24
+
25
+ # Importação condicional do spaces para ZeroGPU
26
+ try:
27
+ import spaces
28
+ HAS_SPACES = True
29
+ except ImportError:
30
+ HAS_SPACES = False
31
+
32
+ # Adiciona o diretório atual ao path para imports locais
33
+ sys.path.insert(0, str(Path(__file__).parent))
34
+
35
+ import config
36
+ from utils.validators import validate_files, ValidationError
37
+ from utils.file_handler import (
38
+ create_temp_directory,
39
+ cleanup_old_files,
40
+ create_zip_output,
41
+ save_output_file,
42
+ )
43
+ from utils.logger import setup_logger, get_logger
44
+ from processors.docling_processor import DoclingProcessor
45
+ from processors.json_formatter import format_to_json, JSONFormatter
46
+ from processors.markdown_formatter import format_to_markdown, MarkdownFormatter
47
+
48
+ # Configura logger
49
+ logger = setup_logger("docling_space")
50
+
51
+ # =============================================================================
52
+ # RATE LIMITING (in-memory)
53
+ # =============================================================================
54
+
55
+ # Armazena requisições por IP: {ip: [timestamps]}
56
+ _rate_limit_store: dict[str, list[datetime]] = defaultdict(list)
57
+
58
+
59
+ def check_rate_limit(request: gr.Request) -> bool:
60
+ """
61
+ Verifica se o IP excedeu o limite de requisições.
62
+
63
+ Args:
64
+ request: Objeto de request do Gradio.
65
+
66
+ Returns:
67
+ True se está dentro do limite, False se excedeu.
68
+ """
69
+ if request is None:
70
+ return True
71
+
72
+ # Obtém IP do cliente
73
+ client_ip = getattr(request, "client", {})
74
+ if isinstance(client_ip, dict):
75
+ ip = client_ip.get("host", "unknown")
76
+ else:
77
+ ip = str(client_ip)
78
+
79
+ now = datetime.now()
80
+ window_start = now - timedelta(hours=config.RATE_LIMIT_WINDOW_HOURS)
81
+
82
+ # Limpa requisições antigas
83
+ _rate_limit_store[ip] = [
84
+ ts for ts in _rate_limit_store[ip]
85
+ if ts > window_start
86
+ ]
87
+
88
+ # Verifica limite
89
+ if len(_rate_limit_store[ip]) >= config.RATE_LIMIT_REQUESTS:
90
+ logger.warning(f"Rate limit excedido para IP: {ip}")
91
+ return False
92
+
93
+ # Registra nova requisição
94
+ _rate_limit_store[ip].append(now)
95
+ return True
96
+
97
+
98
+ # =============================================================================
99
+ # FUNÇÃO DE PROCESSAMENTO PRINCIPAL
100
+ # =============================================================================
101
+
102
+ def _process_documents_internal(
103
+ files: list,
104
+ output_format: str,
105
+ progress: Optional[gr.Progress] = None
106
+ ) -> tuple[str | list[str], str]:
107
+ """
108
+ Função interna de processamento (sem decorator GPU).
109
+
110
+ Args:
111
+ files: Lista de arquivos enviados.
112
+ output_format: Formato de saída ("JSON", "Markdown", "Ambos").
113
+ progress: Objeto de progresso do Gradio.
114
+
115
+ Returns:
116
+ Tupla (caminho(s) do arquivo de saída, mensagem de status).
117
+ """
118
+ start_time = time.time()
119
+
120
+ # Limpa arquivos temporários antigos
121
+ cleanup_old_files()
122
+
123
+ # Valida arquivos
124
+ if progress:
125
+ progress(0.1, desc="Validando arquivos...")
126
+
127
+ try:
128
+ validated_files = validate_files(files)
129
+ except ValidationError as e:
130
+ logger.warning(f"Erro de validação: {e.message}")
131
+ raise gr.Error(e.message)
132
+
133
+ # Prepara processador
134
+ if progress:
135
+ progress(0.2, desc="Inicializando Docling...")
136
+
137
+ processor = DoclingProcessor(
138
+ enable_ocr=True,
139
+ enable_table_detection=True,
140
+ use_gpu=HAS_SPACES
141
+ )
142
+
143
+ # Cria diretório de saída
144
+ output_dir = create_temp_directory(prefix="output_")
145
+ output_files = []
146
+ processed_count = 0
147
+ total_files = len(validated_files)
148
+
149
+ # Processa cada arquivo
150
+ for i, (file_path, sanitized_name) in enumerate(validated_files):
151
+ progress_pct = 0.2 + (0.6 * (i / total_files))
152
+
153
+ if progress:
154
+ progress(progress_pct, desc=f"Processando {sanitized_name}...")
155
+
156
+ try:
157
+ # Processa documento
158
+ processed_data = processor.process_document(file_path)
159
+
160
+ # Gera nome base sem extensão
161
+ base_name = Path(sanitized_name).stem
162
+
163
+ # Formata saída
164
+ if output_format == "JSON":
165
+ json_content = format_to_json(processed_data, sanitized_name)
166
+ json_path = save_output_file(
167
+ json_content,
168
+ f"{base_name}.json",
169
+ output_dir
170
+ )
171
+ output_files.append((json_path, f"{base_name}.json"))
172
+
173
+ elif output_format == "Markdown":
174
+ md_content = format_to_markdown(processed_data)
175
+ md_path = save_output_file(
176
+ md_content,
177
+ f"{base_name}.md",
178
+ output_dir
179
+ )
180
+ output_files.append((md_path, f"{base_name}.md"))
181
+
182
+ else: # Ambos
183
+ json_content = format_to_json(processed_data, sanitized_name)
184
+ md_content = format_to_markdown(processed_data)
185
+
186
+ json_path = save_output_file(
187
+ json_content,
188
+ f"{base_name}.json",
189
+ output_dir
190
+ )
191
+ md_path = save_output_file(
192
+ md_content,
193
+ f"{base_name}.md",
194
+ output_dir
195
+ )
196
+
197
+ output_files.append((json_path, f"{base_name}.json"))
198
+ output_files.append((md_path, f"{base_name}.md"))
199
+
200
+ processed_count += 1
201
+ logger.info(f"Processado: {sanitized_name}")
202
+
203
+ except Exception as e:
204
+ logger.error(f"Erro ao processar {sanitized_name}: {e}")
205
+ logger.debug(traceback.format_exc())
206
+
207
+ # Continua com próximos arquivos
208
+ if total_files == 1:
209
+ raise gr.Error(
210
+ f"❌ Erro ao processar {sanitized_name}: {str(e)}"
211
+ )
212
+
213
+ # Prepara saída final
214
+ if progress:
215
+ progress(0.9, desc="Preparando download...")
216
+
217
+ if not output_files:
218
+ raise gr.Error("❌ Nenhum arquivo foi processado com sucesso.")
219
+
220
+ # Se há múltiplos arquivos ou formato "Ambos", cria ZIP
221
+ if len(output_files) > 1 or output_format == "Ambos":
222
+ zip_path = create_zip_output(
223
+ output_files,
224
+ output_name="documentos_processados"
225
+ )
226
+ final_output = str(zip_path)
227
+ else:
228
+ final_output = str(output_files[0][0])
229
+
230
+ # Calcula tempo total
231
+ elapsed_time = time.time() - start_time
232
+
233
+ if progress:
234
+ progress(1.0, desc="Concluído!")
235
+
236
+ # Mensagem de status
237
+ status_msg = (
238
+ f"✅ Processamento concluído!\n\n"
239
+ f"📄 **Arquivos processados:** {processed_count}/{total_files}\n"
240
+ f"📦 **Formato:** {output_format}\n"
241
+ f"⏱️ **Tempo:** {elapsed_time:.1f} segundos"
242
+ )
243
+
244
+ logger.info(
245
+ f"Batch concluído: {processed_count}/{total_files} arquivos, "
246
+ f"{elapsed_time:.1f}s, formato={output_format}"
247
+ )
248
+
249
+ return final_output, status_msg
250
+
251
+
252
+ # Versão com GPU (se disponível)
253
+ if HAS_SPACES:
254
+ @spaces.GPU(duration=config.GPU_TIMEOUT_SECONDS)
255
+ def process_documents_gpu(
256
+ files: list,
257
+ output_format: str,
258
+ progress: gr.Progress = gr.Progress()
259
+ ) -> tuple[str | list[str], str]:
260
+ """Processamento com aceleração GPU via ZeroGPU."""
261
+ return _process_documents_internal(files, output_format, progress)
262
+ else:
263
+ process_documents_gpu = None
264
+
265
+
266
+ def process_documents(
267
+ files: list,
268
+ output_format: str,
269
+ request: gr.Request,
270
+ progress: gr.Progress = gr.Progress()
271
+ ) -> tuple[str | list[str], str]:
272
+ """
273
+ Função principal de processamento.
274
+
275
+ Usa GPU se disponível, senão fallback para CPU.
276
+
277
+ Args:
278
+ files: Lista de arquivos enviados.
279
+ output_format: Formato de saída.
280
+ request: Request do Gradio (para rate limiting).
281
+ progress: Objeto de progresso.
282
+
283
+ Returns:
284
+ Tupla (caminho do arquivo de saída, mensagem de status).
285
+ """
286
+ # Verifica rate limit
287
+ if not check_rate_limit(request):
288
+ raise gr.Error(
289
+ f"⚠️ Limite de requisições excedido. "
290
+ f"Máximo: {config.RATE_LIMIT_REQUESTS} por hora. "
291
+ f"Tente novamente mais tarde."
292
+ )
293
+
294
+ try:
295
+ # Tenta usar GPU
296
+ if HAS_SPACES and process_documents_gpu is not None:
297
+ logger.info("Usando processamento GPU (ZeroGPU)")
298
+ return process_documents_gpu(files, output_format, progress)
299
+ else:
300
+ logger.info("Usando processamento CPU (fallback)")
301
+ return _process_documents_internal(files, output_format, progress)
302
+
303
+ except gr.Error:
304
+ # Re-raise erros do Gradio
305
+ raise
306
+ except TimeoutError:
307
+ logger.error("Timeout no processamento")
308
+ raise gr.Error(
309
+ "⏱️ Tempo limite excedido. Tente com arquivos menores ou menos arquivos."
310
+ )
311
+ except MemoryError:
312
+ logger.error("Memória insuficiente")
313
+ raise gr.Error(
314
+ "💾 Memória insuficiente. Tente com arquivos menores."
315
+ )
316
+ except Exception as e:
317
+ logger.error(f"Erro inesperado: {e}")
318
+ logger.debug(traceback.format_exc())
319
+ raise gr.Error(f"❌ Erro inesperado: {str(e)}")
320
+
321
+
322
+ # =============================================================================
323
+ # INTERFACE GRADIO
324
+ # =============================================================================
325
+
326
+ # CSS customizado
327
+ CUSTOM_CSS = """
328
+ .main-container {
329
+ max-width: 900px;
330
+ margin: 0 auto;
331
+ }
332
+
333
+ .upload-box {
334
+ border: 2px dashed #4a90a4;
335
+ border-radius: 12px;
336
+ padding: 20px;
337
+ background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
338
+ }
339
+
340
+ .status-box {
341
+ background: #f0f7f4;
342
+ border-radius: 8px;
343
+ padding: 15px;
344
+ margin-top: 10px;
345
+ }
346
+
347
+ .info-text {
348
+ font-size: 0.9em;
349
+ color: #666;
350
+ }
351
+ """
352
+
353
+ # Texto de descrição
354
+ DESCRIPTION = """
355
+ # 📄 Docling Document Processor
356
+
357
+ Converta documentos PDF, DOC e DOCX em formatos estruturados usando IA.
358
+
359
+ ## Recursos
360
+ - 🔍 **Extração inteligente** de texto, tabelas e metadados
361
+ - **Detecção automática** de idioma
362
+ - 🚀 **Aceleração GPU** para processamento rápido
363
+ - 📊 **Preserva estrutura** hierárquica do documento
364
+ """
365
+
366
+ INSTRUCTIONS = """
367
+ ### Como usar
368
+
369
+ 1. **Upload**: Arraste ou selecione seus arquivos (máx. 5 arquivos, 50MB cada)
370
+ 2. **Formato**: Escolha o formato de saída desejado
371
+ 3. **Processar**: Clique no botão e aguarde
372
+ 4. **Download**: Baixe o resultado quando concluído
373
+
374
+ ### Formatos suportados
375
+ - **Entrada**: PDF, DOC, DOCX
376
+ - **Saída**: JSON, Markdown ou ambos (ZIP)
377
+ """
378
+
379
+
380
+ def create_interface() -> gr.Blocks:
381
+ """Cria e retorna a interface Gradio."""
382
+
383
+ with gr.Blocks(
384
+ title="Docling Document Processor",
385
+ theme=gr.themes.Soft(
386
+ primary_hue="teal",
387
+ secondary_hue="blue",
388
+ ),
389
+ css=CUSTOM_CSS,
390
+ ) as demo:
391
+
392
+ # Header
393
+ gr.Markdown(DESCRIPTION)
394
+
395
+ with gr.Row():
396
+ # Coluna principal
397
+ with gr.Column(scale=2):
398
+ # Upload de arquivos
399
+ file_input = gr.File(
400
+ file_count="multiple",
401
+ file_types=[".pdf", ".doc", ".docx"],
402
+ label="📁 Upload de Documentos",
403
+ elem_classes=["upload-box"],
404
+ )
405
+
406
+ # Seletor de formato
407
+ format_selector = gr.Radio(
408
+ choices=config.OUTPUT_FORMATS,
409
+ value="Markdown",
410
+ label="📤 Formato de Saída",
411
+ info="Escolha como deseja receber o documento processado",
412
+ )
413
+
414
+ # Botão de processar
415
+ process_btn = gr.Button(
416
+ "🚀 Processar Documentos",
417
+ variant="primary",
418
+ size="lg",
419
+ )
420
+
421
+ # Coluna de informações
422
+ with gr.Column(scale=1):
423
+ gr.Markdown(INSTRUCTIONS)
424
+
425
+ # Área de resultados
426
+ with gr.Row():
427
+ with gr.Column():
428
+ # Status
429
+ status_output = gr.Markdown(
430
+ label="Status",
431
+ elem_classes=["status-box"],
432
+ )
433
+
434
+ # Arquivo de saída
435
+ file_output = gr.File(
436
+ label="📥 Download do Resultado",
437
+ interactive=False,
438
+ )
439
+
440
+ # Informações de limites
441
+ gr.Markdown(
442
+ f"""
443
+ ---
444
+ **Limites:** {config.MAX_FILES_PER_SESSION} arquivos por vez |
445
+ {config.MAX_FILE_SIZE_MB}MB por arquivo |
446
+ {config.RATE_LIMIT_REQUESTS} requisições/hora
447
+ """,
448
+ elem_classes=["info-text"],
449
+ )
450
+
451
+ # Evento de processamento
452
+ process_btn.click(
453
+ fn=process_documents,
454
+ inputs=[file_input, format_selector],
455
+ outputs=[file_output, status_output],
456
+ show_progress="full",
457
+ )
458
+
459
+ # Limpa status quando novos arquivos são selecionados
460
+ file_input.change(
461
+ fn=lambda: ("", None),
462
+ outputs=[status_output, file_output],
463
+ )
464
+
465
+ return demo
466
+
467
+
468
+ # =============================================================================
469
+ # PONTO DE ENTRADA
470
+ # =============================================================================
471
+
472
+ if __name__ == "__main__":
473
+ # Cria diretórios necessários
474
+ config.TEMP_DIR.mkdir(parents=True, exist_ok=True)
475
+ config.LOGS_DIR.mkdir(parents=True, exist_ok=True)
476
+
477
+ # Limpa arquivos temporários antigos
478
+ cleanup_old_files()
479
+
480
+ logger.info("Iniciando Docling Document Processor...")
481
+ logger.info(f"ZeroGPU disponível: {HAS_SPACES}")
482
+
483
+ # Cria e lança a interface
484
+ demo = create_interface()
485
+
486
+ demo.queue().launch(
487
+ server_name="0.0.0.0",
488
+ server_port=7860,
489
+ max_file_size=f"{config.MAX_FILE_SIZE_MB}mb",
490
+ show_error=True,
491
+ )
config.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Configurações centralizadas para o Docling Document Processor.
3
+
4
+ Este módulo contém todas as constantes e configurações usadas
5
+ em toda a aplicação.
6
+ """
7
+
8
+ from pathlib import Path
9
+
10
+ # =============================================================================
11
+ # LIMITES DE ARQUIVO
12
+ # =============================================================================
13
+
14
+ MAX_FILE_SIZE_MB: int = 50
15
+ """Tamanho máximo de arquivo em megabytes."""
16
+
17
+ MAX_FILE_SIZE_BYTES: int = MAX_FILE_SIZE_MB * 1024 * 1024
18
+ """Tamanho máximo de arquivo em bytes (calculado)."""
19
+
20
+ MAX_FILES_PER_SESSION: int = 5
21
+ """Número máximo de arquivos por upload."""
22
+
23
+ # =============================================================================
24
+ # PROCESSAMENTO
25
+ # =============================================================================
26
+
27
+ PROCESSING_TIMEOUT_SECONDS: int = 300
28
+ """Timeout para processamento de documentos (5 minutos)."""
29
+
30
+ GPU_TIMEOUT_SECONDS: int = 300
31
+ """Timeout para execução GPU via ZeroGPU."""
32
+
33
+ # =============================================================================
34
+ # TIPOS DE ARQUIVO SUPORTADOS
35
+ # =============================================================================
36
+
37
+ SUPPORTED_EXTENSIONS: list[str] = [".pdf", ".doc", ".docx"]
38
+ """Extensões de arquivo aceitas."""
39
+
40
+ SUPPORTED_MIME_TYPES: dict[str, list[str]] = {
41
+ ".pdf": ["application/pdf"],
42
+ ".doc": ["application/msword"],
43
+ ".docx": [
44
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
45
+ ],
46
+ }
47
+ """Mapeamento de extensões para MIME types válidos."""
48
+
49
+ # =============================================================================
50
+ # RATE LIMITING
51
+ # =============================================================================
52
+
53
+ RATE_LIMIT_REQUESTS: int = 10
54
+ """Número máximo de requisições por janela de tempo."""
55
+
56
+ RATE_LIMIT_WINDOW_HOURS: int = 1
57
+ """Janela de tempo para rate limiting em horas."""
58
+
59
+ # =============================================================================
60
+ # DIRETÓRIOS
61
+ # =============================================================================
62
+
63
+ BASE_DIR: Path = Path(__file__).parent
64
+ """Diretório base da aplicação."""
65
+
66
+ TEMP_DIR: Path = BASE_DIR / "temp"
67
+ """Diretório para arquivos temporários."""
68
+
69
+ LOGS_DIR: Path = BASE_DIR / "logs"
70
+ """Diretório para arquivos de log."""
71
+
72
+ TEMP_DIR_CLEANUP_HOURS: int = 1
73
+ """Tempo máximo de retenção de arquivos temporários em horas."""
74
+
75
+ # =============================================================================
76
+ # FORMATOS DE SAÍDA
77
+ # =============================================================================
78
+
79
+ OUTPUT_FORMATS: list[str] = ["JSON", "Markdown", "Ambos"]
80
+ """Formatos de saída disponíveis."""
81
+
82
+ # =============================================================================
83
+ # LOGGING
84
+ # =============================================================================
85
+
86
+ LOG_FILE: str = "docling_space.log"
87
+ """Nome do arquivo de log."""
88
+
89
+ LOG_MAX_BYTES: int = 10 * 1024 * 1024 # 10MB
90
+ """Tamanho máximo do arquivo de log antes de rotacionar."""
91
+
92
+ LOG_BACKUP_COUNT: int = 5
93
+ """Número de arquivos de backup de log a manter."""
94
+
95
+ LOG_FORMAT: str = "[%(asctime)s] [%(levelname)s] [%(module)s] %(message)s"
96
+ """Formato das mensagens de log."""
97
+
98
+ LOG_DATE_FORMAT: str = "%Y-%m-%d %H:%M:%S"
99
+ """Formato de data para logs."""
100
+
101
+ # =============================================================================
102
+ # CARACTERES PROIBIDOS EM NOMES DE ARQUIVO
103
+ # =============================================================================
104
+
105
+ FORBIDDEN_FILENAME_CHARS: str = r'<>:"/\|?*'
106
+ """Caracteres que devem ser removidos de nomes de arquivo."""
107
+
108
+ FILENAME_MAX_LENGTH: int = 255
109
+ """Comprimento máximo de nome de arquivo."""
logs/.gitkeep ADDED
@@ -0,0 +1 @@
 
 
1
+ # Este arquivo mantém o diretório logs no git
processors/__init__.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Módulo de processadores para o Docling Document Processor.
3
+
4
+ Este pacote contém as classes e funções responsáveis pelo
5
+ processamento de documentos e formatação de saída.
6
+ """
7
+
8
+ from processors.docling_processor import DoclingProcessor
9
+ from processors.json_formatter import format_to_json, JSONFormatter
10
+ from processors.markdown_formatter import format_to_markdown, MarkdownFormatter
11
+
12
+ __all__ = [
13
+ "DoclingProcessor",
14
+ "format_to_json",
15
+ "JSONFormatter",
16
+ "format_to_markdown",
17
+ "MarkdownFormatter",
18
+ ]
processors/docling_processor.py ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Processador principal usando Docling.
3
+
4
+ Este módulo contém a classe DoclingProcessor que é responsável por
5
+ converter documentos usando a biblioteca Docling.
6
+ """
7
+
8
+ import time
9
+ from pathlib import Path
10
+ from typing import Any, Optional
11
+
12
+ from docling.document_converter import DocumentConverter
13
+ from docling.datamodel.base_models import InputFormat
14
+ from docling.datamodel.pipeline_options import (
15
+ PdfPipelineOptions,
16
+ TableFormerMode,
17
+ )
18
+ from docling.document_converter import PdfFormatOption
19
+
20
+ from utils.logger import get_logger, ProcessingLogger
21
+
22
+ # Logger para este módulo
23
+ logger = get_logger(__name__)
24
+
25
+
26
+ class DoclingProcessor:
27
+ """
28
+ Processador de documentos usando Docling.
29
+
30
+ Esta classe encapsula a lógica de conversão de documentos,
31
+ incluindo configuração de pipeline e extração de metadados.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ enable_ocr: bool = True,
37
+ enable_table_detection: bool = True,
38
+ use_gpu: bool = True
39
+ ):
40
+ """
41
+ Inicializa o processador Docling.
42
+
43
+ Args:
44
+ enable_ocr: Se deve habilitar OCR para imagens.
45
+ enable_table_detection: Se deve detectar tabelas.
46
+ use_gpu: Se deve tentar usar GPU para aceleração.
47
+ """
48
+ self.enable_ocr = enable_ocr
49
+ self.enable_table_detection = enable_table_detection
50
+ self.use_gpu = use_gpu
51
+
52
+ # Configuração do pipeline
53
+ self._setup_converter()
54
+
55
+ logger.info(
56
+ f"DoclingProcessor inicializado "
57
+ f"(OCR={enable_ocr}, tabelas={enable_table_detection}, GPU={use_gpu})"
58
+ )
59
+
60
+ def _setup_converter(self) -> None:
61
+ """Configura o DocumentConverter com as opções adequadas."""
62
+ # Configurações específicas para PDF
63
+ pipeline_options = PdfPipelineOptions()
64
+ pipeline_options.do_ocr = self.enable_ocr
65
+ pipeline_options.do_table_structure = self.enable_table_detection
66
+
67
+ if self.enable_table_detection:
68
+ # Usa TableFormer para melhor detecção de tabelas
69
+ pipeline_options.table_structure_options.mode = TableFormerMode.ACCURATE
70
+
71
+ # Cria o converter com as opções
72
+ self.converter = DocumentConverter(
73
+ format_options={
74
+ InputFormat.PDF: PdfFormatOption(
75
+ pipeline_options=pipeline_options
76
+ )
77
+ }
78
+ )
79
+
80
+ def process_document(self, file_path: str | Path) -> dict[str, Any]:
81
+ """
82
+ Processa um documento e retorna dados estruturados.
83
+
84
+ Args:
85
+ file_path: Caminho para o arquivo a processar.
86
+
87
+ Returns:
88
+ Dicionário com documento convertido, metadados e tabelas.
89
+
90
+ Raises:
91
+ Exception: Se o processamento falhar.
92
+ """
93
+ file_path = Path(file_path)
94
+
95
+ with ProcessingLogger(logger, "Conversão Docling", file_path.name):
96
+ start_time = time.time()
97
+
98
+ try:
99
+ # Converte o documento
100
+ result = self.converter.convert(str(file_path))
101
+
102
+ # Extrai informações
103
+ document = result.document
104
+
105
+ processing_time = time.time() - start_time
106
+
107
+ return {
108
+ "document": document,
109
+ "metadata": self._extract_metadata(result, file_path),
110
+ "tables": self._extract_tables(document),
111
+ "language": self._detect_language(document),
112
+ "processing_time_seconds": processing_time,
113
+ }
114
+
115
+ except Exception as e:
116
+ logger.error(f"Erro ao processar {file_path.name}: {e}")
117
+ raise
118
+
119
+ def _extract_metadata(
120
+ self,
121
+ result: Any,
122
+ file_path: Path
123
+ ) -> dict[str, Any]:
124
+ """
125
+ Extrai metadados do documento processado.
126
+
127
+ Args:
128
+ result: Resultado da conversão Docling.
129
+ file_path: Caminho do arquivo original.
130
+
131
+ Returns:
132
+ Dicionário com metadados do documento.
133
+ """
134
+ document = result.document
135
+
136
+ metadata = {
137
+ "nome_arquivo": file_path.name,
138
+ "extensao": file_path.suffix.lower(),
139
+ "tamanho_bytes": file_path.stat().st_size if file_path.exists() else 0,
140
+ }
141
+
142
+ # Tenta extrair metadados do documento
143
+ try:
144
+ if hasattr(document, "metadata") and document.metadata:
145
+ doc_meta = document.metadata
146
+ if hasattr(doc_meta, "title") and doc_meta.title:
147
+ metadata["titulo"] = doc_meta.title
148
+ if hasattr(doc_meta, "author") and doc_meta.author:
149
+ metadata["autor"] = doc_meta.author
150
+ if hasattr(doc_meta, "creation_date") and doc_meta.creation_date:
151
+ metadata["data_criacao"] = str(doc_meta.creation_date)
152
+ except Exception as e:
153
+ logger.debug(f"Não foi possível extrair metadados: {e}")
154
+
155
+ # Contagem de elementos
156
+ try:
157
+ if hasattr(document, "pages"):
158
+ metadata["num_paginas"] = len(list(document.pages))
159
+ if hasattr(document, "tables"):
160
+ metadata["num_tabelas"] = len(list(document.tables))
161
+ if hasattr(document, "pictures"):
162
+ metadata["num_imagens"] = len(list(document.pictures))
163
+ except Exception as e:
164
+ logger.debug(f"Erro ao contar elementos: {e}")
165
+
166
+ return metadata
167
+
168
+ def _extract_tables(self, document: Any) -> list[dict[str, Any]]:
169
+ """
170
+ Extrai tabelas do documento.
171
+
172
+ Args:
173
+ document: Documento Docling convertido.
174
+
175
+ Returns:
176
+ Lista de dicionários representando tabelas.
177
+ """
178
+ tables = []
179
+
180
+ try:
181
+ if not hasattr(document, "tables"):
182
+ return tables
183
+
184
+ for i, table in enumerate(document.tables):
185
+ table_data = {
186
+ "indice": i + 1,
187
+ "dados": None,
188
+ "linhas": 0,
189
+ "colunas": 0,
190
+ }
191
+
192
+ # Tenta extrair dados da tabela
193
+ try:
194
+ if hasattr(table, "export_to_dataframe"):
195
+ df = table.export_to_dataframe()
196
+ table_data["dados"] = df.to_dict(orient="records")
197
+ table_data["linhas"] = len(df)
198
+ table_data["colunas"] = len(df.columns)
199
+ table_data["colunas_nomes"] = list(df.columns)
200
+ elif hasattr(table, "to_markdown"):
201
+ table_data["markdown"] = table.to_markdown()
202
+ except Exception as e:
203
+ logger.debug(f"Erro ao exportar tabela {i+1}: {e}")
204
+ # Fallback: tenta obter texto
205
+ if hasattr(table, "text"):
206
+ table_data["texto"] = table.text
207
+
208
+ tables.append(table_data)
209
+
210
+ except Exception as e:
211
+ logger.warning(f"Erro ao extrair tabelas: {e}")
212
+
213
+ logger.debug(f"Extraídas {len(tables)} tabelas")
214
+ return tables
215
+
216
+ def _detect_language(self, document: Any) -> str:
217
+ """
218
+ Detecta o idioma do documento.
219
+
220
+ Args:
221
+ document: Documento Docling convertido.
222
+
223
+ Returns:
224
+ Código do idioma detectado (ex: "pt", "en").
225
+ """
226
+ try:
227
+ # Tenta usar langdetect
228
+ from langdetect import detect, LangDetectException
229
+
230
+ # Extrai texto do documento
231
+ if hasattr(document, "export_to_text"):
232
+ text = document.export_to_text()
233
+ elif hasattr(document, "export_to_markdown"):
234
+ text = document.export_to_markdown()
235
+ else:
236
+ return "desconhecido"
237
+
238
+ # Usa apenas os primeiros 1000 caracteres para detecção
239
+ sample = text[:1000] if text else ""
240
+
241
+ if len(sample) < 20:
242
+ return "desconhecido"
243
+
244
+ lang = detect(sample)
245
+ logger.debug(f"Idioma detectado: {lang}")
246
+ return lang
247
+
248
+ except LangDetectException:
249
+ return "desconhecido"
250
+ except ImportError:
251
+ logger.warning("langdetect não disponível")
252
+ return "nao_detectado"
253
+ except Exception as e:
254
+ logger.debug(f"Erro na detecção de idioma: {e}")
255
+ return "erro"
256
+
257
+ def get_markdown(self, processed_data: dict[str, Any]) -> str:
258
+ """
259
+ Obtém o documento em formato Markdown.
260
+
261
+ Args:
262
+ processed_data: Dados retornados por process_document().
263
+
264
+ Returns:
265
+ String com o documento em Markdown.
266
+ """
267
+ document = processed_data.get("document")
268
+
269
+ if document and hasattr(document, "export_to_markdown"):
270
+ return document.export_to_markdown()
271
+
272
+ return ""
273
+
274
+ def get_text(self, processed_data: dict[str, Any]) -> str:
275
+ """
276
+ Obtém o documento em texto puro.
277
+
278
+ Args:
279
+ processed_data: Dados retornados por process_document().
280
+
281
+ Returns:
282
+ String com o texto do documento.
283
+ """
284
+ document = processed_data.get("document")
285
+
286
+ if document and hasattr(document, "export_to_text"):
287
+ return document.export_to_text()
288
+
289
+ return ""
processors/json_formatter.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Formatador de saída JSON.
3
+
4
+ Este módulo contém funções e classes para formatar documentos
5
+ processados em formato JSON estruturado.
6
+ """
7
+
8
+ import json
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from utils.logger import get_logger
14
+
15
+ # Logger para este módulo
16
+ logger = get_logger(__name__)
17
+
18
+
19
+ def format_to_json(
20
+ processed_data: dict[str, Any],
21
+ filename: str,
22
+ include_raw_content: bool = True,
23
+ pretty_print: bool = True
24
+ ) -> str:
25
+ """
26
+ Formata dados processados em JSON estruturado.
27
+
28
+ Args:
29
+ processed_data: Dados retornados pelo DoclingProcessor.
30
+ filename: Nome do arquivo original.
31
+ include_raw_content: Se deve incluir conteúdo completo.
32
+ pretty_print: Se deve formatar com indentação.
33
+
34
+ Returns:
35
+ String JSON formatada.
36
+ """
37
+ document = processed_data.get("document")
38
+ metadata = processed_data.get("metadata", {})
39
+ tables = processed_data.get("tables", [])
40
+ language = processed_data.get("language", "desconhecido")
41
+
42
+ # Estrutura de saída
43
+ output = {
44
+ "arquivo": filename,
45
+ "idioma": language,
46
+ "processado_em": datetime.now().isoformat(),
47
+ "metadados": metadata,
48
+ "tabelas": tables,
49
+ }
50
+
51
+ # Adiciona conteúdo
52
+ if include_raw_content and document:
53
+ try:
54
+ # Tenta exportar para dict
55
+ if hasattr(document, "export_to_dict"):
56
+ output["conteudo"] = document.export_to_dict()
57
+ elif hasattr(document, "export_to_markdown"):
58
+ output["conteudo_markdown"] = document.export_to_markdown()
59
+ elif hasattr(document, "export_to_text"):
60
+ output["conteudo_texto"] = document.export_to_text()
61
+ except Exception as e:
62
+ logger.warning(f"Erro ao exportar conteúdo: {e}")
63
+ output["conteudo"] = None
64
+ output["erro_exportacao"] = str(e)
65
+
66
+ # Adiciona tempo de processamento se disponível
67
+ if "processing_time_seconds" in processed_data:
68
+ output["tempo_processamento_segundos"] = processed_data["processing_time_seconds"]
69
+
70
+ # Serializa para JSON
71
+ indent = 2 if pretty_print else None
72
+
73
+ try:
74
+ return json.dumps(
75
+ output,
76
+ ensure_ascii=False,
77
+ indent=indent,
78
+ default=_json_serializer
79
+ )
80
+ except Exception as e:
81
+ logger.error(f"Erro ao serializar JSON: {e}")
82
+ # Fallback: tenta sem conteúdo complexo
83
+ output.pop("conteudo", None)
84
+ output["erro_serializacao"] = str(e)
85
+ return json.dumps(output, ensure_ascii=False, indent=indent)
86
+
87
+
88
+ def _json_serializer(obj: Any) -> Any:
89
+ """
90
+ Serializador customizado para objetos não-JSON.
91
+
92
+ Args:
93
+ obj: Objeto a serializar.
94
+
95
+ Returns:
96
+ Representação serializável do objeto.
97
+ """
98
+ if hasattr(obj, "isoformat"):
99
+ return obj.isoformat()
100
+ if hasattr(obj, "__dict__"):
101
+ return obj.__dict__
102
+ if isinstance(obj, bytes):
103
+ return obj.decode("utf-8", errors="replace")
104
+ if isinstance(obj, set):
105
+ return list(obj)
106
+ if isinstance(obj, Path):
107
+ return str(obj)
108
+
109
+ return str(obj)
110
+
111
+
112
+ class JSONFormatter:
113
+ """
114
+ Classe para formatação JSON com configurações personalizadas.
115
+
116
+ Permite manter configurações consistentes entre múltiplas formatações.
117
+ """
118
+
119
+ def __init__(
120
+ self,
121
+ include_raw_content: bool = True,
122
+ pretty_print: bool = True,
123
+ include_tables: bool = True,
124
+ include_metadata: bool = True
125
+ ):
126
+ """
127
+ Inicializa o formatador JSON.
128
+
129
+ Args:
130
+ include_raw_content: Se deve incluir conteúdo completo.
131
+ pretty_print: Se deve formatar com indentação.
132
+ include_tables: Se deve incluir tabelas extraídas.
133
+ include_metadata: Se deve incluir metadados.
134
+ """
135
+ self.include_raw_content = include_raw_content
136
+ self.pretty_print = pretty_print
137
+ self.include_tables = include_tables
138
+ self.include_metadata = include_metadata
139
+
140
+ def format(
141
+ self,
142
+ processed_data: dict[str, Any],
143
+ filename: str
144
+ ) -> str:
145
+ """
146
+ Formata dados processados em JSON.
147
+
148
+ Args:
149
+ processed_data: Dados do DoclingProcessor.
150
+ filename: Nome do arquivo original.
151
+
152
+ Returns:
153
+ String JSON formatada.
154
+ """
155
+ # Copia para não modificar original
156
+ data = processed_data.copy()
157
+
158
+ # Remove elementos não desejados
159
+ if not self.include_tables:
160
+ data["tables"] = []
161
+
162
+ if not self.include_metadata:
163
+ data["metadata"] = {}
164
+
165
+ return format_to_json(
166
+ data,
167
+ filename,
168
+ include_raw_content=self.include_raw_content,
169
+ pretty_print=self.pretty_print
170
+ )
171
+
172
+ def format_batch(
173
+ self,
174
+ items: list[tuple[dict[str, Any], str]]
175
+ ) -> str:
176
+ """
177
+ Formata múltiplos documentos em um único JSON.
178
+
179
+ Args:
180
+ items: Lista de tuplas (processed_data, filename).
181
+
182
+ Returns:
183
+ String JSON com array de documentos.
184
+ """
185
+ documents = []
186
+
187
+ for processed_data, filename in items:
188
+ # Formata individualmente e converte de volta para dict
189
+ json_str = self.format(processed_data, filename)
190
+ doc = json.loads(json_str)
191
+ documents.append(doc)
192
+
193
+ indent = 2 if self.pretty_print else None
194
+
195
+ return json.dumps(
196
+ {"documentos": documents, "total": len(documents)},
197
+ ensure_ascii=False,
198
+ indent=indent
199
+ )
200
+
201
+
202
+ def save_json(
203
+ content: str | dict,
204
+ output_path: str | Path,
205
+ encoding: str = "utf-8"
206
+ ) -> Path:
207
+ """
208
+ Salva conteúdo JSON em arquivo.
209
+
210
+ Args:
211
+ content: String JSON ou dicionário.
212
+ output_path: Caminho do arquivo de saída.
213
+ encoding: Encoding do arquivo.
214
+
215
+ Returns:
216
+ Path para o arquivo salvo.
217
+ """
218
+ output_path = Path(output_path)
219
+
220
+ if isinstance(content, dict):
221
+ content = json.dumps(content, ensure_ascii=False, indent=2)
222
+
223
+ output_path.write_text(content, encoding=encoding)
224
+ logger.debug(f"JSON salvo: {output_path}")
225
+
226
+ return output_path
processors/markdown_formatter.py ADDED
@@ -0,0 +1,333 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Formatador de saída Markdown.
3
+
4
+ Este módulo contém funções e classes para formatar documentos
5
+ processados em formato Markdown.
6
+ """
7
+
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from typing import Any, Optional
11
+
12
+ from utils.logger import get_logger
13
+
14
+ # Logger para este módulo
15
+ logger = get_logger(__name__)
16
+
17
+
18
+ def format_to_markdown(
19
+ processed_data: dict[str, Any],
20
+ include_metadata_header: bool = True,
21
+ include_tables: bool = True
22
+ ) -> str:
23
+ """
24
+ Formata dados processados em Markdown.
25
+
26
+ Args:
27
+ processed_data: Dados retornados pelo DoclingProcessor.
28
+ include_metadata_header: Se deve incluir cabeçalho com metadados.
29
+ include_tables: Se deve incluir tabelas formatadas.
30
+
31
+ Returns:
32
+ String Markdown formatada.
33
+ """
34
+ document = processed_data.get("document")
35
+ metadata = processed_data.get("metadata", {})
36
+ tables = processed_data.get("tables", [])
37
+ language = processed_data.get("language", "desconhecido")
38
+
39
+ sections = []
40
+
41
+ # Cabeçalho com metadados
42
+ if include_metadata_header:
43
+ header = _format_metadata_header(metadata, language)
44
+ if header:
45
+ sections.append(header)
46
+
47
+ # Conteúdo principal do documento
48
+ if document:
49
+ try:
50
+ if hasattr(document, "export_to_markdown"):
51
+ content = document.export_to_markdown()
52
+ if content:
53
+ sections.append(content)
54
+ except Exception as e:
55
+ logger.warning(f"Erro ao exportar Markdown: {e}")
56
+ sections.append(f"> ⚠️ Erro ao exportar conteúdo: {e}")
57
+
58
+ # Tabelas (se não foram incluídas no export padrão)
59
+ if include_tables and tables:
60
+ tables_section = _format_tables_section(tables)
61
+ if tables_section:
62
+ sections.append(tables_section)
63
+
64
+ return "\n\n---\n\n".join(sections)
65
+
66
+
67
+ def _format_metadata_header(
68
+ metadata: dict[str, Any],
69
+ language: str
70
+ ) -> str:
71
+ """
72
+ Formata cabeçalho com metadados.
73
+
74
+ Args:
75
+ metadata: Dicionário de metadados.
76
+ language: Código do idioma.
77
+
78
+ Returns:
79
+ String Markdown com metadados.
80
+ """
81
+ lines = []
82
+
83
+ # Título
84
+ titulo = metadata.get("titulo", metadata.get("nome_arquivo", "Documento"))
85
+ lines.append(f"# {titulo}")
86
+ lines.append("")
87
+
88
+ # Metadados como lista
89
+ meta_items = []
90
+
91
+ if metadata.get("autor"):
92
+ meta_items.append(f"**Autor:** {metadata['autor']}")
93
+
94
+ if metadata.get("data_criacao"):
95
+ meta_items.append(f"**Data de criação:** {metadata['data_criacao']}")
96
+
97
+ if language and language not in ("desconhecido", "erro", "nao_detectado"):
98
+ lang_names = {
99
+ "pt": "Português",
100
+ "en": "Inglês",
101
+ "es": "Espanhol",
102
+ "fr": "Francês",
103
+ "de": "Alemão",
104
+ "it": "Italiano",
105
+ }
106
+ lang_name = lang_names.get(language, language.upper())
107
+ meta_items.append(f"**Idioma:** {lang_name}")
108
+
109
+ if metadata.get("num_paginas"):
110
+ meta_items.append(f"**Páginas:** {metadata['num_paginas']}")
111
+
112
+ if metadata.get("num_tabelas"):
113
+ meta_items.append(f"**Tabelas:** {metadata['num_tabelas']}")
114
+
115
+ if metadata.get("num_imagens"):
116
+ meta_items.append(f"**Imagens:** {metadata['num_imagens']}")
117
+
118
+ if meta_items:
119
+ lines.extend(meta_items)
120
+ lines.append("")
121
+
122
+ return "\n".join(lines)
123
+
124
+
125
+ def _format_tables_section(tables: list[dict[str, Any]]) -> str:
126
+ """
127
+ Formata seção de tabelas.
128
+
129
+ Args:
130
+ tables: Lista de tabelas extraídas.
131
+
132
+ Returns:
133
+ String Markdown com tabelas.
134
+ """
135
+ if not tables:
136
+ return ""
137
+
138
+ lines = ["## Tabelas Extraídas", ""]
139
+
140
+ for table in tables:
141
+ index = table.get("indice", 0)
142
+ lines.append(f"### Tabela {index}")
143
+ lines.append("")
144
+
145
+ # Se tem dados como dict/list, formata como tabela MD
146
+ if table.get("dados"):
147
+ md_table = _dict_to_markdown_table(table["dados"])
148
+ lines.append(md_table)
149
+ elif table.get("markdown"):
150
+ lines.append(table["markdown"])
151
+ elif table.get("texto"):
152
+ lines.append(f"```\n{table['texto']}\n```")
153
+ else:
154
+ lines.append("*Dados da tabela não disponíveis*")
155
+
156
+ lines.append("")
157
+
158
+ return "\n".join(lines)
159
+
160
+
161
+ def _dict_to_markdown_table(data: list[dict[str, Any]]) -> str:
162
+ """
163
+ Converte lista de dicionários em tabela Markdown.
164
+
165
+ Args:
166
+ data: Lista de dicionários (cada dict = uma linha).
167
+
168
+ Returns:
169
+ String com tabela em formato Markdown pipe.
170
+ """
171
+ if not data:
172
+ return "*Tabela vazia*"
173
+
174
+ # Pega colunas do primeiro item
175
+ headers = list(data[0].keys())
176
+
177
+ lines = []
178
+
179
+ # Cabeçalho
180
+ header_line = "| " + " | ".join(str(h) for h in headers) + " |"
181
+ lines.append(header_line)
182
+
183
+ # Separador
184
+ separator = "| " + " | ".join("---" for _ in headers) + " |"
185
+ lines.append(separator)
186
+
187
+ # Dados
188
+ for row in data:
189
+ values = []
190
+ for h in headers:
191
+ value = row.get(h, "")
192
+ # Escapa pipes no conteúdo
193
+ value = str(value).replace("|", "\\|")
194
+ # Remove quebras de linha
195
+ value = value.replace("\n", " ")
196
+ values.append(value)
197
+
198
+ row_line = "| " + " | ".join(values) + " |"
199
+ lines.append(row_line)
200
+
201
+ return "\n".join(lines)
202
+
203
+
204
+ class MarkdownFormatter:
205
+ """
206
+ Classe para formatação Markdown com configurações personalizadas.
207
+
208
+ Permite manter configurações consistentes entre múltiplas formatações.
209
+ """
210
+
211
+ def __init__(
212
+ self,
213
+ include_metadata_header: bool = True,
214
+ include_tables: bool = True,
215
+ include_toc: bool = False,
216
+ max_heading_level: int = 6
217
+ ):
218
+ """
219
+ Inicializa o formatador Markdown.
220
+
221
+ Args:
222
+ include_metadata_header: Se deve incluir cabeçalho com metadados.
223
+ include_tables: Se deve incluir tabelas extraídas.
224
+ include_toc: Se deve incluir sumário (Table of Contents).
225
+ max_heading_level: Nível máximo de heading a usar.
226
+ """
227
+ self.include_metadata_header = include_metadata_header
228
+ self.include_tables = include_tables
229
+ self.include_toc = include_toc
230
+ self.max_heading_level = max_heading_level
231
+
232
+ def format(self, processed_data: dict[str, Any]) -> str:
233
+ """
234
+ Formata dados processados em Markdown.
235
+
236
+ Args:
237
+ processed_data: Dados do DoclingProcessor.
238
+
239
+ Returns:
240
+ String Markdown formatada.
241
+ """
242
+ content = format_to_markdown(
243
+ processed_data,
244
+ include_metadata_header=self.include_metadata_header,
245
+ include_tables=self.include_tables
246
+ )
247
+
248
+ if self.include_toc:
249
+ toc = self._generate_toc(content)
250
+ if toc:
251
+ content = f"{toc}\n\n---\n\n{content}"
252
+
253
+ return content
254
+
255
+ def _generate_toc(self, content: str) -> str:
256
+ """
257
+ Gera sumário (Table of Contents) do conteúdo.
258
+
259
+ Args:
260
+ content: Conteúdo Markdown.
261
+
262
+ Returns:
263
+ String com sumário em Markdown.
264
+ """
265
+ import re
266
+
267
+ lines = []
268
+ lines.append("## Sumário")
269
+ lines.append("")
270
+
271
+ # Encontra headings
272
+ heading_pattern = r"^(#{1,6})\s+(.+)$"
273
+
274
+ for line in content.split("\n"):
275
+ match = re.match(heading_pattern, line)
276
+ if match:
277
+ level = len(match.group(1))
278
+ title = match.group(2)
279
+
280
+ if level <= self.max_heading_level:
281
+ # Cria link
282
+ anchor = self._slugify(title)
283
+ indent = " " * (level - 1)
284
+ lines.append(f"{indent}- [{title}](#{anchor})")
285
+
286
+ return "\n".join(lines) if len(lines) > 2 else ""
287
+
288
+ def _slugify(self, text: str) -> str:
289
+ """
290
+ Converte texto em slug para anchor.
291
+
292
+ Args:
293
+ text: Texto a converter.
294
+
295
+ Returns:
296
+ Slug do texto.
297
+ """
298
+ import re
299
+
300
+ # Converte para lowercase
301
+ slug = text.lower()
302
+
303
+ # Remove caracteres especiais
304
+ slug = re.sub(r"[^\w\s-]", "", slug)
305
+
306
+ # Substitui espaços por hífens
307
+ slug = re.sub(r"\s+", "-", slug)
308
+
309
+ return slug
310
+
311
+
312
+ def save_markdown(
313
+ content: str,
314
+ output_path: str | Path,
315
+ encoding: str = "utf-8"
316
+ ) -> Path:
317
+ """
318
+ Salva conteúdo Markdown em arquivo.
319
+
320
+ Args:
321
+ content: String Markdown.
322
+ output_path: Caminho do arquivo de saída.
323
+ encoding: Encoding do arquivo.
324
+
325
+ Returns:
326
+ Path para o arquivo salvo.
327
+ """
328
+ output_path = Path(output_path)
329
+
330
+ output_path.write_text(content, encoding=encoding)
331
+ logger.debug(f"Markdown salvo: {output_path}")
332
+
333
+ return output_path
requirements.txt ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # Docling Document Processor - Dependências
3
+ # =============================================================================
4
+
5
+ # Framework de interface web
6
+ gradio>=4.44.0
7
+
8
+ # Processamento de documentos
9
+ docling>=2.31.0
10
+
11
+ # Hugging Face
12
+ huggingface-hub>=0.24.0
13
+ spaces>=0.34.0
14
+
15
+ # Manipulação de arquivos
16
+ python-docx>=1.1.0
17
+ PyPDF2>=3.0.0
18
+
19
+ # Validação de MIME types
20
+ python-magic>=0.4.27
21
+
22
+ # Detecção de idioma
23
+ langdetect>=1.0.9
24
+
25
+ # Utilitários
26
+ tqdm>=4.66.0
tests/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """
2
+ Módulo de testes para o Docling Document Processor.
3
+ """
tests/test_processors.py ADDED
@@ -0,0 +1,403 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Testes unitários para os processadores e validadores.
3
+
4
+ Execute com: python -m pytest tests/test_processors.py -v
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import sys
10
+ import tempfile
11
+ from pathlib import Path
12
+ from unittest.mock import MagicMock, patch
13
+
14
+ import pytest
15
+
16
+ # Adiciona o diretório pai ao path
17
+ sys.path.insert(0, str(Path(__file__).parent.parent))
18
+
19
+ import config
20
+ from utils.validators import (
21
+ ValidationError,
22
+ sanitize_filename,
23
+ validate_file_count,
24
+ validate_file_size,
25
+ )
26
+
27
+
28
+ # =============================================================================
29
+ # FIXTURES
30
+ # =============================================================================
31
+
32
+ @pytest.fixture
33
+ def temp_file():
34
+ """Cria um arquivo temporário para testes."""
35
+ with tempfile.NamedTemporaryFile(
36
+ mode="wb",
37
+ suffix=".pdf",
38
+ delete=False
39
+ ) as f:
40
+ # Escreve conteúdo mínimo de PDF
41
+ f.write(b"%PDF-1.4\n")
42
+ f.write(b"1 0 obj\n<< /Type /Catalog >>\nendobj\n")
43
+ f.write(b"%%EOF\n")
44
+ temp_path = f.name
45
+
46
+ yield temp_path
47
+
48
+ # Cleanup
49
+ if os.path.exists(temp_path):
50
+ os.unlink(temp_path)
51
+
52
+
53
+ @pytest.fixture
54
+ def large_temp_file():
55
+ """Cria um arquivo temporário grande (> limite)."""
56
+ with tempfile.NamedTemporaryFile(
57
+ mode="wb",
58
+ suffix=".pdf",
59
+ delete=False
60
+ ) as f:
61
+ # Escreve mais que o limite
62
+ f.write(b"X" * (config.MAX_FILE_SIZE_BYTES + 1000))
63
+ temp_path = f.name
64
+
65
+ yield temp_path
66
+
67
+ if os.path.exists(temp_path):
68
+ os.unlink(temp_path)
69
+
70
+
71
+ @pytest.fixture
72
+ def empty_temp_file():
73
+ """Cria um arquivo temporário vazio."""
74
+ with tempfile.NamedTemporaryFile(
75
+ mode="wb",
76
+ suffix=".pdf",
77
+ delete=False
78
+ ) as f:
79
+ temp_path = f.name
80
+
81
+ yield temp_path
82
+
83
+ if os.path.exists(temp_path):
84
+ os.unlink(temp_path)
85
+
86
+
87
+ # =============================================================================
88
+ # TESTES DE VALIDAÇÃO
89
+ # =============================================================================
90
+
91
+ class TestValidateFileCount:
92
+ """Testes para validate_file_count()."""
93
+
94
+ def test_valid_count_single(self):
95
+ """Teste com um arquivo."""
96
+ assert validate_file_count([1]) is True
97
+
98
+ def test_valid_count_multiple(self):
99
+ """Teste com múltiplos arquivos dentro do limite."""
100
+ files = list(range(config.MAX_FILES_PER_SESSION))
101
+ assert validate_file_count(files) is True
102
+
103
+ def test_empty_list_raises(self):
104
+ """Teste com lista vazia deve falhar."""
105
+ with pytest.raises(ValidationError) as exc_info:
106
+ validate_file_count([])
107
+
108
+ assert exc_info.value.error_code == "NO_FILES"
109
+
110
+ def test_too_many_files_raises(self):
111
+ """Teste com arquivos demais deve falhar."""
112
+ files = list(range(config.MAX_FILES_PER_SESSION + 1))
113
+
114
+ with pytest.raises(ValidationError) as exc_info:
115
+ validate_file_count(files)
116
+
117
+ assert exc_info.value.error_code == "TOO_MANY_FILES"
118
+
119
+
120
+ class TestValidateFileSize:
121
+ """Testes para validate_file_size()."""
122
+
123
+ def test_valid_size(self, temp_file):
124
+ """Teste com arquivo de tamanho válido."""
125
+ assert validate_file_size(temp_file) is True
126
+
127
+ def test_file_too_large(self, large_temp_file):
128
+ """Teste com arquivo muito grande."""
129
+ with pytest.raises(ValidationError) as exc_info:
130
+ validate_file_size(large_temp_file)
131
+
132
+ assert exc_info.value.error_code == "FILE_TOO_LARGE"
133
+
134
+ def test_empty_file(self, empty_temp_file):
135
+ """Teste com arquivo vazio."""
136
+ with pytest.raises(ValidationError) as exc_info:
137
+ validate_file_size(empty_temp_file)
138
+
139
+ assert exc_info.value.error_code == "EMPTY_FILE"
140
+
141
+ def test_file_not_found(self):
142
+ """Teste com arquivo inexistente."""
143
+ with pytest.raises(ValidationError) as exc_info:
144
+ validate_file_size("/caminho/inexistente/arquivo.pdf")
145
+
146
+ assert exc_info.value.error_code == "FILE_NOT_FOUND"
147
+
148
+
149
+ class TestSanitizeFilename:
150
+ """Testes para sanitize_filename()."""
151
+
152
+ def test_normal_filename(self):
153
+ """Teste com nome normal."""
154
+ assert sanitize_filename("documento.pdf") == "documento.pdf"
155
+
156
+ def test_special_characters(self):
157
+ """Teste com caracteres especiais."""
158
+ result = sanitize_filename("doc<>:test.pdf")
159
+ assert "<" not in result
160
+ assert ">" not in result
161
+ assert ":" not in result
162
+
163
+ def test_spaces(self):
164
+ """Teste com espaços."""
165
+ result = sanitize_filename("meu documento.pdf")
166
+ assert result == "meu_documento.pdf"
167
+
168
+ def test_multiple_underscores(self):
169
+ """Teste com underscores múltiplos."""
170
+ result = sanitize_filename("doc___test.pdf")
171
+ assert "___" not in result
172
+
173
+ def test_empty_filename(self):
174
+ """Teste com nome vazio."""
175
+ result = sanitize_filename("")
176
+ assert result == "arquivo_sem_nome"
177
+
178
+ def test_long_filename(self):
179
+ """Teste com nome muito longo."""
180
+ long_name = "a" * 300 + ".pdf"
181
+ result = sanitize_filename(long_name)
182
+ assert len(result) <= config.FILENAME_MAX_LENGTH
183
+
184
+
185
+ # =============================================================================
186
+ # TESTES DE FORMATAÇÃO JSON
187
+ # =============================================================================
188
+
189
+ class TestJSONFormatter:
190
+ """Testes para json_formatter.py."""
191
+
192
+ def test_format_to_json_basic(self):
193
+ """Teste de formatação JSON básica."""
194
+ from processors.json_formatter import format_to_json
195
+
196
+ # Mock de dados processados
197
+ mock_document = MagicMock()
198
+ mock_document.export_to_dict.return_value = {"content": "teste"}
199
+
200
+ processed_data = {
201
+ "document": mock_document,
202
+ "metadata": {"nome_arquivo": "test.pdf"},
203
+ "tables": [],
204
+ "language": "pt",
205
+ }
206
+
207
+ result = format_to_json(processed_data, "test.pdf")
208
+
209
+ assert isinstance(result, str)
210
+
211
+ parsed = json.loads(result)
212
+ assert parsed["arquivo"] == "test.pdf"
213
+ assert parsed["idioma"] == "pt"
214
+ assert "processado_em" in parsed
215
+
216
+ def test_format_to_json_with_tables(self):
217
+ """Teste de formatação JSON com tabelas."""
218
+ from processors.json_formatter import format_to_json
219
+
220
+ mock_document = MagicMock()
221
+ mock_document.export_to_dict.return_value = {}
222
+
223
+ processed_data = {
224
+ "document": mock_document,
225
+ "metadata": {},
226
+ "tables": [
227
+ {"indice": 1, "dados": [{"col1": "val1"}]}
228
+ ],
229
+ "language": "en",
230
+ }
231
+
232
+ result = format_to_json(processed_data, "test.pdf")
233
+ parsed = json.loads(result)
234
+
235
+ assert len(parsed["tabelas"]) == 1
236
+ assert parsed["tabelas"][0]["indice"] == 1
237
+
238
+
239
+ # =============================================================================
240
+ # TESTES DE FORMATAÇÃO MARKDOWN
241
+ # =============================================================================
242
+
243
+ class TestMarkdownFormatter:
244
+ """Testes para markdown_formatter.py."""
245
+
246
+ def test_format_to_markdown_basic(self):
247
+ """Teste de formatação Markdown básica."""
248
+ from processors.markdown_formatter import format_to_markdown
249
+
250
+ mock_document = MagicMock()
251
+ mock_document.export_to_markdown.return_value = "# Conteúdo\n\nTexto aqui."
252
+
253
+ processed_data = {
254
+ "document": mock_document,
255
+ "metadata": {"nome_arquivo": "test.pdf", "num_paginas": 3},
256
+ "tables": [],
257
+ "language": "pt",
258
+ }
259
+
260
+ result = format_to_markdown(processed_data)
261
+
262
+ assert isinstance(result, str)
263
+ assert "# " in result or "## " in result # Tem headings
264
+
265
+ def test_dict_to_markdown_table(self):
266
+ """Teste de conversão de dict para tabela MD."""
267
+ from processors.markdown_formatter import _dict_to_markdown_table
268
+
269
+ data = [
270
+ {"Nome": "Alice", "Idade": 30},
271
+ {"Nome": "Bob", "Idade": 25},
272
+ ]
273
+
274
+ result = _dict_to_markdown_table(data)
275
+
276
+ assert "| Nome | Idade |" in result
277
+ assert "| --- | --- |" in result
278
+ assert "| Alice | 30 |" in result
279
+ assert "| Bob | 25 |" in result
280
+
281
+ def test_empty_table(self):
282
+ """Teste com tabela vazia."""
283
+ from processors.markdown_formatter import _dict_to_markdown_table
284
+
285
+ result = _dict_to_markdown_table([])
286
+ assert "vazia" in result.lower()
287
+
288
+
289
+ # =============================================================================
290
+ # TESTES DE FILE HANDLER
291
+ # =============================================================================
292
+
293
+ class TestFileHandler:
294
+ """Testes para file_handler.py."""
295
+
296
+ def test_create_temp_directory(self):
297
+ """Teste de criação de diretório temporário."""
298
+ from utils.file_handler import create_temp_directory
299
+
300
+ temp_dir = create_temp_directory(prefix="test_")
301
+
302
+ try:
303
+ assert temp_dir.exists()
304
+ assert temp_dir.is_dir()
305
+ assert "test_" in temp_dir.name
306
+ finally:
307
+ # Cleanup
308
+ if temp_dir.exists():
309
+ import shutil
310
+ shutil.rmtree(temp_dir)
311
+
312
+ def test_save_output_file(self):
313
+ """Teste de salvamento de arquivo de saída."""
314
+ from utils.file_handler import save_output_file, create_temp_directory
315
+
316
+ temp_dir = create_temp_directory(prefix="test_")
317
+
318
+ try:
319
+ content = "Conteúdo de teste"
320
+ output_path = save_output_file(content, "teste.txt", temp_dir)
321
+
322
+ assert output_path.exists()
323
+ assert output_path.read_text() == content
324
+ finally:
325
+ import shutil
326
+ if temp_dir.exists():
327
+ shutil.rmtree(temp_dir)
328
+
329
+ def test_format_size(self):
330
+ """Teste de formatação de tamanho."""
331
+ from utils.file_handler import format_size
332
+
333
+ assert "B" in format_size(500)
334
+ assert "KB" in format_size(1024 * 5)
335
+ assert "MB" in format_size(1024 * 1024 * 10)
336
+ assert "GB" in format_size(1024 * 1024 * 1024 * 2)
337
+
338
+
339
+ # =============================================================================
340
+ # TESTES DE INTEGRAÇÃO (MOCK)
341
+ # =============================================================================
342
+
343
+ class TestDoclingProcessorMock:
344
+ """Testes do DoclingProcessor com mocks."""
345
+
346
+ @patch("processors.docling_processor.DocumentConverter")
347
+ def test_processor_initialization(self, mock_converter_class):
348
+ """Teste de inicialização do processador."""
349
+ from processors.docling_processor import DoclingProcessor
350
+
351
+ processor = DoclingProcessor(
352
+ enable_ocr=True,
353
+ enable_table_detection=True,
354
+ use_gpu=False
355
+ )
356
+
357
+ assert processor.enable_ocr is True
358
+ assert processor.enable_table_detection is True
359
+ assert processor.use_gpu is False
360
+
361
+ @patch("processors.docling_processor.DocumentConverter")
362
+ def test_processor_process_document(self, mock_converter_class):
363
+ """Teste de processamento de documento."""
364
+ from processors.docling_processor import DoclingProcessor
365
+
366
+ # Setup mock
367
+ mock_converter = MagicMock()
368
+ mock_converter_class.return_value = mock_converter
369
+
370
+ mock_result = MagicMock()
371
+ mock_document = MagicMock()
372
+ mock_document.export_to_markdown.return_value = "# Teste"
373
+ mock_result.document = mock_document
374
+
375
+ mock_converter.convert.return_value = mock_result
376
+
377
+ # Cria arquivo temporário
378
+ with tempfile.NamedTemporaryFile(
379
+ mode="wb",
380
+ suffix=".pdf",
381
+ delete=False
382
+ ) as f:
383
+ f.write(b"%PDF-1.4\n%%EOF\n")
384
+ temp_path = f.name
385
+
386
+ try:
387
+ processor = DoclingProcessor()
388
+ result = processor.process_document(temp_path)
389
+
390
+ assert "document" in result
391
+ assert "metadata" in result
392
+ assert "tables" in result
393
+ assert "language" in result
394
+ finally:
395
+ os.unlink(temp_path)
396
+
397
+
398
+ # =============================================================================
399
+ # EXECUTAR TESTES
400
+ # =============================================================================
401
+
402
+ if __name__ == "__main__":
403
+ pytest.main([__file__, "-v"])
utils/__init__.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Módulo de utilitários para o Docling Document Processor.
3
+
4
+ Este pacote contém funções auxiliares para validação, manipulação
5
+ de arquivos e logging.
6
+ """
7
+
8
+ from utils.validators import (
9
+ validate_file_count,
10
+ validate_file_size,
11
+ validate_mime_type,
12
+ sanitize_filename,
13
+ ValidationError,
14
+ )
15
+ from utils.file_handler import (
16
+ create_temp_directory,
17
+ cleanup_old_files,
18
+ create_zip_output,
19
+ get_temp_file_path,
20
+ )
21
+ from utils.logger import setup_logger, get_logger
22
+
23
+ __all__ = [
24
+ # Validators
25
+ "validate_file_count",
26
+ "validate_file_size",
27
+ "validate_mime_type",
28
+ "sanitize_filename",
29
+ "ValidationError",
30
+ # File handler
31
+ "create_temp_directory",
32
+ "cleanup_old_files",
33
+ "create_zip_output",
34
+ "get_temp_file_path",
35
+ # Logger
36
+ "setup_logger",
37
+ "get_logger",
38
+ ]
utils/file_handler.py ADDED
@@ -0,0 +1,257 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Manipulação de arquivos temporários e outputs.
3
+
4
+ Este módulo contém funções para gerenciar arquivos temporários,
5
+ criar outputs em diferentes formatos e limpar arquivos antigos.
6
+ """
7
+
8
+ import os
9
+ import shutil
10
+ import tempfile
11
+ import time
12
+ import zipfile
13
+ from datetime import datetime, timedelta
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+ import config
18
+ from utils.logger import get_logger
19
+
20
+ # Logger para este módulo
21
+ logger = get_logger(__name__)
22
+
23
+
24
+ def create_temp_directory(prefix: str = "docling_") -> Path:
25
+ """
26
+ Cria um diretório temporário isolado para processamento.
27
+
28
+ Args:
29
+ prefix: Prefixo para o nome do diretório.
30
+
31
+ Returns:
32
+ Path para o diretório temporário criado.
33
+ """
34
+ # Garante que o diretório base existe
35
+ config.TEMP_DIR.mkdir(parents=True, exist_ok=True)
36
+
37
+ # Cria subdiretório com timestamp
38
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
39
+ temp_path = config.TEMP_DIR / f"{prefix}{timestamp}"
40
+ temp_path.mkdir(parents=True, exist_ok=True)
41
+
42
+ logger.debug(f"Diretório temporário criado: {temp_path}")
43
+ return temp_path
44
+
45
+
46
+ def get_temp_file_path(
47
+ filename: str,
48
+ temp_dir: Optional[Path] = None
49
+ ) -> Path:
50
+ """
51
+ Retorna um caminho de arquivo temporário sanitizado.
52
+
53
+ Args:
54
+ filename: Nome do arquivo (será sanitizado se necessário).
55
+ temp_dir: Diretório temporário opcional. Se não fornecido, cria um novo.
56
+
57
+ Returns:
58
+ Path completo para o arquivo temporário.
59
+ """
60
+ from utils.validators import sanitize_filename
61
+
62
+ if temp_dir is None:
63
+ temp_dir = create_temp_directory()
64
+
65
+ safe_filename = sanitize_filename(filename)
66
+ return temp_dir / safe_filename
67
+
68
+
69
+ def cleanup_old_files(
70
+ max_age_hours: Optional[int] = None,
71
+ target_dir: Optional[Path] = None
72
+ ) -> int:
73
+ """
74
+ Remove arquivos temporários mais antigos que o limite especificado.
75
+
76
+ Args:
77
+ max_age_hours: Idade máxima em horas. Se não especificado, usa config.
78
+ target_dir: Diretório a limpar. Se não especificado, usa TEMP_DIR.
79
+
80
+ Returns:
81
+ Número de arquivos/diretórios removidos.
82
+ """
83
+ if max_age_hours is None:
84
+ max_age_hours = config.TEMP_DIR_CLEANUP_HOURS
85
+
86
+ if target_dir is None:
87
+ target_dir = config.TEMP_DIR
88
+
89
+ if not target_dir.exists():
90
+ return 0
91
+
92
+ cutoff_time = time.time() - (max_age_hours * 3600)
93
+ removed_count = 0
94
+
95
+ try:
96
+ for item in target_dir.iterdir():
97
+ try:
98
+ item_stat = item.stat()
99
+
100
+ # Usa tempo de modificação
101
+ if item_stat.st_mtime < cutoff_time:
102
+ if item.is_dir():
103
+ shutil.rmtree(item)
104
+ logger.info(f"Diretório removido: {item}")
105
+ else:
106
+ item.unlink()
107
+ logger.info(f"Arquivo removido: {item}")
108
+
109
+ removed_count += 1
110
+
111
+ except PermissionError:
112
+ logger.warning(f"Sem permissão para remover: {item}")
113
+ except FileNotFoundError:
114
+ # Já foi removido
115
+ pass
116
+ except Exception as e:
117
+ logger.error(f"Erro ao remover {item}: {e}")
118
+
119
+ except Exception as e:
120
+ logger.error(f"Erro ao limpar diretório {target_dir}: {e}")
121
+
122
+ if removed_count > 0:
123
+ logger.info(f"Limpeza concluída: {removed_count} itens removidos")
124
+
125
+ return removed_count
126
+
127
+
128
+ def create_zip_output(
129
+ files: list[tuple[Path, str]],
130
+ output_name: str = "resultado"
131
+ ) -> Path:
132
+ """
133
+ Cria um arquivo ZIP contendo múltiplos arquivos de saída.
134
+
135
+ Args:
136
+ files: Lista de tuplas (caminho_arquivo, nome_no_zip).
137
+ output_name: Nome base para o arquivo ZIP (sem extensão).
138
+
139
+ Returns:
140
+ Path para o arquivo ZIP criado.
141
+ """
142
+ # Cria diretório temporário para o ZIP
143
+ temp_dir = create_temp_directory(prefix="zip_")
144
+ zip_path = temp_dir / f"{output_name}.zip"
145
+
146
+ try:
147
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
148
+ for file_path, archive_name in files:
149
+ if file_path.exists():
150
+ zf.write(file_path, archive_name)
151
+ logger.debug(f"Adicionado ao ZIP: {archive_name}")
152
+ else:
153
+ logger.warning(f"Arquivo não encontrado para ZIP: {file_path}")
154
+
155
+ logger.info(f"ZIP criado: {zip_path} ({len(files)} arquivos)")
156
+ return zip_path
157
+
158
+ except Exception as e:
159
+ logger.error(f"Erro ao criar ZIP: {e}")
160
+ raise
161
+
162
+
163
+ def copy_file_to_temp(
164
+ source: Path | str,
165
+ temp_dir: Optional[Path] = None
166
+ ) -> Path:
167
+ """
168
+ Copia um arquivo para o diretório temporário.
169
+
170
+ Args:
171
+ source: Caminho do arquivo fonte.
172
+ temp_dir: Diretório de destino opcional.
173
+
174
+ Returns:
175
+ Path para o arquivo copiado.
176
+ """
177
+ source = Path(source)
178
+
179
+ if temp_dir is None:
180
+ temp_dir = create_temp_directory()
181
+
182
+ dest_path = temp_dir / source.name
183
+
184
+ shutil.copy2(source, dest_path)
185
+ logger.debug(f"Arquivo copiado: {source} -> {dest_path}")
186
+
187
+ return dest_path
188
+
189
+
190
+ def save_output_file(
191
+ content: str | bytes,
192
+ filename: str,
193
+ temp_dir: Optional[Path] = None,
194
+ encoding: str = "utf-8"
195
+ ) -> Path:
196
+ """
197
+ Salva conteúdo em um arquivo temporário.
198
+
199
+ Args:
200
+ content: Conteúdo a ser salvo (string ou bytes).
201
+ filename: Nome do arquivo de saída.
202
+ temp_dir: Diretório de destino opcional.
203
+ encoding: Encoding para strings (padrão: utf-8).
204
+
205
+ Returns:
206
+ Path para o arquivo salvo.
207
+ """
208
+ if temp_dir is None:
209
+ temp_dir = create_temp_directory(prefix="output_")
210
+
211
+ output_path = get_temp_file_path(filename, temp_dir)
212
+
213
+ if isinstance(content, str):
214
+ output_path.write_text(content, encoding=encoding)
215
+ else:
216
+ output_path.write_bytes(content)
217
+
218
+ logger.debug(f"Arquivo salvo: {output_path}")
219
+ return output_path
220
+
221
+
222
+ def get_temp_dir_size() -> int:
223
+ """
224
+ Calcula o tamanho total do diretório temporário.
225
+
226
+ Returns:
227
+ Tamanho em bytes.
228
+ """
229
+ if not config.TEMP_DIR.exists():
230
+ return 0
231
+
232
+ total_size = 0
233
+ for item in config.TEMP_DIR.rglob("*"):
234
+ if item.is_file():
235
+ try:
236
+ total_size += item.stat().st_size
237
+ except Exception:
238
+ pass
239
+
240
+ return total_size
241
+
242
+
243
+ def format_size(size_bytes: int) -> str:
244
+ """
245
+ Formata tamanho em bytes para string legível.
246
+
247
+ Args:
248
+ size_bytes: Tamanho em bytes.
249
+
250
+ Returns:
251
+ String formatada (ex: "1.5 MB").
252
+ """
253
+ for unit in ["B", "KB", "MB", "GB"]:
254
+ if size_bytes < 1024:
255
+ return f"{size_bytes:.1f} {unit}"
256
+ size_bytes /= 1024
257
+ return f"{size_bytes:.1f} TB"
utils/logger.py ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Sistema de logging para o Docling Document Processor.
3
+
4
+ Este módulo configura e gerencia o sistema de logging da aplicação,
5
+ incluindo rotação de arquivos e formatação consistente.
6
+ """
7
+
8
+ import logging
9
+ import sys
10
+ from logging.handlers import RotatingFileHandler
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ import config
15
+
16
+ # Flag para evitar configuração duplicada
17
+ _logging_configured = False
18
+
19
+ # Cache de loggers
20
+ _loggers: dict[str, logging.Logger] = {}
21
+
22
+
23
+ def setup_logger(
24
+ name: str = "docling_space",
25
+ level: int = logging.INFO,
26
+ log_to_file: bool = True,
27
+ log_to_console: bool = True
28
+ ) -> logging.Logger:
29
+ """
30
+ Configura e retorna um logger.
31
+
32
+ Args:
33
+ name: Nome do logger.
34
+ level: Nível de logging (default: INFO).
35
+ log_to_file: Se deve logar em arquivo.
36
+ log_to_console: Se deve logar no console.
37
+
38
+ Returns:
39
+ Logger configurado.
40
+ """
41
+ global _logging_configured
42
+
43
+ # Se já existe no cache, retorna
44
+ if name in _loggers:
45
+ return _loggers[name]
46
+
47
+ # Cria o logger
48
+ logger = logging.getLogger(name)
49
+ logger.setLevel(level)
50
+
51
+ # Evita handlers duplicados
52
+ if logger.handlers:
53
+ return logger
54
+
55
+ # Formatter
56
+ formatter = logging.Formatter(
57
+ config.LOG_FORMAT,
58
+ datefmt=config.LOG_DATE_FORMAT
59
+ )
60
+
61
+ # Handler de console
62
+ if log_to_console:
63
+ console_handler = logging.StreamHandler(sys.stdout)
64
+ console_handler.setLevel(level)
65
+ console_handler.setFormatter(formatter)
66
+ logger.addHandler(console_handler)
67
+
68
+ # Handler de arquivo com rotação
69
+ if log_to_file:
70
+ try:
71
+ # Garante que o diretório existe
72
+ config.LOGS_DIR.mkdir(parents=True, exist_ok=True)
73
+
74
+ log_file = config.LOGS_DIR / config.LOG_FILE
75
+
76
+ file_handler = RotatingFileHandler(
77
+ log_file,
78
+ maxBytes=config.LOG_MAX_BYTES,
79
+ backupCount=config.LOG_BACKUP_COUNT,
80
+ encoding="utf-8"
81
+ )
82
+ file_handler.setLevel(level)
83
+ file_handler.setFormatter(formatter)
84
+ logger.addHandler(file_handler)
85
+
86
+ except Exception as e:
87
+ # Se não conseguir criar o arquivo de log, continua só com console
88
+ if log_to_console:
89
+ logger.warning(f"Não foi possível criar arquivo de log: {e}")
90
+
91
+ # Não propaga para o root logger
92
+ logger.propagate = False
93
+
94
+ # Adiciona ao cache
95
+ _loggers[name] = logger
96
+ _logging_configured = True
97
+
98
+ return logger
99
+
100
+
101
+ def get_logger(name: Optional[str] = None) -> logging.Logger:
102
+ """
103
+ Obtém um logger pelo nome.
104
+
105
+ Se o logger não existir, cria um novo com as configurações padrão.
106
+
107
+ Args:
108
+ name: Nome do logger. Se None, usa "docling_space".
109
+
110
+ Returns:
111
+ Logger configurado.
112
+ """
113
+ if name is None:
114
+ name = "docling_space"
115
+
116
+ # Se for um nome de módulo completo, usa apenas a última parte
117
+ if "." in name:
118
+ short_name = name.split(".")[-1]
119
+ else:
120
+ short_name = name
121
+
122
+ logger_name = f"docling_space.{short_name}"
123
+
124
+ if logger_name not in _loggers:
125
+ return setup_logger(logger_name)
126
+
127
+ return _loggers[logger_name]
128
+
129
+
130
+ def log_exception(
131
+ logger: logging.Logger,
132
+ message: str,
133
+ exc: Exception,
134
+ include_traceback: bool = True
135
+ ) -> None:
136
+ """
137
+ Loga uma exceção com detalhes.
138
+
139
+ Args:
140
+ logger: Logger a usar.
141
+ message: Mensagem descritiva.
142
+ exc: Exceção a logar.
143
+ include_traceback: Se deve incluir traceback completo.
144
+ """
145
+ if include_traceback:
146
+ logger.exception(f"{message}: {exc}")
147
+ else:
148
+ logger.error(f"{message}: {type(exc).__name__}: {exc}")
149
+
150
+
151
+ def log_processing_start(
152
+ logger: logging.Logger,
153
+ filename: str,
154
+ file_size: int
155
+ ) -> None:
156
+ """
157
+ Loga o início do processamento de um arquivo.
158
+
159
+ Args:
160
+ logger: Logger a usar.
161
+ filename: Nome do arquivo.
162
+ file_size: Tamanho em bytes.
163
+ """
164
+ size_mb = file_size / (1024 * 1024)
165
+ logger.info(f"Iniciando processamento: {filename} ({size_mb:.2f} MB)")
166
+
167
+
168
+ def log_processing_complete(
169
+ logger: logging.Logger,
170
+ filename: str,
171
+ duration_seconds: float,
172
+ output_format: str
173
+ ) -> None:
174
+ """
175
+ Loga a conclusão do processamento de um arquivo.
176
+
177
+ Args:
178
+ logger: Logger a usar.
179
+ filename: Nome do arquivo.
180
+ duration_seconds: Tempo de processamento em segundos.
181
+ output_format: Formato de saída usado.
182
+ """
183
+ logger.info(
184
+ f"Processamento concluído: {filename} "
185
+ f"({duration_seconds:.2f}s, formato: {output_format})"
186
+ )
187
+
188
+
189
+ def log_validation_error(
190
+ logger: logging.Logger,
191
+ filename: str,
192
+ error_code: str,
193
+ message: str
194
+ ) -> None:
195
+ """
196
+ Loga um erro de validação.
197
+
198
+ Args:
199
+ logger: Logger a usar.
200
+ filename: Nome do arquivo.
201
+ error_code: Código do erro.
202
+ message: Mensagem de erro.
203
+ """
204
+ logger.warning(f"Validação falhou [{error_code}] {filename}: {message}")
205
+
206
+
207
+ class ProcessingLogger:
208
+ """
209
+ Context manager para logging de processamento.
210
+
211
+ Automaticamente loga início e fim do processamento com timing.
212
+ """
213
+
214
+ def __init__(
215
+ self,
216
+ logger: logging.Logger,
217
+ operation: str,
218
+ filename: str
219
+ ):
220
+ self.logger = logger
221
+ self.operation = operation
222
+ self.filename = filename
223
+ self.start_time: float = 0
224
+
225
+ def __enter__(self):
226
+ import time
227
+ self.start_time = time.time()
228
+ self.logger.info(f"[INÍCIO] {self.operation}: {self.filename}")
229
+ return self
230
+
231
+ def __exit__(self, exc_type, exc_val, exc_tb):
232
+ import time
233
+ duration = time.time() - self.start_time
234
+
235
+ if exc_type is None:
236
+ self.logger.info(
237
+ f"[FIM] {self.operation}: {self.filename} ({duration:.2f}s)"
238
+ )
239
+ else:
240
+ self.logger.error(
241
+ f"[ERRO] {self.operation}: {self.filename} "
242
+ f"({duration:.2f}s) - {exc_type.__name__}: {exc_val}"
243
+ )
244
+
245
+ # Não suprime exceções
246
+ return False
utils/validators.py ADDED
@@ -0,0 +1,297 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Validadores para arquivos de entrada.
3
+
4
+ Este módulo contém funções para validar arquivos antes do processamento,
5
+ incluindo verificação de tamanho, contagem, MIME type e sanitização de nomes.
6
+ """
7
+
8
+ import os
9
+ import re
10
+ from pathlib import Path
11
+ from typing import BinaryIO
12
+
13
+ import config
14
+
15
+ # Tenta importar python-magic, mas oferece fallback se não disponível
16
+ try:
17
+ import magic
18
+ HAS_MAGIC = True
19
+ except ImportError:
20
+ HAS_MAGIC = False
21
+
22
+
23
+ class ValidationError(Exception):
24
+ """Exceção levantada quando uma validação falha."""
25
+
26
+ def __init__(self, message: str, error_code: str = "VALIDATION_ERROR"):
27
+ self.message = message
28
+ self.error_code = error_code
29
+ super().__init__(self.message)
30
+
31
+
32
+ def validate_file_count(files: list) -> bool:
33
+ """
34
+ Valida se o número de arquivos está dentro do limite permitido.
35
+
36
+ Args:
37
+ files: Lista de arquivos para validar.
38
+
39
+ Returns:
40
+ True se a contagem está válida.
41
+
42
+ Raises:
43
+ ValidationError: Se houver arquivos demais ou nenhum arquivo.
44
+ """
45
+ if not files:
46
+ raise ValidationError(
47
+ "Nenhum arquivo enviado. Por favor, selecione ao menos um arquivo.",
48
+ error_code="NO_FILES"
49
+ )
50
+
51
+ if len(files) > config.MAX_FILES_PER_SESSION:
52
+ raise ValidationError(
53
+ f"Muitos arquivos! Máximo permitido: {config.MAX_FILES_PER_SESSION}. "
54
+ f"Você enviou: {len(files)}.",
55
+ error_code="TOO_MANY_FILES"
56
+ )
57
+
58
+ return True
59
+
60
+
61
+ def validate_file_size(file_path: str | Path) -> bool:
62
+ """
63
+ Valida se o tamanho do arquivo está dentro do limite permitido.
64
+
65
+ Args:
66
+ file_path: Caminho para o arquivo a ser validado.
67
+
68
+ Returns:
69
+ True se o tamanho está válido.
70
+
71
+ Raises:
72
+ ValidationError: Se o arquivo for muito grande.
73
+ """
74
+ file_path = Path(file_path)
75
+
76
+ if not file_path.exists():
77
+ raise ValidationError(
78
+ f"Arquivo não encontrado: {file_path.name}",
79
+ error_code="FILE_NOT_FOUND"
80
+ )
81
+
82
+ file_size = file_path.stat().st_size
83
+
84
+ if file_size > config.MAX_FILE_SIZE_BYTES:
85
+ size_mb = file_size / (1024 * 1024)
86
+ raise ValidationError(
87
+ f"Arquivo muito grande: {file_path.name} ({size_mb:.1f}MB). "
88
+ f"Máximo permitido: {config.MAX_FILE_SIZE_MB}MB.",
89
+ error_code="FILE_TOO_LARGE"
90
+ )
91
+
92
+ if file_size == 0:
93
+ raise ValidationError(
94
+ f"Arquivo vazio: {file_path.name}",
95
+ error_code="EMPTY_FILE"
96
+ )
97
+
98
+ return True
99
+
100
+
101
+ def _get_mime_type_magic(file_path: str | Path) -> str:
102
+ """
103
+ Obtém o MIME type usando python-magic.
104
+
105
+ Args:
106
+ file_path: Caminho para o arquivo.
107
+
108
+ Returns:
109
+ String com o MIME type detectado.
110
+ """
111
+ mime = magic.Magic(mime=True)
112
+ return mime.from_file(str(file_path))
113
+
114
+
115
+ def _get_mime_type_fallback(file_path: str | Path) -> str:
116
+ """
117
+ Fallback para detecção de MIME type sem python-magic.
118
+ Usa assinaturas de arquivo (magic bytes).
119
+
120
+ Args:
121
+ file_path: Caminho para o arquivo.
122
+
123
+ Returns:
124
+ String com o MIME type detectado ou extensão-based guess.
125
+ """
126
+ file_path = Path(file_path)
127
+
128
+ # Magic bytes para tipos comuns
129
+ signatures = {
130
+ b"%PDF": "application/pdf",
131
+ b"PK\x03\x04": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
132
+ b"\xd0\xcf\x11\xe0": "application/msword", # OLE Compound Document
133
+ }
134
+
135
+ try:
136
+ with open(file_path, "rb") as f:
137
+ header = f.read(8)
138
+
139
+ for sig, mime_type in signatures.items():
140
+ if header.startswith(sig):
141
+ return mime_type
142
+ except Exception:
143
+ pass
144
+
145
+ # Fallback para extensão
146
+ ext = file_path.suffix.lower()
147
+ ext_to_mime = {
148
+ ".pdf": "application/pdf",
149
+ ".doc": "application/msword",
150
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
151
+ }
152
+
153
+ return ext_to_mime.get(ext, "application/octet-stream")
154
+
155
+
156
+ def get_mime_type(file_path: str | Path) -> str:
157
+ """
158
+ Obtém o MIME type de um arquivo.
159
+
160
+ Usa python-magic se disponível, caso contrário usa fallback
161
+ baseado em assinaturas de arquivo.
162
+
163
+ Args:
164
+ file_path: Caminho para o arquivo.
165
+
166
+ Returns:
167
+ String com o MIME type detectado.
168
+ """
169
+ if HAS_MAGIC:
170
+ return _get_mime_type_magic(file_path)
171
+ return _get_mime_type_fallback(file_path)
172
+
173
+
174
+ def validate_mime_type(file_path: str | Path) -> bool:
175
+ """
176
+ Valida se o MIME type do arquivo é suportado.
177
+
178
+ Args:
179
+ file_path: Caminho para o arquivo a ser validado.
180
+
181
+ Returns:
182
+ True se o MIME type é válido.
183
+
184
+ Raises:
185
+ ValidationError: Se o tipo de arquivo não for suportado.
186
+ """
187
+ file_path = Path(file_path)
188
+ extension = file_path.suffix.lower()
189
+
190
+ # Verifica se a extensão é suportada
191
+ if extension not in config.SUPPORTED_EXTENSIONS:
192
+ raise ValidationError(
193
+ f"Extensão não suportada: {extension}. "
194
+ f"Tipos aceitos: {', '.join(config.SUPPORTED_EXTENSIONS)}",
195
+ error_code="UNSUPPORTED_EXTENSION"
196
+ )
197
+
198
+ # Obtém o MIME type real do arquivo
199
+ detected_mime = get_mime_type(file_path)
200
+
201
+ # Verifica se o MIME type corresponde à extensão
202
+ expected_mimes = config.SUPPORTED_MIME_TYPES.get(extension, [])
203
+
204
+ if detected_mime not in expected_mimes:
205
+ # DOCX pode ser detectado como ZIP em alguns casos
206
+ if extension == ".docx" and detected_mime == "application/zip":
207
+ return True
208
+
209
+ raise ValidationError(
210
+ f"Tipo de arquivo inválido: {file_path.name}. "
211
+ f"O conteúdo não corresponde à extensão {extension}. "
212
+ f"Detectado: {detected_mime}",
213
+ error_code="MIME_MISMATCH"
214
+ )
215
+
216
+ return True
217
+
218
+
219
+ def sanitize_filename(filename: str) -> str:
220
+ """
221
+ Remove caracteres especiais/perigosos do nome de arquivo.
222
+
223
+ Args:
224
+ filename: Nome original do arquivo.
225
+
226
+ Returns:
227
+ Nome de arquivo sanitizado.
228
+ """
229
+ if not filename:
230
+ return "arquivo_sem_nome"
231
+
232
+ # Remove caracteres proibidos
233
+ for char in config.FORBIDDEN_FILENAME_CHARS:
234
+ filename = filename.replace(char, "_")
235
+
236
+ # Remove caracteres de controle
237
+ filename = re.sub(r"[\x00-\x1f\x7f]", "", filename)
238
+
239
+ # Substitui espaços múltiplos por um único underscore
240
+ filename = re.sub(r"\s+", "_", filename)
241
+
242
+ # Remove underscores múltiplos
243
+ filename = re.sub(r"_+", "_", filename)
244
+
245
+ # Remove underscores no início e fim
246
+ filename = filename.strip("_")
247
+
248
+ # Limita o comprimento
249
+ if len(filename) > config.FILENAME_MAX_LENGTH:
250
+ # Preserva a extensão
251
+ name, ext = os.path.splitext(filename)
252
+ max_name_len = config.FILENAME_MAX_LENGTH - len(ext)
253
+ filename = name[:max_name_len] + ext
254
+
255
+ # Se ficou vazio após sanitização
256
+ if not filename or filename == "." or filename == "..":
257
+ return "arquivo_sanitizado"
258
+
259
+ return filename
260
+
261
+
262
+ def validate_files(files: list) -> list[tuple[Path, str]]:
263
+ """
264
+ Valida uma lista de arquivos completamente.
265
+
266
+ Args:
267
+ files: Lista de arquivos (podem ser paths ou objetos de arquivo).
268
+
269
+ Returns:
270
+ Lista de tuplas (path, nome_sanitizado) para arquivos válidos.
271
+
272
+ Raises:
273
+ ValidationError: Se qualquer validação falhar.
274
+ """
275
+ validate_file_count(files)
276
+
277
+ validated = []
278
+
279
+ for file_obj in files:
280
+ # Gradio retorna objetos com atributo 'name'
281
+ if hasattr(file_obj, "name"):
282
+ file_path = Path(file_obj.name)
283
+ else:
284
+ file_path = Path(file_obj)
285
+
286
+ # Valida tamanho
287
+ validate_file_size(file_path)
288
+
289
+ # Valida MIME type
290
+ validate_mime_type(file_path)
291
+
292
+ # Sanitiza nome
293
+ sanitized_name = sanitize_filename(file_path.name)
294
+
295
+ validated.append((file_path, sanitized_name))
296
+
297
+ return validated