HotPDF відновлює tables з існуючого PDF через ExtractLoadedTypedTables — Delphi API, який об’єднує row fragments, створені layout pass, будує одну canonical column grid для кожної table, продовжує її через page break, коли geometry це дозволяє, і повертає кожну cell як typed value із page provenance, column span та bounds. ExportLoadedTypedTables записує той самий результат безпосередньо у CSV або JSON. Сценарій, заради якого це варто було будувати, нудний і надзвичайно поширений. Сорокасторінковий invoice register, логічно одна table, надрукована з повтореним header на початку кожної page. Запустіть по ній наївний reading-order pass — і отримаєте сорок tables, 39 зайвих header rows та currency column, яка на кожному row із випадково порожньою middle cell зсувається на одну position ліворуч. Очищення цього downstream, усередині calling application, — місце, де вмирають document-import projects
Чому PDF page дає fragments, а не table?
Тому що PDF page не має жодної table semantics, якщо document не tagged. Content stream містить text-showing operators і positioning matrices (ISO 32000-1 §9.4.3), і більше нічого; ruled box, який ви бачите на screen, є unrelated path painting, яку жоден extractor не зобов’язаний пов’язувати з text. Structure element types Table, TR, TH та TD живуть лише в logical structure hierarchy tagged PDF (ISO 32000-1 §14.8.4), а переважна більшість business documents у circulation не tagged. Усе описане нижче — geometric recovery, а не parsing, і це краще проговорити до того, як хтось побудує поверх цього reconciliation report
Тому HotPDF спочатку запускає semantic layout analysis над extracted glyphs — той самий pass, який лежить в основі structure-order text extraction із loaded PDF та structured HTML і XML exports. Цей pass групує baselines у runs, чиї cells вирівняні вертикально, і продовжує run лише поки consecutive rows мають однакову кількість cells. Для layout engine це correct і cheap. Для caller це неправильна shape: один row із порожньою interior cell розділяє одну visual table на дві source tables. Typed table layer розташований над цим pass саме для того, щоб зібрати шматки назад
Canonical column grids і ручка ColumnTolerance
ExtractLoadedTypedTables спочатку об’єднує same-page fragments, а вже потім робить усе інше, причому об’єднує їх за column geometry, а не за row text. Дві adjacent source tables на одній page join-яться, якщо обидві мають щонайменше дві columns, vertical gap між останнім row першої та першим row другої залишається в межах tolerance band, а column start positions збігаються. Column starts у межах ColumnTolerance одна від одної collapse-яться в одну canonical column і усереднюються під час merge. Default tolerance — 12 user-space units, що підходить для звичайної business typography і потребує збільшення для wide-tracked або deeply indented layouts
Що відбувається з row, якому бракує interior value, — ось важлива частина. HotPDF snap-ить кожну cell до найближчого canonical column start, а потім встановлює ColumnSpan як distance від цієї column до наступної occupied one, замість зсувати решту cells ліворуч. Three-cell row у five-column grid зберігає свої values під правильними headings і точно записує, де gaps. У цьому різниця між table, яку можна reconcile, і table, що тихо приписує гроші не туди
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 units
Options.MinimumTableConfidence := 0.55; // нижче цього tables відкидаються
Options.DateOrder := ttdoDMY; // 03/04/2026 — 3 April
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;
Що насправді гарантує cross-page merging?
Навмисно — conservative behavior. HotPDF з’єднує дві tables через page boundary лише коли enabled MergeAcrossPages, коли друга table починається рівно на page index після завершення першої, коли обидві мають щонайменше дві columns і коли щонайменше два canonical column starts вирівняні в межах ColumnTolerance. Умова consecutive pages — load-bearing. Callers передають PageIndices як open array у будь-якому порядку, і без цієї check запит pages 3, 9 та 14 міг би зварити три unrelated tables в один цілком правдоподібний result. Ціна в тому, що справжнє продовження через пропущену page, interleaved appendix або duplex scan з порожньою verso повертається як дві tables, і жоден option цього не послаблює. Rejoining — це policy call, яку може зробити лише calling application, тому API expose-ить FirstPageIndex, LastPageIndex, SourceTableCount і per-row PageIndex, залишаючи рішення там, де йому місце
Repeated headers позначаються, але ніколи не видаляються
ExtractLoadedTypedTables ніколи не видаляє repeated header row із result. Коли cross-page merge знаходить, що incoming table починається з header text, ідентичного accumulated table після trimming та case folding, він позначає ці rows як IsHeader і IsRepeatedHeader і все одно додає їх у source order. Deletion — lossy, irreversible choice, а різні consumers хочуть різних відповідей: CSV import хоче прибрати repeats, audit trail хоче зберегти їх із page numbers, diffing tool хоче source order byte for byte. Тому library звітує, а caller вирішує
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; // залишити лише перший header block
for C := 0 to High(Row.Cells) do
if Row.Cells[C].ValueKind = ttvkCurrency then
Total := Total + Row.Cells[C].NumberValue;
end;
end;
Typed values і separators, які потрібно передати
Type inference виконується у fixed order, що розв’язує ambiguities в єдиному розумному напрямку: boolean first, потім date, percentage, currency, plain number, а все unmatched залишається string. Саме порядок не дає 2026 у date column бути визначеним number parser до того, як date parser його побачить. Currency розпізнається за leading $, £, ¥ або € чи за three-letter ISO 4217 code, після якого йде space, а code зберігається в CurrencyCode. Принципово, HotPDF не вгадує вашу locale. DecimalSeparator, ThousandsSeparator та DateOrder походять із options, бо 1.234 — це або одне number, або one thousand two hundred and thirty-four, залежно від факту, якого PDF не містить. Raw Unicode Text зберігається в кожній cell поруч із typed value, тому wrong guess завжди можна відновити без другого 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;
Два export formats відповідають на різні questions і навмисно не є equivalent. CSV записує continuation columns merged span як empty fields, чого очікує spreadsheet або bulk loader. JSON зберігає все, що знало extraction: typed value у власному kind, columnSpan, confidence per cell і per row, cell bounds та page і source-table provenance. Обидва formats спочатку stage-ять увесь document у bounded in-memory buffer і лише потім публікують його в destination stream, відновлюючи original bytes, length і position, якщо write провалюється посередині, тому failed export ніколи не залишає half-written file. Budgets для pages, glyphs per page, tables, rows, cells, characters і output bytes рахуються окремо, а rows рахуються до allocation, бо per-row SetLength перетворюється на quadratic copying задовго до default ceiling у million rows
Де geometric table recovery здається
Явно назвати failure modes корисніше за feature list, бо кожна з цих точок вимагає власної policy caller, а не кращого option value
- Vertical merges не відновлюються. HotPDF повідомляє
ColumnSpanдля horizontal spans і залишаєRowSpanрівним 1, тому cell, що охоплює три rows у printed table, приходить як одна cell плюс два gaps - Header detection є data-driven, а не visual. Header block — це run rows до першого row, що містить non-string typed value, тому table, чие body повністю text, повідомляє
HeaderRowCountяк zero незалежно від style - Tables нижче
MinimumTableConfidenceвідкидаються з result без error. ПорівнюйтеInfo.TableCountізInfo.SourceTableCount, коли потрібно знати, що щось відкинуто - Run потребує щонайменше two rows і two columns, перш ніж layout pass назве його table, тож one-line pseudo-table або two-column layout довгої prose цілком правильно, але неприємно, не є table
- Scanned pages не містять text operators, тому геометрично відновлювати нічого, доки на page не з’явиться OCR text layer
Якщо ваші PDFs виходять із власного reporting stack, найдешевше виправлення всього цього — upstream: emit tagged tables або зберігати source data і трактувати extraction як fallback для documents, яких ви не створювали. Для всього іншого pipeline варто вивчати в такому порядку, бо кожен layer будується на попередньому: почніть із plain text extraction із loaded PDF, перейдіть до typed table API, коли потрібно зберегти geometry, і подивіться на rendering data table у new PDF, коли ви на generating side і можете вирішити, наскільки recoverable буде output
ExtractLoadedTypedTables і ExportLoadedTypedTables входять до native HotPDF Delphi PDF Component для Delphi та C++Builder, без external DLL і runtime dependency; product page містить повну reference для options, statuses та records typed table API