Technický článek

Typová extrakce PDF tabulek přes zalomení stránek v Delphi

HotPDF obnovuje tabulky z existujícího PDF přes ExtractLoadedTypedTables, API pro Delphi, které sloučí fragmenty řádků vytvořené layout průchodem, sestaví jednu kanonickou mřížku sloupců na tabulku, pokračuje přes zalomení stránky, když tomu geometrie odpovídá, a vrátí každou buňku jako typovanou hodnotu s původem stránky, rozsahem sloupců a hranicemi. ExportLoadedTypedTables zapisuje stejný výsledek přímo do CSV nebo JSON. Scénář, kvůli kterému stojí za to to postavit, je nudný a velmi běžný. Čtyřicetistránkový registr faktur, logicky jedna tabulka, vytištěný s hlavičkou opakovanou nahoře každé stránky. Naivní průchod podle pořadí čtení z něj udělá čtyřicet tabulek, třicet devět falešných řádků hlavičky a sloupec s měnou se na každém řádku, kde byla prostřední buňka prázdná, posune o jednu pozici doleva. Čistit to downstream v aplikaci volajícího je místo, kde projekty importu dokumentů umírají

Proč vám stránka PDF předá fragmenty místo tabulky

Protože stránka PDF nenese žádnou sémantiku tabulky, pokud dokument není tagovaný. Content stream obsahuje operátory zobrazující text a poziční matice (ISO 32000-1 §9.4.3) a nic víc; rámeček, který vidíte na obrazovce, je nesouvisející kreslení cesty, jehož spojení s textem po extractorovi nikdo nevyžaduje. Typy struktur Table, TR, TH a TD existují jen v hierarchii logické struktury tagovaného PDF (ISO 32000-1 §14.8.4) a naprostá většina firemních dokumentů v oběhu tagovaná není. Všechno popsané níže je geometrická obnova, nikoli parsování, a je dobré to říct nahlas, než nad tím někdo postaví reconciliation report

HotPDF proto nejprve spustí sémantickou layout analýzu nad extrahovanými glyfy, stejný průchod, který podporuje extrakci textu z načteného PDF podle pořadí struktury a strukturovaný export HTML a XML. Tento průchod seskupuje baseline do runů, jejichž buňky se vertikálně zarovnávají, a run prodlouží jen tehdy, když po sobě jdoucí řádky mají stejný počet buněk. Pro layout engine je to správné a levné pravidlo. Pro volajícího má špatný tvar: jediný řádek s prázdnou vnitřní buňkou rozdělí jednu vizuální tabulku na dvě source tables. Vrstva typových tabulek leží přesně nad tímto průchodem, aby kusy znovu spojila

Kanonické mřížky sloupců a přepínač ColumnTolerance

ExtractLoadedTypedTables sloučí fragmenty na stejné stránce dříve, než udělá cokoli jiného, a slučuje podle geometrie sloupců, nikoli podle textu řádků. Dvě sousední source tables na jedné stránce se spojí, když mají obě alespoň dva sloupce, vertikální mezera mezi posledním řádkem první a prvním řádkem druhé zůstává uvnitř tolerančního pásma a startovní pozice sloupců se zarovnají. Starty sloupců v rámci ColumnTolerance se sloučí do jednoho kanonického sloupce a při slučování se zprůměrují. Výchozí tolerance je 12 user-space jednotek, což vyhovuje běžné firemní typografii a u široce prostrkaných nebo hluboce odsazených layoutů chce zvýšení

Co se stane s řádkem, kterému chybí vnitřní hodnota, je klíčová část. HotPDF přichytí každou buňku k nejbližšímu kanonickému startu sloupce a potom nastaví ColumnSpan na vzdálenost od tohoto sloupce k dalšímu obsazenému, místo aby zbývající buňky posunul doleva. Tříbuněčný řádek v pětisloupcové mřížce ponechá hodnoty pod správnými hlavičkami a přesně zaznamená, kde mezery jsou. To je rozdíl mezi tabulkou, kterou lze reconciliovat, a tabulkou, která potichu přiřadí peníze špatně

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;           // jednotky user space
    Options.MinimumTableConfidence := 0.55;  // pod tím se tabulky zahodí
    Options.DateOrder := ttdoDMY;            // 03/04/2026 je 3. duben
    Options.DecimalSeparator := ',';
    Options.ThousandsSeparator := '.';
    if Pdf.ExtractLoadedTypedTables([0, 1, 2, 3], Options, Tables, Info) then
      // Info.TableCount proti Info.SourceTableCount ukazuje míru sloučení
      ProcessTables(Tables)
    else if Info.Status = ttesBudgetExceeded then
      Log(string(Info.Diagnostic));
  finally
    Pdf.Free;
  end;
end;

Co skutečně zaručuje cross-page merging

Záměrně zaručuje konzervativní chování. HotPDF spojí dvě tabulky přes hranici stránky pouze tehdy, když je zapnuto MergeAcrossPages, druhá tabulka začíná přesně na indexu stránky následujícím po konci první, obě mají alespoň dva sloupce a alespoň dva kanonické starty sloupců se zarovnají uvnitř ColumnTolerance. Podmínka sousedních stránek je nosná. Volající předávají PageIndices jako open array v libovolném pořadí a bez této kontroly by požadavek na stránky 3, 9 a 14 mohl svařit tři nesouvisející tabulky do jednoho výsledku, který vypadá zcela věrohodně. Cenou je, že skutečné pokračování přes vynechanou stránku, proložená příloha nebo duplexní sken s prázdnou zadní stranou se vrátí jako dvě tabulky a žádná volba to neuvolní. Jejich opětovné spojení je policy call, který může udělat jen volající aplikace, takže API zveřejní FirstPageIndex, LastPageIndex, SourceTableCount a PageIndex pro každý řádek a rozhodnutí nechá tam, kam patří

Opakované hlavičky se označí, nikdy nemažou

ExtractLoadedTypedTables opakovaný řádek hlavičky z výsledku nikdy neodstraní. Když cross-page merge zjistí, že příchozí tabulka začíná textem hlavičky shodným s již nashromážděnou tabulkou, porovnaným po ořezání a case foldingu, označí tyto řádky IsHeader a IsRepeatedHeader a připojí je v pořadí zdroje tak jako tak. Mazání je ztrátová a nevratná volba a různí konzumenti chtějí různé odpovědi: CSV import chce opakování pryč, audit trail je chce přítomné s čísly stránek a diffovací nástroj chce pořadí zdroje zachované bajt po bajtu. Knihovna proto hlásí a volající rozhoduje

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;                    // ponech pouze první blok hlavičky
      for C := 0 to High(Row.Cells) do
        if Row.Cells[C].ValueKind = ttvkCurrency then
          Total := Total + Row.Cells[C].NumberValue;
    end;
end;

Typované hodnoty a oddělovače, které musíte dodat

Inference typu běží v pevném pořadí, které nejednoznačnosti řeší jediným rozumným směrem: nejprve boolean, potom datum, procento, měna a obyčejné číslo, přičemž vše, co neodpovídá, zůstane stringem. Pořadí brání tomu, aby 2026 ve sloupci data vyřešil number parser dřív, než se k němu dostane date parser. Měna se rozpozná z úvodního $, £, ¥ nebo , případně z třípísmenného kódu ISO 4217 následovaného mezerou, a kód se zachová v CurrencyCode. Zásadní je, že HotPDF nehádá vaše locale. DecimalSeparator, ThousandsSeparator a DateOrder přicházejí z options, protože 1.234 je buď jedno číslo, nebo jeden tisíc dvě stě třicet čtyři podle skutečnosti, kterou PDF neobsahuje. Surový Unicode Text zůstává v každé buňce vedle typované hodnoty, takže chybný odhad lze vždy obnovit bez druhé extrakce

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;

Dva exportní formáty odpovídají různým otázkám a záměrně nejsou ekvivalentní. CSV zapíše pokračující sloupce sloučeného spanu jako prázdná pole, což čeká spreadsheet nebo bulk loader. JSON zachová vše, co extrakce věděla: typovanou hodnotu pod vlastním kind, columnSpan, confidence pro buňku i řádek, hranice buňky a původ stránky i source table. Oba formáty připraví celý dokument do omezeného bufferu v paměti a teprve potom ho publikují do cílového streamu, přičemž při částečném selhání zápisu obnoví původní bajty, délku i pozici, takže chybný export nikdy nenechá napůl zapsaný soubor. Budgety pro stránky, glyfy na stránku, tabulky, řádky, buňky, znaky i výstupní bajty se účtují samostatně a řádky se počítají před alokací, protože SetLength pro každý řádek se zvrhne v kvadratické kopírování dlouho před výchozím limitem milionu řádků

Kde geometrická obnova tabulek vzdá

Výslovné pojmenování failure modes je užitečnější než seznam funkcí, protože každý z nich je místo, kde volající potřebuje vlastní policy místo lepší hodnoty volby

  • Vertikální sloučení se neobnovuje. HotPDF hlásí ColumnSpan u horizontálních spanů a ponechá RowSpan na 1, takže buňka přes tři řádky tištěné tabulky přijde jako jedna buňka se dvěma mezerami
  • Detekce hlavičky je řízena daty, nikoli vzhledem. Blok hlavičky tvoří řádky před prvním řádkem obsahujícím netypovanou string hodnotu, takže tabulka, jejíž tělo je celé textové, nahlásí HeaderRowCount nula bez ohledu na styl
  • Tabulky pod MinimumTableConfidence se bez chyby zahodí z výsledku. Když potřebujete vědět, že se něco zahodilo, porovnejte Info.TableCount s Info.SourceTableCount
  • Run potřebuje alespoň dva řádky a alespoň dva sloupce, než ho layout průchod vůbec označí jako tabulku, takže jednřádková pseudotabulka nebo dvousloupcový layout dlouhé prózy správně, ale nepříjemně, tabulkou není
  • Naskenované stránky neobsahují textové operátory, takže geometricky není co obnovovat, dokud stránka nedostane OCR textovou vrstvu

Pokud vaše PDF vycházejí z vlastního reporting stacku, nejlevnější oprava toho všeho je upstream: generujte tagované tabulky nebo uchovávejte zdrojová data a extrakci berte jako fallback pro dokumenty, které jste nevytvořili. U všeho ostatního stojí za to se pipeline učit v tomto pořadí, protože každá vrstva staví na té pod ní: začněte u prosté extrakce textu z načteného PDF, přejděte na API typových tabulek, když musí zůstat geometrie, a podívejte se na vykreslení datové tabulky do nového PDF, když jste na straně generování a můžete rozhodnout, jak obnovitelný výstup bude

ExtractLoadedTypedTables a ExportLoadedTypedTables se dodávají jako součást nativního HotPDF Delphi PDF Component pro Delphi a C++Builder bez externí DLL a runtime závislosti; produktová stránka obsahuje úplnou referenci options, statusů a recordů typového table API