Technischer Artikel

Inhaltsbasierte Tabellenfortsetzung über PDF-Seiten (Delphi)

PDFium Component ab Version 3.117.0 verknüpft eine Tabelle, die über eine Seitengrenze hinweg umbrochen wird, wenn entweder beide Fragmente die Seitenkanten berühren oder kein Fließtext unter dem ersten und über dem zweiten Fragment sitzt, wobei laufende Kopf- und Fußzeilen ignoriert werden. ExtractDocumentTables wendet diesen inhaltsbasierten Test als Alternative zum älteren Seitenrand-Test an, weist ein Folgeseiten-Fragment zurück, dessen erste Zeile eine einzelne, die volle Breite einnehmende Überschriftzelle ist, und behält eine einzelne Zeile, die auf die nächste Seite überläuft, als Teil ihrer Fortsetzungskette

Der Artikel zu Tabellenerkennung und -extraktion stellte die Fortsetzung als vier strenge Tore dar und behandelte „berührt die Seitenkante“ als eines davon. Diese Beschreibung war für die Release, die sie begleitete, korrekt – und für die meisten Tabellen, die Leute tatsächlich in die Komponente füttern, trotzdem falsch. Dieser Artikel ist die Korrektur: welche Dokumente der Rand-Test nicht bewältigt, was ihn ersetzt hat, und die zwei Nebenfälle, die der Fix mit eingeliefert hat

Warum versagt der Seitenrand-Test bei Word-Exporten?

Der Seitenrand-Test versagt, weil ein Textverarbeitungsprogramm Zeilen am unteren Rand aufhört zu setzen, nicht an der Papierkante. Bei der voreingestellten ContinuationMargin von 36 Punkten verlangte die ursprüngliche Regel, dass die Unterkante des früheren Fragments innerhalb von 36 Punkten über dem Seitenende liegt und die Oberkante des späteren Fragments innerhalb von 36 Punkten unter dem Seitenanfang. Ein aus Word mit den Standard-Ein-Zoll-Rändern exportiertes Dokument setzt die letzte Zeile mindestens 72 Punkte über das Seitenende, mit Fußzeile noch weiter, also griff die Bedingung nie. Jede lange Tabelle in so einem Dokument kam als unabhängige Fragmente mit ContinuationGroup null zurück, und der Aufrufer stand wieder von Hand zusammen. Für das, wofür er entworfen wurde, behält der Test weiterhin Sinn: Berichte, die Layout-Engines erzeugen, die eine Seite bis zu einer festen Content-Box füllen und die nächste Seite bündig oben beginnen. Er ist keine schlechte Regel, nur eine unvollständige – deshalb hat Version 3.117.0 ihn behalten und statt ihn zu ersetzen einen zweiten Pfad ergänzt

Was prüft der inhaltsbasierte Test stattdessen?

Der inhaltsbasierte Test prüft, ob etwas anderes als die Tabelle den Raum zwischen den beiden Fragmenten belegt, und zwar über die Wortboxen jeder Seite statt über die Seitengeometrie. Während ExtractDocumentTables das Dokument durchläuft, zeichnet es pro Seite die unterste Unterkante jedes Worts auf, dessen Oberkante über dem Fußzeilenband liegt, und die oberste Oberkante jedes Worts, dessen Unterkante unter dem Kopfzeilenband liegt. Beide Bänder sind ContinuationMargin Punkte tief, sodass dieselbe Option jetzt Doppeldienst tut als Seitenkanten-Toleranz und als Höhe der Kopf- und Fußzeilenzonen. Ein Fragmentpaar besteht, wenn die Unterkante des früheren Fragments auf oder unter dem niedrigsten Fließtext seiner Seite liegt und die Oberkante des späteren Fragments auf oder über dem höchsten Fließtext der nächsten Seite, jeweils innerhalb von AlignmentTolerance. Plastisch gesagt: Die Tabelle war das Letzte auf Seite N und das Erste auf Seite N+1, und eine Seitenzahl oder ein Dokumenttitel im Randband zählt nicht. Dieser Ausschluss ist nicht willkürlich. ISO 32000-1 §14.8.2.2 stuft laufende Kopf- und Fußzeilen als Paginierungsartefakte ein, Inhalte, die wegen des Seitenumbruchs existieren und nicht trotz ihm, und dieselbe Idee, die einen getaggten Reader sie überspringen lässt, lässt eine Tabelle über sie hinweg weiterlaufen. Der Artikel zu Marked Content behandelt, wie getaggte Dateien diese Artefakte explizit deklarieren; hier wird die Einstufung aus der Position geschlossen, weil die meisten exportierten Tabellen gar keine Tags tragen

Warum die Tabellenfortsetzung im PDFium Component zwei Tests braucht: Mit Word-Ein-Zoll-Rändern verlangt der Seitenrand-Test Fragmentkanten in 36-pt-Fenstern, die das Layout nie erreicht, während der inhaltsbasierte Test Wortboxen vergleicht und verknüpft, wenn die Tabelle der letzte Fließinhalt auf Seite N und der erste auf Seite N+1 ist, mit ignorierten Kopf- und Fußzeilenbändern
Einer der beiden Tests öffnet das Tor, erst danach laufen die übrigen Prüfungen: benachbarte Seiten, keine volle-Breite-Überschriftzeile am späteren Fragment und Spaltengrenzen, die innerhalb des doppelten AlignmentTolerance übereinstimmen

Die beiden Tests kombinieren per OR. Ein Layout-Engine-Bericht, dessen Tabellen bis an die Papierkante laufen, besteht den ersten; ein Word-Export, dessen Tabellen am Rand stoppen, den zweiten; ein Dokument, das beides tut, besteht doppelt. Erst nachdem einer von ihnen Erfolg hat, laufen die übrigen Tore, und zwar in fester Reihenfolge: Die Seitennummern müssen benachbart sein, das spätere Fragment darf nicht mit einer Überschriftzeile beginnen, und die Spaltengrenzen müssen innerhalb des doppelten AlignmentTolerance übereinstimmen, bei den Voreinstellungen also 6 Punkte. Die Aufzählung ist TPdfTableContinuation mit den Werten ptcNone, ptcStart, ptcMiddle und ptcEnd. Ein als ptcEnd markiertes Fragment, das dann noch auf eine weitere Seite verlinkt, wird zum ptcMiddle befördert, sodass eine dreiseitige Tabelle in Seitenordnung start, middle, end liest. Gruppennummern beginnen bei 1, 0 heißt unverknüpft, und ToJson gibt dieselbe Information als continuation- und continuationGroup-Member aus – die Form, die man bevorzugen sollte, wenn ein nachgelagerter Service das Zusammenfügen übernimmt

uses
  PDFium;

var
  Pdf: TPdf;
  Options: TPdfTableExtractionOptions;
  Tables: TPdfTables;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'itinerary-from-word.pdf';
    Pdf.LoadDocument;

    Options := TPdfTableExtractionOptions.Default;
    Options.DetectContinuations := True;     // Voreinstellung; der Deutlichkeit halber angegeben
    Options.ContinuationMargin := 54;        // zweizeilige Fußzeile, rund 50 pt tief

    Tables := Pdf.ExtractDocumentTables(Options);
    for I := 0 to High(Tables) do
      case Tables[I].Continuation of
        ptcStart:
          Writeln(Format('group %d starts on page %d (%d rows)',
            [Tables[I].ContinuationGroup, Tables[I].PageNumber,
             Tables[I].RowCount]));
        ptcMiddle, ptcEnd:
          Writeln(Format('group %d continues on page %d (%d rows)',
            [Tables[I].ContinuationGroup, Tables[I].PageNumber,
             Tables[I].RowCount]));
      else
        Writeln(Format('standalone table on page %d (%d rows)',
          [Tables[I].PageNumber, Tables[I].RowCount]));
      end;
  finally
    Pdf.Free;
  end;
end;

Wie verhindert eine Überschriftzeile das Verschmelzen zweier Tabellen?

Ein Folgeseiten-Fragment, dessen erste Zeile eine einzige Zelle ist, die alle Spalten überspannt, wird als neue Tabelle behandelt, nie als Rest der vorherigen. Diese Regel existiert, weil der inhaltsbasierte Test allein zu eifrig verknüpft. Der Fall, der das bloßlegte, war ein Formular im Transcript-Stil: Eine Tabelle endet nahe dem unteren Rand von Seite 1, eine zweite Tabelle mit identischen Spaltenbreiten beginnt nahe der Oberkante von Seite 2, nichts als die Fußzeile sitzt dazwischen, und die Spalten passen auf den Punkt. Unter dem Rand-Test begegneten sich die beiden nie, weil keine eine Kante berührte; unter dem Inhaltstest verknüpften sie sich sofort, und ein Formular mit Abschnitten wurde ein einziges zusammenhangloses Gitter. Was sie trennt, ist in der Zellstruktur sichtbar. Die zweite Tabelle beginnt mit einer Abschnittsüberschrift wie „RECIPIENT INFORMATION“, gesetzt als einzelne verbundene Zelle über die volle Breite, und eine echte Fortsetzung tut das nie, denn die Überschrift gehört zur Tabelle, die auf der vorherigen Seite schon begonnen hat. TableStartsWithCaptionRow kodiert exakt das: Das Fragment hat mindestens zwei Spalten und enthält eine Zelle mit RowIndex = 0, ColumnIndex = 0 und ColumnSpan = ColumnCount. Die Prüfung läuft nur auf dem späteren Fragment, also bleibt eine Tabelle, deren eigene Überschriftzeile auf ihrer ersten Seite sitzt, unberührt; die Überschrift liegt auf Seite N, und nur das Seite-N+1-Fragment wird inspiziert

Das Überschriftzeilen-Tor im PDFium Component: Eine echte Fortsetzung beginnt mit Datenzellen und schließt sich derselben ContinuationGroup an, während ein späteres Fragment, dessen Zeile null eine verbundene Zelle mit RowIndex 0, ColumnIndex 0 und ColumnSpan gleich ColumnCount hält, als Fortsetzung abgelehnt und als neue Tabelle gemeldet wird
Die Inspektion berührt nur das spätere Fragment, eine Tabelle, deren eigene Überschriftzeile auf ihrer ersten Seite sitzt, bleibt also unberührt, und das Tor läuft erst, nachdem einer der beiden Kantentests das Paar bereits verknüpft hat

Der Spaltenvergleich danach, TablesHaveMatchingColumns, ist strenger als „gleiche Spaltenzahl“. Er baut die Grenzpositionen jedes Fragments aus den Zellrechtecken neu auf, interpoliert Grenzen, die verbundene Zellen verdecken, und lehnt das Paar ab, sobald eine Grenze um mehr als die Toleranz abdriftet. Zwei vierspaltige Tabellen mit unterschiedlichen Proportionen bleiben deshalb getrennt, selbst wenn alles andere passt

Was geschieht mit einer einzelnen Zeile, die auf die nächste Seite überläuft?

Ein Linienraster, das eine Zeile auf die folgende Seite hinüberträgt, wird jetzt erkannt und verknüpft, sofern es in einer Fortsetzungskette landet; allein wird es verworfen. Die voreingestellten MinRows von 2 existieren, damit ein verirrtes Linienpaar nicht als Tabelle gemeldet wird, aber eine über den Umbruch geschobene letzte Zeile ist eine echte Zeile, die eine harte Untergrenze von 2 stillschweigend fallen ließ, und der Rest der Tabelle sah vollständig aus, war es aber nicht. Die dokumentweite Abfrage behandelt das in drei Schritten. Wenn DetectContinuations und DetectRuledTables beide gesetzt sind, läuft der Pro-Seite-Durchgang den Linien-Detektor mit vorübergehend auf 1 gesenkter Zeilenuntergrenze – deshalb akzeptiert ExtractTables jetzt MinRows von 1 für Linienraster, während die Whitespace-Erkennung eine interne Untergrenze von 2 behält. Fortsetzungen werden über das Gesamtergebnis markiert. Danach wird jede Tabelle entfernt, die kürzer ist als die MinRows des Aufrufers und zu keiner Kette gehört. Das Einzeilen-Fragment überlebt allein, weil es verknüpft wurde, und ein Einzeilen-Gitter mitten auf einer ansonsten gewöhnlichen Seite wird genauso herausgefiltert wie vorher

Wie PDFium Component eine Linienzeile erhält, die über einen Seitenumbruch überläuft: Der Pro-Seite-Linien-Durchgang läuft mit Zeilenuntergrenze eins, wenn DetectContinuations und DetectRuledTables gesetzt sind, Fortsetzungen werden über das Gesamtergebnis markiert, und nur Fragmente kürzer als MinRows außerhalb jeder Kette werden entfernt
Die überlaufene Zeile überlebt, weil ihre Kette sie verknüpft, während ein eigenständiges Einzeilen-Gitter auf einer gewöhnlichen Seite genau wie vorher gefiltert wird, und Whitespace-erkannte Tabellen behalten ihre Zweizeilen-Untergrenze ohne solche Gnade
// Jede Kette als eine CSV neu aufbauen und wiederholte Kopfzeilen
// auf den Fortsetzungsfragmenten verwerfen
procedure ExportChains(const Tables: TPdfTables; const Folder: string);
var
  I, R: Integer;
  Lines: TStringList;
  Csv: TStringList;
begin
  Csv := TStringList.Create;
  Lines := TStringList.Create;
  try
    for I := 0 to High(Tables) do
    begin
      if Tables[I].Continuation in [ptcNone, ptcStart] then
        Csv.Clear;
      Lines.Text := string(Tables[I].ToCsv);
      if (Tables[I].Continuation in [ptcMiddle, ptcEnd]) and
         (Lines.Count > 1) and (Tables[I].RowCount > 1) then
        Lines.Delete(0);            // von der Textverarbeitung wiederholte Kopfzeile
      for R := 0 to Lines.Count - 1 do
        Csv.Add(Lines[R]);
      if Tables[I].Continuation in [ptcNone, ptcEnd] then
        Csv.SaveToFile(Format('%s\page%d-group%d.csv',
          [Folder, Tables[I].PageNumber, Tables[I].ContinuationGroup]));
    end;
  finally
    Lines.Free;
    Csv.Free;
  end;
end;

Zwei Details in dieser Routine sind Absicht. Der Einzeilen-Überlauf wird nie abgeschnitten, denn die Prüfung auf RowCount behält ihn, und eine Textverarbeitung, die die Kopfzeile auf jeder Seite wiederholt, produziert ein Fragment, dessen erste Zeile wieder die Kopfzeile ist – Zeile null auf middle- und end-Fragmenten zu verwerfen ist für diesen Fall richtig und für einen Generator, der keine Kopfzeilen wiederholt, falsch. Prüfen Sie ein Dokument, bevor Sie die Routine auf einen Ordner loslassen

Wo die Regeln weiterhin enden

Der inhaltsbasierte Test ist nur so gut wie die Textebene, die er liest. Auf einer gescannten Seite ohne jeden Text fallen die aufgezeichneten Fließtext-Extreme auf die Seitenmaße zurück, die Bedingung „nichts dazwischen“ ist leer erfüllt, und nur das Überschriftzeilen- und das Spaltentor bleiben; ein Linienraster auf so einer Seite wird weiterhin als leeres Skelett gefunden, die Kette verknüpft also womöglich korrekt, aber nichts am umgebenden Text war tatsächlich verifiziert. Fügen Sie vorher eine Textebene hinzu, wenn Ihnen das wichtig ist. Als Bilder statt als Text gesetzte Fußzeilen sind für die Bandlogik unsichtbar und aus demselben Grund harmlos

Die Bänder sind eine einzige Zahl. Eine Fußzeile, die tiefer als ContinuationMargin ist, lässt ihre unteren Zeilen in der Fließtextzone zurück, was das frühere Fragment wie von Text gefolgt aussehen lässt und die Verknüpfung blockiert; setzen Sie die Option auf die echte Bandtiefe, wie das erste Beispiel es tut. Setzen Sie sie zu hoch, rutscht ein kurzer Schlusssatz nahe dem unteren Seitenrand ins Band und wird ignoriert, was eine Tabelle an alles anknüpft, was darauf folgt. Die Überschriftenregel hat ein spiegelbildliches Versagen: Ein Generator, der ein verbundenes „Fortsetzung“-Banner als erste Zeile jedes Fortsetzungsfragments schreibt, bekommt diese Fragmente als neue Tabellen abgelehnt, und die einzige Abhilfe heute ist, selbst nach ContinuationGroup zusammenzufügen, ohne irgendetwas zu lockern, denn die Regel hat keinen Schalter

Whitespace-erkannte Tabellen bekommen keine der Einzeilen-Gnadenfristen. Die Whitespace-Strategie braucht zwei ausgerichtete Zeilen, um überhaupt eine Tabelle zu sehen, also wird eine unlinierte Tabelle, die eine Zeile überlaufen lässt, weiterhin um genau diese Zeile zu kurz gemeldet. Wenn Sie darauf stoßen, geben Ihnen die Wortboxen hinter strukturierten Textblöcken und Lesereihenfolge die Rohpositionen, um sie zurückzugewinnen. Auf dem Sampleset, das diese Arbeit antrieb – dreizehn Textverarbeitungs- und Browser-Exporte –, verketteten sich die fünf Dokumente mit echten mehrseitigen Tabellen alle zu Einzelketten, und das Transcript-Formular, das vorher verschmolz, blieb getrennt; das ist die Messlatte, an der die Release gemessen wurde, kein Versprechen über jedes Layout

Die Fortsetzungsmarkierung, die Überschriftenregel und der Einzeilen-Durchgang stecken alle im dokumentweiten Pfad, den Delphi-, C++Builder- und Lazarus-Builds teilen; die vollständige Tabellenextraktions-API ist auf der PDFium Component for Delphi-Seite beschrieben