Parsing de PDFs com Layouts Complexos: Colunas, Caixas e Fluxos Não-Lineares

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

Parsing de PDFs com Layouts Complexos: Colunas, Caixas e Fluxos Não-Lineares

O que você vai aprender

1. A Falácia da Extração Linear em Documentos PDF

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.

2. Geometria de Página e o Algoritmo XY-Cut

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)        │ |
| └───────────────────────────────────────────────────────────────────────────────┘ |
+-----------------------------------------------------------------------------------+

3. Implementação: Parser Layout-Aware com PyMuPDF e PyMuPDF4LLM

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