Technischer Artikel

PDF-Tabellen-Fehlalarme durch Blocksatz in Delphi beheben

PDFium Component Version 3.117.0 hört auf, Blocksatz-Absätze als Whitespace-ausgerichtete Tabellen zu melden, indem sie verlangt, dass jede Spaltengrenze ein vertikaler Korridor ohne Text auf jeder Zeile ist, die sie trennt, Wörter überspringt, die bereits von einem Liniengitter beansprucht wurden, und Zelltext nach vertikaler Überlappung statt nach Glyph-Box-Zentrumsdistanz zusammensetzt. Alle drei Änderungen stecken in ExtractTables und ExtractDocumentTables und brauchen keine Option

Der Bericht, der das anstieß, war unspektakulär. Eine Pressemitteilungsseite ohne jede Tabelle kam aus ExtractTables mit einer 5x4-Whitespace-Tabelle zurück, Confidence bequem über dem voreingestellten MinConfidence von 0,5, und die Zellen enthielten Fragmente gewöhnlichen Fließtexts. Ein Aufnahmeformular tat dasselbe mit seinen Essay-Absätzen und produzierte eine 3x4 und eine 5x3. Beide Dokumente waren als Blocksatz gesetzt. Die naheliegende Reaktion ist, an den Schwellenwerten zu drehen, und die nützliche Lektion dieser Release ist, dass Justage das nicht reparieren kann, weil die justierte Regel die falsche Frage stellte

uses
  PDFium;

// Regressionsprüfung: jede Whitespace-Tabelle in einem Dokument listen,
// damit sich eine bekannte reine-Prosa-Seite als sauber bestätigen lässt
procedure ReportWhitespaceTables(Pdf: TPdf);
var
  Options: TPdfTableExtractionOptions;
  Tables: TPdfTables;
  I: Integer;
begin
  Options := TPdfTableExtractionOptions.Default;   // MinColumnGap 12pt
  Tables := Pdf.ExtractDocumentTables(Options);
  for I := 0 to High(Tables) do
    if Tables[I].DetectionMode = ptdmWhitespace then
      Writeln(Format('page %d: %dx%d whitespace table, confidence %.2f, ' +
        'first cell "%s"',
        [Tables[I].PageNumber, Tables[I].RowCount, Tables[I].ColumnCount,
         Tables[I].Confidence, Tables[I].Cells[0].Text]));
end;

Warum sieht Blocksatz wie eine Tabelle aus?

Ein Blocksatz-Absatz sieht wie eine Tabelle aus, weil eine Blocksatzzeile eine Reihe von Wörtern ist, getrennt durch Lücken, die die Layout-Engine gestreckt hat, und sobald eine gestreckte Lücke MinColumnGap erreicht, hat der Detektor keine zeilenlokale Möglichkeit, sie von einem Spaltentrenner zu unterscheiden. Die Whitespace-Strategie in PDFium Component gruppiert Wortboxen in visuelle Zeilen, teilt jede Zeile in Wortgruppen, wo immer der horizontale Abstand zum vorherigen Wort mindestens MinColumnGap beträgt (standardmäßig 12 Punkte), und akzeptiert eine Tabelle, wenn mindestens zwei aufeinanderfolgende Zeilen mindestens MinColumns linksbündige Gruppenanker innerhalb von AlignmentTolerance wiederholen, das 3 Punkte beträgt. Das ist die Regel aus der Tabellenerkennungs-Übersicht, und für eine echte ausgerichtete Tabelle ist sie exakt richtig

Wendet man das auf zwanzig Zeilen Blocksatz in 10 Punkt an: Jede Zeile wird auf denselben rechten Rand gestreckt, also reißt eine Zeile, die mit einem langen Wort endet, ihre inneren Zwischenräume auf, und in einem Absatz mit ein paar kurzen Zeilen überschreiten einige dieser Zwischenräume 12 Punkte. Zwei aufeinanderfolgende Zeilen brauchen je nur eine gestreckte Lücke, die innerhalb von 3 Punkten auf derselben X-Position landet, um einen Zwei-Zeilen-, Zwei-Spalten-Kandidaten zu bilden. Über genug Zeilen ist das kein Pech; es ist eine Wahrscheinlichkeit, die gegen Gewissheit geht, und die 5x4 auf der Pressemitteilung war schlicht der Lauf, in dem vier solcher Lücken sich über fünf Zeilen ausrichteten

PDFium-Component-Diagramm dazu, warum Blocksatz-Prosa als Tabelle punktete: Jede Zeile wird auf denselben Rand gestreckt, also kreuzen einzelne Lücken MinColumnGap auf jeder Zeile an einer anderen X-Position, und zwei aufeinanderfolgende Lücken innerhalb von AlignmentTolerance bauten die falschen Kandidaten, die der Korridor-Test jetzt verwirft
Eine echte Tabelle wiederholt ihre Spaltenanker auf jeder Zeile, während ein Blocksatz-Absatz in jeder Zeile einen anderen Zwischenraum streckt, weshalb Zeilen-Justage allein die beiden nicht trennen konnte

Jeder Schwellenwert handelt eine Dokumentklasse gegen eine andere. MinColumnGap auf 20 Punkte zu heben verliert die kompakten Spalten dichter Finanzberichte – genau der Fall, für den der Default bereits gesenkt worden war. MinRows auf 3 zu heben verwirft echte Zweizeilen-Tabellen und senkt bloß die Wahrscheinlichkeit für lange Absätze. AlignmentTolerance unter 3 Punkte zu spannen bricht OCR-gewonnene Wortboxen, deren linke Kanten um mehr als das wackeln. Das Zeilensignal ist echt zweideutig, also muss der Fix aus einem Signal kommen, das Zeilen nicht für sich tragen

Was macht eine Spaltengrenze echt?

Eine echte Spaltengrenze ist ein vertikaler Streifen der Seite, der über jede Zeile, die er trennt, leer bleibt. Eine Tabelle hat zwischen jedem Spaltenpaar einen davon, das liegt in der Konstruktion, denn die Zellen wurden gegen gemeinsame X-Positionen gesetzt. Ein Blocksatz-Absatz streckt seine Wortzwischenräume auf jeder Zeile an anderen horizontalen Positionen, also überlebt kein Streifen die Schnittmenge von mehr als ein, zwei Zeilen. PDFium Component testet jetzt genau das: Nachdem die Wortgruppen des Kandidaten auf Ankerspalten verteilt wurden, nimmt er für jedes Paar benachbarter Spalten auf jeder Zeile, die in beiden Zellen Inhalt hat, das Intervall von der rechtesten Kante der Wörter der linken Zelle bis zur linkesten Kante der Wörter der rechten Zelle, schneidet diese Intervalle über die Zeilen und verwirft den ganzen Kandidaten, wenn die Schnittmenge schmaler ist als MinColumnGap mal 0,5 – bei den Voreinstellungen 6 Punkte

PDFium-Component-Diagramm des textfreien Korridor-Tests hinter ExtractTables: Jede Zeile stiftet das Intervall von der rechten Kante ihrer linken Zelle zur linken Kante ihrer rechten Zelle, die Schnittmenge bleibt in einer echten Tabelle breiter als die Hälfte von MinColumnGap und kollabiert in Blocksatz zu nichts
Eine echte Spaltengrenze ist auf jeder Zeile, die sie trennt, leer, das Schneiden der Zeilen-Lücken lässt also für eine Tabelle einen gemeinsamen Streifen übrig und für gestreckte Prosa gar keinen

Zwei Details zählen. Zeilen, in denen eine der Zellen leer ist, stimmen nicht mit ab, also besteht eine Tabelle mit einer leeren Zelle oder einem Header, der weniger Spalten überspannt als der Body, weiterhin. Und die Korridorbreite wird aus MinColumnGap abgeleitet statt als eigene Option angeboten, denn beide beschreiben dieselbe physische Sache: den Zwischenraum, den ein Designer zwischen Spalten lässt. Die Logik ist klein genug zum Nachbauen, wenn Sie auf rohen Wortboxen statt auf der Tabellen-API aufbauen, und das Beispiel unten spiegelt die Prüfung in der Komponente:

uses
  Math, PDFium;

type
  TIndexList = array of Integer;
  TCellIndexes = array of TIndexList;   // Row * ColumnCount + Column

// Liefert False, wenn einem benachbarten Spaltenpaar der textfreie vertikale
// Korridor von mindestens MinColumnGap / 2 Breite über den genutzten Zeilen fehlt
function HasTextFreeCorridors(const Words: TPdfWordBoxes;
  const Cells: TCellIndexes; RowCount, ColumnCount: Integer;
  MinColumnGap: Double): Boolean;
var
  Col, Row, I, LeftCell, RightCell, Supported: Integer;
  CorridorLeft, CorridorRight, RowLeft, RowRight: Double;
begin
  for Col := 0 to ColumnCount - 2 do
  begin
    CorridorLeft := -MaxDouble;
    CorridorRight := MaxDouble;
    Supported := 0;
    for Row := 0 to RowCount - 1 do
    begin
      LeftCell := Row * ColumnCount + Col;
      RightCell := LeftCell + 1;
      if (Length(Cells[LeftCell]) = 0) or (Length(Cells[RightCell]) = 0) then
        Continue;                             // leere Zellen stimmen nicht mit ab
      RowLeft := -MaxDouble;
      RowRight := MaxDouble;
      for I in Cells[LeftCell] do
        RowLeft := Max(RowLeft, Words[I].Rect.Right);
      for I in Cells[RightCell] do
        RowRight := Min(RowRight, Words[I].Rect.Left);
      CorridorLeft := Max(CorridorLeft, RowLeft);
      CorridorRight := Min(CorridorRight, RowRight);
      Inc(Supported);
    end;
    if (Supported > 0) and
       (CorridorRight - CorridorLeft < MinColumnGap * 0.5) then
      Exit(False);
  end;
  Result := True;
end;

Warum wurden Liniengitter-Tabellen doppelt extrahiert?

Liniengitter-Tabellen wurden doppelt extrahiert, weil der Whitespace-Durchgang jedes Wort auf der Seite sah, auch die, die der Linien-Durchgang bereits in ein Gitter einsortiert hatte, und ein sauberes Liniengitter ist konstruktionsbedingt auch eine perfekt ausgerichtete Whitespace-Tabelle. Eine Überlappungsprüfung lehnte bereits einen Whitespace-Kandidaten ab, dessen Bounds mehr als die Hälfte einer bestehenden Tabelle abdeckten, aber ein Kandidat, der die unteren Zeilen der Tabelle mit ein paar ausgerichteten Textzeilen darunter kombinierte, konnte unter dieses Verhältnis fallen und als zweite, leicht größere Tabelle überleben, die in den Nachbarn hineinblutete. ExtractTables entfernt diese Wörter jetzt, bevor der Whitespace-Durchgang läuft. Ein Wort wird fallen gelassen, wenn sein Mittelpunkt innerhalb der Bounds einer Tabelle liegt, die der Linien-Durchgang produziert hat; der Mittelpunkt dient statt vollständiger Containment-Prüfung, damit ein Wort, das um einen Bruchteil eines Punktes über eine Umrandung reicht, der Tabelle folgt, zu der es visuell gehört. Die Whitespace-Strategie arbeitet dann nur noch auf den freien Wörtern, was auch heißt, dass eine kleine unlinierte Tabelle direkt unter einem Liniengitter nach eigenen Verdiensten erkannt wird, statt mit dem Gitter darüber zu verschmelzen

Warum wurde aus „Purpose of Request:“ „of Purpose Request:“?

Die Wörter kamen vertauscht heraus, weil die Wortboxen, die PDFium Component baut, Vereinigungen der Glyph-Bounding-Boxen sind, und „of“ hat keine Unterlänge, während „Purpose“ und „Request:“ welche haben. FPDFText_GetCharBox liefert die enge Box der Tinte des Glyphs im Seitenraum, keine Box, die auf Ascent und Descent der Schrift gepolstert ist, und die Wortbox ist die Vereinigung der Boxen ihrer Zeichen. Ein Wort ohne Unterlängen ist also kürzer und sein vertikales Zentrum sitzt höher, auf dem betreffenden Formular um 2 bis 3 Punkte. Die alte Zelltext-Routine sortierte Wörter zuerst nach Zentrum-Y, mit 1 Punkt Toleranz für „gleiche Zeile“, dann nach linker Kante; „of“ räumte die Toleranz, sortierte als eigene Zeile über den anderen und wurde zuerst ausgegeben

Das ist weniger eine PDFium-Schrulle als eine Folge davon, wie PDF Text positioniert. ISO 32000-1 §9.2.2 und §9.4.4 definieren die Glyph-Platzierung als horizontale Verschiebung entlang der Grundlinie im Textraum, und die einzigen vertikalen Metriken, die die Datei trägt, sind pro Schrift: die Einträge Ascent, Descent und FontBBox des Font-Descriptors in §9.8.1. Nichts in der Datei sagt, dass zwei Glyphs eine Zeile teilen; das muss aus der Geometrie geschlossen werden, und die engen Glyph-Boxen, die Selection-Highlighting richtig aussehen lassen, wie in Textzeilen-Auswahl mit PDFium-Char-Boxen beschrieben, sind der falsche Input für einen Zentrumsdistanz-Vergleich

Der Fix in Version 3.117.0 ändert die Frage von „wie weit liegen die Zentren auseinander“ zu „wie stark überlappen die Boxen vertikal“. Zelltext wird zusammengesetzt, indem zuerst die Wörter der Zelle in visuelle Zeilen gruppiert werden – ein Wort schließt sich einer Zeile an, wenn seine vertikale Überlappung mit den laufenden Bounds der Zeile mindestens 25 Prozent der kleineren der beiden Höhen beträgt –, dann wird jede Zeile per Insertion-Sort nach linker Kante sortiert und schließlich werden die Zeilen mit einem Zeilenumbruch verbunden. „Purpose“ und „of“ überlappen über die volle x-Höhe, was weit mehr als 25 Prozent der kürzeren Box ist, also landen sie auf derselben Zeile und sortieren wie beabsichtigt nach X

PDFium-Component-Diagramm des Purpose-of-Request-Reorder-Fix: Enge Glyph-Boxen aus FPDFText_GetCharBox geben dem unterlängenfreien of ein höheres Zentrum, das die alte 1-pt-Zentrum-Y-Toleranz als eigene Zeile sortierte, während eine 25-Prozent-Regel für vertikale Überlappung es auf der Grundlinie hält und die Wortreihenfolge wiederherstellt
Zentrum-Y bewegt sich mit den Ober- und Unterlängen, die die Tinte gerade trägt, während zwei Boxen auf einer Grundlinie über die gemeinsame x-Höhe überlappen, was auch immer ihre Höhen tun

Textzeilen nach Überlappung gruppieren, nicht nach Zentrumsdistanz

Die Regel, die aus diesem Bug mitzunehmen ist, ist allgemein: Jeder PDF-Textlayout-Code, der „gleiche Zeile“ entscheidet, indem er vertikale Zentren gegen eine feste Toleranz vergleicht, scheitert an echten Schriften, und das Scheitern ist still: Nichts wirft einen Fehler, die Wörter kommen einfach in falscher Reihenfolge heraus. Gemischte Unterlängen sind der mildeste Auslöser. Ein fettes 12-Punkt-Label neben 10-Punkt-Werten, ein hochgestellter Fußnotenverweis, ein aus einer Fallback-Schrift gezogenes Währungssymbol und OCR-Wortboxen mit per-Wort-Höhenrauschen verschieben Zentren um mehr als jede Toleranz, die benachbarte Zeilen von 10-Punkt-Text bei 12-Punkt-Leading noch trennt. Das Überlappungsverhältnis ist größeninvariant: Zwei Boxen auf einer Grundlinie überlappen über ihre gemeinsame x-Höhe, was auch immer ihre Ober- und Unterlängen tun, und zwei Boxen auf benachbarten Zeilen überlappen um nichts

Dieselbe Regel lässt sich auch außerhalb der Tabellenextraktion leicht anwenden. TPdf.PageWordBoxes liefert jedes Wort der aktiven Seite mit seinem Seitenraum-Rechteck, also ist das Gruppieren einer Seite in visuelle Zeilen eine kurze Schleife:

uses
  Math, PDFium;

function SameVisualLine(const A, B: TPdfRectangle): Boolean;
var
  Overlap, MinHeight: Double;
begin
  Overlap := Min(A.Top, B.Top) - Max(A.Bottom, B.Bottom);
  MinHeight := Min(A.Top - A.Bottom, B.Top - B.Bottom);
  Result := (MinHeight > 0) and (Overlap >= MinHeight * 0.25);
end;

procedure GroupPageIntoLines(Pdf: TPdf; out Lines: TArray<TPdfWordBoxes>);
var
  Words: TPdfWordBoxes;
  Bounds: TArray<TPdfRectangle>;   // laufende Vereinigung pro Zeile
  I, J, Found: Integer;
begin
  Words := Pdf.PageWordBoxes;
  Lines := nil;
  Bounds := nil;
  for I := 0 to High(Words) do
  begin
    Found := -1;
    for J := High(Lines) downto 0 do
      if SameVisualLine(Bounds[J], Words[I].Rect) then
      begin
        Found := J;
        Break;
      end;
    if Found < 0 then
    begin
      SetLength(Lines, Length(Lines) + 1);
      SetLength(Bounds, Length(Bounds) + 1);
      Found := High(Lines);
      Bounds[Found] := Words[I].Rect;
    end;
    SetLength(Lines[Found], Length(Lines[Found]) + 1);
    Lines[Found][High(Lines[Found])] := Words[I];
    Bounds[Found].Left := Min(Bounds[Found].Left, Words[I].Rect.Left);
    Bounds[Found].Right := Max(Bounds[Found].Right, Words[I].Rect.Right);
    Bounds[Found].Top := Max(Bounds[Found].Top, Words[I].Rect.Top);
    Bounds[Found].Bottom := Min(Bounds[Found].Bottom, Words[I].Rect.Bottom);
  end;
  // Jede Zeile vor dem Lesen nach Rect.Left sortieren; PageWordBoxes liefert
  // Wörter in Content-Stream-Reihenfolge, nicht garantiert visuell
end;

Was sich für bestehende Aufrufer ändert und wo die Grenzen liegen

Der Punkt dieses Snippets ist das Prädikat, nicht die Schleife; für alles über einen schnellen Dump hinaus: Beginnen Sie beim strukturierten Textmodell, das bereits Blöcke, Zeilen und eine Lesereihenfolgen-Quelle trägt, wie in strukturierter PDF-Textextraktion mit Lesereihenfolge behandelt. Bestehende Tabellen-Aufrufer bekommen alle drei Korrekturen, ohne ihre Optionen anzufassen. Der Korridor-Schwellenwert steht fix auf der Hälfte von MinColumnGap, die Whitespace-Strategie behält ihre Zweizeilen-Untergrenze, selbst wenn MinRows auf 1 gesetzt wird (was die Linien-Strategie jetzt akzeptiert), und das Linien-zuerst-Wortfiltern ist bedingungslos, sobald beide Strategien an sind. Auf dem 13-Dokumente-Sampleset der Release lieferte der Whitespace-Durchgang vorher 34 Fragmente und Fehlalarme neben 9 Liniengitter-Tabellen; nach der Release liefert er null, und die Zahl der Liniengitter-Tabellen stieg auf 41, wobei der größte Teil dieses Anstiegs daher kommt, dass dieselbe Release dem Linien-Detektor beibrachte, Umrandungen als gefüllte Rechtecke zu lesen – eine eigene Geschichte

Die ehrlichen Grenzen: Der Korridor-Test braucht mindestens eine Zeile mit Inhalt auf beiden Seiten einer Grenze, um irgendetwas zu verwerfen, also besteht ein Zweizeilen-Kandidat, dessen zwei gestreckte Lücken zufällig innerhalb von 6 Punkten voneinander liegen, weiterhin. Das ist ein enges Zusammentreffen statt der fast Gewissheit von vorher, aber prosalastige Dokumente ohne echte Zweizeilen-Tabellen können es schließen, indem sie MinRows auf 3 setzen. Linksbündiger Flattersatz war nie das Problem und ist nicht betroffen. Und PDF hat weiterhin kein Tabellenobjekt; ISO 32000-1 §14.8.4.3 definiert ein Table-Strukturelement, aber nur Tagged PDF trägt es, also bleibt das Gitter für alles andere ein Rückschluss aus der Geometrie, und der Confidence-Wert auf jedem TPdfTable ist da, weil ein Rückschluss eine Punktzahl verdient

Tabellenextraktion, strukturierter Text und Wortboxen lesen alle aus demselben Seitenmodell in Delphi, C++Builder und Lazarus; die vollständige API einschließlich TPdfTableExtractionOptions und der mitgelieferten TableExtractionLab-Demo ist auf der PDFium Component for Delphi-Seite beschrieben