Skip to content

Extraer tablas de PDF en Python

Extraer tablas de documentos PDF es una de las tareas más frecuentes en los flujos de procesamiento de documentos. Ya sea que necesites recuperar datos financieros de informes anuales, leer catálogos de productos o alimentar datos estructurados a un LLM, la extracción fiable de tablas es fundamental. Esta guía cubre todo lo que necesitas saber para extraer tablas de PDFs en Python: desde una sola línea de código hasta flujos de producción para tablas multipágina.

Motor de detección

PDF Oxide utiliza la pipeline de detección universal bordes → alinear/fusionar → intersecciones → celdas → grupos — el mismo enfoque que Tabula, pdfplumber y PyMuPDF, implementado en Rust puro.

Capacidades de detección:

  • Basada en intersecciones — encuentra cruces de líneas H×V, construye celdas a partir de rectángulos de cuatro esquinas, agrupa en tablas mediante union-find.
  • Cuadrícula extendida — cuando las líneas horizontales y verticales se encuentran en zonas distintas de la página, construye una cuadrícula virtual a partir del producto cartesiano de todas las coordenadas.
  • Detección de texto orientada a columnas — segmenta diseños de dos columnas mediante histograma de proyección en X y luego detecta tablas de texto por columna.
  • Tablas de texto delimitadas por líneas horizontales — detecta tablas delimitadas por líneas horizontales sin líneas verticales (frecuente en artículos académicos).
  • Detección híbrida de filas — infiere límites de fila a partir de posiciones Y del texto cuando solo existen bordes verticales (elementos de factura).
  • Reconstrucción de líneas punteadas/discontinuas — une segmentos de línea cortos en bordes continuos.
  • División por separadores de sección — divide formularios con varias secciones en separadores horizontales de ancho completo.
  • Filtrado por cobertura de bordes — elimina bordes huérfanos que no participan en ninguna cuadrícula potencial.

Configuración

TableDetectionConfig ofrece parámetros ajustables:

Campo Predeterminado Descripción
horizontal_strategy "lines_strict" "lines_strict", "lines", "text" o "explicit"
vertical_strategy "lines_strict" Mismas opciones
v_split_gap 20.0 pt Separación entre líneas verticales que activa la división en tablas separadas (fijo en 4pt antes de v0.3.20)
snap_tolerance 3.0 pt Tolerancia para fusión de alineación de bordes
text_tolerance 3.0 pt Tolerancia para fusión de líneas de texto

Cambio de comportamiento

A partir de v0.3.20, la estrategia predeterminada para extract_tables() de Python es Both (detecta mediante líneas y texto). Las páginas que dependían del antiguo modo de solo texto deben pasar horizontal_strategy="text" y vertical_strategy="text" explícitamente.

El binding de Python ahora lee correctamente vertical_strategy del diccionario table_settings — antes el parámetro se ignoraba silenciosamente.

Renderizado

Las tablas extraídas se muestran con alineación de columnas rellena con espacios (sustituye los caracteres de cuadro ASCII de versiones anteriores). Las columnas de valores monetarios y numéricos se alinean automáticamente a la derecha. Los prefijos de número de formulario ("1 Apr 11""Apr 11") y las celdas con guiones decorativos/subrayados ("------") se eliminan durante el renderizado.

Extrae datos de una tabla de un PDF mediante conversión a Markdown:

Python

from pdf_oxide import PdfDocument

doc = PdfDocument("invoice.pdf")
md = doc.to_markdown(0, detect_headings=True)
print(md)
# Output includes tables in GFM format:
# | Item | Qty | Price |
# |------|-----|-------|
# | Widget | 10 | $9.99 |

WASM

import { WasmPdfDocument } from "pdf-oxide-wasm";

const doc = new WasmPdfDocument(bytes);
const md = doc.toMarkdown(0);
console.log(md);
// Output includes tables in GFM format:
// | Item | Qty | Price |
// |------|-----|-------|
// | Widget | 10 | $9.99 |
doc.free();

Rust

use pdf_oxide::PdfDocument;

let mut doc = PdfDocument::open("invoice.pdf")?;
let md = doc.to_markdown(0, true)?;
println!("{}", md);

Go

package main

import (
    "fmt"
    "log"
    pdfoxide "github.com/yfedoseev/pdf_oxide/go"
)

func main() {
    doc, err := pdfoxide.Open("invoice.pdf")
    if err != nil { log.Fatal(err) }
    defer doc.Close()

    md, err := doc.ToMarkdown(0)
    if err != nil { log.Fatal(err) }
    fmt.Println(md)
}

C#

using PdfOxide;

using var doc = PdfDocument.Open("invoice.pdf");
Console.WriteLine(doc.ToMarkdown(0));

Java

import fyi.oxide.pdf.PdfDocument;

try (PdfDocument doc = PdfDocument.open(java.nio.file.Path.of("invoice.pdf"))) {
    System.out.println(doc.toMarkdown(0));
}

PHP

use PdfOxide\PdfDocument;

$doc = PdfDocument::open('invoice.pdf');
echo $doc->toMarkdown(0);
$doc->close();

Ruby

require 'pdf_oxide'

PdfOxide::PdfDocument.open('invoice.pdf') do |doc|
  puts doc.to_markdown(0)
end

C++

#include <pdf_oxide/pdf_oxide.hpp>
#include <iostream>

auto doc = pdf_oxide::Document::open("invoice.pdf");
std::cout << doc.to_markdown(0) << '\n';

Swift

import PdfOxide

let doc = try Document.open("invoice.pdf")
print(try doc.toMarkdown(0))

Kotlin

import fyi.oxide.pdf.PdfDocument

PdfDocument.open(java.nio.file.Path.of("invoice.pdf")).use { doc ->
    println(doc.toMarkdown(0))
}

Dart

import 'package:pdf_oxide/pdf_oxide.dart';

final doc = PdfDocument.open('invoice.pdf');
print(doc.toMarkdown(0));
doc.close();

R

library(pdfoxide)

doc <- pdf_open("invoice.pdf")
cat(pdf_to_markdown(doc, 0))

Julia

using PdfOxide

doc = open_document("invoice.pdf")
println(to_markdown(doc, 0))

Zig

const pdf_oxide = @import("pdf_oxide");
const a = std.heap.page_allocator;

var doc = try pdf_oxide.Document.open("invoice.pdf");
const md = try doc.toMarkdown(a, 0);
defer a.free(md);
std.debug.print("{s}\n", .{md});

Scala

import fyi.oxide.pdf.PdfDocument
import scala.util.Using

Using.resource(PdfDocument.open("invoice.pdf")) { doc =>
  println(doc.toMarkdown(0))
}

Clojure

(require '[pdf-oxide.core :as pdf])

(with-open [d (pdf/open "invoice.pdf")]
  (println (pdf/to-markdown d 0)))

Objective-C

#import "POXPdfOxide.h"
NSError *err = nil;

POXDocument *doc = [POXDocument openPath:@"invoice.pdf" error:&err];
NSLog(@"%@", [doc toMarkdown:0 error:&err]);

Elixir

{:ok, doc} = PdfOxide.open("invoice.pdf")
{:ok, md} = PdfOxide.to_markdown(doc, 0)
IO.puts(md)

PDF Oxide detecta diseños tabulares mediante análisis espacial de bloques de texto alineados y emite tablas en formato GitHub Flavored Markdown.

Por qué la extracción de tablas de PDF es tan difícil

Quien haya intentado copiar una tabla de un PDF y pegarla en una hoja de cálculo sabe que el resultado casi siempre es un desastre. Esto no es un fallo del visor PDF — refleja una limitación fundamental del propio formato PDF.

Los PDFs no tienen concepto de «tabla». A diferencia de HTML, que utiliza etiquetas <table>, <tr> y <td> para definir la estructura tabular, un archivo PDF solo almacena instrucciones de posicionamiento de caracteres: colocar este carácter en las coordenadas (x, y), trazar una línea del punto A al punto B. No existe una capa semántica que indique «estos caracteres pertenecen a una celda en la fila 3, columna 2». Cualquier biblioteca de extracción de tablas debe reconstruir esa estructura analizando las posiciones espaciales del texto y las líneas en la página.

Esta reconstrucción es difícil por varios motivos:

  • Tablas con y sin bordes. Si una tabla tiene líneas de cuadrícula visibles, las herramientas de extracción pueden usarlas como límites de celda. Las tablas sin bordes — habituales en informes financieros, documentos gubernamentales y artículos académicos — no tienen líneas en absoluto. La biblioteca debe inferir los límites de columna únicamente a partir del espaciado entre bloques de texto, lo que es propenso a errores cuando las columnas tienen anchos diferentes o los números están alineados a la derecha.

  • Celdas fusionadas y encabezados que abarcan varias columnas. Una celda de encabezado que abarca tres columnas parece un único bloque de texto ancho. Sin líneas de cuadrícula que delimiten las columnas, un analizador no tiene forma fiable de saber cuántas columnas cubre el encabezado. Algunas bibliotecas lo manejan bien; muchas producen silenciosamente resultados ilegibles.

  • Contenido de celda en varias líneas. Si una celda contiene un párrafo con saltos de línea, el análisis ingenuamente basado en líneas tratará cada línea como una fila de tabla separada. Asignar correctamente esas líneas a una sola celda requiere comprender la extensión vertical de cada fila.

  • Tablas que abarcan varias páginas. Las tablas grandes se extienden frecuentemente a lo largo de dos o más páginas. La fila de encabezado puede repetirse en cada página o no, y los pies de página, marcas de agua o números de página pueden aparecer entre filas de la tabla. Unir estos fragmentos en una única tabla coherente requiere lógica que tenga en cuenta las páginas.

  • Texto rotado y diseños no estándar. Algunos PDFs utilizan texto rotado para los encabezados de columna o colocan tablas dentro de diseños de página de varias columnas. Estos casos extremos rompen las suposiciones que la mayoría de los analizadores hacen sobre el orden de lectura de izquierda a derecha y de arriba a abajo.

Entender estos desafíos ayuda a elegir la herramienta correcta para cada tipo de documento. Para tablas simples y bien alineadas — la mayoría de facturas, pedidos e informes básicos — un enfoque rápido de análisis espacial como PDF Oxide funciona bien. Para documentos con fusiones complejas, diseños sin bordes o formatos inusuales, puede ser necesaria una biblioteca con heurística más sofisticada.

Extracción de tablas: PDF Oxide vs. otras bibliotecas

La elección de una biblioteca para extracción de tablas de PDF en Python depende de los documentos, los requisitos de rendimiento y el formato de salida necesario. Así es como se comparan las principales opciones:

Biblioteca Detección de tablas Tablas con bordes Tablas sin bordes Formato de salida Velocidad
PDF Oxide Integrada Básica Markdown/HTML 0,8ms
pdfplumber Integrada Avanzada Listas Python 23,2ms
Camelot Integrada Sí (lattice/stream) DataFrames ~50ms+
PyMuPDF Básica (v1.23+) Limitada DataFrames 4,6ms
pypdf No No No N/A N/A
tabula-py Integrada DataFrames ~100ms+ (Java)

PDF Oxide es con diferencia la opción más rápida. Detecta tablas mediante análisis espacial de bloques de texto alineados y emite tablas limpias en GitHub Flavored Markdown. Con un tiempo de extracción medio de 0,8ms, es 29× más rápido que pdfplumber y más de 100× más rápido que tabula-py. Maneja bien las tablas con bordes y las tablas simples alineadas sin bordes. Para pipelines de LLM que de todas formas necesitan salida Markdown, es la elección natural.

pdfplumber tiene la detección más sofisticada de tablas sin bordes. Su método find_tables() utiliza estrategias configurables para detectar columnas y filas basadas en la alineación del texto, y maneja mejor que la mayoría las celdas fusionadas y el contenido de celdas en varias líneas. El coste es la velocidad: 23,2ms por página es considerablemente más lento para procesamiento en lote.

Camelot ofrece dos modos de detección — lattice (para tablas con bordes) y stream (para tablas sin bordes). Produce DataFrames de pandas directamente, lo cual es conveniente para flujos de análisis de datos. Sin embargo, depende de Ghostscript y OpenCV, lo que complica la instalación, y su velocidad es la más baja entre las opciones puras de Python.

PyMuPDF (fitz) añadió extracción básica de tablas en la versión 1.23. Es rápido (4,6ms) y funciona bien con tablas simples con bordes, pero el soporte para tablas sin bordes es limitado en comparación con pdfplumber o Camelot.

pypdf no tiene detección de tablas. Extrae texto plano, por lo que habría que escribir la lógica de análisis propia para reconstruir la estructura tabular.

tabula-py es un wrapper de Python alrededor de la biblioteca Java Tabula. Ofrece buena detección para tablas con y sin bordes, pero requiere un entorno de ejecución Java y es la opción más lenta por la sobrecarga de la JVM. Es más adecuado para tareas de extracción puntuales que para pipelines de alto rendimiento.

Para la mayoría de los casos de uso en producción, se recomienda usar PDF Oxide como extractor principal por velocidad y simplicidad, y recurrir a pdfplumber para el subconjunto de documentos con diseños de tabla complejos.

Instalación

pip install pdf_oxide

Extracción básica de tablas

Como tablas Markdown

El enfoque más sencillo — convierte la página a Markdown, que incluye las tablas en sintaxis GFM:

Python

from pdf_oxide import PdfDocument

doc = PdfDocument("report.pdf")
for i in range(doc.page_count()):
    md = doc.to_markdown(i, detect_headings=True)
    if "|" in md:  # Page contains a table
        print(f"--- Page {i + 1} ---")
        print(md)

WASM

const doc = new WasmPdfDocument(bytes);
for (let i = 0; i < doc.pageCount(); i++) {
    const md = doc.toMarkdown(i);
    if (md.includes("|")) { // Page contains a table
        console.log(`--- Page ${i + 1} ---`);
        console.log(md);
    }
}
doc.free();

Rust

let mut doc = PdfDocument::open("report.pdf")?;
for i in 0..doc.page_count()? {
    let md = doc.to_markdown(i, true)?;
    if md.contains("|") {
        println!("--- Page {} ---", i + 1);
        println!("{}", md);
    }
}

Go

doc, _ := pdfoxide.Open("report.pdf")
defer doc.Close()

n, _ := doc.PageCount()
for i := 0; i < n; i++ {
    md, _ := doc.ToMarkdown(i)
    if strings.Contains(md, "|") {
        fmt.Printf("--- Page %d ---\n%s\n", i+1, md)
    }
}

C#

using var doc = PdfDocument.Open("report.pdf");
for (int i = 0; i < doc.PageCount; i++)
{
    var md = doc.ToMarkdown(i);
    if (md.Contains("|"))
        Console.WriteLine($"--- Page {i + 1} ---\n{md}");
}

Java

try (PdfDocument doc = PdfDocument.open(java.nio.file.Path.of("report.pdf"))) {
    for (int i = 0; i < doc.pageCount(); i++) {
        String md = doc.toMarkdown(i);
        if (md.contains("|")) {  // Page contains a table
            System.out.println("--- Page " + (i + 1) + " ---");
            System.out.println(md);
        }
    }
}

PHP

$doc = PdfDocument::open('report.pdf');
for ($i = 0; $i < $doc->pageCount(); $i++) {
    $md = $doc->toMarkdown($i);
    if (str_contains($md, '|')) {  // Page contains a table
        echo "--- Page " . ($i + 1) . " ---\n";
        echo $md;
    }
}
$doc->close();

Ruby

PdfOxide::PdfDocument.open('report.pdf') do |doc|
  doc.page_count.times do |i|
    md = doc.to_markdown(i)
    if md.include?('|')  # Page contains a table
      puts "--- Page #{i + 1} ---"
      puts md
    end
  end
end

C++

auto doc = pdf_oxide::Document::open("report.pdf");
for (int i = 0; i < doc.page_count(); i++) {
    auto md = doc.to_markdown(i);
    if (md.find('|') != std::string::npos) {  // Page contains a table
        std::cout << "--- Page " << (i + 1) << " ---\n" << md << '\n';
    }
}

Swift

let doc = try Document.open("report.pdf")
for i in 0..<(try doc.pageCount()) {
    let md = try doc.toMarkdown(i)
    if md.contains("|") {  // Page contains a table
        print("--- Page \(i + 1) ---")
        print(md)
    }
}

Kotlin

PdfDocument.open(java.nio.file.Path.of("report.pdf")).use { doc ->
    for (i in 0 until doc.pageCount()) {
        val md = doc.toMarkdown(i)
        if (md.contains("|")) {  // Page contains a table
            println("--- Page ${i + 1} ---")
            println(md)
        }
    }
}

Dart

final doc = PdfDocument.open('report.pdf');
for (var i = 0; i < doc.pageCount; i++) {
  final md = doc.toMarkdown(i);
  if (md.contains('|')) {  // Page contains a table
    print('--- Page ${i + 1} ---');
    print(md);
  }
}
doc.close();

R

doc <- pdf_open("report.pdf")
for (i in 0:(pdf_page_count(doc) - 1)) {
  md <- pdf_to_markdown(doc, i)
  if (grepl("\\|", md)) {  # Page contains a table
    cat(sprintf("--- Page %d ---\n%s\n", i + 1, md))
  }
}

Julia

doc = open_document("report.pdf")
for i in 0:(page_count(doc) - 1)
    md = to_markdown(doc, i)
    if occursin("|", md)  # Page contains a table
        println("--- Page $(i + 1) ---")
        println(md)
    end
end

Zig

var doc = try pdf_oxide.Document.open("report.pdf");
const n = try doc.pageCount();
var i: i32 = 0;
while (i < n) : (i += 1) {
    const md = try doc.toMarkdown(a, i);
    defer a.free(md);
    if (std.mem.indexOfScalar(u8, md, '|') != null) {  // Page contains a table
        std.debug.print("--- Page {d} ---\n{s}\n", .{ i + 1, md });
    }
}

Scala

Using.resource(PdfDocument.open("report.pdf")) { doc =>
  for (i <- 0 until doc.pageCount()) {
    val md = doc.toMarkdown(i)
    if (md.contains("|")) {  // Page contains a table
      println(s"--- Page ${i + 1} ---")
      println(md)
    }
  }
}

Clojure

(with-open [d (pdf/open "report.pdf")]
  (doseq [i (range (pdf/page-count d))]
    (let [md (pdf/to-markdown d i)]
      (when (.contains md "|")  ; Page contains a table
        (println (str "--- Page " (inc i) " ---"))
        (println md)))))

Objective-C

NSError *err = nil;
POXDocument *doc = [POXDocument openPath:@"report.pdf" error:&err];
for (NSInteger i = 0; i < [doc pageCountError:&err]; i++) {
    NSString *md = [doc toMarkdown:i error:&err];
    if ([md containsString:@"|"]) {  // Page contains a table
        NSLog(@"--- Page %ld ---\n%@", (long)(i + 1), md);
    }
}

Elixir

{:ok, doc} = PdfOxide.open("report.pdf")
{:ok, n} = PdfOxide.page_count(doc)
for i <- 0..(n - 1) do
  {:ok, md} = PdfOxide.to_markdown(doc, i)
  if String.contains?(md, "|") do  # Page contains a table
    IO.puts("--- Page #{i + 1} ---")
    IO.puts(md)
  end
end

Extracción estructurada de tablas (v0.3.34)

Para acceso tipado a filas y bounding boxes sin necesidad de parsear Markdown, llama a ExtractTables(pageIndex) (Go, C#) / extract_tables(page) (Python, Rust). Cada tabla expone celdas estructuradas, por lo que puedes pasar los resultados directamente a una base de datos o un DataFrame sin expresiones regulares.

Python

from pdf_oxide import PdfDocument

doc = PdfDocument("invoice.pdf")
for table in doc.extract_tables(0):
    for row in table.rows:
        print(row)

Rust

let mut doc = PdfDocument::open("invoice.pdf")?;
for table in doc.extract_tables(0)? {
    for row in &table.rows {
        println!("{:?}", row);
    }
}

Go

doc, _ := pdfoxide.Open("invoice.pdf")
defer doc.Close()

tables, _ := doc.ExtractTables(0)
for _, t := range tables {
    for _, row := range t.Rows {
        fmt.Println(row)
    }
}

C#

using var doc = PdfDocument.Open("invoice.pdf");
foreach (var table in doc.ExtractTables(0))
    foreach (var row in table.Rows)
        Console.WriteLine(string.Join(" | ", row));

Java

import fyi.oxide.pdf.PdfDocument;
import fyi.oxide.pdf.table.Table;
import fyi.oxide.pdf.table.TableCell;

try (PdfDocument doc = PdfDocument.open(java.nio.file.Path.of("invoice.pdf"))) {
    for (Table table : doc.page(0).tables()) {
        String[][] grid = new String[table.rows()][table.cols()];
        for (TableCell c : table.cells()) grid[c.row()][c.col()] = c.text();
        for (String[] row : grid) System.out.println(String.join(" | ", row));
    }
}

C++

auto doc = pdf_oxide::Document::open("invoice.pdf");
for (const auto& table : doc.extract_tables(0)) {
    for (int r = 0; r < table.row_count; r++) {
        for (int c = 0; c < table.col_count; c++) {
            std::cout << table.cell(r, c);
            if (c + 1 < table.col_count) std::cout << " | ";
        }
        std::cout << '\n';
    }
}

Swift

let doc = try Document.open("invoice.pdf")
for table in try doc.extractTables(0) {
    for r in 0..<table.rowCount {
        let row = (0..<table.colCount).map { table.cell(r, $0) }
        print(row.joined(separator: " | "))
    }
}

Kotlin

import fyi.oxide.pdf.PdfDocument

PdfDocument.open(java.nio.file.Path.of("invoice.pdf")).use { doc ->
    for (table in doc.page(0).tables()) {
        val grid = Array(table.rows()) { arrayOfNulls<String>(table.cols()) }
        table.cells().forEach { grid[it.row()][it.col()] = it.text() }
        grid.forEach { println(it.joinToString(" | ")) }
    }
}

Dart

final doc = PdfDocument.open('invoice.pdf');
for (final table in doc.extractTables(0)) {
  for (var r = 0; r < table.rowCount; r++) {
    final row = [for (var c = 0; c < table.colCount; c++) table.cell(r, c)];
    print(row.join(' | '));
  }
}
doc.close();

R

doc <- pdf_open("invoice.pdf")
for (table in pdf_extract_tables(doc, 0)) {
  for (r in seq_len(table$row_count)) {
    cat(paste(table$cells[r, ], collapse = " | "), "\n")
  }
}

Julia

doc = open_document("invoice.pdf")
for table in extract_tables(doc, 0)
    for r in 1:table.row_count
        println(join(table.cells[r, :], " | "))
    end
end

Zig

var doc = try pdf_oxide.Document.open("invoice.pdf");
const tables = try doc.extractTables(a, 0);
defer pdf_oxide.Document.freeTables(a, tables);
for (tables) |table| {
    var r: i32 = 0;
    while (r < table.rowCount) : (r += 1) {
        var c: i32 = 0;
        while (c < table.colCount) : (c += 1) {
            std.debug.print("{s}", .{table.cell(r, c)});
            if (c + 1 < table.colCount) std.debug.print(" | ", .{});
        }
        std.debug.print("\n", .{});
    }
}

Scala

import fyi.oxide.pdf.PdfDocument
import scala.jdk.CollectionConverters._
import scala.util.Using

Using.resource(PdfDocument.open("invoice.pdf")) { doc =>
  for (table <- doc.page(0).tables().asScala) {
    val grid = Array.ofDim[String](table.rows(), table.cols())
    table.cells().asScala.foreach(c => grid(c.row())(c.col()) = c.text())
    grid.foreach(row => println(row.mkString(" | ")))
  }
}

Clojure

(with-open [d (pdf/open "invoice.pdf")]
  (doseq [table (pdf/tables (pdf/page d 0))]
    (let [grid (make-array String (.rows table) (.cols table))]
      (doseq [c (.cells table)]
        (aset grid (.row c) (.col c) (.text c)))
      (doseq [row grid]
        (println (clojure.string/join " | " row))))))

Objective-C

NSError *err = nil;
POXDocument *doc = [POXDocument openPath:@"invoice.pdf" error:&err];
for (POXTable *table in [doc extractTables:0 error:&err]) {
    for (NSInteger r = 0; r < table.rowCount; r++) {
        NSMutableArray<NSString *> *row = [NSMutableArray array];
        for (NSInteger c = 0; c < table.colCount; c++)
            [row addObject:([table cellTextAtRow:r col:c] ?: @"")];
        NSLog(@"%@", [row componentsJoinedByString:@" | "]);
    }
}

Elixir

{:ok, doc} = PdfOxide.open("invoice.pdf")
{:ok, tables} = PdfOxide.extract_tables(doc, 0)
for table <- tables do
  for r <- 0..(table.row_count - 1) do
    row = for c <- 0..(table.col_count - 1), do: PdfOxide.cell(table, r, c)
    IO.puts(Enum.join(row, " | "))
  end
end

Leer filas de tablas Markdown

Python

from pdf_oxide import PdfDocument

doc = PdfDocument("invoice.pdf")
md = doc.to_markdown(0)

# Extract table rows from Markdown
rows = []
for line in md.split("\n"):
    line = line.strip()
    if line.startswith("|") and not line.startswith("|--"):
        cells = [cell.strip() for cell in line.split("|")[1:-1]]
        rows.append(cells)

header = rows[0] if rows else []
data = rows[1:] if len(rows) > 1 else []
print(f"Columns: {header}")
for row in data:
    print(row)

WASM

const doc = new WasmPdfDocument(bytes);
const md = doc.toMarkdown(0);

const rows = [];
for (const line of md.split("\n")) {
    const trimmed = line.trim();
    if (trimmed.startsWith("|") && !trimmed.startsWith("|--")) {
        const cells = trimmed.split("|").slice(1, -1).map(c => c.trim());
        rows.push(cells);
    }
}

const header = rows[0] || [];
const data = rows.slice(1);
console.log("Columns:", header);
data.forEach(row => console.log(row));
doc.free();

Rust

let mut doc = PdfDocument::open("invoice.pdf")?;
let md = doc.to_markdown(0, false)?;

let rows: Vec<Vec<String>> = md.lines()
    .map(|l| l.trim())
    .filter(|l| l.starts_with('|') && !l.starts_with("|--"))
    .map(|l| l.split('|').skip(1).map(|c| c.trim().to_string())
        .take_while(|c| !c.is_empty()).collect())
    .collect();

if let Some(header) = rows.first() {
    println!("Columns: {:?}", header);
    for row in &rows[1..] {
        println!("{:?}", row);
    }
}

Java

import fyi.oxide.pdf.PdfDocument;
import java.util.*;

try (PdfDocument doc = PdfDocument.open(java.nio.file.Path.of("invoice.pdf"))) {
    String md = doc.toMarkdown(0);
    List<List<String>> rows = new ArrayList<>();
    for (String line : md.split("\n")) {
        line = line.strip();
        if (line.startsWith("|") && !line.startsWith("|--")) {
            String[] parts = line.substring(1, line.length() - 1).split("\\|");
            List<String> cells = new ArrayList<>();
            for (String p : parts) cells.add(p.strip());
            rows.add(cells);
        }
    }
    System.out.println("Columns: " + (rows.isEmpty() ? List.of() : rows.get(0)));
    for (int i = 1; i < rows.size(); i++) System.out.println(rows.get(i));
}

PHP

$doc = PdfDocument::open('invoice.pdf');
$md = $doc->toMarkdown(0);

$rows = [];
foreach (explode("\n", $md) as $line) {
    $line = trim($line);
    if (str_starts_with($line, '|') && !str_starts_with($line, '|--')) {
        $cells = array_map('trim', array_slice(explode('|', $line), 1, -1));
        $rows[] = $cells;
    }
}
$header = $rows[0] ?? [];
echo "Columns: " . implode(', ', $header) . "\n";
foreach (array_slice($rows, 1) as $row) {
    echo implode(' | ', $row) . "\n";
}
$doc->close();

Ruby

PdfOxide::PdfDocument.open('invoice.pdf') do |doc|
  md = doc.to_markdown(0)

  rows = md.lines.map(&:strip)
            .select { |l| l.start_with?('|') && !l.start_with?('|--') }
            .map { |l| l.split('|')[1..-2].map(&:strip) }

  header = rows.first || []
  puts "Columns: #{header.inspect}"
  rows.drop(1).each { |row| puts row.inspect }
end

C++

#include <pdf_oxide/pdf_oxide.hpp>
#include <sstream>
#include <vector>

auto doc = pdf_oxide::Document::open("invoice.pdf");
auto md = doc.to_markdown(0);

std::vector<std::vector<std::string>> rows;
std::istringstream stream(md);
for (std::string line; std::getline(stream, line);) {
    auto s = line.find_first_not_of(" \t");
    if (s == std::string::npos) continue;
    line = line.substr(s);
    if (line.rfind("|", 0) != 0 || line.rfind("|--", 0) == 0) continue;
    std::vector<std::string> cells;
    std::istringstream cs(line.substr(1, line.size() - 2));
    for (std::string cell; std::getline(cs, cell, '|');) cells.push_back(cell);
    rows.push_back(cells);
}

Swift

let doc = try Document.open("invoice.pdf")
let md = try doc.toMarkdown(0)

let rows = md.split(separator: "\n").map { $0.trimmingCharacters(in: .whitespaces) }
    .filter { $0.hasPrefix("|") && !$0.hasPrefix("|--") }
    .map { line -> [String] in
        line.dropFirst().dropLast().split(separator: "|", omittingEmptySubsequences: false)
            .map { $0.trimmingCharacters(in: .whitespaces) }
    }

if let header = rows.first {
    print("Columns:", header)
    for row in rows.dropFirst() { print(row) }
}

Kotlin

PdfDocument.open(java.nio.file.Path.of("invoice.pdf")).use { doc ->
    val md = doc.toMarkdown(0)
    val rows = md.split("\n").map { it.trim() }
        .filter { it.startsWith("|") && !it.startsWith("|--") }
        .map { it.removeSurrounding("|").split("|").map(String::trim) }

    rows.firstOrNull()?.let { println("Columns: $it") }
    rows.drop(1).forEach { println(it) }
}

Dart

final doc = PdfDocument.open('invoice.pdf');
final md = doc.toMarkdown(0);

final rows = md.split('\n').map((l) => l.trim())
    .where((l) => l.startsWith('|') && !l.startsWith('|--'))
    .map((l) => l.substring(1, l.length - 1).split('|').map((c) => c.trim()).toList())
    .toList();

if (rows.isNotEmpty) {
  print('Columns: ${rows.first}');
  for (final row in rows.skip(1)) print(row);
}
doc.close();

R

doc <- pdf_open("invoice.pdf")
md  <- pdf_to_markdown(doc, 0)

lines <- trimws(strsplit(md, "\n")[[1]])
lines <- lines[startsWith(lines, "|") & !startsWith(lines, "|--")]
rows  <- lapply(lines, function(l) {
  cells <- strsplit(l, "\\|")[[1]]
  trimws(cells[2:(length(cells) - 1)])
})

if (length(rows) > 0) {
  cat("Columns:", rows[[1]], "\n")
  for (row in rows[-1]) cat(row, "\n")
}

Julia

doc = open_document("invoice.pdf")
md  = to_markdown(doc, 0)

rows = [strip.(split(l, "|")[2:end-1])
        for l in strip.(split(md, "\n"))
        if startswith(l, "|") && !startswith(l, "|--")]

if !isempty(rows)
    println("Columns: ", rows[1])
    for row in rows[2:end]
        println(row)
    end
end

Zig

var doc = try pdf_oxide.Document.open("invoice.pdf");
const md = try doc.toMarkdown(a, 0);
defer a.free(md);

var lines = std.mem.splitScalar(u8, md, '\n');
while (lines.next()) |raw| {
    const line = std.mem.trim(u8, raw, " \t\r");
    if (!std.mem.startsWith(u8, line, "|") or std.mem.startsWith(u8, line, "|--")) continue;
    const inner = line[1 .. line.len - 1];
    var cells = std.mem.splitScalar(u8, inner, '|');
    while (cells.next()) |cell| {
        std.debug.print("{s}\t", .{std.mem.trim(u8, cell, " \t")});
    }
    std.debug.print("\n", .{});
}

Scala

Using.resource(PdfDocument.open("invoice.pdf")) { doc =>
  val md = doc.toMarkdown(0)
  val rows = md.split("\n").map(_.trim)
    .filter(l => l.startsWith("|") && !l.startsWith("|--"))
    .map(_.stripPrefix("|").stripSuffix("|").split("\\|").map(_.trim).toList)
    .toList

  rows.headOption.foreach(h => println(s"Columns: $h"))
  rows.drop(1).foreach(println)
}

Clojure

(with-open [d (pdf/open "invoice.pdf")]
  (let [md   (pdf/to-markdown d 0)
        rows (->> (clojure.string/split-lines md)
                  (map clojure.string/trim)
                  (filter #(and (.startsWith % "|") (not (.startsWith % "|--"))))
                  (map #(->> (clojure.string/split % #"\|")
                             (drop 1) (butlast) (map clojure.string/trim) vec)))]
    (when-let [header (first rows)]
      (println "Columns:" header)
      (doseq [row (rest rows)] (println row)))))

Objective-C

NSError *err = nil;
POXDocument *doc = [POXDocument openPath:@"invoice.pdf" error:&err];
NSString *md = [doc toMarkdown:0 error:&err];

NSMutableArray<NSArray<NSString *> *> *rows = [NSMutableArray array];
for (NSString *raw in [md componentsSeparatedByString:@"\n"]) {
    NSString *line = [raw stringByTrimmingCharactersInSet:
        [NSCharacterSet whitespaceCharacterSet]];
    if (![line hasPrefix:@"|"] || [line hasPrefix:@"|--"]) continue;
    NSArray<NSString *> *parts = [line componentsSeparatedByString:@"|"];
    NSMutableArray<NSString *> *cells = [NSMutableArray array];
    for (NSUInteger i = 1; i + 1 < parts.count; i++)
        [cells addObject:[parts[i] stringByTrimmingCharactersInSet:
            [NSCharacterSet whitespaceCharacterSet]]];
    [rows addObject:cells];
}
if (rows.count > 0) NSLog(@"Columns: %@", rows[0]);

Elixir

{:ok, doc} = PdfOxide.open("invoice.pdf")
{:ok, md} = PdfOxide.to_markdown(doc, 0)

rows =
  md
  |> String.split("\n")
  |> Enum.map(&String.trim/1)
  |> Enum.filter(&(String.starts_with?(&1, "|") and not String.starts_with?(&1, "|--")))
  |> Enum.map(fn line ->
    line |> String.split("|") |> Enum.slice(1..-2//1) |> Enum.map(&String.trim/1)
  end)

case rows do
  [header | data] ->
    IO.puts("Columns: #{inspect(header)}")
    Enum.each(data, &IO.inspect/1)
  [] ->
    :ok
end

Exportar como CSV

import csv
from pdf_oxide import PdfDocument

doc = PdfDocument("invoice.pdf")
md = doc.to_markdown(0)

rows = []
for line in md.split("\n"):
    line = line.strip()
    if line.startswith("|") and not line.startswith("|--"):
        cells = [cell.strip() for cell in line.split("|")[1:-1]]
        rows.append(cells)

with open("table.csv", "w", newline="") as f:
    writer = csv.writer(f)
    writer.writerows(rows)

Exportar como DataFrame de Pandas

import pandas as pd
from pdf_oxide import PdfDocument

doc = PdfDocument("report.pdf")
md = doc.to_markdown(0)

rows = []
for line in md.split("\n"):
    line = line.strip()
    if line.startswith("|") and not line.startswith("|--"):
        cells = [cell.strip() for cell in line.split("|")[1:-1]]
        rows.append(cells)

if rows:
    df = pd.DataFrame(rows[1:], columns=rows[0])
    print(df)

Usar posiciones de caracteres para parsing personalizado de tablas

Para un control más detallado, puedes usar la extracción a nivel de carácter y el análisis espacial:

Python

from pdf_oxide import PdfDocument

doc = PdfDocument("financial.pdf")
chars = doc.extract_chars(0)

# Group characters by Y position (rows)
rows = {}
for ch in chars:
    row_key = round(ch.y / 2) * 2  # Snap to 2pt grid
    rows.setdefault(row_key, []).append(ch)

# Sort rows top-to-bottom, characters left-to-right
for y in sorted(rows.keys(), reverse=True):
    line_chars = sorted(rows[y], key=lambda c: c.x)
    text = "".join(c.char for c in line_chars)
    print(text)

WASM

const doc = new WasmPdfDocument(bytes);
const chars = doc.extractChars(0);

// Group characters by Y position (rows)
const rows = new Map();
for (const ch of chars) {
    const rowKey = Math.round(ch.y / 2) * 2; // Snap to 2pt grid
    if (!rows.has(rowKey)) rows.set(rowKey, []);
    rows.get(rowKey).push(ch);
}

// Sort rows top-to-bottom, characters left-to-right
const sortedKeys = [...rows.keys()].sort((a, b) => b - a);
for (const y of sortedKeys) {
    const lineChars = rows.get(y).sort((a, b) => a.x - b.x);
    const text = lineChars.map(c => c.char).join("");
    console.log(text);
}
doc.free();

Rust

use std::collections::BTreeMap;

let mut doc = PdfDocument::open("financial.pdf")?;
let chars = doc.extract_chars(0)?;

let mut rows: BTreeMap<i32, Vec<_>> = BTreeMap::new();
for ch in &chars {
    let row_key = ((ch.y / 2.0).round() * 2.0) as i32;
    rows.entry(row_key).or_default().push(ch);
}

for (_, line_chars) in rows.iter().rev() {
    let mut sorted = line_chars.clone();
    sorted.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap());
    let text: String = sorted.iter().map(|c| c.char).collect();
    println!("{}", text);
}

Go

doc, _ := pdfoxide.Open("financial.pdf")
defer doc.Close()

chars, _ := doc.ExtractChars(0)
rows := map[int][]pdfoxide.Char{}
for _, ch := range chars {
    key := int(math.Round(float64(ch.Y)/2) * 2)
    rows[key] = append(rows[key], ch)
}

keys := make([]int, 0, len(rows))
for k := range rows { keys = append(keys, k) }
sort.Sort(sort.Reverse(sort.IntSlice(keys)))

for _, y := range keys {
    line := rows[y]
    sort.Slice(line, func(i, j int) bool { return line[i].X < line[j].X })
    var b strings.Builder
    for _, c := range line { b.WriteString(c.Char) }
    fmt.Println(b.String())
}

C#

using var doc = PdfDocument.Open("financial.pdf");
var chars = doc.ExtractChars(0);

var rows = chars
    .GroupBy(c => (int)(Math.Round(c.Y / 2) * 2))
    .OrderByDescending(g => g.Key);

foreach (var row in rows)
{
    var line = string.Concat(row.OrderBy(c => c.X).Select(c => c.Char));
    Console.WriteLine(line);
}

C++

#include <pdf_oxide/pdf_oxide.hpp>
#include <map>
#include <vector>
#include <algorithm>

auto doc = pdf_oxide::Document::open("financial.pdf");
auto chars = doc.extract_chars(0);

// Group characters by Y position (rows)
std::map<int, std::vector<pdf_oxide::Char>> rows;
for (const auto& ch : chars) {
    int key = static_cast<int>(std::lround(ch.bbox.y / 2.0) * 2);
    rows[key].push_back(ch);
}

// Sort rows top-to-bottom, characters left-to-right
for (auto it = rows.rbegin(); it != rows.rend(); ++it) {
    auto& line = it->second;
    std::sort(line.begin(), line.end(),
              [](const auto& a, const auto& b) { return a.bbox.x < b.bbox.x; });
    std::string text;
    for (const auto& c : line) text += static_cast<char>(c.character);
    std::cout << text << '\n';
}

Swift

let doc = try Document.open("financial.pdf")
let chars = try doc.extractChars(0)

// Group characters by Y position (rows)
var rows: [Int: [Char]] = [:]
for ch in chars {
    let key = Int((ch.bbox.y / 2).rounded()) * 2  // Snap to 2pt grid
    rows[key, default: []].append(ch)
}

// Sort rows top-to-bottom, characters left-to-right
for y in rows.keys.sorted(by: >) {
    let line = rows[y]!.sorted { $0.bbox.x < $1.bbox.x }
    let text = String(line.compactMap { Unicode.Scalar($0.character).map(Character.init) })
    print(text)
}

Dart

final doc = PdfDocument.open('financial.pdf');
final chars = doc.extractChars(0);

// Group characters by Y position (rows)
final rows = <int, List<Char>>{};
for (final ch in chars) {
  final key = (ch.bbox.y / 2).round() * 2; // Snap to 2pt grid
  rows.putIfAbsent(key, () => []).add(ch);
}

// Sort rows top-to-bottom, characters left-to-right
final keys = rows.keys.toList()..sort((a, b) => b - a);
for (final y in keys) {
  final line = rows[y]!..sort((a, b) => a.bbox.x.compareTo(b.bbox.x));
  final text = String.fromCharCodes(line.map((c) => c.character));
  print(text);
}
doc.close();

R

doc   <- pdf_open("financial.pdf")
chars <- pdf_extract_chars(doc, 0)

# Group characters by Y position (rows), snapped to a 2pt grid
keys <- sapply(chars, function(ch) round(ch$bbox$y / 2) * 2)
for (y in sort(unique(keys), decreasing = TRUE)) {
  line <- chars[keys == y]
  line <- line[order(sapply(line, function(c) c$bbox$x))]
  text <- paste(intToUtf8(sapply(line, function(c) c$character), multiple = TRUE),
                collapse = "")
  cat(text, "\n")
}

Julia

doc   = open_document("financial.pdf")
chars = extract_chars(doc, 0)

# Group characters by Y position (rows), snapped to a 2pt grid
rows = Dict{Int,Vector}()
for ch in chars
    key = round(Int, ch.bbox.y / 2) * 2
    push!(get!(rows, key, []), ch)
end

for y in sort(collect(keys(rows)), rev = true)
    line = sort(rows[y], by = c -> c.bbox.x)
    text = join(Char.(getfield.(line, :character)))
    println(text)
end

Zig

var doc = try pdf_oxide.Document.open("financial.pdf");
const chars = try doc.extractChars(a, 0);
defer pdf_oxide.Document.freeChars(a, chars);

// Group characters by Y position (rows)
var rows = std.AutoArrayHashMap(i32, std.ArrayList(pdf_oxide.Char)).init(a);
for (chars) |ch| {
    const key: i32 = @intFromFloat(@round(ch.bbox.y / 2.0) * 2.0);
    const gop = try rows.getOrPut(key);
    if (!gop.found_existing) gop.value_ptr.* = std.ArrayList(pdf_oxide.Char).init(a);
    try gop.value_ptr.append(ch);
}

// Sort keys descending (top-to-bottom), characters left-to-right
const keys = rows.keys();
std.mem.sort(i32, keys, {}, comptime std.sort.desc(i32));
for (keys) |y| {
    var line = rows.get(y).?;
    std.mem.sort(pdf_oxide.Char, line.items, {}, struct {
        fn lt(_: void, x: pdf_oxide.Char, z: pdf_oxide.Char) bool { return x.bbox.x < z.bbox.x; }
    }.lt);
    for (line.items) |c| {
        var buf: [4]u8 = undefined;
        const len = std.unicode.utf8Encode(@intCast(c.character), &buf) catch 0;
        std.debug.print("{s}", .{buf[0..len]});
    }
    std.debug.print("\n", .{});
}

Objective-C

NSError *err = nil;
POXDocument *doc = [POXDocument openPath:@"financial.pdf" error:&err];
NSArray<POXChar *> *chars = [doc extractChars:0 error:&err];

// Group characters by Y position (rows)
NSMutableDictionary<NSNumber *, NSMutableArray<POXChar *> *> *rows =
    [NSMutableDictionary dictionary];
for (POXChar *ch in chars) {
    NSNumber *key = @((NSInteger)(round(ch.bbox.y / 2.0) * 2));
    if (!rows[key]) rows[key] = [NSMutableArray array];
    [rows[key] addObject:ch];
}

// Sort rows top-to-bottom, characters left-to-right
NSArray<NSNumber *> *keys = [[rows allKeys]
    sortedArrayUsingSelector:@selector(compare:)];
for (NSNumber *y in [keys reverseObjectEnumerator]) {
    NSArray<POXChar *> *line = [rows[y] sortedArrayUsingComparator:
        ^(POXChar *x, POXChar *z) { return [@(x.bbox.x) compare:@(z.bbox.x)]; }];
    NSMutableString *text = [NSMutableString string];
    for (POXChar *c in line)
        [text appendString:[[NSString alloc] initWithBytes:&(uint32_t){c.character}
            length:4 encoding:NSUTF32LittleEndianStringEncoding]];
    NSLog(@"%@", text);
}

Elixir

{:ok, doc} = PdfOxide.open("financial.pdf")
{:ok, chars} = PdfOxide.extract_chars(doc, 0)

# Group characters by Y position (rows), snapped to a 2pt grid
chars
|> Enum.group_by(fn ch -> round(ch.bbox.y / 2) * 2 end)
|> Enum.sort_by(fn {y, _} -> -y end)
|> Enum.each(fn {_y, line} ->
  text =
    line
    |> Enum.sort_by(fn c -> c.bbox.x end)
    |> Enum.map(fn c -> <<c.character::utf8>> end)
    |> Enum.join()

  IO.puts(text)
end)

Exportar tablas como Markdown

Markdown es el formato de salida ideal cuando necesitas pasar contenido de un PDF a un modelo de lenguaje, construir una pipeline RAG o guardar datos extraídos en un formato legible tanto por humanos como por máquinas. PDF Oxide emite tablas de forma nativa en formato GitHub Flavored Markdown (GFM), por lo que no es necesario ningún paso de conversión adicional.

from pdf_oxide import PdfDocument

doc = PdfDocument("quarterly-report.pdf")

# Extract all tables across all pages as Markdown
all_tables = []
for i in range(doc.page_count()):
    md = doc.to_markdown(i, detect_headings=True)
    # Split the markdown into sections and find table blocks
    in_table = False
    current_table = []
    for line in md.split("\n"):
        if line.strip().startswith("|"):
            in_table = True
            current_table.append(line)
        else:
            if in_table and current_table:
                all_tables.append("\n".join(current_table))
                current_table = []
            in_table = False

    if current_table:
        all_tables.append("\n".join(current_table))

print(f"Found {len(all_tables)} tables")
for idx, table in enumerate(all_tables):
    print(f"\n--- Table {idx + 1} ---")
    print(table)

La salida GFM es directamente compatible con los prompts de LLM. Puedes pasarla directamente a una llamada a la API de OpenAI o Anthropic, y el modelo entenderá la estructura tabular sin formato adicional:

# Feed extracted table to an LLM for analysis
prompt = f"""Analyze the following financial table and summarize the key trends:

{all_tables[0]}
"""

Este enfoque es considerablemente más rápido que extraer tablas con pdfplumber y luego convertirlas manualmente a Markdown.

Manejo de tablas multipágina

Las tablas que se extienden por varias páginas son un reto habitual en la extracción de PDFs. Los estados financieros, los inventarios y los documentos gubernamentales suelen contener tablas que se extienden a lo largo de dos, cinco o incluso decenas de páginas. La clave está en extraer la tabla página a página y luego concatenar las filas, gestionando cuidadosamente los encabezados repetidos y los artefactos de página.

from pdf_oxide import PdfDocument

doc = PdfDocument("long-report.pdf")

def extract_table_rows(md_text):
    """Extract table rows from markdown text, returning header and data separately."""
    header = None
    data_rows = []
    for line in md_text.split("\n"):
        line = line.strip()
        if not line.startswith("|") or line.startswith("|--"):
            continue
        cells = [cell.strip() for cell in line.split("|")[1:-1]]
        if header is None:
            header = cells
        else:
            data_rows.append(cells)
    return header, data_rows

# Collect rows across all pages
combined_header = None
combined_rows = []

for i in range(doc.page_count()):
    md = doc.to_markdown(i)
    header, rows = extract_table_rows(md)

    if header is None:
        continue  # No table on this page

    if combined_header is None:
        combined_header = header
    elif header == combined_header:
        pass  # Skip repeated header on subsequent pages
    else:
        # Different table — save current and start new
        print(f"Table with {len(combined_rows)} rows found")
        combined_header = header
        combined_rows = []

    combined_rows.extend(rows)

if combined_header and combined_rows:
    print(f"Columns: {combined_header}")
    print(f"Total rows: {len(combined_rows)}")
    for row in combined_rows[:5]:
        print(row)
    if len(combined_rows) > 5:
        print(f"... and {len(combined_rows) - 5} more rows")

Este patrón funciona de forma fiable para tablas donde el encabezado se repite en cada página (el caso más habitual). Para tablas donde el encabezado solo aparece en la primera página, puedes simplificar la lógica capturando el encabezado solo de la primera página con tabla y tratando todas las filas posteriores como datos.

Exportar tablas a CSV o DataFrame

Tras extraer los datos de las tablas, a menudo necesitas transformarlos a un formato estructurado para análisis posterior. Los siguientes ejemplos muestran cómo pasar de un PDF a un DataFrame de pandas o un archivo CSV en pocas líneas.

Exportación en lote: todas las tablas en archivos CSV separados

import csv
from pdf_oxide import PdfDocument

doc = PdfDocument("catalog.pdf")
table_count = 0

for i in range(doc.page_count()):
    md = doc.to_markdown(i)
    rows = []
    for line in md.split("\n"):
        line = line.strip()
        if line.startswith("|") and not line.startswith("|--"):
            cells = [cell.strip() for cell in line.split("|")[1:-1]]
            rows.append(cells)

    if len(rows) > 1:  # At least header + one data row
        table_count += 1
        filename = f"table_page{i + 1}_{table_count}.csv"
        with open(filename, "w", newline="") as f:
            writer = csv.writer(f)
            writer.writerows(rows)
        print(f"Saved {filename} ({len(rows) - 1} data rows)")

print(f"Exported {table_count} tables total")

Tabla multipágina como DataFrame

Para tablas distribuidas en varias páginas, combina el patrón de concatenación con pandas:

import pandas as pd
from pdf_oxide import PdfDocument

doc = PdfDocument("financial-statement.pdf")

header = None
all_rows = []

for i in range(doc.page_count()):
    md = doc.to_markdown(i)
    for line in md.split("\n"):
        line = line.strip()
        if not line.startswith("|") or line.startswith("|--"):
            continue
        cells = [cell.strip() for cell in line.split("|")[1:-1]]
        if header is None:
            header = cells
        elif cells == header:
            continue  # Skip repeated header
        else:
            all_rows.append(cells)

if header and all_rows:
    df = pd.DataFrame(all_rows, columns=header)
    # Clean up numeric columns
    for col in df.columns:
        # Try to convert columns that look numeric
        cleaned = df[col].str.replace(r"[$,%]", "", regex=True).str.strip()
        try:
            df[col] = pd.to_numeric(cleaned)
        except (ValueError, TypeError):
            pass  # Keep as string

    print(df.dtypes)
    print(df.head(10))
    df.to_csv("financial_data.csv", index=False)

Este flujo de trabajo produce un DataFrame limpio con tipos numéricos correctos, listo para análisis con pandas, visualización con matplotlib o carga en una base de datos.

Tablas complejas: cuándo usar pdfplumber

La detección de tablas de PDF Oxide maneja bien las tablas alineadas estándar. Para casos más complejos — celdas fusionadas, encabezados que abarcan varias columnas, tablas sin bordes o contenido de celda en varias líneas — los algoritmos de extracción dedicados de pdfplumber son más robustos:

import pdfplumber

with pdfplumber.open("complex-report.pdf") as pdf:
    page = pdf.pages[0]
    tables = page.extract_tables()
    for table in tables:
        for row in table:
            print(row)

Cuándo usar cada herramienta

Escenario Recomendación
Tablas simples y alineadas PDF Oxide (29× más rápido)
Tablas como parte del Markdown de toda la página PDF Oxide
Celdas fusionadas complejas / encabezados que abarcan columnas pdfplumber
Tablas sin bordes pdfplumber
Procesamiento en lote crítico para el rendimiento PDF Oxide

Usar ambas juntas

Extracción de texto rápida con PDF Oxide, extracción de tablas complejas con pdfplumber:

from pdf_oxide import PdfDocument
import pdfplumber

# Fast full-text extraction
doc = PdfDocument("report.pdf")
text = doc.extract_text(0)

# Targeted table extraction for complex pages
with pdfplumber.open("report.pdf") as pdf:
    tables = pdf.pages[0].extract_tables()

Páginas relacionadas