Teknisk artikel

Typad PDF-tabellutvinning över sidbrytningar i Delphi

HotPDF återställer tabeller från en befintlig PDF genom ExtractLoadedTypedTables, ett Delphi-API som sammanfogar radfragmenten från layoutpasset, bygger ett kanoniskt kolumnrutnät per tabell, fortsätter tabellen över en sidbrytning när geometrin stöder det och returnerar varje cell som ett typat värde med sidproveniens, kolumnspann och gränser. ExportLoadedTypedTables skriver samma resultat direkt till CSV eller JSON. Scenariot som gör detta värt att bygga är tråkigt och extremt vanligt. Ett fyrtio sidor långt fakturaregister, en logisk tabell, utskriven med rubriken upprepad överst på varje sida. Kör en naiv läsordningspassage över den och du får fyrtio tabeller, trettionio falska rubrikrader och en valutakolumn som flyttar en position åt vänster på varje rad där mittcellen råkade vara tom. Att städa upp det nedströms i den anropande applikationen är där dokumentimportprojekt dör

Varför ger en PDF-sida dig fragment i stället för en tabell?

För att en PDF-sida inte bär någon tabellsemantik alls om inte dokumentet är taggat. Innehållsströmmen innehåller textvisande operatorer och positioneringsmatriser (ISO 32000-1 §9.4.3) och inget mer; den linjerade ruta du ser på skärmen är orelaterad banmålning som ingen extraherare måste korrelera med texten. Strukturelementtyperna Table, TR, TH och TD finns bara i den logiska strukturhierarkin i en taggad PDF (ISO 32000-1 §14.8.4), och den överväldigande majoriteten av affärsdokument i omlopp är inte taggade. Allt som beskrivs nedan är geometrisk återställning, inte parsing, och det är värt att säga högt innan någon bygger en avstämningsrapport ovanpå den

HotPDF kör därför först en semantisk layoutanalys över de extraherade glyferna, samma pass som driver strukturordnad textextrahering från en inläst PDF och de strukturerade HTML- och XML-exporterna. Det passet grupperar baslinjer i körningar vars celler linjerar vertikalt, och det fortsätter bara en körning så länge på varandra följande rader har samma antal celler. För en layoutmotor är regeln korrekt och billig. För en anropare är den fel form: en enda rad med en tom inre cell delar en visuell tabell i två källtabeller. Det typade tabellagret ligger ovanpå passet just för att sätta ihop delarna igen

Kanoniska kolumnrutnät och reglaget ColumnTolerance

ExtractLoadedTypedTables sammanfogar fragment från samma sida innan den gör något annat, och den sammanfogar efter kolumngeometri snarare än efter radtext. Två intilliggande källtabeller på en sida slås ihop när båda har minst två kolumner, när det vertikala avståndet mellan den första tabellens sista rad och den andras första rad ligger inom toleransbandet och när deras kolumnstarter linjerar. Kolumnstarter inom ColumnTolerance från varandra faller ihop till en kanonisk kolumn och medelvärdesbildas när de slås ihop. Standardtoleransen är 12 enheter i användarutrymme, vilket passar vanlig affärstypografi och bör höjas för bredt spärrade eller djupt indragna layouter

Det som händer med en rad som saknar ett inre värde är den viktiga delen. HotPDF fäster varje cell vid sin närmaste kanoniska kolumnstart och sätter sedan ColumnSpan till avståndet från den kolumnen till nästa upptagna, i stället för att flytta de återstående cellerna åt vänster. En rad med tre celler i ett rutnät med fem kolumner behåller sina värden under rätt rubriker och registrerar exakt var luckorna finns. Det är skillnaden mellan en tabell du kan stämma av och en som i tysthet tilldelar pengar till fel plats

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;           // enheter i användarutrymme
    Options.MinimumTableConfidence := 0.55;  // under detta tas tabeller bort
    Options.DateOrder := ttdoDMY;            // 03/04/2026 är 3 april
    Options.DecimalSeparator := ',';
    Options.ThousandsSeparator := '.';
    if Pdf.ExtractLoadedTypedTables([0, 1, 2, 3], Options, Tables, Info) then
      // Info.TableCount kontra Info.SourceTableCount visar hur mycket som slogs ihop
      ProcessTables(Tables)
    else if Info.Status = ttesBudgetExceeded then
      Log(string(Info.Diagnostic));
  finally
    Pdf.Free;
  end;
end;

Vad garanterar sammanfogning över sidor egentligen?

Den garanterar konservatism, avsiktligt. HotPDF sammanfogar två tabeller över en sidgräns endast när MergeAcrossPages är aktiverat, när den andra tabellen börjar på exakt det sidindex som följer efter den första tabellens slut, när båda har minst två kolumner och när minst två kanoniska kolumnstarter linjerar inom ColumnTolerance. Villkoret med på varandra följande sidor är det bärande. Anropare skickar PageIndices som en öppen array i vilken ordning de vill, och utan den kontrollen skulle en begäran om sidorna 3, 9 och 14 kunna svetsa ihop tre orelaterade tabeller till ett helt trovärdigt resultat. Kostnaden är att en äkta fortsättning som hoppar över en sida, en mellanlagd bilaga eller en duplexskanning med en tom baksida kommer tillbaka som två tabeller och ingen flagga luckrar upp det. Att foga ihop sådana igen är ett policybeslut som bara den anropande applikationen kan fatta, så API:et exponerar FirstPageIndex, LastPageIndex, SourceTableCount och ett PageIndex per rad och lämnar beslutet där det hör hemma

Upprepade rubriker märks, aldrig raderas

ExtractLoadedTypedTables tar aldrig bort en upprepad rubrikrad från resultatet. När en sammanfogning över sidor upptäcker att den inkommande tabellen öppnar med rubriktext som är identisk med den ackumulerade tabellens, jämförd efter trimning och skiftlägesnormalisering, markerar den raderna som IsHeader och IsRepeatedHeader och lägger ändå till dem i källordningen. Borttagning är ett informationsförstörande och oåterkalleligt val, och olika konsumenter vill ha olika svar: en CSV-import vill ta bort upprepningarna, ett revisionsspår vill ha dem kvar med sina sidnummer och ett diffverktyg vill bevara källordningen byte för byte. Därför rapporterar biblioteket och anroparen bestämmer

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;                    // behåll bara det första rubrikblocket
      for C := 0 to High(Row.Cells) do
        if Row.Cells[C].ValueKind = ttvkCurrency then
          Total := Total + Row.Cells[C].NumberValue;
    end;
end;

Typade värden och separatorerna du måste ange

Typinferens körs i en fast ordning som löser tvetydigheterna i den enda rimliga riktningen: booleskt först, sedan datum, procent, valuta och vanligt tal, medan allt som inte matchar förblir en sträng. Ordningen hindrar 2026 i en datumkolumn från att avgöras av en talparser innan datumparsern hinner se det. Valuta känns igen från ett inledande $, £, ¥ eller , eller från en treställig ISO 4217-kod följd av ett blanksteg, och koden bevaras i CurrencyCode. Avgörande är att HotPDF inte gissar din lokal. DecimalSeparator, ThousandsSeparator och DateOrder kommer från alternativen, eftersom 1.234 är antingen ett tal eller ett tusen tvåhundratrettiofyra beroende på en uppgift som PDF-filen inte innehåller. Den råa Unicode-Text behålls i varje cell bredvid det typade värdet, så en felaktig gissning alltid kan återvinnas utan en andra extraheringspassage

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 två exportformaten svarar på olika frågor och är medvetet inte likvärdiga. CSV skriver fortsättningskolumnerna i ett sammanslaget spann som tomma fält, vilket är vad ett kalkylblad eller en massinläsare förväntar sig. JSON behåller allt som extraheringen visste: det typade värdet under sin egen typ, columnSpan, konfidens per cell och rad, cellgränserna samt sid- och källtabellsproveniens. Båda formaten mellanlagrar hela dokumentet i en begränsad minnesbuffert och publicerar först därefter till destinationsströmmen, och återställer de ursprungliga bytena, längden och positionen om skrivningen misslyckas halvvägs, så en misslyckad export lämnar aldrig efter sig en halvt skriven fil. Budgetar för sidor, glyfer per sida, tabeller, rader, celler, tecken och utdata i byte räknas alla separat, och rader räknas före allokering eftersom ett SetLength per rad degenererar till kvadratisk kopiering långt innan standardtaket på en miljon rader

Var den geometriska tabellåterställningen ger upp

Att vara tydlig med fellägena är mer användbart än en funktionslista, eftersom vart och ett är en plats där en anropare behöver sin egen policy snarare än ett bättre alternativvärde

  • Vertikala sammanslagningar återställs inte. HotPDF rapporterar ColumnSpan för horisontella spann och lämnar RowSpan på 1, så en cell som spänner över tre utskrivna rader kommer som en cell plus två luckor
  • Rubrikdetektering är datadriven, inte visuell. Rubrikblocket är körningen av rader före den första raden som innehåller ett typat värde som inte är en sträng, så en tabell vars kropp helt består av text rapporterar HeaderRowCount som noll oavsett hur den är formgiven
  • Tabeller under MinimumTableConfidence tas bort från resultatet utan ett fel. Jämför Info.TableCount med Info.SourceTableCount när du behöver veta att något kasserades
  • En körning behöver minst två rader och minst två kolumner innan layoutpasset över huvud taget kallar den en tabell, så en pseudotabell på en rad eller en layout med två kolumner av lång prosa är korrekt, och ohjälpsamt, inte en tabell
  • Skannade sidor innehåller inga textoperatorer, så det finns inget att återställa geometriskt förrän ett OCR-textlager finns på sidan

Om dina PDF-filer kommer från din egen rapportstack är den billigaste lösningen på allt detta uppströms: avge taggade tabeller eller behåll källdatan, och behandla extrahering som en reservväg för dokument du inte producerade. För allt annat är pipelinen värd att lära sig i den här ordningen eftersom varje lager bygger på det under: börja med vanlig textextrahering från en inläst PDF, gå upp till API:et för typade tabeller när geometrin måste bevaras och titta på att rendera en datatabell till en ny PDF när du är på genereringssidan och själv kan avgöra hur återställningsbar utdata ska vara

ExtractLoadedTypedTables och ExportLoadedTypedTables levereras som en del av den inbyggda HotPDF Delphi PDF-komponenten för Delphi och C++Builder, utan extern DLL och utan runtime-beroende; produktsidan innehåller hela referensen för alternativ, status och poster i API:et för typade tabeller