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

Typed PDF table extraction в Delphi през page breaks

HotPDF възстановява tables от съществуващ PDF чрез ExtractLoadedTypedTables, Delphi API, който merge-ва row fragments, създадени от layout pass-а, изгражда една canonical column grid на table, продължава table през page break, когато geometry-ът го позволява, и връща всеки cell като typed value с page provenance, column span и bounds. ExportLoadedTypedTables записва същия result директно в CSV или JSON. Сценарият, който прави това полезно, е скучен и изключително често срещан. 40-page invoice register, една логическа table, отпечатана с header, повторен в началото на всяка page. Пуснете naive reading-order pass и получавате 40 tables, 39 spurious header rows и currency column, която се измества с една position наляво на всеки row, в който средният cell случайно е празен. Почистването downstream, вътре в calling application-а, е мястото, където document-import проектите отиват да умрат

Защо 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-ът, който виждате на екрана, е unrelated path painting, което никой extractor не е длъжен да свърже с text-а. Structure element types Table, TR, TH и TD живеят само в logical structure hierarchy на tagged PDF (ISO 32000-1 §14.8.4), а огромното мнозинство от business document-и в обращение не са tagged. Всичко, описано по-долу, е geometric recovery, а не parsing, и си струва да го кажем на глас, преди някой да построи reconciliation report върху него

Затова HotPDF първо изпълнява semantic layout analysis върху extracted glyph-ове, същия pass, който стои зад structure-order text extraction от loaded PDF и structured HTML и XML exports. Този pass групира baselines в runs, чиито cells се подравняват vertical, и продължава run само докато consecutive rows имат един и същ cell count. За layout engine това правило е правилно и евтино. За caller-а е грешната форма: един row с празен interior cell разделя една visual table на две source tables. Typed table layer-ът стои над този pass точно за да събере парчетата обратно

Canonical column grids и ColumnTolerance knob

ExtractLoadedTypedTables merge-ва same-page fragments преди да направи каквото и да е друго и merge-ва по column geometry, а не по row text. Две съседни 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 или дълбоко indented layouts

Какво се случва с row, който няма interior value, е частта, която има значение. HotPDF snap-ва всеки cell към най-близкия canonical column start и след това задава ColumnSpan на разстоянието от тази column до следващата occupied, вместо да измества останалите 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 април
    Options.DecimalSeparator := ',';
    Options.ThousandsSeparator := '.';
    if Pdf.ExtractLoadedTypedTables([0, 1, 2, 3], Options, Tables, Info) then
      // Info.TableCount спрямо Info.SourceTableCount показва колко е merge-нато
      ProcessTables(Tables)
    else if Info.Status = ttesBudgetExceeded then
      Log(string(Info.Diagnostic));
  finally
    Pdf.Free;
  end;
end;

Какво всъщност гарантира cross-page merging?

То гарантира conservatism, нарочно. HotPDF join-ва две tables през page boundary само когато MergeAcrossPages е enabled, когато втората table започва точно на page index-а след края на първата, когато и двете имат поне две columns и когато поне два canonical column starts се подравняват в рамките на ColumnTolerance. Consecutive-page условието е носещото. Caller-ите подават PageIndices като open array в какъвто ред искат и без тази проверка заявка за pages 3, 9 и 14 би могла да зашие три несвързани tables в един напълно правдоподобен резултат. Цената е, че истинско продължение, което прескача page, interleaved appendix или duplex scan с празен verso, се връща като две tables и никоя option не го разхлабва. Повторното им съединяване е policy call, който само calling application може да направи, затова API-то expose-ва FirstPageIndex, LastPageIndex, SourceTableCount и per-row PageIndex и оставя решението там, където му е мястото

Повторените headers се маркират, никога не се изтриват

ExtractLoadedTypedTables никога не премахва repeated header row от result-а. Когато cross-page merge открие, че incoming table започва с header text, идентичен с този на натрупаната table, сравнен след trimming и case folding, той маркира тези rows с IsHeader и IsRepeatedHeader и все пак ги append-ва в source order. Deletion е lossy, irreversible choice, а различните consumers искат различни answers: CSV import иска repeat-ите да изчезнат, audit trail иска да са налични с page numbers, diffing tool иска source order да е запазен byte по 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, после date, после percentage, после currency, после plain number, а всичко unmatched остава string. Именно order-ът спира 2026 в date column да бъде решено от number parser, преди date parser да го види. Currency се разпознава по водещи $, £, ¥ или , или по three-letter ISO 4217 code, последван от space, като code-ът се запазва в CurrencyCode. Критичното е, че HotPDF не гадае за вашия locale. DecimalSeparator, ThousandsSeparator и DateOrder идват от options, защото 1.234 е или едно число, или хиляда двеста тридесет и четири според факт, който PDF не съдържа. Raw Unicode Text се запазва във всеки cell заедно с typed value, така че грешен 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 format-а отговарят на различни въпроси и нарочно не са equivalent. CSV записва continuation columns на merged span като empty fields, което е точно това, което spreadsheet или bulk loader очаква. JSON пази всичко, което extraction е знаел: typed value под собствения му kind, columnSpan, per-cell и per-row confidence, cell bounds и page и source-table provenance. И двата format-а stage-ват целия document в bounded in-memory buffer и чак след това publish-ват към destination stream, като възстановяват оригиналните 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-а от милион rows

Къде geometric table recovery се отказва

Да сте explicit за failure modes е по-полезно от feature list, защото всяко от тях е място, където caller-ът се нуждае от своя policy, а не от по-добра 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, чието тяло е изцяло text, докладва HeaderRowCount като zero независимо от стила
  • Tables под MinimumTableConfidence се изхвърлят от result-а без error. Сравнете Info.TableCount с Info.SourceTableCount, когато трябва да знаете, че нещо е било discarded
  • Run се нуждае от поне два rows и поне две columns, преди layout pass да го нарече table, така че едноредов pseudo-table или two-column layout с дълга prose е правилно, макар и неособено полезно, да не бъде table
  • Scanned pages не съдържат text operators, така че няма нищо за geometric recovery, докато на page-а не съществува OCR text layer

Ако PDF-ите ви излизат от собствената reporting stack, най-евтиният fix за всичко това е upstream: emit-вайте tagged tables или пазете source data и третирайте extraction като fallback за document-и, които не сте произвели вие. За всичко останало pipeline-ът си струва да се учи в този ред, защото всеки layer се гради върху долния: започнете с plain text extraction от loaded PDF, преминете към typed table API, когато geometry трябва да бъде запазена, и разгледайте rendering на data table в нов PDF, когато сте от страната на generation и можете да решите колко recoverable ще бъде output-ът

ExtractLoadedTypedTables и ExportLoadedTypedTables са част от native HotPDF Delphi PDF Component за Delphi и C++Builder, без external DLL и без runtime dependency; продуктова страница съдържа пълния option, status и record reference за typed table API