Articol tehnic

Extragerea tipizată a tabelelor PDF în Delphi între pagini

HotPDF recuperează tabele dintr-un PDF existent prin ExtractLoadedTypedTables, un API Delphi care unește fragmentele de rând produse de layout pass, construiește o grilă canonică de coloane per tabel, continuă tabelul peste un page break când geometria permite și returnează fiecare celulă ca valoare tipizată cu proveniența paginii, column span și bounds. ExportLoadedTypedTables scrie același rezultat direct în CSV sau JSON. Scenariul care justifică această funcționalitate este banal și extrem de comun. Un registru de facturi de patruzeci de pagini, un singur tabel logic, tipărit cu header-ul repetat în partea de sus a fiecărei pagini. Rulează peste el un pass naiv de reading order și primești patruzeci de tabele, treizeci și nouă de header rows false și o coloană de currency care alunecă cu o poziție la stânga pe fiecare rând în care celula din mijloc s-a întâmplat să fie goală. Curățarea asta downstream, în aplicația caller, este locul în care mor proiectele de document import

De ce îți dă pagina PDF fragmente în loc de un tabel?

Deoarece o pagină PDF nu poartă deloc semantics de tabel dacă documentul nu este tagged. Content stream-ul conține operatori text-showing și positioning matrices (ISO 32000-1 §9.4.3) și nimic mai mult; chenarul pe care îl vezi pe ecran este un path painting fără legătură pe care niciun extractor nu este obligat să îl coreleze cu textul. Tipurile de structure elements Table, TR, TH și TD există doar în ierarhia logical structure a unui PDF tagged (ISO 32000-1 §14.8.4), iar majoritatea covârșitoare a documentelor de business aflate în circulație nu sunt tagged. Tot ce urmează este recuperare geometrică, nu parsing, iar asta trebuie spus direct înainte ca cineva să construiască peste ea un reconciliation report

De aceea HotPDF rulează mai întâi o semantic layout analysis peste glifele extrase, același pass care susține extragerea textului în ordinea structurii dintr-un PDF încărcat și exporturile structurate HTML și XML. Pass-ul grupează baseline-uri în runs ale căror celule se aliniază vertical și va continua un run doar cât timp rândurile consecutive au același număr de celule. Pentru un layout engine regula este corectă și ieftină. Pentru un caller este forma greșită: un singur rând cu o celulă interioară goală desparte un tabel vizual în două source tables. Layer-ul typed table stă deasupra acelui pass tocmai pentru a pune piesele la loc

Grile canonice de coloane și reglajul ColumnTolerance

ExtractLoadedTypedTables unește fragmentele de pe aceeași pagină înainte să facă orice altceva și le unește după geometria coloanelor, nu după textul rândurilor. Două source tables adiacente pe o pagină se unesc când ambele au cel puțin două coloane, când spațiul vertical dintre ultimul rând al primei și primul rând al celei de-a doua rămâne în banda de toleranță și când pozițiile de start ale coloanelor se aliniază. Starturile de coloană aflate la cel mult ColumnTolerance unul de altul se reduc la o singură coloană canonică și sunt mediate pe măsură ce se unesc. Toleranța implicită este de 12 unități în user space, potrivită pentru tipografia obișnuită de business și care merită mărită pentru layout-uri cu tracking larg sau indentare adâncă

Ce se întâmplă cu un rând căruia îi lipsește o valoare interioară este partea importantă. HotPDF fixează fiecare celulă pe cel mai apropiat column start canonic și apoi setează ColumnSpan la distanța de la acea coloană la următoarea ocupată, în loc să deplaseze celulele rămase spre stânga. Un rând cu trei celule într-o grilă de cinci coloane își păstrează valorile sub heading-urile corecte și înregistrează exact unde sunt golurile. Aceasta este diferența dintre un tabel pe care îl poți reconcilia și unul care atribuie în tăcere banii greșit

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;           // unități user-space
    Options.MinimumTableConfidence := 0.55;  // sub acest nivel, tabelele sunt eliminate
    Options.DateOrder := ttdoDMY;            // 03/04/2026 înseamnă 3 aprilie
    Options.DecimalSeparator := ',';
    Options.ThousandsSeparator := '.';
    if Pdf.ExtractLoadedTypedTables([0, 1, 2, 3], Options, Tables, Info) then
      // Info.TableCount față de Info.SourceTableCount arată cât s-a unit
      ProcessTables(Tables)
    else if Info.Status = ttesBudgetExceeded then
      Log(string(Info.Diagnostic));
  finally
    Pdf.Free;
  end;
end;

Ce garantează în realitate unirea între pagini?

Garantează conservatorismul, intenționat. HotPDF unește două tabele peste o limită de pagină doar când MergeAcrossPages este activat, când al doilea tabel începe exact pe indexul de pagină după care se termină primul, când ambele au cel puțin două coloane și când cel puțin două column starts canonice se aliniază în ColumnTolerance. Condiția de pagini consecutive este cea care poartă greutatea. Caller-ii transmit PageIndices ca open array în orice ordine doresc, iar fără acest check o cerere pentru paginile 3, 9 și 14 ar putea suda trei tabele fără legătură într-un rezultat complet plauzibil. Costul este că o continuare reală care sare o pagină, un appendix intercalat sau un duplex scan cu verso gol revine ca două tabele și nicio opțiune nu relaxează asta. Reunirea lor este o decizie de policy pe care doar aplicația caller o poate lua, așa că API-ul expune FirstPageIndex, LastPageIndex, SourceTableCount și un PageIndex per row și lasă decizia acolo unde îi este locul

Header-ele repetate sunt marcate, niciodată șterse

ExtractLoadedTypedTables nu elimină niciodată un repeated header row din rezultat. Când un cross-page merge găsește că tabelul incoming începe cu text de header identic cu cel al tabelului acumulat, comparat după trimming și case folding, marchează acele rows cu IsHeader și IsRepeatedHeader și le adaugă totuși în source order. Ștergerea este o alegere lossy și ireversibilă, iar consumatori diferiți vor răspunsuri diferite: un import CSV vrea repetările eliminate, un audit trail vrea să le păstreze cu numerele de pagină, un tool de diff vrea source order păstrat byte cu byte. Așa că biblioteca raportează, iar caller-ul decide

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;                    // păstrează doar primul bloc de header
      for C := 0 to High(Row.Cells) do
        if Row.Cells[C].ValueKind = ttvkCurrency then
          Total := Total + Row.Cells[C].NumberValue;
    end;
end;

Valori tipizate și separatorii pe care trebuie să îi furnizezi

Type inference rulează într-o ordine fixă ce rezolvă ambiguitățile în singura direcție sănătoasă: boolean mai întâi, apoi date, percentage, currency și plain number, iar orice nu se potrivește rămâne string. Ordinea împiedică 2026 dintr-o coloană de date să fie decis de un number parser înainte ca date parser-ul să îl vadă. Currency este recunoscut dintr-un $, £, ¥ sau la început ori dintr-un cod ISO 4217 de trei litere urmat de un spațiu, iar codul este păstrat în CurrencyCode. Important, HotPDF nu îți ghicește locale-ul. DecimalSeparator, ThousandsSeparator și DateOrder vin din options, deoarece 1.234 este fie un număr, fie o mie două sute treizeci și patru, în funcție de un fapt pe care PDF-ul nu îl conține. Unicode Text raw este păstrat pe fiecare celulă alături de valoarea tipizată, așa că o presupunere greșită poate fi recuperată mereu fără un al doilea pass de extraction

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;

Cele două formate de export răspund la întrebări diferite și nu sunt echivalente intenționat. CSV scrie coloanele de continuare ale unui span unit ca field-uri goale, exact ce așteaptă un spreadsheet sau un bulk loader. JSON păstrează tot ce a știut extraction-ul: valoarea tipizată sub propriul kind, columnSpan, confidence per celulă și per row, bounds ale celulei și proveniența paginii și a source table-ului. Ambele formate pregătesc întregul document într-un buffer bounded în memorie și abia apoi publică în stream-ul destinație, restaurând bytes-ii, lungimea și poziția originale dacă write-ul eșuează la mijloc, astfel încât un export eșuat nu lasă niciodată un fișier scris pe jumătate. Bugetele pentru pagini, glife per pagină, tabele, rows, cells, caractere și output bytes sunt contabilizate separat, iar rows sunt numărate înainte de alocare, pentru că un SetLength per row ajunge la copiere quadratică cu mult înainte de plafonul implicit de un milion de rows

Unde renunță recuperarea geometrică a tabelelor

Este mai util să fii explicit cu failure modes decât să enumeri feature-uri, deoarece fiecare dintre acestea este un loc în care caller-ul are nevoie de propria policy, nu de o valoare mai bună pentru option

  • Vertical merges nu sunt recuperate. HotPDF raportează ColumnSpan pentru span-uri orizontale și lasă RowSpan la 1, așa că o celulă care acoperă trei rows în tabelul tipărit ajunge drept o celulă plus două goluri
  • Header detection este data-driven, nu vizual. Blocul de header este run-ul de rows dinaintea primului row care conține o valoare typed non-string, așa că un tabel al cărui body este complet text raportează HeaderRowCount ca zero indiferent de styling
  • Tabelele sub MinimumTableConfidence sunt eliminate din rezultat fără eroare. Compară Info.TableCount cu Info.SourceTableCount când trebuie să știi că ceva a fost abandonat
  • Un run are nevoie de cel puțin două rows și cel puțin două coloane înainte ca layout pass-ul să îl numească tabel, așa că un pseudo-table cu un singur rând sau un layout cu două coloane de prose lungă nu este, corect și nefolositor, un tabel
  • Paginile scanate nu conțin text operators, deci nu există nimic de recuperat geometric până când pe pagină nu există un OCR text layer

Dacă PDF-urile tale vin din propriul reporting stack, cea mai ieftină soluție pentru toate acestea este upstream: emite tabele tagged sau păstrează datele sursă și tratează extraction-ul ca fallback pentru documente pe care nu le-ai produs. Pentru restul, merită să înveți pipeline-ul în această ordine, deoarece fiecare layer se sprijină pe cel de sub el: începe cu plain text extraction dintr-un PDF încărcat, urcă la typed table API când geometria trebuie păstrată și uită-te la randarea unui tabel de date într-un PDF nou când ești pe partea de generare și poți decide cât de recuperabil va fi output-ul

ExtractLoadedTypedTables și ExportLoadedTypedTables sunt livrate ca parte din HotPDF Delphi PDF Component nativ pentru Delphi și C++Builder, fără DLL extern și fără runtime dependency; pagina produsului conține referința completă pentru options, status și records a API-ului typed table