Технічна стаття

Типізоване вилучення PDF tables у Delphi через page breaks

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