Eine PDF-Textseite legt Zeichen und Boxen offen, niemals Zeilen. Die PDFium-Komponente baut eine visuelle Zeile, indem sie Zeichenboxen gruppiert, deren vertikale Mittelpunkte innerhalb der halben Höhe des Startzeichens liegen, und dabei vom angeklickten Zeichen aus nach außen scannt, bis die Toleranz überschritten wird. Jeder Auswahlpfad im Viewer ruft diesen einen Helper auf, sodass Maus, Tastatur und Code übereinstimmen
Das Symptom, das einen zu diesem Thema führt, ist konkret und unangenehm. Ein Anwender klickt dreifach auf einen Absatz in einem zweispaltigen Bericht und erhält die halbe Seite. Oder er klickt dreifach auf eine Tabellenzelle, und die Auswahl verschlingt die gesamte Zeile plus die Seitenzahl in der Fußzeile. Der Viewer ist nicht defekt; er beantwortet eine Frage, die die Datei nicht beantworten kann. Es gibt keine Zeile in einer PDF-Datei, die man auswählen könnte, und jede Implementierung, die etwas anderes vorgibt, rät. Dieser Artikel handelt davon, das Raten bewusst und konsistent zu machen. Wer eigentlich nur Text aus einem Dokument ziehen will, findet das in Text aus PDF-Dokumenten mit PDFium extrahieren; wer Text layoutet und Breiten braucht, findet das in Textmessung und Zeilenumbruch. Hier geht es um eine engere Frage: entscheiden, wo eine visuelle Zeile beginnt und endet, und genau das auswählen
Warum hat eine PDF-Textseite keine Zeilenobjekte?
Weil ein PDF-Content-Stream Zeichnen beschreibt, keine Struktur. ISO 32000-1 §9.4 definiert ein Textobjekt als ein BT / ET-Paar mit Positionierungs- und Anzeigeoperatoren. Die Positionierungsoperatoren aus §9.4.2 (Td, TD, Tm, T*) bewegen eine Textmatrix über die Seite, und die Anzeigeoperatoren aus §9.4.3 (Tj, TJ, ', ") malen Glyphen dorthin, wohin diese Matrix gerade zeigt. Nichts in diesem Modell sagt "diese Folge von Glyphen ist eine Zeile". Eine Zeile ist das, was ein Mensch sieht, nachdem das Malen abgeschlossen ist
Producer verschlimmern das auf Weisen, die man nicht kontrollieren kann. Ein Blocksatz-Absatz kann als ein TJ-Array pro Zeile ausgegeben werden, oder als ein Tj pro Wort mit einem expliziten Tm davor, oder als eine einzelne Anzeigeoperation, bei der Kerning-Anpassungen den Abstand tragen. Ein zweispaltiges Layout kann die linke Spalte von oben nach unten ausgeben und dann die rechte, oder es kann sie verschachteln, wenn der Producer seine eigene interne Objektliste in anderer Reihenfolge durchlaufen hat. Die Zeichenfolge, die PDFium liefert, folgt dem Content-Stream, und der Content-Stream folgt dem, was die erzeugende Anwendung eben getan hat. Die beiden Funktionen, die man tatsächlich bekommt, sind also FPDFText_CountChars, das meldet, wie viele Zeichen die Seite enthält, und FPDFText_GetCharBox, das die Bounding-Box eines Zeichens im Seitenraum zurückgibt. Das ist das gesamte rohe Vokabular. Alles darüber, Wörter, Zeilen, Absätze, Spalten, ist eine aus der Geometrie abgeleitete Schlussfolgerung
Warum ist die Erkennung von CR und LF der falsche Test?
Weil die Zeichen, gegen die man testen würde, nicht zuverlässig vorhanden sind, und wenn sie vorhanden sind, nicht zuverlässig die eigenen. PDFium fügt synthetische Zeichen in die Textseite ein, um extrahierten Text lesbar zu machen: ein Leerzeichen, wo zwei Läufe visuell getrennt sind, ein CR oder LF, wo der nächste Lauf auf einer neuen Grundlinie beginnt. FPDFText_IsGenerated existiert genau deshalb, damit man diese von Zeichen unterscheiden kann, die tatsächlich aus der Datei stammen, und die PDFium-Komponente legt das als Eigenschaft CharacterGenerated offen
Trennt man an diesen Zeichen, erbt man jede Ermessensentscheidung, die PDFium bei ihrer Synthese getroffen hat. Ein harter Zeilenumbruch in einem umgebrochenen Absatz und ein weicher Umbruch sehen nach der Synthese identisch aus. Eine Tabellenzeile, die der Producer Zelle für Zelle ausgegeben hat, bekommt womöglich überhaupt keinen Umbruch zwischen der letzten Zelle und der ersten Zelle der nächsten Zeile, weil die Grundlinien zufällig nah genug beieinander liegen. Gleichzeitig kann eine Überschrift, gefolgt von Fließtext in anderer Größe, zwei Umbrüche bekommen, wo ein Mensch einen sieht. Die generierten Zeichen sind eine Rendering-Annehmlichkeit für die Extraktion der ganzen Seite; sie sind kein Zeilenmodell, und sie werden gerade in den Dokumenten schlechter, in denen Auswahl am wichtigsten ist
Zeichenboxen nach vertikalem Mittelpunkt gruppieren
Das verlässliche Signal ist Geometrie. Man nimmt das vom Anwender angeklickte Zeichen als Startpunkt, berechnet den vertikalen Mittelpunkt seiner Box und wandert in beide Richtungen nach außen, solange die vertikalen Mittelpunkte benachbarter Boxen innerhalb der Toleranz bleiben. Die PDFium-Komponente verwendet die halbe Höhe der Startbox als diese Toleranz, mit einer Untergrenze von 0.5 Seiteneinheiten, damit entartete Boxen, ein Punkt, ein schmales Leerzeichen, eine Glyphe mit einer nahezu höhenlosen Box, die Toleranz nicht auf null kollabieren lassen und die Zeile nach einem einzigen Zeichen abschneiden
function TPdfView.LineRangeAt(TxtPage: FPDF_TEXTPAGE; CharIndex: Integer;
out StartIndex, Count: Integer): Boolean;
var
Lo, Hi, Total: Integer;
SeedBox, Box: TPdfRectangle;
SeedYMid, BoxYMid, HalfH: Double;
begin
Result := False;
StartIndex := -1;
Count := 0;
Total := FPDFText_CountChars(TxtPage);
if (CharIndex < 0) or (CharIndex >= Total) then
Exit;
if FPDFText_GetCharBox(TxtPage, CharIndex, SeedBox.Left, SeedBox.Right,
SeedBox.Bottom, SeedBox.Top) = 0 then
Exit;
SeedYMid := (SeedBox.Top + SeedBox.Bottom) / 2;
HalfH := Abs(SeedBox.Top - SeedBox.Bottom) / 2;
if HalfH < 0.5 then // floor for degenerate boxes
HalfH := 0.5;
Lo := CharIndex;
Hi := CharIndex;
while Lo > 0 do
begin
if FPDFText_GetCharBox(TxtPage, Lo - 1, Box.Left, Box.Right,
Box.Bottom, Box.Top) = 0 then
Break;
BoxYMid := (Box.Top + Box.Bottom) / 2;
if Abs(BoxYMid - SeedYMid) > HalfH then
Break;
Dec(Lo);
end;
while Hi < Total - 1 do
begin
if FPDFText_GetCharBox(TxtPage, Hi + 1, Box.Left, Box.Right,
Box.Bottom, Box.Top) = 0 then
Break;
BoxYMid := (Box.Top + Box.Bottom) / 2;
if Abs(BoxYMid - SeedYMid) > HalfH then
Break;
Inc(Hi);
end;
StartIndex := Lo;
Count := Hi - Lo + 1;
Result := True;
end;
Drei Details in dieser Schleife verdienen ihren Platz. Die Toleranz leitet sich vom Startzeichen ab statt von einer Konstanten, sodass eine 24pt-Überschrift ein breites Band bekommt und 7pt-Fußnotentext ein schmales, und keines der beiden stiehlt dem anderen Zeichen. Der Vergleich verwendet vertikale Mittelpunkte statt Grundlinien oder Box-Oberkanten, was ein hochgestelltes Zeichen, einen inline eingebetteten Lauf anderer Größe oder einen Satz mit gemischten Schriftarten auf derselben Zeile wie seine Nachbarn hält. Und ein fehlgeschlagener FPDFText_GetCharBox-Aufruf beendet den Scan, statt übersprungen zu werden, weil ein Zeichen ohne abrufbare Geometrie in keine Richtung Beweise liefert, und ein Fortsetzen darüber hinaus den Lauf allein aufgrund eines weiter entfernten Zeichens über eine echte Grenze hinweg springen lassen würde
Warum müssen alle Auswahlpfade einen Helper teilen?
Weil drei Codepfade, die jeweils "die Zeile" eigenständig implementieren, auseinanderdriften, und zwar leise. In der PDFium-Komponente lösen die Dreifachklick-Erweiterung, Shift+Home, Shift+End und die öffentliche Methode SelectLineAt ihre Grenzen alle über denselben LineRangeAt-Aufruf auf. Der Dreifachklick startet ihn vom Auswahlanker; die Umschalttasten starten ihn vom Auswahlcursor und bewegen nur dieses Ende; SelectLineAt startet ihn von einem vom Aufrufer angegebenen Zeichenindex und übergibt das Ergebnis an SelectTextRange, denselben Bereichsvalidator, den auch der Mauspfad verwendet. Dupliziert man die Logik stattdessen, ist der Fehlschlag kein Absturz, sondern eine langsame Drift. Jemand justiert die Dreifachklick-Toleranz, um einen Bericht mit engem Zeilenabstand zu reparieren, und plötzlich stoppt Shift+End ein Zeichen zu früh, verglichen mit dort, wo der Dreifachklick im selben Absatz stoppt. Ein Anwender wählt eine Zeile mit der Maus, erweitert sie mit der Tastatur, und sieht die Auswahl schrumpfen. Weil SelectLineAt in die gewöhnliche Auswahl-Pipeline einspeist, bleibt die programmatische Auswahl auch unabhängig davon, ob Mauseingabe aktiviert ist, und erhält trotzdem Bereichsvalidierung, Neuzeichnen und die OnSelectionChange-Benachrichtigung kostenlos
// Select the visual line under a client-space point, then read it back
procedure TForm1.SelectLineUnderCursor(X, Y: Integer);
var
CharIndex: Integer;
begin
CharIndex := PdfView1.CharacterIndexAtPos(X, Y, 6.0, 6.0);
if CharIndex < 0 then
Exit;
if PdfView1.SelectLineAt(PdfView1.CurrentPage, CharIndex) then
Memo1.Lines.Add(PdfView1.SelectedText);
end;
Man beachte die Toleranz-Argumente bei CharacterIndexAtPos. Hit-Testing hat seinen eigenen Spielraum, ausgedrückt in Seiteneinheiten, und das ist ein separates Anliegen gegenüber der Zeilentoleranz. Ein Klick, der zwischen zwei Zeilen im Durchschuss landet, löst zum jeweils nächstgelegenen Zeichen innerhalb dieser Box auf; der Zeilen-Scan läuft dann von diesem ermittelten Zeichen aus. Eine zu großzügige Hit-Toleranz in den Startpunkt einzuspeisen, ist einer der einfacheren Wege, eine Zeile auszuwählen, auf die der Anwender gar nicht gezeigt hat
Zwei Indexräume: Zeichenindex und Textindex
Hat man einen Bereich, sollte man dem Drang widerstehen, ihn als String-Offset zu verwenden. FPDFText_GetText gibt den Seitentext als UTF-16-Puffer zurück, aber dessen Indizes sind nicht derselbe Indexraum wie die Zeichenindizes, die von FPDFText_GetCharBox und FPDFText_CountChars verwendet werden. Die zuvor besprochenen generierten Zeichen sitzen im Textpuffer, während sie Zeichenplätze ohne verwertbare Geometrie belegen, und die beiden Nummerierungen driften über die Seite hinweg auseinander. Die Brücken sind FPDFText_GetTextIndexFromCharIndex und FPDFText_GetCharIndexFromTextIndex, von der PDFium-Komponente als CharacterIndexToTextIndex und TextIndexToCharacterIndex umhüllt
var
TextStart, TextEnd: Integer;
begin
// char-index range from LineRangeAt -> offsets into the page text buffer
TextStart := Pdf.CharacterIndexToTextIndex(StartIndex);
TextEnd := Pdf.CharacterIndexToTextIndex(StartIndex + Count - 1);
if (TextStart >= 0) and (TextEnd >= TextStart) then
Caption := Pdf.Text(TextStart, TextEnd - TextStart + 1);
end;
Die Richtung, die am stärksten zubeißt, ist die umgekehrte. Eine über die extrahierte Zeichenkette implementierte Suche liefert Textindizes, und diese direkt an eine Box- oder Auswahl-API zu übergeben, adressiert still die falschen Zeichen, mit einem Fehler, der umso größer wird, je weiter man die Seite hinabgeht. Man konvertiert mit TextIndexToCharacterIndex, bevor irgendetwas Geometrisches die Zahl berührt. Surrogatpaare fügen dem ein zweites, unabhängiges Offset-Problem hinzu, behandelt im Artikel zu Emoji, CJK und Surrogatpaaren
Wo die Heuristik an ihre Grenzen stößt
Man sollte ehrlich mit sich selbst über die Grenzen sein, denn sie sind real und erreichbar. Gedrehter Text ist der klarste Fall: Eine Zeichenbox ist ein achsenparalleles Rechteck im Seitenraum, sodass bei um 90 Grad gedrehtem Text die Boxen einer visuellen Zeile vertikale Mittelpunkte haben, die über die Seite verstreut sind, und der Scan fast sofort stoppt. Man erhält eine kurze Auswahl statt einer falschen, was der bessere Fehlerfall ist, aber immer noch ein Fehlerfall. Vertikale Schreibrichtungen verhalten sich aus demselben Grund gleich. Zweispaltige Layouts funktionieren, wenn die Spalten vertikal gegeneinander versetzt sind, und brechen, wenn das nicht der Fall ist. Teilen sich beide Spalten ein gemeinsames Grundlinienraster, liegen Zeichen aus der rechten Spalte innerhalb der Toleranz der Zeile in der linken Spalte, und der Scan läuft geradewegs über den Spaltenzwischenraum hinweg, weil in reiner Geometrie nichts da ist, das ihn stoppen würde. Das zu erkennen, braucht einen horizontalen Lückentest zusätzlich zur vertikalen Gruppierung, und die Wahl der Lückenschwelle ist ihre eigene Ermessensentscheidung darüber, bei welchen Dokumenten man bereit ist, falschzuliegen. Gemischte Schriftgrößen sind der Fall, den die relativ zum Startzeichen gewählte Toleranz gut handhabt: eine inline eingebettete 8pt-Codepassage innerhalb von 11pt-Fließtext behält ihren Mittelpunkt innerhalb des Bandes, und eine 24pt-Überschrift auf der nächsten Grundlinie zieht die Fließtextzeile nicht in sich hinein
Die hier beschriebene Zeilenauswahl-Semantik ist Teil der PDFium-Komponente für Delphi und C++Builder, zusammen mit den in den Beispielen verwendeten APIs für Hit-Testing, Auswahlbereich und Textindex; die Produktseite trägt die vollständige Referenz für das Textseiten- und Auswahlmodell