HotPDF восстанавливает таблицы из существующего PDF через ExtractLoadedTypedTables — API Delphi, который объединяет фрагменты строк, созданные layout pass, строит для каждой таблицы единую canonical column grid, продолжает таблицу через разрыв страницы, когда геометрия это подтверждает, и возвращает каждую ячейку как typed value с provenance страницы, span колонки и bounds. ExportLoadedTypedTables записывает тот же результат напрямую в CSV или JSON. Сценарий, ради которого всё это пришлось строить, скучен и чрезвычайно распространён. Реестр счетов на сорока страницах — логически одна таблица, напечатанная с повторяющимся заголовком вверху каждой страницы. Запустите по нему наивный pass порядка чтения и получите сорок таблиц, тридцать девять ложных строк заголовка и колонку валюты, которая сдвигается на одну позицию влево в каждой строке, где средняя ячейка случайно оказалась пустой. Очистка этого результата downstream, внутри вызывающего приложения, — место, где умирают проекты импорта документов
Почему страница PDF отдаёт фрагменты, а не таблицу?
Потому что страница PDF вообще не содержит семантики таблицы, если документ не размечен тегами. Content stream хранит операторы показа текста и positioning matrices (ISO 32000-1 §9.4.3) и больше ничего; нарисованная на экране рамка никак не связана с рисованием paths, и ни один extractor не обязан соотносить её с текстом. Типы structure elements Table, TR, TH и TD существуют только в иерархии logical structure tagged PDF (ISO 32000-1 §14.8.4), а подавляющее большинство бизнес-документов в обращении не имеют тегов. Всё, что описано ниже, — геометрическое восстановление, а не parsing, и это стоит проговорить вслух до того, как кто-то построит поверх него reconciliation report
Поэтому HotPDF сначала выполняет semantic layout analysis над извлечёнными glyph — тот же pass, на котором основаны извлечение текста из загруженного PDF в порядке структуры и структурированные HTML- и XML-экспорты. Этот pass группирует baseline в runs, чьи ячейки вертикально выровнены, и продолжает run только пока последовательные строки имеют одинаковое число ячеек. Для layout engine это правило правильное и дешёвое. Для вызывающего кода форма неверна: одна строка с пустой внутренней ячейкой разделяет одну визуальную таблицу на две source tables. Typed table layer существует именно для того, чтобы собрать эти части обратно
Canonical column grids и регулятор ColumnTolerance
ExtractLoadedTypedTables объединяет фрагменты на одной странице до любой другой работы и объединяет их по геометрии колонок, а не по тексту строк. Две соседние source tables на одной странице соединяются, когда у каждой есть минимум две колонки, вертикальный зазор между последней строкой первой и первой строкой второй остаётся в пределах tolerance band, а позиции начала колонок совпадают. Начала колонок, находящиеся друг от друга в пределах ColumnTolerance, сворачиваются в одну canonical column и усредняются по мере слияния. Значение tolerance по умолчанию — 12 единиц user space, что подходит обычной деловой типографике, но его стоит увеличивать для layouts с широким tracking или глубокими отступами
Важнее всего, что происходит со строкой, в которой пропущено внутреннее значение. HotPDF привязывает каждую ячейку к ближайшему началу canonical column и задаёт ColumnSpan как расстояние от этой колонки до следующей занятой, а не сдвигает оставшиеся ячейки влево. Строка из трёх ячеек в сетке из пяти колонок сохраняет значения под правильными заголовками и точно фиксирует положение пробелов. В этом разница между таблицей, которую можно сверить, и таблицей, которая тихо приписывает деньги не той колонке
var
Pdf: THotPDF;
Options: THPDFTypedTableExtractionOptions;
Tables: THPDFTypedTables;
Info: THPDFTypedTableExtractionInfo;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('register.pdf', '') <= 0 then
Exit;
Options := THPDFTypedTableExtractionOptions.Default;
Options.ColumnTolerance := 12; // единицы user space
Options.MinimumTableConfidence := 0.55; // ниже этого значения таблицы отбрасываются
Options.DateOrder := ttdoDMY; // 03/04/2026 означает 3 апреля
Options.DecimalSeparator := ',';
Options.ThousandsSeparator := '.';
if Pdf.ExtractLoadedTypedTables([0, 1, 2, 3], Options, Tables, Info) then
// Info.TableCount и Info.SourceTableCount показывают, сколько было объединено
ProcessTables(Tables)
else if Info.Status = ttesBudgetExceeded then
Log(string(Info.Diagnostic));
finally
Pdf.Free;
end;
end;
Что на самом деле гарантирует слияние через страницы?
Оно гарантирует консервативность, и это сделано намеренно. HotPDF соединяет две таблицы через границу страниц только при включённом MergeAcrossPages, когда вторая таблица начинается ровно на странице с индексом, следующим за последней страницей первой, когда у обеих есть минимум две колонки и когда как минимум два canonical column start совпадают в пределах ColumnTolerance. Условие последовательных страниц — несущая конструкция. Вызывающий код может передать PageIndices как open array в любом порядке, и без этой проверки запрос страниц 3, 9 и 14 мог бы сварить три несвязанные таблицы в один вполне правдоподобный результат. Цена — настоящая continuation, пропускающая страницу, interleaved appendix или duplex scan с пустой оборотной стороной, вернётся как две таблицы, и ни одна опция это не ослабит. Повторное соединение — policy decision, которую может принять только вызывающее приложение, поэтому API выставляет FirstPageIndex, LastPageIndex, SourceTableCount и PageIndex для каждой строки, оставляя решение там, где ему место
Повторяющиеся заголовки помечаются, но никогда не удаляются
ExtractLoadedTypedTables никогда не удаляет повторяющуюся строку заголовка из результата. Когда cross-page merge обнаруживает, что входящая таблица открывается текстом заголовка, идентичным накопленной таблице после trim и case folding, он помечает эти строки флагами IsHeader и IsRepeatedHeader, но всё равно добавляет их в исходном порядке. Удаление — необратимое преобразование с потерей данных, а разные потребители хотят разные ответы: CSV-import хочет убрать повторы, audit trail хочет видеть их с номерами страниц, diffing tool хочет сохранить исходный порядок побайтно. Поэтому библиотека сообщает, а решение принимает вызывающий код
var
T, R, C: Integer;
Row: THPDFTypedTableRow;
Total: Double;
begin
Total := 0;
for T := 0 to High(Tables) do
for R := 0 to High(Tables[T].Rows) do
begin
Row := Tables[T].Rows[R];
if Row.IsRepeatedHeader then
Continue; // оставить только первый блок заголовка
for C := 0 to High(Row.Cells) do
if Row.Cells[C].ValueKind = ttvkCurrency then
Total := Total + Row.Cells[C].NumberValue;
end;
end;
Типизированные значения и разделители, которые нужно задать
Вывод типа идёт в фиксированном порядке, который разрешает неоднозначности единственным разумным способом: сначала boolean, затем date, percentage, currency, plain number, а всё, что не совпало, остаётся string. Именно порядок не даёт 2026 в колонке дат быть разобранным number parser до того, как до него доберётся date parser. Currency распознаётся по ведущему $, £, ¥ или € либо по трёхбуквенному ISO 4217 code с пробелом после него, а code сохраняется в CurrencyCode. Важно, что HotPDF не угадывает вашу locale. DecimalSeparator, ThousandsSeparator и DateOrder приходят из options, потому что 1.234 — либо одно число, либо тысяча двести тридцать четыре, в зависимости от факта, которого в PDF нет. Исходный Unicode Text сохраняется в каждой ячейке рядом с typed value, поэтому неверное предположение всегда можно восстановить без второго extraction pass
var
Stream: TFileStream;
Info: THPDFTypedTableExtractionInfo;
begin
Stream := TFileStream.Create('tables.json', fmCreate);
try
if not Pdf.ExportLoadedTypedTables([0, 1, 2], ttefJSON,
Stream, Options, Info) then
case Info.Status of
ttesInvalidOptions: ReportBadConfiguration;
ttesBudgetExceeded: ReportOversizedDocument;
ttesCancelled: ReportUserCancelled;
ttesWriteFailed: ReportDestinationProblem;
else
ReportExtractionFailure;
end;
finally
Stream.Free;
end;
end;
Два формата экспорта отвечают на разные вопросы и намеренно не эквивалентны. CSV записывает колонки continuation merged span как пустые поля, чего ожидают spreadsheet или bulk loader. JSON сохраняет всё, что знало извлечение: typed value под собственным kind, columnSpan, confidence для каждой ячейки и строки, bounds ячейки, а также page и source-table provenance. Оба формата сначала помещают весь документ в ограниченный буфер памяти и только затем публикуют его в stream назначения, восстанавливая исходные байты, длину и позицию, если запись частично завершилась ошибкой, поэтому неудачный export никогда не оставляет наполовину записанный файл. Бюджеты страниц, glyph на страницу, таблиц, строк, ячеек, символов и выходных байт учитываются раздельно, а строки считаются до выделения памяти, потому что per-row SetLength превращается в квадратичное копирование задолго до потолка в миллион строк по умолчанию
Где геометрическое восстановление таблицы сдаётся
Явно описать режимы отказа полезнее, чем дать список возможностей, потому что в каждом из них вызывающему коду нужна собственная policy, а не ещё одно значение опции
- Вертикальные объединения не восстанавливаются. HotPDF сообщает
ColumnSpanдля горизонтальных span и оставляетRowSpanравным 1, поэтому ячейка, занимающая три строки печатной таблицы, приходит как одна ячейка и два пробела - Поиск заголовка управляется данными, а не видом. Блок заголовка — это run строк до первой строки, содержащей typed value не типа string, поэтому таблица, чьё тело целиком состоит из текста, сообщает
HeaderRowCountкак 0 независимо от оформления - Таблицы ниже
MinimumTableConfidenceудаляются из результата без ошибки. СравнивайтеInfo.TableCountсInfo.SourceTableCount, если нужно понять, что что-то было отброшено - Run должен содержать минимум две строки и минимум две колонки, прежде чем layout pass вообще назовёт его таблицей, поэтому псевдотаблица в одну строку или двухколоночная раскладка длинной прозы корректно и, возможно, неудобно не считается таблицей
- На отсканированных страницах нет операторов текста, поэтому геометрически нечего восстанавливать, пока на странице не появился OCR text layer
Если PDF выходят из вашего собственного reporting stack, самый дешёвый исправитель всех этих проблем находится upstream: создавайте tagged tables или сохраняйте исходные данные, а extraction считайте fallback для документов, которые вы не производили. Для остальных документов pipeline стоит изучать в таком порядке, потому что каждый слой строится на нижнем: начните с обычного извлечения текста из загруженного PDF, поднимитесь к typed table API, когда нужно сохранить геометрию, и обратитесь к рендерингу таблицы данных в новый PDF, если вы на стороне генерации и можете решить, насколько восстановимым будет результат
ExtractLoadedTypedTables и ExportLoadedTypedTables входят в нативный PDF Component HotPDF для Delphi для Delphi и C++Builder без внешней DLL и runtime dependency; на странице продукта есть полная справка по options, status и record typed table API