Извлечение таблиц из 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()
Связанные страницы
- Конвертация в Markdown — полный справочник Markdown API
- Извлечение текста — извлечение обычного текста и символов
- PDF Oxide vs pdfplumber — подробное сравнение
- PDF в Markdown — руководство по конвертации в Markdown