Technical Article

Messen von PDF-Text für Layout und Textumbruch in Delphi

Der Aufruf, der Text auf eine PDF-Seite bringt, ist unkompliziert. Sie übergeben AddText einen String, eine Schriftart, eine Größe und eine Position, und die Glyphen erscheinen. Was er jedoch nicht tut, ist Ihnen mitzuteilen, wie breit dieser String sein wird, sobald er gezeichnet ist, und er bricht einen langen String nicht über mehrere Zeilen um. Ein einzelner Aufruf zeichnet einen Textabschnitt an einer bestimmten Position. Wenn der Abschnitt breiter ist als die Spalte, in die er passen sollte, läuft er einfach über den Rand hinaus, und nichts im Zeichenaufruf warnt Sie davor. Sobald Sie einen Absatz statt einer einzelnen Beschriftung wünschen, ist das fehlende Puzzleteil die Breite eines Strings in der gewählten Schriftart und Größe, gemessen, bevor Sie ihn auf die Seite bringen

Das ist das klassische Layout-Problem. Um einen Absatz in eine Spalte umzubrechen, müssen Sie Wort für Wort wissen, wie viel horizontalen Platz jede in Frage kommende Zeile einnehmen wird, und zwar bevor Sie irgendetwas zeichnen. Ein Textumbruch (Word Wrap) ist eine Messschleife, die um einen Zeichenaufruf gewickelt ist, und ein Binding, das nur zeichnet, liefert Ihnen lediglich die zweite Hälfte. Die Textmessungs-Unterstützung in der PDFium-Komponente schließt diese Lücke mit zwei Funktionen, MeasureText und MeasureTextWidth, die die gerenderte Ausdehnung eines Strings zurückgeben, ohne auch nur eine Markierung auf irgendeiner Seite zu hinterlassen

Warum die Messung ein Class Helper ist und keine neue Methode an TPdf

Die Textmessungs-Unterstützung wird als Delphi Class Helper für TPdf geliefert und lebt in einer eigenen Unit, anstatt als neue Methoden an die Klasse TPdf angeflanscht zu werden. Ein Class Helper ist ein Sprachmerkmal, das es Ihnen ermöglicht, Methoden an einen bestehenden Typ von außerhalb seiner Deklaration anzuhängen. Sobald die Unit im Gültigkeitsbereich (Scope) ist, werden die neuen Methoden exakt so aufgerufen, als gehörten sie zur Klasse. Eine Helper-Methode liest sich also wie Pdf.MeasureTextWidth(...), ohne dass ein separates Objekt konstruiert oder herumgereicht werden muss

Der Grund für diese Schichtung ist die Trennung der Belange (Separation). Der Kern-Typ TPdf bleibt wie er ist; es wird kein Feld hinzugefügt und keine bestehende Signatur angetastet. Ein Projekt, das niemals Layouts benötigt, trägt also auch nie den Mess-Code mit sich herum. Ein Projekt, das ihn benötigt, fügt der uses-Klausel eine einzige Unit hinzu, und die Methoden leuchten auf. Funktionalität wird auf der Granularität einer einzelnen Unit zum Opt-in, was der sauberste Weg ist, einen Typ zu erweitern, der Ihnen nicht gehört oder den Sie nicht stören möchten

uses
  PDFium, FPdfView, FPdfEdit,
  FPdfMeasure;   // the helper unit; brings MeasureText into scope on TPdf

// With the unit in scope the methods read as members of TPdf:
var
  W, H: Double;
begin
  Pdf.MeasureText('Subtotal', 'Helvetica', 11, W, H);
  // W and H are now the rendered width and height in PDF user units
end;

Messen, ohne die Seite zu berühren

Die Messung muss frei von Seiteneffekten sein. Sie muss eine Breite zurückgeben, ohne irgendetwas zu hinterlassen, denn Sie rufen sie bei der Entscheidung über ein Layout viele Male auf, und die Seite muss am Ende exakt so aussehen, als hätten Sie nie gemessen. Die Technik, die das ermöglicht, besteht darin, ein Textobjekt aufzubauen, es nach seiner Größe zu fragen und es wieder wegzuwerfen, bevor es jemals an eine Seite angehängt wird

Die Sequenz besteht aus vier PDFium-Aufrufen. FPDFPageObj_NewTextObj erstellt ein Textobjekt gegenüber dem Dokument, basierend auf dem Schriftartnamen und der Größe. FPDFText_SetText setzt den String, den das Objekt trägt. FPDFPageObj_GetBounds liest die Bounding Box (den Begrenzungsrahmen) des Objekts aus. FPDFPageObj_Destroy gibt das Objekt frei. Ganz entscheidend: Nichts in dieser Sequenz ruft die API zum Einfügen auf einer Seite auf. Das Objekt wird isoliert erstellt, abgefragt und zerstört, sodass das Dokument unverändert bleibt, wenn die Funktion zurückkehrt. Es ist eine Wegwerf-Sonde, deren einzige Ausgabe die vier Zahlen ihrer Bounding Box sind

Dies ist der robuste Weg, weil PDFium keine bequeme Vorschubbreite (Advance Width) pro Glyphe offenlegt, die Sie selbst aufsummieren könnten. Glyphen-Metriken hängen vom Schriftartenprogramm, vom Encoding und davon ab, wie PDFium die Schrift lädt, und es gibt keinen öffentlichen Aufruf, der Ihnen den Vorschub jedes einzelnen Zeichens in einem String liefert. Die Bounding Box eines echten Textobjekts hingegen wird von exakt derselben Maschinerie berechnet, die die Glyphen auch zum Zeichnen anordnen würde. Sie spiegelt also die tatsächliche gerenderte Ausdehnung wider, nicht nur eine Annäherung. Der Aufbau eines wegwerfbaren Objekts und das Auslesen seiner Grenzen ist die zuverlässigste Messung, die die Bibliothek bieten kann

// The shape of MeasureText, expressed against the verified PDFium calls.
// A text object is built, measured, and destroyed; no page is involved.
procedure TPdfMeasureHelper.MeasureText(const Text, Font: WString;
  FontSize: Single; out Width, Height: Double);
var
  TextObject: FPDF_PAGEOBJECT;
  L, B, R, T: Single;
begin
  Width  := 0;
  Height := 0;
  if Self.Document = nil then
    Exit;
  TextObject := FPDFPageObj_NewTextObj(Self.Document,
    FPDF_BYTESTRING(AnsiString(Font)), FontSize);
  if TextObject = nil then
    Exit;
  try
    if FPDFText_SetText(TextObject, FPDF_WIDESTRING(WideString(Text))) = 0 then
      Exit;
    if FPDFPageObj_GetBounds(TextObject, L, B, R, T) <> 0 then
    begin
      Width  := R - L;
      Height := T - B;
    end;
  finally
    FPDFPageObj_Destroy(TextObject);   // probe discarded, page untouched
  end;
end;

Koordinaten und Einheiten des Ergebnisses

Die Bounding Box wird in Form von vier Kanten zurückgegeben: links, unten, rechts und oben. Die beiden Dimensionen ergeben sich durch Subtraktion. Die Breite ist rechts minus links, und die Höhe ist oben minus unten. Beide werden in PDF-Benutzereinheiten (User Units) ausgedrückt, wobei eine Einheit einem Zweiundsiebzigstel Zoll (1/72 Inch) entspricht – also derselbe Koordinatenraum, in dem Sie Text auf der Seite positionieren. Es gibt auf dieser Stufe keine versteckte Geräteeinheit und kein Pixel. Eine Breite von 36 bedeutet ein halbes Zoll (Half an Inch) auf der Seite, unabhängig von der späteren Rendering-Auflösung

Die vertikale Achse verläuft so, wie PDF es definiert: Y nimmt nach oben hin zu, weshalb die Höhe oben minus unten ist und nicht umgekehrt. Dieses Detail ist wichtig, wenn Sie einen Cursor in einer Spalte nach unten bewegen. Sie messen die Höhe einer Zeile und subtrahieren sie dann von der aktuellen Grundlinie (Baseline), um die nächste zu finden, denn eine Bewegung auf der Seite nach unten bedeutet eine Bewegung hin zu kleineren Y-Werten. Wenn Ihr Ziel ein Bildschirm und nicht Papier ist, konvertieren Sie Benutzereinheiten anhand der Display-Auflösung in Gerätepixel: Ein Wert in Benutzereinheiten, multipliziert mit der DPI-Zahl und geteilt durch 72, ergibt Pixel. Eine Spaltenbreite, die Sie in Punkten festlegen, kann somit mit einem gemessenen Textabschnitt abgeglichen werden, bevor Sie entscheiden, wo der Umbruch erfolgt

Was bei fehlerhafter Eingabe passiert

Die Funktionen sind so geschrieben, dass sie stillschweigend fehlschlagen (Fail Quietly). Wenn kein Dokument geöffnet ist oder wenn das Textobjekt nicht erstellt werden kann, ist das Ergebnis eine Ausdehnung von Null und keine ausgelöste Ausnahme (Exception). Breite und Höhe werden zu Beginn auf Null initialisiert und erst überschrieben, wenn eine Bounding Box erfolgreich zurückgelesen wurde. Ein leerer String, ein fehlendes Dokument, eine Schriftart, die die Bibliothek nicht in ein Objekt auflösen kann – all dies führt zur Rückgabe von Null anstatt zum Werfen einer Exception

Diese Designentscheidung hält eine Messschleife einfach, denn eine Schleife, die über Tausende von Wörtern läuft, ist nicht der richtige Ort für Exception Handling in jedem Schleifendurchlauf. Der Preis dafür ist, dass der Aufrufer die Überprüfung durchführen muss. Eine Breite von Null ist ein Wächterwert (Sentinel), kein Fakt über den Text. Code, der durch eine gemessene Breite dividiert oder einen positiven Wert annimmt, muss sich gegen Null absichern, bevor er dem Wert vertraut. Behandeln Sie Null als „konnte nicht messen“, und der Vertrag ist eindeutig; ignorieren Sie es, und eine fehlerhafte Eingabe wird stillschweigend zu einem Layout mit einer Spalte überlappender Glyphen

Ein gieriger Textumbruch, der auf der Messung aufbaut

Mit einer Breitenfunktion in der Hand ist der Textumbruch eine kurze, gierige (Greedy) Schleife. Sie zerlegen den Absatz in Wörter, führen eine aktuelle Zeile mit, und für jedes Wort messen Sie, wie lang die Zeile wäre, wenn Sie dieses Wort anfügten. Solange die Testzeile noch in die Spaltenbreite passt, fügen Sie weiter an. Sobald sie überlaufen würde, geben Sie die aktuelle Zeile mit AddText aus (Flush) und beginnen eine neue mit dem Wort, das nicht mehr hineingepasst hat. Das Aufsummieren geschieht vollständig mit MeasureTextWidth, und das Einzige, was jemals die Seite erreicht, ist eine Zeile, von der Sie bereits bestätigt haben, dass sie passt

procedure WrapParagraph(Pdf: TPdf; const Para, Font: WString;
  FontSize: Single; X, TopY, ColumnWidth, LineHeight: Double);
var
  Words: TArray<WideString>;
  Line, Trial: WideString;
  I: Integer;
  Y: Double;
begin
  Words := WideString(Para).Split([' ']);
  Line  := '';
  Y     := TopY;
  for I := 0 to High(Words) do
  begin
    if Line = '' then
      Trial := Words[I]
    else
      Trial := Line + ' ' + Words[I];
    // Measure the candidate line before drawing anything.
    if (Line <> '') and (Pdf.MeasureTextWidth(Trial, Font, FontSize) > ColumnWidth) then
    begin
      Pdf.AddText(X, Y, Font, FontSize, Line);   // flush the line that fit
      Y    := Y - LineHeight;                    // Y decreases going down
      Line := Words[I];                          // overflowing word starts next line
    end
    else
      Line := Trial;
  end;
  if Line <> '' then
    Pdf.AddText(X, Y, Font, FontSize, Line);      // flush the final line
end;

Die Schleife misst die Testzeile anstatt jedes Wort einzeln zu messen und zu summieren, denn die Breite einer Zeile ist nicht die Summe der Breiten ihrer Wörter. Leerzeichen zwischen Wörtern tragen dazu bei, und ein gemessener Abschnitt (Run) erfasst das direkt. Die Greedy-Regel – passe so viele Wörter ein, wie die Spalte zulässt, und brich beim letzten Wort um, das noch passt – ist exakt die Regel, die die Lücke zwischen einem rohen AddText und einem echten Absatz füllt. Der Zeichenaufruf war nie der schwierige Teil. Die Messung, die ihm vorausgehen muss, ist es, und genau das ist es, was der Helper liefert

Wo sich das einfügt

Die Messung ist die Schicht zwischen der Erstellung von Inhalten und deren Rendering, sie passt also auf natürliche Weise zum Rest eines Workflows für die Dokumenterstellung von Grund auf (From-Scratch). Wenn Sie Seiten zusammenstellen und Text überhaupt erst platzieren, finden Sie die Grundlagen in Erstellen von PDF-Dokumenten von Grund auf mit der PDFium-Komponente in Delphi, wo AddText und die Seiteneinrichtung ausführlich behandelt werden. Wenn die Schriftart, die Sie messen, genauso wichtig ist wie der String, weil Metriken von der Schriftgestaltung abhängen, zeigt Analysieren von PDF-Schriftarteigenschaften mit der PDFium-Komponente in Delphi, wie die Bibliothek die Schriftartinformationen liefert, die diese Bounding Boxes steuern. Beides baut auf demselben Binding auf, der PDFium-Komponente für Delphi und Lazarus, bei der der Measurement Helper zusammen mit den Dokument-, Seiten- und Text-APIs ausgeliefert wird, die überall in diesem Blog beschrieben werden