Die HotPDF Delphi Component baut Wortabstände und Zeilenbrüche in THotPDF.ExtractLoadedPageText aus der Glyphengeometrie nach, nicht aus Leerzeichen. Ein Leerzeichen wandert hinein, wenn der Abstand hinter der eigenen Breite einer Glyphe 0,15 der Texthöhe überschreitet, und eine neue Zeile beginnt erst, wenn der Textursprung die Schreibrichtung um mehr als die halbe Texthöhe quert. Seit v2.768.3 umfasst der Seitentext auch Text, der über Form XObjects gemalt wird, und lässt Glyphen außerhalb des sichtbaren Beschnittbereichs weg. Der Rest dieses Texts erklärt, warum jede dieser Regeln so aussieht, wie sie aussieht — jede einzelne hat eine einfachere Regel ersetzt, die auf echten Dokumenten plausible, aber falsche Ausgabe produzierte
Die Symptome kennt jeder, der schon PDF-Text in einen Suchindex gefüttert hat. Ein Deckblatt extrahiert als PDFReferenceManualNovember4,1998, ein Steuerformular zerfällt in 156 Zeilen, ein diagonales Wasserzeichen kommt mit einem Zeichen pro Zeile, und ein beschnittener Korrekturabzug führt die Beschnittzeile an, die kein Viewer je zeigt. Keine dieser Dateien ist kaputt. Jede benutzt eine völlig legale Art, Text zu platzieren, die ein naiver Extraktor falsch liest
Warum verliert extrahierter PDF-Text seine Wortabstände?
Extrahierter Text verliert seine Wortabstände, weil eine PDF sie nie enthalten muss. Ein Producer kann Wörter durch ein Leerzeichen trennen, er kann den Stift aber genauso gut mit einer Zahl in einem TJ-Array (ISO 32000-1 §9.4.3) oder mit einem frischen Td (§9.4.2) weiterbewegen, und TeX-Ausgabe, viele Distiller-Dateien und die meisten Blocksatz-Layouts tun genau das. Vor v2.766.76 schaute HPDFAssemblePageText nur auf die vertikale Bewegung, ein durch Positionierung erzeugter Wortumbruch verschwand also einfach. Der Assembler misst jetzt entlang der Schreibrichtung der vorigen Glyphe den Abstand vom Ende ihrer eigenen Breite zum Ursprung der aktuellen Glyphe und fügt ein Leerzeichen ein, wenn der Abstand 0,15 der Höhe der Glyphen-Box der aktuellen Glyphe überschreitet, gemessen von Ascender bis Descender im User Space. Kein Leerzeichen wird eingefügt, wenn eine der beiden Seiten bereits blank ist, und keins zwischen zwei CJK-Zeichen, denn Blocksatz dehnt Ideogramme auseinander, ohne dass diese Dehnung eine Wortgrenze bedeutet. Die Glyphen-Records legen dieselbe Geometrie offen, Sie können die Entscheidung also nachvollziehen, wenn eine bestimmte Datei Sie ratlos macht
uses
SysUtils, HPDFDoc, HPDFContentStream;
procedure DumpWordGaps(Pdf: THotPDF; PageIndex: Integer);
var
Glyphs: THPDFGlyphArray;
I: Integer;
Height, Gap: Double;
begin
if not Pdf.ExtractLoadedPageGlyphs(PageIndex, Glyphs) then
Exit;
for I := 1 to High(Glyphs) do
begin
// Ascent-to-Descent-Höhe der Glyphen-Box, im User Space
Height := Sqrt(Sqr(Glyphs[I].QuadX[3] - Glyphs[I].QuadX[0]) +
Sqr(Glyphs[I].QuadY[3] - Glyphs[I].QuadY[0]));
// horizontaler Text: Abstand vom Ende der eigenen Breite der vorigen Glyphe
Gap := Glyphs[I].BaselineStartX - Glyphs[I - 1].GlyphEndX;
if (Height > 0) and (Gap > 0.15 * Height) then
Writeln(Format('U+%.4x gap %.2f height %.2f: space',
[Glyphs[I].Unicode, Gap, Height]));
end;
end;
Warum vom Ende der eigenen Glyphenbreite messen statt von der Stiftposition?
HotPDF misst Wortabstände von GlyphEndX / GlyphEndY, weil die Stiftposition hinter einer Glyphe bereits Abstände enthält, die kein gap sind. ISO 32000-1 §9.4.4 definiert die horizontale Verschiebung als Glyphenbreite mal Schriftgröße, plus Zeichenabstand Tc, plus Wortabstand Tw, alles skaliert mit Tz. BaselineEndX / BaselineEndY halten diese volle Verschiebung, während GlyphEndX / GlyphEndY nur den Font-Advance und Tz halten. Der Unterschied zählt bei Producern, die Spationierung mit negativem Tc verengen und den Platz danach über eine TJ-Adjustierung nach jedem Glyphen zurückschieben: Von der Stiftposition aus gemessen sieht die Rückgabe wie ein Abstand aus, und der chinesische Begriff “95后” kam als “9 5 后” heraus. Die Schwelle hängt an der Höhe der Glyphen-Box statt an der Tf-Größe, aus einem ähnlichen Grund. Word-Exporte schreiben oft 1 Tf und tragen die echte Größe in einer skalierten Tm mit, Tfs sagt also 1, während der Text 10 Punkt hoch ist, und eine an Tfs gekoppelte Regel hätte die beiden Schreibweisen derselben Seite unterschiedlich behandelt
Die Regel hat ehrliche Kanten. Eine Überschrift mit sehr weiter Spationierung, bei der Tc allein mehr als 0,15 der Texthöhe zwischen den Buchstaben öffnet, extrahiert mit Leerzeichen zwischen jedem Buchstaben — was die Seite zeigt, aber wohl nicht das, was Sie indexieren wollten. Auf einer Grundlinie aus der Reihe gemalte Teile ergeben einen negativen Abstand und verbinden sich ohne Leerzeichen. Beide Fälle sind im Fließtext selten, und auf einem Testkorpus erhöhte die Änderung die Worttreffer gegen einen Referenz-Extraktor auf 28 Seiten, ohne einen einzigen zu senken
Wann beginnt HotPDF in extrahiertem Text eine neue Zeile?
Seit v2.766.79 beginnt eine neue Zeile, wenn die Bewegung vom Ursprung der vorigen Glyphe zur aktuellen, projiziert auf die Normale der vorigen Schreibrichtung, die halbe größere Boxhöhe der beiden Glyphen überschreitet. Die frühere Regel verglich die rohe Y-Bewegung mit der Hälfte von Tfs, was in zwei Richtungen scheiterte. Mit 1 Tf und einer skalierten Tm schrumpfte die Schwelle auf eine halbe Einheit, ein Hochgestellt-Stück mit einem Text-Rise von 0,4 oder gewöhnliches Grundlinien-Zittern brach also die Zeile. Die Regel ignorierte auch X komplett, Text unter einer gedrehten Tm wanderte also seitwärts die Seite hinunter und kam mit einer Glyphe pro Zeile heraus. Die Projektion auf die Richtungs-Normale lässt gedrehte Läufe sich wie horizontale benehmen, und die größere der beiden Höhen hält ein großes Musterwort und seine kleine Beschriftung auf einer Zeile, solange sie eine Grundlinie teilen. Auf dem oben erwähnten Steuerformular fiel die Zeilenzahl von 156 auf 97. Vertikaler Text im Schreibmodus 1 (§9.7.4.3) geht einen eigenen Weg: Diese Glyphen werden in Spalten gruppiert, von rechts nach links und von oben nach unten gelesen, mit einem Zeilenbruch bei jedem Spaltenwechsel
Welchen Text nimmt ExtractLoadedPageText auf, welchen lässt er weg?
ExtractLoadedPageText liefert den Text, den ein Viewer zeigt. Seit v2.766.80 arbeitet er nur mit den sichtbaren Glyphen und verwirft jede Glyphe, deren Box-Zentrum außerhalb von GetLoadedPageVisibleBox liegt — der auf die MediaBox beschnittenen CropBox (§14.11.2). Das entfernt Beschnittzeilen und andere Druckereimarken, die als Text außerhalb des Zuschnittbereichs gesetzt sind. ExtractLoadedPageGlyphs gibt bewusst weiterhin jede Glyphe des Seiten-Content-Streams zurück, dieses Material finden Sie also noch, wenn Sie es brauchen. Der Filter ist ein Box-Test, kein Sichtbarkeitstest: Von einem Clipping-Pfad verdeckter, weiß gemalter oder von einem Bild überdeckter Text wird weiterhin extrahiert
var
Pdf: THotPDF;
Glyphs: THPDFGlyphArray;
PageText: UnicodeString;
L, B, R, T: Single;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.LoadFromFile('trimmed-proof.pdf');
if Pdf.GetLoadedPageVisibleBox(0, L, B, R, T) then
Writeln(Format('Visible box: %.1f %.1f %.1f %.1f', [L, B, R, T]));
// jede Glyphe des Seiten-Content-Streams, Beschnittzeile eingeschlossen
if Pdf.ExtractLoadedPageGlyphs(0, Glyphs) then
Writeln(Length(Glyphs), ' glyphs in the page content stream');
// nur was die Seite zeigt, mit eingefügtem Form-XObject-Text
if Pdf.ExtractLoadedPageText(0, PageText) then
Writeln(PageText);
finally
Pdf.Free;
end;
end;
Text, der durch Form XObjects gemalt wird, gehört seit v2.768.3 zum Seitentext. Kopfzeilen, Stempel und Wasserzeichen leben sehr oft in Formen, und manche Normdokumente verloren vor der Änderung 30 bis 35 Prozent ihrer Zeichen. THotPDF.InterpretContentWithForms notiert jedes Do mitsamt der gerade gültigen CTM, interpretiert die Form bei ihrer /Matrix mal dieser CTM (§8.10.1) und fügt die Glyphen der Form an der Position des Do ein, rekursiv in verschachtelte Formen hinein. Eine Form ohne eigene /Resources leiht sich die des Streams, der sie malt, wie §7.8.3 es erlaubt. Form-Glyphen tragen TokenIndex = -1, und ExtractLoadedPageGlyphs liefert weiterhin nur Seiten-Stream-Glyphen, denn Suche, Ersetzen und Redaktion schreiben Änderungen über TokenIndex zurück und würden die falschen Bytes editieren, wenn eine Form-Glyphe dazwischenschlüpfte. Zwei Vereinfachungen sind gut zu wissen: Formtext wird nicht auf die /BBox der Form beschnitten, und die Rekursion stoppt bei 12 Ebenen statt über Zykluserkennung, eine missratene Form, die sich selbst malt, wiederholt ihren Text also bis zu dieser Obergrenze
Warum dekodierte Text nach einem Q-Operator als Datenmüll?
Text nach Q konnte vor v2.766.73 falsch dekodieren, weil der Extraktor bei q nur die CTM sicherte. Die Text-State-Parameter — Font, Größe, Tc, Tw, Tz, TL, Rendering-Modus und Rise — gehören zum Grafikzustand (§9.3.1), Q muss sie also samt allem anderen auf dem Stack wiederherstellen (§8.4.2). Ein Industrie-Report wählte innerhalb von q … Q einen Zweibyte-Identity-H-Font und zeigte danach Einbyte-WinAnsi-Text ohne eigenes Tf. Der Extraktor behielt den inneren Font, las die Punktreihen und das Wort “Adobe” im Inhaltsverzeichnis als Zweibyte-Codes und verlor 15 % der Zeichen der Seite. Der q/Q-Stack des Interpreters hält jetzt den vollen Textzustand. Die hier beschriebenen Extraktionsregeln gelten für jede Seite, ein ganzes Dokument geht also in einem Aufruf in eine Datei
var
Output: TFileStream;
Pages: Integer;
begin
Output := TFileStream.Create('report.txt', fmCreate);
try
// leerer Bereich = alle Seiten; Formfeed zwischen Seiten; UTF-8-BOM
Pages := Pdf.ExtractLoadedPagesTextToStream(Output, '', #12, True);
Writeln(Pages, ' pages extracted');
finally
Output.Free;
end;
end;
Welche HotPDF-Text-API sollten Sie benutzen?
ExtractLoadedPageText bleibt in Content-Stream-Reihenfolge, was die richtige Voreinstellung für Suche und Indexierung ist; die Dekodierkette darunter behandelt Text aus geladenen PDFs mit HotPDF extrahieren. Für getaggte Dokumente, bei denen die Autorenreihenfolge zählt, geht die Textextraktion in Strukturreihenfolge den Strukturbaum entlang, statt aus der Geometrie zu raten, und für Daten, die in Tabellen stecken, liefert die getypte Tabellenextraktion über Seitenumbrüche hinweg Zellen statt Zeilen. Die vollständige API-Referenz und ein Trial-Download stehen auf der HotPDF Delphi PDF Component Produktseite