Skip to content

Извлечение таблиц из PDF на Python

Извлечение таблиц из PDF-документов — одна из самых распространённых задач в конвейерах обработки документов. Будь то финансовые данные из годовых отчётов, парсинг каталогов товаров или передача структурированных данных в LLM — надёжное извлечение таблиц абсолютно необходимо. В этом руководстве рассматривается всё, что нужно знать об извлечении таблиц из PDF на Python: от простых однострочников до готовых к продакшену рабочих процессов для многостраничных таблиц.

Движок обнаружения

PDF Oxide использует универсальный конвейер обнаружения таблиц края → выравнивание/слияние → пересечения → ячейки → группы — тот же подход, что используют Tabula, pdfplumber и PyMuPDF, реализованный на чистом Rust.

Возможности обнаружения:

  • На основе пересечений — находит пересечения горизонтальных и вертикальных линий, строит ячейки из прямоугольников по четырём углам, группирует в таблицы с помощью алгоритма union-find.
  • Расширенная сетка — когда горизонтальные и вертикальные линии находятся в разных областях страницы, строится виртуальная сетка из декартова произведения всех координат.
  • Обнаружение текста с учётом колонок — разбивает двухколоночные макеты с помощью X-проекционной гистограммы, затем выполняет обнаружение таблиц по тексту для каждой колонки отдельно.
  • Текстовые таблицы, ограниченные горизонтальными линиями — обнаруживает таблицы, ограниченные горизонтальными линиями без вертикальных (характерно для академических статей).
  • Гибридное определение строк — определяет границы строк по Y-позициям текста, когда существуют только вертикальные рамки (позиции в счетах-фактурах).
  • Восстановление пунктирных/штриховых линий — объединяет короткие отрезки в непрерывные рёбра.
  • Разбивка по разделителям секций — разбивает многосекционные формы по горизонтальным разделителям на всю ширину страницы.
  • Фильтрация по покрытию рёбер — удаляет одиночные рёбра, не участвующие ни в какой потенциальной сетке.

Конфигурация

TableDetectionConfig предоставляет настраиваемые параметры:

Поле По умолчанию Описание
horizontal_strategy "lines_strict" "lines_strict", "lines", "text" или "explicit"
vertical_strategy "lines_strict" Те же значения
v_split_gap 20.0 pt Зазор между вертикальными линиями, при котором происходит разбивка на отдельные таблицы (до v0.3.20 было жёстко задано 4pt)
snap_tolerance 3.0 pt Допуск выравнивания краёв
text_tolerance 3.0 pt Допуск слияния строк текста

Изменение поведения

Начиная с v0.3.20, стратегия по умолчанию для Python extract_tables()Both (обнаружение по линиям и тексту одновременно). Страницы, которые полагались на прежнее значение по умолчанию «только текст», должны явно передавать horizontal_strategy="text" и vertical_strategy="text".

Python-привязка теперь корректно читает vertical_strategy из словаря table_settings — ранее этот параметр игнорировался без предупреждения.

Рендеринг

Извлечённые таблицы выводятся с выравниванием столбцов по пробелам (вместо символов псевдографики ASCII из более ранних версий). Столбцы с валютами и числами выравниваются по правому краю автоматически. Префиксы номеров форм ("1 Apr 11""Apr 11") и декоративные ячейки из тире или подчёркиваний ("------") удаляются при рендеринге.

Извлечение данных таблицы из PDF с помощью конвертации в 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 обнаруживает табличные структуры посредством пространственного анализа выровненных текстовых блоков и генерирует таблицы в формате GitHub Flavored Markdown.

Почему извлечение таблиц из PDF — сложная задача

Если вы когда-нибудь пытались скопировать таблицу из PDF и вставить её в таблицу Excel, вы знаете, что результат обычно получается хаотичным. Это не баг в вашем просмотрщике PDF — это отражение фундаментального ограничения самого формата PDF.

В PDF нет понятия «таблица». В отличие от HTML, где теги <table>, <tr> и <td> задают табличную структуру, PDF-файл хранит лишь инструкции по рисованию: поместить этот глиф по координатам (x, y), провести линию от точки A до точки B. Нет семантического слоя, который бы говорил «эти символы принадлежат ячейке в строке 3, столбце 2». Каждая библиотека извлечения таблиц должна самостоятельно восстанавливать эту структуру, анализируя пространственное расположение текста и линий на странице.

Это восстановление затруднено по нескольким причинам:

  • Таблицы с рамками и без. Когда таблица имеет видимую сетку, инструменты извлечения могут использовать линии как границы ячеек. Таблицы без рамок — распространённые в финансовой отчётности, государственных документах и академических статьях — не имеют линий вообще. Библиотека должна определять границы столбцов исключительно по пустому пространству между текстовыми блоками, что чревато ошибками при переменной ширине столбцов или правом выравнивании чисел.

  • Объединённые ячейки и объединённые заголовки. Заголовок, охватывающий три столбца, выглядит как один широкий текстовый блок. Без сетки, которая бы его разграничивала, парсер не может достоверно определить, какие столбцы охватывает заголовок. Некоторые библиотеки справляются с этим; многие тихо выдают искажённый вывод.

  • Многострочный текст в ячейке. Когда ячейка содержит абзац, переносящийся на несколько строк, наивный построчный разбор трактует каждую строку как отдельную строку таблицы. Чтобы правильно сгруппировать эти строки в одну ячейку, нужно понимать вертикальный охват каждой строки.

  • Многостраничные таблицы. Большие таблицы нередко занимают две и более страниц. Заголовок может повторяться на каждой странице, а может и нет; между строками таблицы могут появляться колонтитулы, водяные знаки или номера страниц. Для сшивания этих фрагментов в единую связную таблицу требуется логика с учётом страниц.

  • Повёрнутый текст и нестандартные макеты. В некоторых PDF-файлах заголовки столбцов повёрнуты, или таблицы расположены в многоколоночном макете страницы. Эти пограничные случаи ломают предположения большинства парсеров о чтении слева направо и сверху вниз.

Понимание этих сложностей помогает выбрать правильный инструмент для конкретных документов. Для прямолинейных выровненных таблиц — большинства счетов, подтверждений заказов и простых отчётов — хорошо подходит быстрый пространственный анализ, как в PDF Oxide. Для документов со сложными объединениями, таблицами без рамок или необычным форматированием может потребоваться библиотека с более сложной эвристикой.

Извлечение таблиц: PDF Oxide vs другие библиотеки

Выбор библиотеки для извлечения таблиц из PDF на Python зависит от ваших документов, требований к производительности и нужного формата вывода. Вот сравнение основных вариантов:

Библиотека Обнаружение таблиц Таблицы с рамками Таблицы без рамок Формат вывода Скорость
PDF Oxide Встроено Да Базовое Markdown/HTML 0.8ms
pdfplumber Встроено Да Продвинутое Списки Python 23.2ms
Camelot Встроено Да Да (lattice/stream) DataFrames ~50ms+
PyMuPDF Базовое (v1.23+) Да Ограниченное DataFrames 4.6ms
pypdf Нет Нет Нет N/A N/A
tabula-py Встроено Да Да DataFrames ~100ms+ (Java)

PDF Oxide — самый быстрый вариант с большим отрывом. Таблицы обнаруживаются пространственным анализом выровненных текстовых блоков, а вывод — чистые таблицы в GitHub Flavored Markdown. При среднем времени извлечения 0.8ms он в 29 раз быстрее pdfplumber и более чем в 100 раз быстрее tabula-py. Хорошо справляется с таблицами с рамками и простыми выровненными таблицами без рамок. Идеален для LLM-конвейеров, где вывод Markdown нужен в любом случае.

pdfplumber имеет наиболее зрелое обнаружение таблиц без рамок. Метод find_tables() использует настраиваемые стратегии определения строк и столбцов на основе выравнивания текста и лучше других справляется с объединёнными ячейками и многострочным содержимым. Компромисс — скорость: 23.2ms на страницу существенно замедляет пакетную обработку.

Camelot предлагает два режима обнаружения — lattice (для таблиц с рамками) и stream (для таблиц без рамок). Напрямую создаёт pandas DataFrame, что удобно для аналитических рабочих процессов. Однако зависит от Ghostscript и OpenCV, что делает установку тяжёлой, а скорость — самой низкой среди чистопитоновских вариантов.

PyMuPDF (fitz) добавил базовое извлечение таблиц в версии 1.23. Быстрый (4.6ms) и хорошо работает с простыми таблицами с рамками, но поддержка таблиц без рамок ограничена по сравнению с pdfplumber или Camelot.

pypdf не имеет возможности обнаружения таблиц. Он извлекает чистый текст, поэтому для восстановления структуры таблицы нужно писать собственную логику разбора.

tabula-py — обёртка Python для Java-библиотеки Tabula. Обеспечивает хорошее обнаружение таблиц как с рамками, так и без, но требует Java-рантайма и работает медленнее всего из-за накладных расходов JVM. Лучше подходит для разовых задач извлечения, а не для высокопроизводительных конвейеров.

Для большинства продакшен-сценариев рекомендуется использовать PDF Oxide как основной инструмент ради скорости и простоты, а для подмножества документов со сложными табличными макетами, требующими продвинутой эвристики, переключаться на pdfplumber.

Установка

pip install pdf_oxide

Базовое извлечение таблиц

В виде Markdown-таблиц

Самый простой подход — конвертировать страницу в Markdown, где таблицы включаются в синтаксисе 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

Структурированное извлечение таблиц (v0.3.34)

Для типизированного доступа к строкам и ограничивающим прямоугольникам без разбора Markdown вызовите ExtractTables(pageIndex) (Go, C#) / extract_tables(page) (Python, Rust). Каждая таблица предоставляет структурированные ячейки, что позволяет напрямую передавать результаты в базу данных или DataFrame без регулярных выражений.

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

Разбор 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

Экспорт в 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)

Экспорт в Pandas DataFrame

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)

Использование позиций символов для кастомного разбора таблиц

Для детальной настройки используйте извлечение на уровне символов и пространственный анализ:

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)

Экспорт таблиц в Markdown

Markdown — идеальный формат вывода, когда вы передаёте содержимое PDF в большую языковую модель, строите RAG-конвейер или сохраняете извлечённые данные в формате, удобном и для человека, и для машины. PDF Oxide нативно выводит таблицы в формате GitHub Flavored Markdown (GFM), поэтому дополнительный шаг конвертации не нужен.

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)

Вывод таблиц в GFM напрямую совместим с промптами LLM. Вы можете передавать его прямо в вызов API OpenAI или Anthropic — модель поймёт табличную структуру без дополнительного форматирования:

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

{all_tables[0]}
"""

Этот подход значительно быстрее, чем извлекать таблицы с помощью pdfplumber и затем самостоятельно конвертировать их в Markdown.

Обработка многостраничных таблиц

Многостраничные таблицы — распространённая задача при извлечении из PDF. Финансовые отчёты, инвентарные списки и регуляторные документы нередко содержат таблицы, занимающие две, пять или даже десятки страниц. Ключевой момент: нужно извлекать таблицу с каждой страницы отдельно, а затем сшивать строки воедино, тщательно обрабатывая повторяющиеся заголовки и артефакты страниц.

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")

Этот паттерн надёжно работает для таблиц, где заголовок повторяется на каждой странице (самый распространённый случай). Для таблиц, где заголовок появляется только на первой странице, логику можно упростить: захватывать заголовок только с первой страницы, содержащей таблицу, а все последующие строки рассматривать как данные.

Экспорт таблиц в CSV или DataFrame

После извлечения данных таблицы часто нужен структурированный формат для дальнейшего анализа. Примеры ниже показывают, как перейти от PDF к pandas DataFrame или CSV-файлу за несколько строк.

Пакетный экспорт: все таблицы в отдельные CSV-файлы

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")

Многостраничная таблица в DataFrame

Для таблиц, охватывающих несколько страниц, объедините паттерн сшивки страниц с 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)

Этот рабочий процесс даёт чистый DataFrame с правильными числовыми типами, готовый к анализу в pandas, построению графиков в matplotlib или загрузке в базу данных.

Сложные таблицы: когда использовать pdfplumber

Обнаружение таблиц в PDF Oxide хорошо работает со стандартными выровненными таблицами. Для сложных случаев — объединённых ячеек, объединённых заголовков, таблиц без рамок или многострочного содержимого ячеек — алгоритмы pdfplumber более надёжны:

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)

Когда что использовать

Сценарий Рекомендация
Простые выровненные таблицы PDF Oxide (в 29 раз быстрее)
Таблицы как часть Markdown всей страницы PDF Oxide
Сложные объединённые ячейки / объединённые заголовки pdfplumber
Таблицы без рамок pdfplumber
Высокопроизводительная пакетная обработка PDF Oxide

Совместное использование обоих инструментов

Быстрое извлечение текста с PDF Oxide, сложные таблицы — с 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()

Связанные страницы