Technischer Artikel

Typisierte PDF-Tabellenextraktion über Seitenumbrüche

HotPDF gewinnt Tabellen aus einem vorhandenen PDF über ExtractLoadedTypedTables zurück, eine Delphi-API, die die von der Layoutphase erzeugten Zeilenfragmente zusammenführt, pro Tabelle ein kanonisches Spaltenraster bildet, die Tabelle bei passender Geometrie über einen Seitenumbruch fortsetzt und jede Zelle als typisierten Wert mit Seitenherkunft, Spaltenspanne und Begrenzung zurückgibt. ExportLoadedTypedTables schreibt dasselbe Ergebnis direkt in CSV oder JSON. Der Anwendungsfall ist unspektakulär und extrem häufig: ein vierzigseitiges Rechnungsregister, logisch eine Tabelle, deren Kopf am Seitenanfang wiederholt wird. Läuft darüber ein naiver Lesereihenfolge-Durchlauf, entstehen vierzig Tabellen, 39 falsche Kopfzeilen und eine Währungsspalte, die auf jeder Zeile, in der die mittlere Zelle zufällig leer war, um eine Position nach links rutscht. Diese Bereinigung nachgelagert in der aufrufenden Anwendung zu erledigen, lässt Dokumentimportprojekte sterben

Warum liefert eine PDF-Seite Fragmente statt einer Tabelle?

Weil eine PDF-Seite überhaupt keine Tabellensemantik trägt, sofern das Dokument nicht getaggt ist. Der Content-Stream enthält Textanzeigeoperatoren und Positionierungsmatrizen (ISO 32000-1 §9.4.3) und sonst nichts. Die auf dem Bildschirm sichtbare Rahmenbox ist unabhängiges Pfadzeichnen, das kein Extractor mit dem Text korrelieren muss. Strukturelementtypen wie Table, TR, TH und TD existieren nur in der logischen Strukturhierarchie eines getaggten PDFs (ISO 32000-1 §14.8.4), und die überwältigende Mehrheit der umlaufenden Geschäftsdokumente ist nicht getaggt. Alles Folgende ist geometrische Rekonstruktion und kein Parsing. Das sollte ausgesprochen werden, bevor jemand darauf einen Abgleichsbericht baut

HotPDF führt daher zuerst eine semantische Layoutanalyse über den extrahierten Glyphs aus, denselben Durchlauf, der strukturgeführte Textextraktion aus einem geladenen PDF und die strukturierten HTML- und XML-Exporte speist. Dieser Durchlauf gruppiert Grundlinien in Läufe, deren Zellen vertikal ausgerichtet sind, und setzt einen Lauf nur fort, solange aufeinanderfolgende Zeilen dieselbe Zellanzahl haben. Für eine Layout-Engine ist diese Regel korrekt und günstig. Für einen Aufrufer ist sie die falsche Form: Eine einzelne Zeile mit einer leeren inneren Zelle teilt eine visuelle Tabelle in zwei Quelltabellen. Die typisierte Tabellenebene sitzt genau über diesem Durchlauf, um die Stücke wieder zusammenzusetzen

Kanonische Spaltenraster und der Regler ColumnTolerance

ExtractLoadedTypedTables führt Fragmente derselben Seite zusammen, bevor es irgendetwas anderes tut, und verbindet sie über Spaltengeometrie statt über Zeilentext. Zwei benachbarte Quelltabellen auf einer Seite werden zusammengeführt, wenn beide mindestens zwei Spalten haben, der vertikale Abstand zwischen letzter Zeile der ersten und erster Zeile der zweiten innerhalb des Toleranzbands liegt und ihre Spaltenanfangspositionen ausgerichtet sind. Spaltenanfänge innerhalb von ColumnTolerance werden zu einer kanonischen Spalte zusammengelegt und beim Zusammenführen gemittelt. Die Standardtoleranz beträgt 12 User-Space-Einheiten, was für gewöhnliche Geschäftstypografie passt und bei weit gesperrten oder tief eingerückten Layouts erhöht werden sollte

Entscheidend ist, was mit einer Zeile geschieht, der ein innerer Wert fehlt. HotPDF ordnet jede Zelle dem nächstgelegenen kanonischen Spaltenanfang zu und setzt dann ColumnSpan auf den Abstand von dieser Spalte zur nächsten belegten, statt die übrigen Zellen nach links zu verschieben. Eine Dreizellenzeile in einem Fünfspaltenraster behält ihre Werte unter den richtigen Überschriften und verzeichnet exakt, wo Lücken liegen. Das ist der Unterschied zwischen einer Tabelle, die man abgleichen kann, und einer, die Geldwerte still der falschen Spalte zuordnet

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-Einheiten
    Options.MinimumTableConfidence := 0.55;  // darunter werden Tabellen verworfen
    Options.DateOrder := ttdoDMY;            // 03/04/2026 ist der 3. April
    Options.DecimalSeparator := ',';
    Options.ThousandsSeparator := '.';
    if Pdf.ExtractLoadedTypedTables([0, 1, 2, 3], Options, Tables, Info) then
      // Info.TableCount gegenüber Info.SourceTableCount zeigt den Umfang des Mergings
      ProcessTables(Tables)
    else if Info.Status = ttesBudgetExceeded then
      Log(string(Info.Diagnostic));
  finally
    Pdf.Free;
  end;
end;

Was garantiert das seitenübergreifende Merging tatsächlich?

Es garantiert bewusst Zurückhaltung. HotPDF verbindet zwei Tabellen über eine Seitengrenze nur, wenn MergeAcrossPages aktiviert ist, die zweite Tabelle genau auf dem Seitenindex nach dem Ende der ersten beginnt, beide mindestens zwei Spalten besitzen und mindestens zwei kanonische Spaltenanfänge innerhalb von ColumnTolerance übereinstimmen. Die Bedingung aufeinanderfolgender Seiten trägt die ganze Sicherheit. Aufrufer übergeben PageIndices als Open Array in beliebiger Reihenfolge; ohne diese Prüfung könnte eine Anfrage für die Seiten 3, 9 und 14 drei unabhängige Tabellen zu einem völlig plausibel aussehenden Ergebnis verschweißen. Der Preis ist, dass eine echte Fortsetzung mit einer ausgelassenen Seite, einem eingeschobenen Anhang oder einem Duplexscan mit leerer Rückseite als zwei Tabellen zurückkommt und keine Option diese Prüfung lockert. Das Wiederzusammenführen ist eine Richtlinienentscheidung, die nur die aufrufende Anwendung treffen kann. Deshalb stellt die API FirstPageIndex, LastPageIndex, SourceTableCount und pro Zeile PageIndex bereit und lässt die Entscheidung dort, wo sie hingehört

Wiederholte Kopfzeilen werden markiert und niemals gelöscht

ExtractLoadedTypedTables entfernt keine wiederholte Kopfzeile aus dem Ergebnis. Wenn ein seitenübergreifendes Merge erkennt, dass die eingehende Tabelle mit Kopftext beginnt, der nach Trimmen und Vereinheitlichung der Groß-/Kleinschreibung mit der bereits aufgebauten Tabelle identisch ist, markiert es diese Zeilen mit IsHeader und IsRepeatedHeader und hängt sie trotzdem in Quellreihenfolge an. Löschen ist eine verlustbehaftete und irreversible Entscheidung, und verschiedene Verbraucher wollen unterschiedliche Antworten: Ein CSV-Import möchte die Wiederholungen entfernen, ein Audit-Trail möchte sie mit Seitennummern behalten, ein Diff-Tool möchte die Quellreihenfolge bytegenau bewahren. Die Bibliothek meldet, der Aufrufer entscheidet

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;                    // nur den ersten Kopfblock behalten
      for C := 0 to High(Row.Cells) do
        if Row.Cells[C].ValueKind = ttvkCurrency then
          Total := Total + Row.Cells[C].NumberValue;
    end;
end;

Typisierte Werte und die Separatoren, die Sie liefern müssen

Die Typinferenz läuft in fester Reihenfolge und löst Mehrdeutigkeiten in der einzigen vernünftigen Richtung: zuerst Boolean, dann Datum, Prozentwert, Währung und einfache Zahl; was nicht passt, bleibt ein String. Die Reihenfolge verhindert, dass 2026 in einer Datumsspalte von einem Zahlenparser entschieden wird, bevor der Datumparser es sieht. Währung wird an einem führenden $, £, ¥ oder oder an einem dreibuchstabigen ISO-4217-Code gefolgt von einem Leerzeichen erkannt; der Code bleibt in CurrencyCode erhalten. Entscheidend: HotPDF rät nicht nach Ihrer Locale. DecimalSeparator, ThousandsSeparator und DateOrder kommen aus den Optionen, denn 1.234 ist entweder eine Zahl oder eintausendzweihundertvierunddreißig, abhängig von einer Tatsache, die das PDF nicht enthält. Der rohe Unicode-Text bleibt neben dem typisierten Wert in jeder Zelle erhalten, sodass ein falsches Raten ohne zweiten Extraktionsdurchlauf korrigiert werden kann

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;

Die beiden Exportformate beantworten unterschiedliche Fragen und sind absichtlich nicht gleichwertig. CSV schreibt die Fortsetzungsspalten einer zusammengeführten Spanne als leere Felder, wie es eine Tabellenkalkulation oder ein Bulk-Loader erwartet. JSON behält alles, was die Extraktion wusste: den typisierten Wert unter seinem eigenen Typ, columnSpan, Konfidenz je Zelle und Zeile, die Zellgrenzen sowie Seiten- und Quelltabellenherkunft. Beide Formate bauen das gesamte Dokument in einem begrenzten Speicherpuffer auf und veröffentlichen es erst dann im Zielstream. Scheitert das Schreiben teilweise, werden Originalbytes, Länge und Position wiederhergestellt, sodass ein fehlgeschlagener Export nie eine halb geschriebene Datei hinterlässt. Budgets für Seiten, Glyphs je Seite, Tabellen, Zeilen, Zellen, Zeichen und Ausgabebytes werden separat abgerechnet, und Zeilen werden vor der Allokation gezählt, weil ein SetLength pro Zeile lange vor der Standardgrenze von einer Million Zeilen in quadratisches Kopieren ausartet

Wo die geometrische Tabellenrekonstruktion aufgibt

Die Fehlerfälle klar zu benennen, ist nützlicher als eine Featureliste, denn jeder ist eine Stelle, an der ein Aufrufer seine eigene Richtlinie und keinen besseren Optionswert braucht

  • Vertikale Zusammenführungen werden nicht rekonstruiert. HotPDF meldet für horizontale Spannen ColumnSpan und lässt RowSpan bei 1. Eine Zelle, die sich in der gedruckten Tabelle über drei Zeilen erstreckt, kommt daher als eine Zelle plus zwei Lücken an
  • Kopfzeilenerkennung ist datengetrieben und nicht visuell. Der Kopfblock ist der Lauf von Zeilen vor der ersten Zeile mit einem nicht als String typisierten Wert. Eine Tabelle, deren Körper vollständig aus Text besteht, meldet daher HeaderRowCount als null, unabhängig von ihrer Gestaltung
  • Tabellen unter MinimumTableConfidence werden ohne Fehler aus dem Ergebnis entfernt. Vergleichen Sie Info.TableCount mit Info.SourceTableCount, wenn Sie wissen müssen, ob etwas verworfen wurde
  • Ein Lauf benötigt mindestens zwei Zeilen und mindestens zwei Spalten, bevor die Layoutphase ihn überhaupt als Tabelle bezeichnet. Eine einzeilige Pseudotabelle oder ein zweispaltiges Layout aus langem Fließtext ist daher korrekt und unhilfreich keine Tabelle
  • Gescannte Seiten enthalten keine Textoperatoren, daher gibt es geometrisch nichts zu rekonstruieren, bis eine OCR-Textebene auf der Seite vorhanden ist

Wenn Ihre PDFs aus dem eigenen Reporting-Stack kommen, ist die günstigste Lösung für all das vorgelagert: getaggte Tabellen ausgeben oder die Quelldaten behalten und Extraktion als Fallback für fremde Dokumente behandeln. Für alles andere lohnt es sich, die Pipeline in dieser Reihenfolge zu lernen, weil jede Ebene auf der darunterliegenden aufbaut: Beginnen Sie mit einfacher Textextraktion aus einem geladenen PDF, gehen Sie zur typisierten Tabellen-API, sobald die Geometrie erhalten bleiben muss, und sehen Sie sich das Rendern einer Datentabelle in ein neues PDF an, wenn Sie erzeugen und selbst entscheiden können, wie gut die Ausgabe rekonstruierbar wird

ExtractLoadedTypedTables und ExportLoadedTypedTables gehören zur nativen HotPDF Delphi PDF Component für Delphi und C++Builder, ohne externe DLL und ohne Laufzeitabhängigkeit; die Produktseite enthält die vollständige Referenz zu Optionen, Statuswerten und Records der typisierten Tabellen-API