PDFium Component обнаруживает таблицы на странице PDF и возвращает их в виде сетки ячеек с охватами строк и столбцов, строками заголовков и значением достоверности через ExtractTables для одной страницы и ExtractDocumentTables для всего документа. Каждая таблица преобразуется в CSV или JSON одним вызовом, а таблицы, продолжающиеся через разрыв страницы, можно связать в цепочку продолжения
В PDF нет объекта таблицы. Таблица в PDF — это набор текстовых прогонов, расположенных так, что человек читает их как сетку, иногда с проведёнными вокруг них линиями, а часто и без них. Восстановление сетки означает реконструкцию замысла, который файл никогда не зафиксировал, поэтому каждый инструмент извлечения даёт слегка отличающиеся результаты, и поэтому инструмент, сообщающий свою достоверность, полезнее того, что этого не делает
Два режима обнаружения для двух видов таблиц
Линейчатое обнаружение использует проведённые линии. Каждый обведённый сегмент пути преобразуется в координаты страницы через матрицу объекта страницы, горизонтальные и вертикальные линии пересекаются, а пересечения образуют связные компоненты. Каждый компонент становится собственной отсортированной сеткой позиций X и Y — именно это не даёт двум отдельным таблицам на одной странице слиться в одну бессмысленную сетку
Обнаружение по пробелам обрабатывает таблицы, нарисованные с помощью выравнивания вместо линий. Прямоугольники слов группируются в визуальные строки, промежутки внутри строки разбивают её на кандидаты в столбцы, и таблица принимается только тогда, когда как минимум MinRows строк повторяют как минимум MinColumns выровненных по левому краю якорей в пределах AlignmentTolerance. Коэффициент промежутка между строками по умолчанию равен 3, что покрывает типичный интервал базовой линии примерно в 30 пунктов для текста в 12 пунктов, не позволяя одной строке с несколькими текстовыми прогонами выдавать себя за таблицу
uses
PDFium;
var
Pdf: TPdf;
Options: TPdfTableExtractionOptions;
Tables: TPdfTables;
I: Integer;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'annual-report.pdf';
Pdf.LoadDocument;
Pdf.PageNumber := 12; // с отсчётом от 1
Options := TPdfTableExtractionOptions.Default;
Options.DetectRuledTables := True;
Options.DetectWhitespaceTables := True;
Options.MinConfidence := 0.6; // по умолчанию 0.5
Options.HeaderRowCount := 1;
Tables := Pdf.ExtractTables(Options);
for I := 0 to High(Tables) do
Writeln(Format('table %d: %dx%d cells, confidence %.2f, mode %d',
[I, Tables[I].RowCount, Tables[I].ColumnCount,
Tables[I].Confidence, Ord(Tables[I].DetectionMode)]));
if Length(Tables) > 0 then
SaveText('page12-table0.csv', Tables[0].ToCsv);
finally
Pdf.Free;
end;
end;
Как восстанавливаются объединённые ячейки?
Именно в этом месте наивные экстракторы ошибаются. Объединённую ячейку невозможно определить только по глобальной сетке, потому что сетка выводится из всех линий на странице, а в объединённой области попросту отсутствует внутренняя линия, которая бы её разделяла
Используемое здесь правило локально: две соседние базовые ячейки объединяются, когда ни одна граничная линия не покрывает интервал между ними. Структура данных union-find объединяет их, получившиеся прямоугольные компоненты становятся значениями RowSpan и ColumnSpan, а текст присваивается базовой ячейке по её центральной точке, а затем следует за этой ячейкой до её корня объединения. Такой подход также удерживает стоимость линейной по числу слов плюс ячеек, вместо квадратичного сканирования, которое получилось бы при проверке каждого слова против каждой ячейки
Практический эффект в том, что финансовая таблица с объединённым заголовком «Итого», охватывающим три столбца, выходит как одна ячейка с охватом три, а не одна заполненная ячейка и две загадочно пустые
Продолжение через страницы
Длинные таблицы разрываются между страницами, и обработка фрагмента каждой страницы как независимой таблицы вынуждает вызывающий код сшивать их. ExtractDocumentTables может связать их вместо этого, но только при строгих условиях: фрагмент должен быть самой нижней таблицей на предыдущей странице, следующий — самой верхней таблицей на следующей странице, номера страниц должны быть соседними, а границы столбцов должны совпадать
Именно все четыре условия вместе предотвращают очевидную ошибку — сцепление каждой четырёхстолбцовой таблицы в документе в одну воображаемую мегатаблицу лишь потому, что у них случайно совпадает число столбцов. Когда условия выполняются, таблицы разделяют идентификатор группы продолжения и несут метаданные продолжения; когда нет — вы получаете отдельные таблицы и можете решать сами
Извлечение на уровне документа разделяет бюджеты MaxCells и MaxTables между страницами, а не сбрасывает их для каждой страницы, и восстанавливает активную страницу в блоке finally, поэтому запуск извлечения в программе просмотра оставляет пользователя на той странице, где он был
Экспорт без порчи данных
Оба экспортёра тщательно подходят к экранированию. CSV всегда заключает поля в кавычки и удваивает внутренние кавычки, что избегает классического сбоя, когда ячейка с запятой молча превращается в два столбца. Для объединённых ячеек содержимое выводится только в верхнем левом якоре, поэтому обратное преобразование в CSV не дублирует охватывающий заголовок по столбцам, которые он покрывает
JSON сохраняет Юникод, а не экранирует его в ASCII, экранирует управляющие символы и включает метаданные, нужные потребителю для оценки качества: режим обнаружения, достоверность, границы, значения охвата, флаги заголовков и информацию о продолжении. Если вы передаёте извлечённые таблицы в нижестоящую систему, предпочитайте JSON, потому что строка CSV не может сообщить вам, что таблица, из которой она взята, набрала достоверность 0,51:
// Извлечение по всему документу с сохранением только заслуживающих доверия таблиц
Tables := Pdf.ExtractDocumentTables(Options);
for I := 0 to High(Tables) do
begin
if Tables[I].Confidence < 0.75 then
begin
Log(Format('page %d table needs review (%.2f)',
[Tables[I].PageNumber, Tables[I].Confidence]));
Continue;
end;
if Tables[I].ContinuationGroup > 0 then
AppendToGroup(Tables[I].ContinuationGroup, Tables[I].ToJson)
else
EmitStandalone(Tables[I].ToJson);
end;
Настройка и умение вовремя остановиться
Три настройки важнее остальных. MinConfidence — это порог качества, и значение 0,5 намеренно нестрогое; поднимите его для автоматизированной загрузки данных и понизьте для интерфейса проверки, где человек подтверждает каждый результат. MinColumnGap решает, что считается границей столбца в режиме пробелов, и плотно сжатые таблицы в насыщенных отчётах могут потребовать снижения этого значения относительно значения по умолчанию в 12 пунктов. MaxRowGapFactor решает, когда вертикальное расстояние завершает таблицу, что важно для таблиц со случайными пустыми строками
Будьте честны насчёт ограничений. Линейчатые таблицы извлекаются надёжно. Аккуратно выровненные таблицы по пробелам извлекаются хорошо. Таблицы с повёрнутым текстом, вложенные таблицы или ячейки, чьё содержимое переносится так, что выглядит как ещё одна строка, потребуют проверки независимо от настройки параметров. Для них модель структурированного текста даёт исходный материал для построения предметно-специфичного считывателя, описанная в статье блоки структурированного текста и порядок чтения
Одно полезное сочетание: когда в отсканированном документе вообще нет текста, обнаружению таблиц не с чем работать, пока не появится текстовый слой. Сначала добавьте его, как описано в статье добавление слоя текста, доступного для поиска, в отсканированные PDF, затем извлекайте. Прямоугольники слов, которые возвращает провайдер OCR, — это именно тот вход, что нужен обнаружению по пробелам
Извлечение таблиц, структурированный текст и рефлоу читают из одной и той же модели страницы в Delphi, C++Builder и Lazarus; полное описание API — на странице PDFium Component для Delphi