Техническая статья

Извлечение таблиц со страниц PDF в Delphi с PDFium

PDFium Component обнаруживает таблицы на странице PDF и возвращает их в виде сетки ячеек с охватами строк и столбцов, строками заголовков и значением достоверности через ExtractTables для одной страницы и ExtractDocumentTables для всего документа. Каждая таблица преобразуется в CSV или JSON одним вызовом, а таблицы, продолжающиеся через разрыв страницы, можно связать в цепочку продолжения

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

Два режима обнаружения для двух видов таблиц

Линейчатое обнаружение использует проведённые линии. Каждый обведённый сегмент пути преобразуется в координаты страницы через матрицу объекта страницы, горизонтальные и вертикальные линии пересекаются, а пересечения образуют связные компоненты. Каждый компонент становится собственной отсортированной сеткой позиций X и Y — именно это не даёт двум отдельным таблицам на одной странице слиться в одну бессмысленную сетку

Обнаружение по пробелам обрабатывает таблицы, нарисованные с помощью выравнивания вместо линий. Прямоугольники слов группируются в визуальные строки, промежутки внутри строки разбивают её на кандидаты в столбцы, и таблица принимается только тогда, когда как минимум MinRows строк повторяют как минимум MinColumns выровненных по левому краю якорей в пределах AlignmentTolerance. Коэффициент промежутка между строками по умолчанию равен 3, что покрывает типичный интервал базовой линии примерно в 30 пунктов для текста в 12 пунктов, не позволяя одной строке с несколькими текстовыми прогонами выдавать себя за таблицу

Диаграмма конвейера обнаружения таблиц PDFium Component в Delphi, где пересечения линейчатых линий и выровненные по пробелам строки слов питают одну оценённую запись таблицы с экспортом в CSV и JSON
Линейчатое обнаружение пересекает проведённые штрихи, тогда как обнаружение по пробелам считает выровненные строки прямоугольников слов; кандидаты, прошедшие MinRows и MinColumns, получают оценку достоверности и присвоенный DetectionMode
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, а текст присваивается базовой ячейке по её центральной точке, а затем следует за этой ячейкой до её корня объединения. Такой подход также удерживает стоимость линейной по числу слов плюс ячеек, вместо квадратичного сканирования, которое получилось бы при проверке каждого слова против каждой ячейки

Диаграмма восстановления объединённых ячеек при извлечении таблиц PDFium для Delphi, где union-find объединяет соседние базовые ячейки всякий раз, когда общий интервал не покрыт граничной линией, давая RowSpan и ColumnSpan
Union-find объединяет соседние базовые ячейки, чей общий интервал не несёт проведённой границы, поэтому объединённый заголовок возвращается как одна ячейка с установленным ColumnSpan, а не одна заполненная ячейка в окружении пустых

Практический эффект в том, что финансовая таблица с объединённым заголовком «Итого», охватывающим три столбца, выходит как одна ячейка с охватом три, а не одна заполненная ячейка и две загадочно пустые

Продолжение через страницы

Длинные таблицы разрываются между страницами, и обработка фрагмента каждой страницы как независимой таблицы вынуждает вызывающий код сшивать их. ExtractDocumentTables может связать их вместо этого, но только при строгих условиях: фрагмент должен быть самой нижней таблицей на предыдущей странице, следующий — самой верхней таблицей на следующей странице, номера страниц должны быть соседними, а границы столбцов должны совпадать

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

Диаграмма продолжения таблицы через страницы PDF в Delphi, где четыре строгих условия решают, присоединяется ли самый нижний фрагмент на одной странице к самому верхнему фрагменту на следующей
Извлечение на уровне документа связывает фрагменты только тогда, когда выполнены все четыре условия, не давая не связанным между собой четырёхстолбцовым таблицам слиться в одну воображаемую мегатаблицу

Извлечение на уровне документа разделяет бюджеты 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