Műszaki cikk

Típusos PDF-táblázatok kinyerése oldaltörésen át

A HotPDF a ExtractLoadedTypedTables Delphi API-val nyer ki táblázatokat meglévő PDF-ből: összeolvasztja a tördelési menet sorfragmentumait, minden táblához egy kanonikus oszloprácsot épít, a geometriát támogató oldaltörésen át folytatja a táblát, és minden cellát típusos értékként ad vissza oldalprovenienciával, oszloppal és határokkal. Az ExportLoadedTypedTables ugyanezt az eredményt közvetlenül CSV-be vagy JSON-ba írja. A problémát, ami miatt ez megéri, unalmasan gyakori. Egy negyvenoldalas számlanyilvántartás logikailag egyetlen tábla, minden oldal tetején megismételt fejléccel. Egy naiv olvasási sorrenddel negyven táblát, harminckilenc téves fejlécsort és olyan pénznemoszlopot kapsz, amely minden olyan sorban egy pozícióval balra csúszik, ahol a középső cella üres volt. Ennek utólagos rendbetétele, a hívó alkalmazásban, ott öli meg a dokumentumimport-projekteket

Miért adnak a PDF-oldalak tábla helyett fragmentumokat?

Azért, mert a PDF-oldal egyáltalán nem hordoz táblaszemantikát, hacsak a dokumentum nincs tagelve. A content stream csak szövegmegjelenítő operátorokat és pozicionáló mátrixokat tartalmaz (ISO 32000-1 §9.4.3), mást nem; a képernyőn látható keretezett doboz független path-rajzolás, amelyet semmilyen extractor nem köteles a szöveghez kapcsolni. A Table, TR, TH és TD struktúraelemtípusok csak egy tagelt PDF logikai struktúrahierarchiájában élnek (ISO 32000-1 §14.8.4), a forgalomban lévő üzleti dokumentumok túlnyomó része pedig nem tagelt. Minden, ami lentebb jön, geometriai helyreállítás, nem parsing, és ezt még azelőtt érdemes hangosan kimondani, hogy bárki reconciliation reportot építene rá

A HotPDF ezért először szemantikus layout-elemzést végez a kinyert glypheken, ugyanazt a menetet, amely a betöltött PDF struktúrasorrendű szövegkinyerését és a strukturált HTML- és XML-exportokat is ellátja. Ez a menet a baseline-okat olyan runokba csoportosítja, amelyek cellái függőlegesen igazodnak, és csak addig folytat egy runt, amíg az egymást követő soroknak azonos a cellaszáma. A layout-engine számára ez helyes és olcsó szabály. A hívó számára viszont rossz alak: egy üres belső cellát tartalmazó sor két source table-re bontja az egyetlen vizuális táblát. A typed-table réteg éppen azért ül e menet fölött, hogy újra összeillessze a darabokat

Kanonikus oszloprácsok és a ColumnTolerance kapcsoló

Az ExtractLoadedTypedTables bármi más előtt azonos oldalon lévő fragmentumokat egyesít, és sorok szövege helyett oszlopgeometriával dönt. Két szomszédos forrástábla akkor olvad össze, ha mindkettőnek legalább két oszlopa van, az első utolsó sora és a második első sora közötti függőleges rés a tűrési sávon belül marad, az oszlopkezdések pedig egy vonalba esnek. A ColumnTolerance értékén belüli oszlopkezdések egy kanonikus oszloppá olvadnak, az egyesítés során átlagolva. Az alapértelmezett tűrés 12 user-space egység, ami a szokásos üzleti tipográfiához illik, széles trackinggel vagy mélyen behúzott layouttal viszont emelni kell

Az igazán fontos rész az, mi történik egy belső értéket nélkülöző sorral. A HotPDF minden cellát a legközelebbi kanonikus oszlopkezdésre pattint, majd a ColumnSpan-t az adott oszloptól a következő foglaltig terjedő távolságra állítja, nem tolja balra a megmaradt cellákat. Egy ötoszlopos rács háromcellás sora így a helyes fejlécek alatt tartja meg az értékeket, és pontosan jelzi, hol vannak a hézagok. Ettől lesz a táblázat reconciliálható, ahelyett hogy csendben rossz összeghez rendelné a pénzt

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 egységek
    Options.MinimumTableConfidence := 0.55;  // ez alatt a táblák kiesnek
    Options.DateOrder := ttdoDMY;            // 03/04/2026 = április 3.
    Options.DecimalSeparator := ',';
    Options.ThousandsSeparator := '.';
    if Pdf.ExtractLoadedTypedTables([0, 1, 2, 3], Options, Tables, Info) then
      // Info.TableCount és Info.SourceTableCount mutatja, mennyi olvadt össze
      ProcessTables(Tables)
    else if Info.Status = ttesBudgetExceeded then
      Log(string(Info.Diagnostic));
  finally
    Pdf.Free;
  end;
end;

Mit garantál valójában az oldalak közötti egyesítés?

Szándékosan konzervativizmust garantál. A HotPDF csak akkor kapcsol össze két táblát oldaltörésen át, ha a MergeAcrossPages engedélyezve van, a második tábla pontosan az első utolsó oldala utáni oldalon indul, mindkettőnek legalább két oszlopa van, és legalább két kanonikus oszlopkezdés a ColumnTolerance-on belül igazodik. A szomszédos oldalak feltétele a teherhordó rész. A hívók a PageIndices-et tetszőleges sorrendű open arrayként adják át, és ezzel az ellenőrzéssel egy 3., 9. és 14. oldalt kérő hívás három egymáshoz nem tartozó táblát hegeszthetne össze egy teljesen hihető eredménnyé. Az ára az, hogy egy valódi, oldalt kihagyó folytatás, egy közbeékelt appendix vagy egy üres hátoldalt tartalmazó duplex scan két táblaként érkezik, és nincs olyan opció, amely ezt lazítaná. Ezek újraegyesítése csak a hívó alkalmazás policy-döntése lehet, ezért az API kiteszi a FirstPageIndex, LastPageIndex, SourceTableCount és soronkénti PageIndex értéket, a döntést pedig ott hagyja, ahová való

A megismételt fejléceket megjelöli, sosem törli

Az ExtractLoadedTypedTables soha nem töröl megismételt fejlécsort az eredményből. Amikor egy oldalak közötti egyesítés azt látja, hogy a beérkező tábla ugyanazzal a fejléccel indul, mint az addig összegyűjtött tábla — trimelés és case folding után összevetve —, a sorokat IsHeader és IsRepeatedHeader flaggel jelöli, de forrássorrendben így is hozzáfűzi. A törlés veszteséges és visszafordíthatatlan döntés, a különböző fogyasztók pedig mást akarnak: a CSV-import nem kéri az ismétléseket, az auditnyom szeretné őket az oldalszámukkal, egy diffeszköz pedig bájtról bájtra megőrzött forrássorrendet akar. A library ezért jelent, a hívó pedig dönt

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;                    // csak az első fejlécblokk maradjon
      for C := 0 to High(Row.Cells) do
        if Row.Cells[C].ValueKind = ttvkCurrency then
          Total := Total + Row.Cells[C].NumberValue;
    end;
end;

Típusos értékek és a megadandó elválasztók

A típuskövetkeztetés fix sorrendben fut, és az egyértelműtlenségeket az egyetlen józan irányban oldja fel: először boolean, aztán dátum, százalék, pénznem, sima szám, minden más pedig string marad. A sorrend akadályozza meg, hogy egy dátumoszlop 2026 értékét a számparser eldöntse, mielőtt a dátumparser meglátná. A pénznemet kezdő $, £, ¥ vagy , illetve szóközt követő hárombetűs ISO 4217 kód alapján ismeri fel, a kódot pedig megőrzi a CurrencyCode-ban. A HotPDF döntő módon nem találgatja ki a locale-odat. A DecimalSeparator, ThousandsSeparator és DateOrder az opciókból jön, mert az 1.234 lehet egyetlen szám vagy ezerkettőszázharmincnégy attól függően, amit a PDF nem tartalmaz. A nyers Unicode Text minden cellában megmarad a típusos érték mellett, így egy rossz találgatás mindig visszafejthető új extraction menet nélkül

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;

A két exportformátum eltérő kérdésekre válaszol, és szándékosan nem ekvivalens. A CSV az összevont span folytatóoszlopait üres mezőként írja ki, ezt várja egy spreadsheet vagy bulk loader. A JSON mindent megőriz, amit az extraction tudott: a típusos értéket a saját kindja alatt, a columnSpan-t, cella- és soronkénti konfidenciát, a cellahatárokat, valamint az oldal- és forrástábla-provenienciát. Mindkét formátum a teljes dokumentumot korlátozott memóriapufferbe állítja össze, és csak ezután publikálja a célstreambe, hiba esetén visszaállítva az eredeti byte-okat, hosszt és pozíciót, így egy elbukott export soha nem hagy fél fájlt maga után. Az oldalak, oldalankénti glyphek, táblák, sorok, cellák, karakterek és kimeneti byte-ok budgetje külön számolódik, a sorokat pedig allokáció előtt számolja, mert a soronkénti SetLength jóval a milliós alapértelmezett plafon előtt kvadratikus másolásba fordulna

Hol adja fel a geometriai táblarekonstruálás?

A hibamódok explicitté tétele hasznosabb egy feature-listánál, mert mindegyik olyan pont, ahol a hívónak saját policyra van szüksége, nem egy jobb opcióértékre

  • Függőleges összevonásokat nem állít helyre. A HotPDF a vízszintes spanekre ColumnSpan-t jelent, a RowSpan-t pedig 1-en hagyja, ezért egy nyomtatott táblában három soron átívelő cella egy cellaként és két hézagként érkezik
  • A fejlécfelismerés adatvezérelt, nem vizuális. A fejlécblokk az első nem string típusos értéket tartalmazó sor előtti sorfutás, ezért egy teljesen szöveges törzsű tábla stílustól függetlenül nulla HeaderRowCount-ot jelent
  • A MinimumTableConfidence alatti táblák hiba nélkül kiesnek az eredményből. Hasonlítsd össze az Info.TableCount és Info.SourceTableCount értékét, ha tudni akarod, hogy valami el lett-e dobva
  • Egy runnak legalább két sor és legalább két oszlop kell, hogy a layout-pass egyáltalán táblának nevezze, így egy egysoros ál-tábla vagy egy hosszú prózát tartalmazó kétoszlopos layout helyesen, bár nem túl hasznosan nem lesz tábla
  • A szkennelt oldalak nem tartalmaznak szövegoperátorokat, ezért geometriailag nincs mit helyreállítani, amíg nem kerül OCR text layer az oldalra

Ha a PDF-ek a saját reporting stackedből jönnek, mindezek legolcsóbb javítása upstream történik: állíts elő tagelt táblákat, vagy őrizd meg a forrásadatot, az extractiont pedig kezeld olyan dokumentumok fallbackjeként, amelyeket nem te gyártottál. Minden másnál érdemes ebben a sorrendben megismerni a pipeline-t, mert minden réteg az alatta lévőre épül: kezdd a betöltött PDF sima szövegkinyerésével, lépj fel a typed-table API-ra, amikor a geometriát is meg kell őrizni, és nézd meg az adattábla új PDF-be renderelését, amikor generáló oldalon vagy, és te döntöd el, mennyire legyen helyreállítható a kimenet

Az ExtractLoadedTypedTables és az ExportLoadedTypedTables a natív HotPDF Delphi PDF Component része Delphi és C++Builder számára, külső DLL és runtime-függőség nélkül; a termékoldal tartalmazza a typed-table API teljes opció-, státusz- és rekord-referenciáját