Technischer Artikel

Doppelten Text durch Fake Bold bei der PDF-Extraktion fixen

Die HotPDF Delphi Component entfernt seit v2.768.15 die Dubletten, die Fake Bold und überdruckte Logos im extrahierten PDF-Text hinterlassen. Wenn ein Producer dieselbe Glyphe zweimal im Abstand eines Bruchteils eines Punkts malt, lesen ExtractLoadedPageText, ExtractLoadedPageTextLayout und ExtractLoadedPageTextColumns dieses Zeichen jetzt einmal, sodass aus „Bold words“ nicht mehr „Bold wordsBold words“ wird

Das ist einer dieser Bugs, die wie ein Decoder-Fehler aussehen und keiner sind. Jede Glyphe dekodiert korrekt; der Text steht wirklich zweimal auf der Seite. Der Fix muss entscheiden, welche von zwei völlig gültigen Glyphen die Kopie ist, und das, ohne legitime Wiederholungen zu verschlucken, etwa das doppelte l in „fill“ oder die identischen Zellen einer Tabelle. Der Rest dieses Artikels erklärt die Regel, die HotPDF benutzt, die Schwellenwerte dahinter, wo sie sich absichtlich von anderen Extraktions-Tools unterscheidet, und einen zweiten, unabhängigen Extraktions-Bug aus demselben Release-Zyklus, der ebenfalls Text an Zeilenenden abgeschnitten hat

Warum erzeugt Fake Bold doppelten Text bei der PDF-Extraktion?

Fake Bold verdoppelt extrahierten Text, weil der Producer den String zweimal zeichnet und beide Kopien echte Text-Show-Operations sind. Hat eine Font-Familie keinen Bold-Schnitt, oder die Autoren-Software macht sich schlicht keine Mühe, emulieren viele Generatoren Bold, indem sie denselben String mit Tj oder TJ zeigen, die Textposition um ein paar Zehntelpunkte nach rechts schieben und ihn nochmal zeigen. Auf dem Bildschirm verschmelzen die beiden Kopien zu einem kräftigeren Strich. Im Content Stream stehen zwei komplette Folgen von Zeichencodes, und ISO 32000-1 §9.4 gibt einem Extraktor kein Flag, das sagt „dieses hier ist Dekoration“

Überdruckte Logos treffen dasselbe Problem aus einem anderen Winkel. Ein Logo kann einmal als Fill und nochmal als Stroke oder leicht versetzter Schatten gemalt sein, mit derselben Schrift und denselben Zeichen. Ein naiver Extraktor, der den Content Stream in Malreihenfolge durchläuft, emittiert den ganzen ersten Run, dann den ganzen zweiten Run – deshalb liest sich das Ergebnis als „Bold wordsBold words“ statt als „BBoolldd“. Vor v2.768.15 behielt die HotPDF-Textmontage beide Kopien, und das Duplikat brach das, was den meisten Aufrufern wichtig ist: Eine Suche nach „Bold words“ über den Seitentext konnte fehlschlagen, weil die Wortgrenze zwischen den beiden Runs weg war

Echter Text Rendering Mode 2 (Fill dann Stroke, ISO 32000-1 §9.3.6) hat das Problem nicht, denn er ist eine einzige Text-Show-Operation, die einen Satz Glyphen erzeugt. Die Duplikate tauchen nur auf, wenn der Producer den Text zweimal ausgibt

Wie entscheidet HotPDF, welche Glyphe das Duplikat ist?

HotPDF behandelt zwei Glyphen als ein gemaltes Zeichen, wenn sie denselben Unicode-Wert tragen, dieselbe Schreibrichtung haben, in der Größe höchstens 5% auseinanderliegen und fast übereinander sitzen: entlang der Baseline höchstens 0.1 der Glyphengröße auseinander und quer dazu höchstens 0.2 der Glyphengröße. Von jedem solchen Paar bleibt die zuerst gemalte Glyphe erhalten, die spätere fällt weg. Das sind die Werte der v2.768.15-Implementierung, und jeder hat seinen Grund

  • Gleicher Unicode, gleiche Richtung. Nur identische Zeichen können Duplikate sein, und eine horizontale Glyphe wird nie mit einer vertikalen (WMode 1) verglichen
  • Größe innerhalb von 5%. Ein großer Überschriften-Buchstabe und ein kleiner Bildunterschrift-Buchstabe, die zufällig überlappen, sind nicht derselbe Text; ein Größenunterschied disqualifiziert das Paar
  • 0.1 der Größe entlang der Baseline. Fake-Bold-Offsets liegen bei wenigen Zehntelpunkten, ein paar Prozent einer normalen Textgröße. Zwei echte benachbarte Buchstaben, selbst ein eng gesperrtes „ll“, trennt in etwa eine volle Vorschubbreite, eine Größenordnung mehr als diese Toleranz
  • 0.2 der Größe quer zur Baseline. Schatten- und Outline-Durchgänge verschieben oft auch nach unten, nicht nur nach rechts, deshalb ist die vertikale Toleranz weiter, aber immer noch weit unter dem Abstand zur nächsten Zeile
  • Zuerst gemalt gewinnt. Die frühere Glyphe zu behalten bewahrt die Lesereihenfolge des ersten Runs, sodass der überlebende Text weiter so fließt, wie der Producer ihn geschrieben hat

Die Abstände werden im eigenen Baseline-Frame der Glyphe gemessen, nicht in rohen Seiten-X und -Y, gedrehter Text und Text auf schräger Baseline nutzen also dieselben Toleranzen wie normale horizontale Zeilen. Die Größe ist die der Glyphe, wie sie auf der Seite gemalt ist, abgeleitet aus ihrem Quad, nicht der Size-Operand für Tf; das zählt bei Dokumenten, die Tf auf 1 setzen und alles über die Text-Matrix skalieren, wo eine rohe Font-Größe jede Toleranz wertlos machen würde

Leerzeichen sind nie Kandidaten. Codepunkte bis U+0020, das No-Break-Space U+00A0 und das ideografische Leerzeichen U+3000 werden übersprungen, denn leere Glyphen tragen keine sichtbare Tinte, die sich duplizieren ließe, und eine zu streichen würde Wörter verschmelzen

Die Prüfung auf dichten Seiten schnell halten

Die Duplikatprüfung in HotPDF läuft in etwa n log n, nicht n Quadrat, weil sie erst sortiert und nur Glyphen innerhalb eines kleinen Fensters vergleicht. Jede Glyphe mit jeder anderen zu vergleichen wäre korrekt und unbrauchbar: Eine dichte Seite Kleingedrucktes trägt leicht zehntausende Glyphen, und die Prüfung läuft bei jedem Text-Extraktions-Aufruf

Die interne Routine sortiert die Nicht-Leerzeichen-Glyphen nach Unicode-Wert, dann nach Ursprungs-X, dann nach Malreihenfolge, mit einem stabilen Merge Sort. Nach dem Sortieren sitzt jeder potenzielle Duplikat-Kandidat einer Glyphe unmittelbar hinter ihr in der Ordnung, der Scan läuft also nur vorwärts, solange der Unicode-Wert matcht und der X-Unterschied innerhalb von 0.25 der Glyphengröße bleibt. Das 0.25-Fenster ist absichtlich weiter als die 0.1-Toleranz entlang der Baseline, damit ein Paar auf leicht schräger Baseline noch hineinfällt, bevor der präzise Baseline-Frame-Test läuft. Ein subtiler Fall: Trifft der Scan von Glyphe A auf eine passende Glyphe, die vor A gemalt wurde, ist A die Kopie; A fällt weg und der Scan stoppt, während die frühere Glyphe den Vergleich mit allem Weiteren fortsetzt. Diese Regel lässt „zuerst gemalt gewinnt“ auch bei Dreifach-Schlägen gelten, nicht nur bei Paaren

Der Vergleich läuft über den Unicode-Wert, nicht über Font oder Zeichencode. Zwei Glyphen, die über verschiedene Fonts auf denselben Unicode mappen, etwa ein Fill-Durchgang in einem eingebetteten Subset und ein Stroke-Durchgang in einem anderen, werden trotzdem als ein Zeichen erkannt. Die Kehrseite: Eine Glyphe, deren Code zu Unicode 0 auflöst, hat nichts Verlässliches zum Vergleichen, ein Grund mehr, den unresolved count im Blick zu behalten, den Text aus geladenen PDFs mit HotPDF extrahieren behandelt

Welche HotPDF-Extraktionsaufrufe entfernen doppelte Glyphen?

Alle texterzeugenden Aufrufe in HotPDF entfernen Duplikate, der rohe Glyphen-Aufruf nicht. Die Deduplizierung sitzt in THotPDF.ExtractVisibleLoadedPageGlyphs, dem Schritt, der auch Glyphen außerhalb des sichtbaren Seitenbereichs verwirft, und die simple Text-, Spalten-, Layout- und semantische Textextraktion lesen ihre Glyphen alle daraus. THotPDF.ExtractLoadedPageGlyphs gibt weiterhin jede Glyphe zurück, die der Content Stream malt, Overprints eingeschlossen, denn Aufrufer, die Highlighting, Redaction oder Forensik-Tooling bauen, brauchen die vollständige Aufzeichnung dessen, was gezeichnet wurde

Für gewöhnliche Textarbeit ändert sich im Aufrufcode nichts. Dieselbe Schleife, die vor v2.768.15 verdoppelten Text extrahierte, extrahiert jetzt jedes Wort einmal

var
  Pdf: THotPDF;
  PageCount, I: Integer;
  PageText: UnicodeString;
begin
  Pdf := THotPDF.Create(nil);
  try
    PageCount := Pdf.LoadFromFile('brochure.pdf');
    for I := 0 to PageCount - 1 do
      if Pdf.ExtractLoadedPageText(I, PageText) then
        if Pos('Bold words', PageText) > 0 then
          Writeln('Found on page ', I + 1);
  finally
    Pdf.Free;
  end;
end;

Layout- und Spaltenausgabe profitieren genauso, denn ein duplizierter Run hat den Rest einer Zeile auf dem Zeichengitter seitlich verschoben. ExtractLoadedPageTextColumns behält seinen optionalen Gap-Multiplikator; der Duplikatfilter läuft vor der Spaltenteilung, interagiert mit dieser Einstellung also nicht

var
  Grid, Columns: UnicodeString;
begin
  if Pdf.ExtractLoadedPageTextLayout(0, Grid) then
    TFile.WriteAllText('page1-layout.txt', Grid, TEncoding.UTF8);
  // Spalten-Trennung, wo eine Lücke 3x die mittlere Glyphen-Vorschubbreite übersteigt
  if Pdf.ExtractLoadedPageTextColumns(0, Columns, 3.0) then
    TFile.WriteAllText('page1-columns.txt', Columns, TEncoding.UTF8);
end;

Wenn Sie wissen wollen, ob eine Seite überhaupt überdruckten Text enthält, vergleichen Sie die beiden Glyphen-Aufrufe. Beachten Sie, dass die Differenz auch Glyphen zählt, die außerhalb des Crop Box fallen; sehen Sie sie also als Obergrenze der Duplikate, nicht als exakte Zahl

var
  AllGlyphs, Visible: THPDFGlyphArray;
begin
  if Pdf.ExtractLoadedPageGlyphs(0, AllGlyphs) and
     Pdf.ExtractVisibleLoadedPageGlyphs(0, Visible) then
    Writeln(Format('%d painted, %d read as text (%d overprinted or off-page)',
      [Length(AllGlyphs), Length(Visible),
       Length(AllGlyphs) - Length(Visible)]));
end;

Die Wortabstands- und Zeilenumbruch-Regeln, die diese Glyphen in Text verwandeln, bleiben vom Filter unberührt; sie sind detailliert behandelt in wie HotPDF Wortabstände und Zeilenumbrüche im Seitentext platziert

Wo die Regel sich absichtlich von anderen Tools unterscheidet

HotPDF wirft exakte Duplikate weg, die manche anderen Extraktions-Tools behalten, und dieser Unterschied ist Absicht. Als die v2.768.15-Änderung gegen ein Referenz-Korpus geprüft wurde, extrahierte eine zweimal an exakt derselben Position gemalte Seitenfußzeile in HotPDF einmal und in einem Referenz-Extraktor zweimal. Eine Glyphe, die auf eine identische Glyphe gemalt wird, ist als separates Zeichen unsichtbar, einmal gelesen ist also das, was die Seite tatsächlich zeigt

Die ehrliche Grenze läuft in die andere Richtung. Fake Bold, der den zweiten Durchgang um mehr als 0.1 der Glyphengröße entlang der Baseline versetzt, wird nicht erwischt, ebensowenig ein Schatten-Durchgang in deutlich anderer Größe. Diese Layouts sind in der Praxis selten, weil der visuelle Effekt aufhört, wie Bold auszusehen, sobald der Versatz so groß wird; sehen Sie aber verdoppelten Text aus einer Zierüberschrift, vergleichen Sie die Glyphen-X-Werte aus ExtractLoadedPageGlyphs, bevor Sie annehmen, der Filter sei kaputt. Für Duplikate, die nicht geometrisch am selben Ort sitzen – etwa eine wiederholte Kopfzeile in einem versteckten Layer woanders auf der Seite –, tut der Filter ebenfalls nichts; das ist eine Content-Frage, kein Overprint

Warum wurden Zeilenenden in CID-Font-Text abgeschnitten?

Ein separater, in HotPDF v2.768.12 gefixter Bug ließ Zeilenenden in Text fallen, der mit CID-keyed (Type 0) Fonts gesetzt war, deren /W-Width-Array nicht nach CID sortiert war. ISO 32000-1 §9.7.4.3 definiert das /W-Array-Format, verlangt aber nicht, dass seine Einträge aufsteigend nach CID sortiert sind, und manche Generatoren schreiben sie in der Reihenfolge, in der Glyphen im Dokument zuerst benutzt wurden

Der Text-Extraktions-Decoder schlug Breiten mit binärer Suche nach, die stillschweigend sortierte Eingabe voraussetzt. Auf einem unsortierten Array gingen die meisten Nachschlagevorgänge daneben und fielen auf die Default-Breite /DW zurück, 1000 Einheiten, ein volles em. Lateinischer Text in so einem Font wurde also gesetzt, als wäre jeder Buchstabe volle Breite, die Zeilen liefen weit über den rechten Seitenrand hinaus, und die Glyphen außerhalb des sichtbaren Bereichs wurden verworfen – die Zeilenenden verschwanden aus extrahiertem Text und aus der Suche. Nichts stürzte ab, nichts wurde gemeldet, was das Ganze schwer auffindbar machte

HotPDF sortiert die /W-Einträge jetzt direkt nach dem Lesen mit einem stabilen Merge Sort, sodass bei überlappenden Einträgen der zuerst geschriebene gewinnt, passend dazu, wie das Array in Dateireihenfolge liest. Der Seiten-Renderer war nie betroffen, denn er hält eine eigene Kopie der Breitendaten und scannt sie linear, und das vertikale Width-Array /W2 wird ebenfalls linear gescannt. Diese Trennung ist es wert, im Kopf zu behalten, wenn eine Seite perfekt aussieht, aber schlecht extrahiert: Rendering und Extraktion teilen sich in einer PDF-Bibliothek oft weit weniger Code, als man erwarten würde

FAQ

Entfernt HotPDF wiederholte Buchstaben wie das doppelte l in „fill“?

Nein. Zwei echte benachbarte Buchstaben liegen etwa eine Vorschubbreite auseinander, während HotPDF nur identische Zeichen verschmilzt, die innerhalb von 0.1 der Glyphengröße entlang der Baseline und 0.2 quer dazu liegen; legitime Wiederholungen bleiben erhalten

Wie bekomme ich jede Glyphe, einschließlich überdruckter Duplikate?

Rufen Sie THotPDF.ExtractLoadedPageGlyphs auf, das jede Glyphe zurückgibt, die die Seite malt, in Malreihenfolge. THotPDF.ExtractVisibleLoadedPageGlyphs und alle Text-Extraktionsaufrufe geben die deduplizierte, sichtbare Menge zurück

Welche HotPDF-Version fixt verdoppelten Text durch Fake Bold?

HotPDF v2.768.15 führte die Duplikat-Glyphen-Entfernung in Text-, Layout-, Spalten- und semantischer Textextraktion ein, und v2.768.12 fixte fehlende Zeilenenden in CID-Fonts mit unsortiertem /W-Array

Beide Fixes sind in der aktuellen HotPDF Delphi PDF component enthalten, die Text aus geladenen PDFs in Delphi und C++Builder extrahiert, neben ihren PDF-Erstellungs-, Bearbeitungs- und Rendering-Features