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
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
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
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