Technisch artikel

Getypeerde PDF-tabellen over pagina-einden in Delphi

HotPDF haalt tabellen uit een bestaande PDF via ExtractLoadedTypedTables, een Delphi-API die de rijfragmenten die de layoutpass produceert samenvoegt, één canoniek kolomgrid per tabel bouwt, een tabel over een pagina-einde voortzet wanneer de geometrie dat ondersteunt en elke cel teruggeeft als een typed value met herkomstpagina, column span en bounds. ExportLoadedTypedTables schrijft hetzelfde resultaat rechtstreeks naar CSV of JSON. Het scenario waarvoor dit de moeite waard is, is saai en extreem gebruikelijk. Een factuurregister van veertig pagina's, logisch gezien één tabel, geprint met bovenaan elke pagina dezelfde header. Draai er een naïeve reading-order-pass overheen en je krijgt veertig tabellen, 39 overbodige headerrijen en een currencykolom die op elke rij één positie naar links schuift wanneer de middelste cel toevallig leeg was. Dat downstream in de aanroepende applicatie opruimen is waar documentimportprojecten ten onder gaan

Waarom geeft een PDF-pagina je fragmenten in plaats van een tabel?

Omdat een PDF-pagina helemaal geen tablesemantiek bevat tenzij het document is getagd. De contentstream bevat text-showing-operators en positioneringsmatrices (ISO 32000-1 §9.4.3) en verder niets; de omkaderde box die je op het scherm ziet is losstaande path painting die geen enkele extractor hoeft te correleren met de tekst. Structure-elementtypes Table, TR, TH en TD leven alleen in de logische structuurhiërarchie van een getagde PDF (ISO 32000-1 §14.8.4), en de overgrote meerderheid van de zakelijke documenten in omloop is niet getagd. Alles wat hieronder wordt beschreven is geometrisch herstel en geen parsing, en dat moet je hardop zeggen voordat iemand er een reconciliationrapport op bouwt

HotPDF voert daarom eerst een semantische layoutanalyse uit op de geëxtraheerde glyphs, dezelfde pass achter structure-order text extraction uit een geladen PDF en de gestructureerde HTML- en XML-exports. Die pass groepeert baselines in runs waarvan de cellen verticaal uitlijnen, en hij zet een run alleen voort zolang opeenvolgende rijen hetzelfde aantal cellen hebben. Voor een layoutengine is die regel correct en goedkoop. Voor een caller heeft hij de verkeerde vorm: één rij met een lege cel in het midden splitst één visuele tabel in twee source tables. De typed-tablelaag zit precies boven die pass om de stukken weer samen te voegen

Canonieke kolomgrids en de ColumnTolerance-knop

ExtractLoadedTypedTables voegt fragmenten op dezelfde pagina samen voordat het iets anders doet, en het voegt ze samen op basis van kolomgeometrie en niet van rijtekst. Twee aangrenzende sourcetabellen op één pagina worden samengevoegd wanneer beide minstens twee kolommen hebben, wanneer de verticale afstand tussen de laatste rij van de eerste en de eerste rij van de tweede binnen de tolerance band blijft en wanneer hun kolomstartposities uitlijnen. Kolomstarts die binnen ColumnTolerance van elkaar liggen, vloeien samen tot één canonieke kolom en worden tijdens het samenvoegen gemiddeld. De standaardtolerantie is 12 units in user space, geschikt voor gewone zakelijke typografie, maar verhoging is nodig voor ruim getrackte of diep ingesprongen layouts

Wat er met een rij gebeurt die een binnenwaarde mist, is het belangrijke deel. HotPDF snapt elke cel op de dichtstbijzijnde canonieke kolomstart en stelt daarna ColumnSpan in op de afstand van die kolom tot de volgende bezette kolom, in plaats van de overgebleven cellen naar links te schuiven. Een rij met drie cellen in een grid van vijf kolommen houdt zijn waarden onder de juiste headers en legt exact vast waar de gaten zitten. Dat is het verschil tussen een tabel waarmee je kunt reconciliëren en een tabel die stilletjes geld aan de verkeerde kolom toeschrijft

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;           // units in user space
    Options.MinimumTableConfidence := 0.55;  // onder deze waarde worden tabellen verwijderd
    Options.DateOrder := ttdoDMY;            // 03/04/2026 is 3 april
    Options.DecimalSeparator := ',';
    Options.ThousandsSeparator := '.';
    if Pdf.ExtractLoadedTypedTables([0, 1, 2, 3], Options, Tables, Info) then
      // Info.TableCount tegenover Info.SourceTableCount toont hoeveel is samengevoegd
      ProcessTables(Tables)
    else if Info.Status = ttesBudgetExceeded then
      Log(string(Info.Diagnostic));
  finally
    Pdf.Free;
  end;
end;

Wat garandeert samenvoegen over pagina's werkelijk?

Het garandeert bewust conservatisme. HotPDF voegt twee tabellen over een paginagrens alleen samen wanneer MergeAcrossPages is ingeschakeld, wanneer de tweede tabel precies op de pagina-index na het einde van de eerste begint, wanneer beide minstens twee kolommen hebben en wanneer minstens twee canonieke kolomstarts binnen ColumnTolerance uitlijnen. De voorwaarde van opeenvolgende pagina's draagt het meeste gewicht. Callers geven PageIndices door als een open array in elke gewenste volgorde, en zonder die check zou een verzoek om pagina's 3, 9 en 14 drie ongerelateerde tabellen aan elkaar kunnen lassen tot één volkomen aannemelijk resultaat. De prijs is dat een echte voortzetting die een pagina overslaat, een interleaved appendix of een duplexscan met een lege achterzijde als twee tabellen terugkomt en geen enkele optie dit losser maakt. Die opnieuw samenvoegen is een beleidskeuze die alleen de aanroepende applicatie kan maken, dus de API stelt FirstPageIndex, LastPageIndex, SourceTableCount en een PageIndex per rij beschikbaar en laat de beslissing waar die hoort

Herhaalde headers worden gelabeld en nooit verwijderd

ExtractLoadedTypedTables verwijdert een herhaalde headerrij nooit uit het resultaat. Wanneer een cross-page-merge merkt dat de binnenkomende tabel opent met headertekst die identiek is aan de verzamelde tabel, vergeleken na trimmen en case folding, markeert het die rijen met IsHeader en IsRepeatedHeader en voegt het ze toch in source order toe. Verwijderen is een verliesgevende en onomkeerbare keuze, en verschillende consumers willen verschillende antwoorden: een CSV-import wil de herhalingen kwijt, een audit trail wil ze met hun paginanummers behouden en een diffingtool wil de source order byte voor byte bewaren. Dus rapporteert de library en beslist de 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;                    // alleen het eerste headerblok behouden
      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 en de separators die je moet meegeven

Type inference draait in een vaste volgorde die ambiguïteiten in de enige verstandige richting oplost: eerst boolean, dan date, dan percentage, dan currency, daarna plain number en alles wat niet matcht blijft een string. Die volgorde voorkomt dat 2026 in een datekolom door een numberparser wordt beslist voordat de dateparser het ziet. Currency wordt herkend aan een vooropstaande $, £, ¥ of , of aan een drieletterige ISO 4217-code gevolgd door een spatie, en de code wordt behouden in CurrencyCode. Cruciaal is dat HotPDF jouw locale niet raadt. DecimalSeparator, ThousandsSeparator en DateOrder komen uit de options, omdat 1.234 óf één getal óf duizend tweehonderdvierendertig is, afhankelijk van een feit dat de PDF niet bevat. De ruwe Unicode-Text blijft op elke cel bewaard naast de typed value, zodat een verkeerde gok altijd herstelbaar is zonder een tweede extractiepass

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;

De twee exportformaten beantwoorden verschillende vragen en zijn bewust niet equivalent. CSV schrijft de continuation columns van een merged span als lege velden, precies wat een spreadsheet of bulk loader verwacht. JSON houdt alles bij wat de extractie wist: de typed value onder zijn eigen kind, columnSpan, confidence per cel en rij, de bounds van de cel en de provenance van pagina en sourcetabel. Beide formaten zetten het hele document in een begrensde in-memorybuffer en publiceren pas daarna naar je destination stream, waarbij de oorspronkelijke bytes, lengte en positie worden hersteld als het schrijven halverwege faalt, zodat een mislukte export nooit een halfgeschreven bestand achterlaat. Budgetten voor pagina's, glyphs per pagina, tabellen, rijen, cellen, tekens en outputbytes worden allemaal afzonderlijk bijgehouden, en rijen worden vóór allocatie geteld omdat een SetLength per rij lang vóór de standaardgrens van een miljoen rijen ontaardt in kwadratisch kopiëren

Waar geeft geometrisch tabelherstel het op?

Expliciet zijn over de faalmodi is nuttiger dan een featurelijst, omdat elk van deze punten een eigen policy van de caller nodig heeft in plaats van een betere optionwaarde

  • Verticale merges worden niet hersteld. HotPDF rapporteert ColumnSpan voor horizontale spans en laat RowSpan op 1 staan, zodat een cel die in de gedrukte tabel drie rijen beslaat binnenkomt als één cel plus twee gaten
  • Headerdetectie is datagedreven en niet visueel. Het headerblok is de run van rijen vóór de eerste rij met een non-string typed value, dus een tabel waarvan de body volledig uit tekst bestaat rapporteert HeaderRowCount als nul, hoe die ook is gestyled
  • Tabellen onder MinimumTableConfidence worden zonder fout uit het resultaat verwijderd. Vergelijk Info.TableCount met Info.SourceTableCount wanneer je moet weten of iets is weggegooid
  • Een run heeft minstens twee rijen en minstens twee kolommen nodig voordat de layoutpass hem überhaupt een tabel noemt, dus een pseudo-tabel van één regel of een tweekolomsopmaak met lange proza is terecht, maar onhandig, geen tabel
  • Gescande pagina's bevatten geen text operators, dus geometrisch valt er niets te herstellen totdat er een OCR-tekstlaag op de pagina staat

Als je PDF's uit je eigen reportingstack komen, is de goedkoopste fix voor dit alles upstream: emit getagde tabellen of bewaar de brondataset en behandel extractie als fallback voor documenten die je niet zelf hebt geproduceerd. Voor de rest is het de moeite waard de pipeline in deze volgorde te leren, omdat elke laag op de vorige voortbouwt: begin met platte tekstextractie uit een geladen PDF, ga omhoog naar de typed-table-API wanneer de geometrie behouden moet blijven en kijk naar een datatabel naar een nieuwe PDF renderen wanneer je aan de genererende kant zit en zelf kunt bepalen hoe herstelbaar de output wordt

ExtractLoadedTypedTables en ExportLoadedTypedTables worden geleverd als onderdeel van de native HotPDF Delphi PDF Component voor Delphi en C++Builder, zonder externe DLL en zonder runtime-dependency; de productpagina bevat de volledige referentie voor opties, statussen en records van de typed-table-API