Видобування таблиць із PDF у Python
Видобування таблиць із PDF-документів — одна з найпоширеніших задач у пайплайнах обробки документів. Чи потрібно вам отримати фінансові дані з річних звітів, зчитати каталоги продуктів або передати структуровані дані до LLM — надійне видобування таблиць є критично важливим. Цей посібник охоплює все, що потрібно знати для видобування таблиць із PDF у Python: від простих однорядкових рішень до виробничих пайплайнів для багатосторінкових таблиць.
Механізм виявлення
PDF Oxide використовує універсальний пайплайн виявлення ребра → вирівнювання/злиття → перетини → комірки → групи — той самий підхід, що й Tabula, pdfplumber і PyMuPDF, реалізований на чистому Rust.
Можливості виявлення:
- На основі перетинів — знаходить перетини H×V ліній, будує комірки з прямокутників із чотирьох кутів, групує в таблиці за допомогою 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, стандартна стратегія для extract_tables() у Python — 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 і вставити її в таблицю, знає, що результат майже завжди виглядає хаотично. Це не помилка PDF-переглядача — це фундаментальне обмеження самого формату PDF.
PDF не має поняття «таблиця». На відміну від HTML, де теги <table>, <tr> і <td> визначають табличну структуру, файл PDF зберігає лише інструкції позиціонування символів: розмістити цей символ у координаті (x, y), намалювати лінію від точки A до точки B. Немає семантичного шару, який би казав: «ці символи належать до комірки в рядку 3, стовпці 2». Кожна бібліотека видобування таблиць має реконструювати цю структуру, аналізуючи просторові позиції тексту й ліній на сторінці.
Ця реконструкція складна з кількох причин:
-
Таблиці з рамками та без. Якщо таблиця має видимі лінії сітки, інструменти видобування можуть використовувати їх як межі комірок. Таблиці без рамок — поширені у фінансових звітах, урядових документах і наукових статтях — взагалі не мають ліній. Бібліотека має виводити межі стовпців виключно з відстані між текстовими блоками, що є ненадійним, коли стовпці мають різну ширину або числа вирівняно праворуч.
-
Об’єднані комірки та заголовки, що охоплюють кілька стовпців. Комірка заголовка, що охоплює три стовпці, виглядає як один широкий текстовий блок. Без ліній сітки для розмежування парсер не може надійно визначити, скільки стовпців охоплює заголовок. Деякі бібліотеки обробляють це добре; багато — мовчки виводять нечитабельний результат.
-
Багаторядковий вміст комірок. Якщо комірка містить абзац із перенесенням рядка, наївний рядковий парсинг трактуватиме кожен перенесений рядок як окремий рядок таблиці. Щоб правильно призначити ці рядки одній комірці, потрібно розуміти вертикальний розмір кожного рядка.
-
Багатосторінкові таблиці. Великі таблиці часто охоплюють дві або більше сторінок. Рядок заголовка може повторюватися на кожній сторінці або ні, а нижні колонтитули, водяні знаки або номери сторінок можуть з’являтися між рядками таблиці. Об’єднання цих фрагментів в одну цілісну таблицю вимагає логіки, що враховує сторінки.
-
Повернутий текст і нестандартні макети. Деякі PDF використовують повернутий текст для заголовків стовпців або розміщують таблиці в багатоколонковому макеті сторінки. Такі крайні випадки руйнують припущення, які більшість парсерів роблять щодо порядку читання зліва направо та зверху вниз.
Розуміння цих труднощів допомагає вибрати правильний інструмент для конкретних документів. Для простих вирівняних таблиць — більшості рахунків-фактур, замовлень і простих звітів — швидкий підхід просторового аналізу, як у PDF Oxide, добре справляється. Для документів зі складними злиттями, макетами без рамок або незвичним форматуванням може знадобитися бібліотека з більш витонченою евристикою.
Видобування таблиць: PDF Oxide проти інших бібліотек
Вибір бібліотеки для видобування таблиць із PDF у Python залежить від документів, вимог до продуктивності та необхідного формату виводу. Ось порівняння основних варіантів:
| Бібліотека | Виявлення таблиць | Таблиці з рамками | Таблиці без рамок | Формат виводу | Швидкість |
|---|---|---|---|---|---|
| PDF Oxide | Вбудоване | Так | Базове | Markdown/HTML | 0,8мс |
| pdfplumber | Вбудоване | Так | Розширене | Списки Python | 23,2мс |
| Camelot | Вбудоване | Так | Так (lattice/stream) | DataFrames | ~50мс+ |
| PyMuPDF | Базове (v1.23+) | Так | Обмежене | DataFrames | 4,6мс |
| pypdf | Ні | Ні | Ні | N/A | N/A |
| tabula-py | Вбудоване | Так | Так | DataFrames | ~100мс+ (Java) |
PDF Oxide є найшвидшим варіантом із великим відривом. Він виявляє таблиці через просторовий аналіз вирівняних текстових блоків і виводить чисті таблиці у форматі GitHub Flavored Markdown. Із середнім часом видобування 0,8мс він у 29× швидший за pdfplumber і понад 100× швидший за tabula-py. Добре обробляє таблиці з рамками та прості вирівняні таблиці без рамок. Для пайплайнів LLM, де все одно потрібен вивід у Markdown, це природній вибір.
pdfplumber має найбільш витончене виявлення таблиць без рамок. Метод find_tables() використовує налаштовувані стратегії для виявлення стовпців і рядків на основі вирівнювання тексту, а також краще за більшість аналогів обробляє об’єднані комірки та багаторядковий вміст комірок. Ціна — швидкість: 23,2мс на сторінку суттєво повільніше для пакетної обробки.
Camelot пропонує два режими виявлення — lattice (для таблиць із рамками) і stream (для таблиць без рамок). Він безпосередньо виводить DataFrames pandas, що зручно для аналітичних пайплайнів. Однак він залежить від Ghostscript і OpenCV, що ускладнює встановлення, а його швидкість — найнижча серед чисто Python-варіантів.
PyMuPDF (fitz) додав базове видобування таблиць у версії 1.23. Він швидкий (4,6мс) і добре підходить для простих таблиць із рамками, але підтримка таблиць без рамок обмежена порівняно з 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)
Для типізованого доступу до рядків і bounding box без парсингу 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 до DataFrame pandas або файлу 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 — повний довідник API Markdown
- Видобування тексту — видобування звичайного тексту та символів
- PDF Oxide vs. pdfplumber — детальне порівняння
- PDF у Markdown — посібник із конвертації в Markdown