Parsing de PDFs com Layouts Complexos: Colunas, Caixas e Fluxos Não-Lineares — aula do módulo Parsing de Documentos Complexos & Extração Multimodal, na trilha…
Leitura de aproximadamente 9 minutos.
Módulo: Parsing de Documentos Complexos & Extração Multimodal · Curso: Ingestão de Dados Complexos & Chunking Semântico · Formação: Formação em RAG Avançado & GraphRAG de Produção
pypdf puro ou .extract_text()) quebra a coerência semântica em layouts de múltiplas colunas.PyMuPDF (fitz) com detecção de blocos e enriquecimento de metadados.O formato PDF (Portable Document Format) foi concebido como uma linguagem de descrição de página voltada à fidelidade visual de impressão, e não à representação estruturada de dados. Em sua camada mais baixa, um PDF não armazena parágrafos, frases ou tabelas; ele armazena instruções vetoriais de posicionamento absoluto de caracteres (glifos) na tela (ex: "coloque o caractere 'A' na coordenada $(x=72, y=540)$ com tamanho de fonte 12pt").
Quando um extrator ingênuo processa um PDF de duas ou três colunas varrendo as instruções na ordem em que aparecem no stream binário, ocorre o fenômeno catastrófico de intercalação horizontal:
+-----------------------------------------------------------------------------------+
| EXTRAÇÃO INGÊNUA vs RECONSTRUÇÃO DA ORDEM DE LEITURA |
+-----------------------------------------------------------------------------------+
| Layout Real da Página (2 Colunas): |
| ┌───────────────────────────────┐ ┌───────────────────────────────┐ |
| │ [Coluna 1: Parágrafo A] │ │ [Coluna 2: Parágrafo B] │ |
| │ O EBITDA consolidado subiu │ │ A taxa de juros futura afeta │ |
| │ 14% no primeiro trimestre... │ │ diretamente a margem líquida..│ |
| └───────────────────────────────┘ └───────────────────────────────┘ |
| |
| Extração Ingênua (Varredura Horizontal Desordenada - Ruptura Semântica): |
| "O EBITDA consolidado subiu A taxa de juros futura afeta 14% no primeiro..." |
| ❌ Consequência: Vetorização de texto corrompido, alucinação fatal no RAG. |
| |
| Reconstrução Espacial por Bounding Boxes (Layout-Aware Parsing): |
| [Bloco 1 (Col 1)] ──► [Bloco 2 (Col 1)] ──► [Bloco 3 (Col 2)] ──► [Bloco 4 (Col 2)] |
| ✅ Consequência: Continuidade semântica intacta, embeddings de alta fidelidade. |
+-----------------------------------------------------------------------------------+
Se o texto for extraído de forma intercalada, o modelo de embedding gerará vetores sem sentido no espaço latente. Quando o usuário fizer uma busca semântica sobre "EBITDA consolidado", o sistema recuperará um fragmento corrompido que mistura balanço financeiro com taxas de juros, destruindo a acurácia do gerador.
Para recuperar a ordem lógica de leitura, os parsers modernos utilizam a geometria dos blocos de texto (bounding boxes ou bbox). Cada elemento textual possui um retângulo delimitador definido por quatro coordenadas: $[x_0, y_0, x_1, y_1]$, onde $(x_0, y_0)$ é o canto superior esquerdo e $(x_1, y_1)$ é o canto inferior direito.
O algoritmo XY-Cut Recursivo funciona dividindo o espaço da página iterativamente: 1. Histograma de Projeção Horizontal: Projeta a densidade de tinta/texto ao longo do eixo Y para identificar espaços em branco horizontais que separam parágrafos ou seções. 2. Histograma de Projeção Vertical: Projeta a densidade de texto ao longo do eixo X para identificar calhas (gutters) de separação entre colunas. 3. Divisão Hierárquica em Árvore: A página é decomposta recursivamente em uma árvore de blocos (Tree Layout) até que cada nó folha contenha apenas um bloco de texto unificado.
+-----------------------------------------------------------------------------------+
| DIVISÃO ESPACIAL HIERÁRQUICA COM O ALGORITMO XY-CUT |
+-----------------------------------------------------------------------------------+
| ┌───────────────────────────────────────────────────────────────────────────────┐ |
| │ Cabeçalho Corporativo / Metadados de Seção (Header - Top Bounding Box) │ |
| ├───────────────────────────────────────┬───────────────────────────────────────┤ |
| │ Coluna Esquerda (Calha Vertical X) │ Coluna Direita (Offset Espacial X) │ |
| │ ┌───────────────────────────────────┐ │ ┌───────────────────────────────────┐ │ |
| │ │ Bloco 1 (Top-Left BBox) │ │ │ Bloco 3 (Top-Right BBox) │ │ |
| │ │ [Texto contínuo da Seção 1.1...] │ │ │ [Texto contínuo da Seção 1.2...] │ │ |
| │ ├───────────────────────────────────┤ │ ├───────────────────────────────────┤ │ |
| │ │ Bloco 2 (Bottom-Left BBox) │ │ │ Bloco 4 (Bottom-Right BBox) │ │ |
| │ │ [Conclusão da Coluna 1...] │ │ │ [Conclusão da Coluna 2...] │ │ |
| │ └───────────────────────────────────┘ │ └───────────────────────────────────┘ │ |
| ├───────────────────────────────────────┴───────────────────────────────────────┤ |
| │ Rodapé / Numeração de Página / Disclaimer Legal (Footer - Bottom BBox) │ |
| └───────────────────────────────────────────────────────────────────────────────┘ |
+-----------------------------------------------------------------------------------+
A biblioteca PyMuPDF (módulo fitz) em C++ é significativamente mais rápida que parsers puramente em Python (como pypdf ou pdfminer.six), sendo ideal para processamento em larga escala em pipelines de produção. O método page.get_text("blocks") analisa a geometria e agrupa linhas contíguas em blocos de texto com suas respectivas coordenadas espaciais.
Abaixo, implementamos um extrator robusto que:
"""
Pipeline de parsing de PDFs layout-aware com ordenação espacial e remoção de headers.
"""
from dataclasses import dataclass, field
from typing import List, Optional
import fitz # PyMuPDF
@dataclass
class TextBlock:
text: str
bbox: tuple[float, float, float, float]
page_number: int
is_heading: bool = False
font_size: float = 0.0
@dataclass
class ParsedDocument:
filename: str
blocks: list[TextBlock] = field(default_factory=list)
total_pages: int = 0
@property
def full_ordered_text(self) -> str:
return "\n\n".join(b.text for b in self.blocks)
class LayoutAwarePDFParser:
def __init__(
self,
header_margin_ratio: float = 0.08,
footer_margin_ratio: float = 0.08,
min_block_length: int = 20,
) -> None:
"""
:param header_margin_ratio: Fração superior da página tratada como header.
:param footer_margin_ratio: Fração inferior da página tratada como footer.
:param min_block_length: Tamanho mínimo de caracteres para reter o bloco.
"""
self.header_margin_ratio = header_margin_ratio
self.footer_margin_ratio = footer_margin_ratio
self.min_block_length = min_block_length
def parse_file(self, file_path: str) -> ParsedDocument:
doc = fitz.open(file_path)
parsed = ParsedDocument(filename=file_path, total_pages=len(doc))
for page_idx in range(len(doc)):
page = doc[page_idx]
page_height = page.rect.height
page_width = page.rect.width
header_limit = page_height * self.header_margin_ratio
footer_limit = page_height * (1.0 - self.footer_margin_ratio)
# Extração de blocos: (x0, y0, x1, y1, text, block_no, block_type)
raw_blocks = page.get_text("blocks")
valid_blocks: list[tuple[float, float, float, float, str]] = []
for b in raw_blocks:
x0, y0, x1, y1, text, _, b_type = b
# b_type == 0 indica bloco de texto (1 é imagem raster)
if b_type != 0:
continue
clean_text = text.strip()
if len(clean_text) < self.min_block_length:
continue
# Filtragem de cabeçalhos e rodapés repetitivos
if y1 <= header_limit or y0 >= footer_limit:
continue
valid_blocks.append((x0, y0, x1, y1, clean_text))
# Ordenação de colunas: agrupa blocos por posição X e depois por Y
# Para layouts de 2 colunas, o limiar é a metade da largura da página
ordered_blocks = self._sort_reading_order(valid_blocks, page_width)
for x0, y0, x1, y1, text in ordered_blocks:
parsed.blocks.append(
TextBlock(
text=text,
bbox=(x0, y0, x1, y1),
page_number=page_idx + 1,
)
)
doc.close()
return parsed
def _sort_reading_order(
self, blocks: list[tuple[float, float, float, float, str]], page_width: float
) -> list[tuple[float, float, float, float, str]]:
"""
Reconstrói a ordem de leitura separando colunas quando aplicável.
"""
if not blocks:
return []
mid_point = page_width / 2.0
left_col = []
right_col = []
full_width_blocks = []
for b in blocks:
x0, y0, x1, y1, _ = b
width = x1 - x0
# Se o bloco ocupa mais de 70% da largura, é bloco único (título/banner)
if width > page_width * 0.70:
full_width_blocks.append(b)
elif x1 <= mid_point + 20:
left_col.append(b)
else:
right_col.append(b)
# Se não há separação nítida de colunas, ordena linearmente por Top-to-Bottom
if not left_col or not right_col:
return sorted(blocks, key=lambda b: (round(b[1] / 10) * 10, b[0]))
# Ordena cada coluna de cima para baixo
left_col_sorted = sorted(left_col, key=lambda b: b[1])
right_col_sorted = sorted(right_col, key=lambda b: b[1])
# Se houver blocos full-width, insere na ordem vertical
all_sorted = sorted(
full_width_blocks + left_col_sorted + right_col_sorted,
key=lambda b: (0 if b in full_width_blocks and b[1] < left_col_sorted[0][1] else (1 if b in left_col_sorted else 2), b[1])
)
return all_sorted
# Padrão Moderno de Alto Nível: PyMuPDF4LLM
# Para pipelines que necessitam de saída direta em Markdown estruturado com layout analysis:Outras aulas do módulo