Три месяца назад бухгалтерия попросила меня помочь с рутиной: каждый день вручную переносить данные из 30-40 PDF-счетов в Excel. Задача выглядела тривиальной — PDF содержит таблицу, нужно извлечь строки и записать в spreadsheet. Я думал, что справлюсь за выходные. В итоге потратил месяц и переписал парсер четырежды. Главный урок: то, что выглядит как таблица в PDF, на уровне документа — это случайно выровненные текстовые блоки, парящие в координатном пространстве.
PDF не знает, что такое таблицы
Первая версия использовала pdfplumber — популярную Python-библиотеку с методом extract_table(). Код выглядел элегантно:
import pdfplumber
with pdfplumber.open('invoice.pdf') as pdf:
page = pdf.pages[0]
table = page.extract_table()
# profit?
На тестовом счёте от крупного поставщика всё работало идеально. Я отправил скрипт в бухгалтерию с гордостью. Через час получил 15 файлов, где парсер возвращал None или извлекал абсолютный хаос.
Проблема в том, что PDF — это формат для отображения, а не для хранения данных. Внутри PDF нет концепции "таблицы" или "строки таблицы". Есть команды рендеринга вроде "нарисуй текст 'Товар А' в позиции (72, 600)", "нарисуй линию от (70, 595) до (500, 595)". Библиотеки парсинга пытаются угадать структуру, анализируя расстояния между текстовыми блоками и линиями. Но это эвристика, которая ломается на нестандартных макетах.
Счета от разных поставщиков используют разные шрифты, отступы, размеры полей. У одних таблицы с жирными границами, у других — без границ вообще, только whitespace для выравнивания. У третьих текст внутри ячеек переносится на несколько строк, и парсер думает, что это отдельные записи.
Координаты вместо семантики
Я перешёл к низкоуровневому подходу: извлекать все текстовые элементы с координатами, затем группировать их в строки и колонки вручную. pdfplumber предоставляет метод extract_words(), который возвращает каждое слово с его bounding box.
words = page.extract_words()
Идея: группировать слова по Y-координате в строки, затем внутри строки сортировать по X-координате для определения колонок. Звучит логично, но на практике:
Проблема 1: Базовая линия текста. Разные шрифты и размеры имеют разные baseline. Слова в одной визуальной строке могут иметь Y-координаты, отличающиеся на 2-3 пикселя. Пришлось ввести tolerance threshold (±5 пикселей), но это создало новую проблему — строки с малым межстрочным интервалом начали слипаться.
Проблема 2: Многострочные ячейки. Если в ячейке "Описание" текст переносится на две строки, парсер видит это как две отдельные строки таблицы. Пришлось добавлять логику: если строка начинается не с первой колонки (где обычно номер или артикул), считать её продолжением предыдущей.
Проблема 3: Merged cells. В некоторых счетах заголовок секции занимает всю ширину таблицы (например, "Дополнительные услуги"). Визуально это merged cell, но на уровне координат — просто текст, который начинается в первой колонке и заканчивается за пределами обычной ширины. Логика определения колонок сломалась.
OCR — это не решение, это другая проблема
Когда встретился счёт, отсканированный как изображение (да, в 2026 году), я добавил Tesseract OCR. Это открыло портал в ад.
OCR выдавал результаты с точностью 85-90%, что звучит неплохо, но на практике означает, что в каждом счёте из 20 строк 2-3 содержат ошибки. Цифра 8 превращалась в B, 0 в O, точка в запятую. Особенно весело с суммами: 1,234.56 становилось 1.234,56 или I,Z34.56.
Я добавил валидацию: суммы должны быть числами, даты — в формате DD.MM.YYYY, итоговая сумма равна сумме строк. Это отлавливало ошибки, но не исправляло их. Приходилось падать с ошибкой и просить ручную корректировку.
Затем я попробовал улучшить качество OCR: предобработка изображения (увеличение контраста, бинаризация, денойзинг). Это помогло, но увеличило время обработки одного документа с 2 секунд до 15. Для батча из 40 файлов — 10 минут вместо двух. Бухгалтерия возмутилась.
Проблема бизнес-логики, а не технологии
После трёх недель борьбы с координатами и OCR я понял главное: техническое решение не масштабируется, потому что проблема не техническая. PDF-счета не стандартизированы. Каждый поставщик использует свой шаблон, и даже один поставщик может менять формат после обновления бухгалтерской системы.
Я написал парсер для 10 самых частых форматов, покрывающих 80% документов. Для каждого формата — свой набор правил: координаты колонок, регулярные выражения для извлечения данных, логика группировки строк. Конфиг выглядел так:
PARSERS = {
'supplier_a': {
'table_start': 'Наименование',
'columns': [
{'name': 'item', 'x_range': (70, 300)},
{'name': 'qty', 'x_range': (310, 360)},
{'name': 'price', 'x_range': (370, 450)},
{'name': 'total', 'x_range': (460, 540)}
],
'table_end': 'Итого:'
}
}
Для определения формата я добавил детектор: извлекаю первые 100 слов документа, ищу уникальные маркеры (название компании, специфичные фразы, расположение логотипа). Точность детекции — 95%.
Оставшиеся 20% документов обрабатываются fallback-парсером, который использует более общие эвристики. Точность там ниже (70-75%), но для редких случаев приемлемо — бухгалтер просто проверяет результат вручную.
Excel — это тоже не просто CSV
Когда парсинг начал работать стабильно, возникла новая задача: как записывать в Excel. Первая версия использовала pandas.to_excel() — работает, но создаёт файлы без форматирования. Бухгалтерия хотела:
- Автоподбор ширины колонок
- Форматирование сумм (разделитель тысяч, два знака после запятой)
- Цветовое выделение строк с ошибками валидации
- Фиксацию первой строки (заголовки)
- Формулу для подсчёта итога внизу таблицы
Пришлось переписать на openpyxl, чтобы управлять стилями:
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment
wb = Workbook()
ws = wb.active
# Заголовки с жирным шрифтом и фоном
header_fill = PatternFill(start_color="366092", fill_type="solid")
for col, header in enumerate(['Товар', 'Кол-во', 'Цена', 'Сумма'], start=1):
cell = ws.cell(row=1, column=col, value=header)
cell.font = Font(bold=True, color="FFFFFF")
cell.fill = header_fill
# Формат чисел для денежных колонок
for row in range(2, ws.max_row + 1):
ws.cell(row=row, column=3).number_format = '#,##0.00'
ws.cell(row=row, column=4).number_format = '#,##0.00'
Размер кода увеличился втрое, но результат выглядел профессионально.
Что я узнал о документах и данных
1. Формат для отображения ≠ формат для данных. PDF оптимизирован для печати и просмотра, не для извлечения данных. Если есть возможность получить исходные данные (XML, JSON, API) — используйте их. Парсинг PDF — это последнее средство.
2. Эвристики не масштабируются. Универсальный парсер для всех форматов невозможен. Нужно либо писать правила для каждого формата, либо использовать ML (но это требует датасета размеченных документов).
3. Валидация важнее точности парсинга. Лучше обнаружить ошибку и попросить исправить вручную, чем пропустить неправильные данные в систему. Я добавил проверки: суммы положительные, даты валидные, итоговая сумма сходится, обязательные поля не пустые.
4. UX важен даже для внутренних инструментов. Бухгалтер не должен знать про Python, pdfplumber или координаты. Я завернул парсер в простой веб-интерфейс на Flask: загрузил файлы → нажал кнопку → скачал Excel. Добавил прогресс-бар и детальный лог ошибок.
5. OCR — это капекс, а не опекс. Качественное распознавание требует мощного железа или облачных API (Google Vision, AWS Textract). Для внутреннего инструмента это может быть дороже, чем просто попросить поставщиков отправлять машиночитаемые форматы.
Заключение
Парсинг PDF — это задача, которая кажется простой, пока не начнёшь её решать. Табличные данные в PDF — это иллюзия, созданная визуальным выравниванием независимых текстовых элементов. Универсального решения нет, только компромиссы между точностью, скоростью и затратами на поддержку.
Мой инструмент обрабатывает 80% документов автоматически, 15% требуют ручной проверки, 5% ломаются и падают с ошибкой. Это экономит бухгалтерии 10 часов в неделю, что оправдывает месяц разработки и два часа поддержки в месяц (добавление парсеров для новых форматов).
Главный вывод: если вы контролируете источник данных — договоритесь о машиночитаемом формате (CSV, JSON, XML). Если не контролируете — закладывайте время на написание форматоспецифичных правил парсинга и тщательную валидацию. И никогда не обещайте "сделаю за выходные".