Technischer Artikel

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

Der Aufruf, der Text auf einer PDF-Seite platziert, ist unkompliziert. Sie übergeben AddText eine Zeichenfolge, eine Schriftart, eine Größe und eine Position, und die Glyphen erscheinen. Was er jedoch nicht tut, ist Ihnen mitzuteilen, wie breit diese Zeichenfolge sein wird, sobald sie gezeichnet ist, und er bricht eine lange Zeichenfolge nicht über mehrere Zeilen um. Ein einzelner Aufruf zeichnet einen Textdurchlauf an einer Position. Wenn der Durchlauf breiter ist als die Spalte, in die er passen sollte, ragt er einfach über den Rand hinaus, und nichts im Zeichenaufruf warnt Sie. In dem Moment, in dem Sie einen Absatz anstelle einer einzelnen Beschriftung wünschen, ist das fehlende Teil die Breite einer Zeichenfolge in der gewählten Schriftart und Größe, gemessen, bevor Sie sie auf die Seite übertragen

Dies 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 Sie müssen dies wissen, bevor Sie irgendetwas zeichnen. Ein Zeilenumbruch ist eine Messschleife, die um einen Zeichenaufruf gewickelt ist, und eine Anbindung, die nur zeichnet, liefert Ihnen die zweite Hälfte. Die Unterstützung für Textmessungen in der PDFium-Komponente schließt diese Lücke mit zwei Funktionen, MeasureText und MeasureTextWidth, die die gerenderte Ausdehnung einer Zeichenfolge melden, ohne eine Markierung auf einer Seite zu hinterlassen

Warum die Messung ein Class-Helper und keine neue Methode für TPdf ist

Die Messunterstützung wird als Delphi-Class-Helper für TPdf geliefert, der in einer eigenen Unit lebt, und nicht als neue Methoden, die an die TPdf-Klasse angeflanscht werden. Ein Class-Helper ist eine Sprachfunktion, mit der Sie Methoden von außerhalb seiner Deklaration an einen vorhandenen Typ anfügen können. Sobald sich die Unit im Gültigkeitsbereich (Scope) befindet, werden die neuen Methoden genauso aufgerufen, als ob sie zur Klasse gehörten. Eine Hilfsmethode liest sich also wie Pdf.MeasureTextWidth(...), ohne dass ein separates Objekt konstruiert oder weitergegeben werden muss

Der Grund für diese Art der Schichtung ist die Trennung. Der Kern-Typ TPdf bleibt wie er ist, es wird kein Feld hinzugefügt und keine vorhandene Signatur angetastet, sodass ein Projekt, das niemals ein Layout benötigt, auch niemals den Messcode mit sich führt. Ein Projekt, das ihn benötigt, fügt einer uses-Klausel eine Unit hinzu, und die Methoden leuchten auf. Die Funktionalität wird auf der Granularität einer einzelnen Unit optional (opt-in), was der sauberste Weg ist, einen Typ zu erweitern, der Ihnen nicht gehört oder den Sie nicht verändern 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 Nebenwirkungen sein. Sie muss eine Breite melden, ohne etwas zu hinterlassen, da Sie sie bei der Festlegung eines Layouts viele Male aufrufen und die Seite genau so aussehen muss, wie sie ausgesehen hätte, wenn Sie überhaupt nie gemessen hätten. Die Technik, die dies ermöglicht, besteht darin, ein Textobjekt zu erstellen, es nach seiner Größe zu fragen und es wegzuwerfen, bevor es jemals an eine Seite angehängt wird

Die Sequenz besteht aus vier PDFium-Aufrufen. FPDFPageObj_NewTextObj erstellt ein Textobjekt für das Dokument unter Angabe des Schriftartnamens und der Größe. FPDFText_SetText legt die Zeichenfolge fest, die das Objekt trägt. FPDFPageObj_GetBounds liest den Begrenzungsrahmen (Bounding Box) des Objekts aus. FPDFPageObj_Destroy gibt das Objekt frei. Entscheidend ist, dass in dieser Sequenz nichts die Seiten-Einfüge-API aufruft. Das Objekt wird isoliert erstellt, abgefragt und zerstört, sodass das Dokument bei der Rückkehr der Funktion unverändert ist. Es ist eine Wegwerf-Sonde, deren einzige Ausgabe die vier Zahlen ihres Begrenzungsrahmens sind

Dies ist der robuste Weg, da PDFium keine praktische Vorschubbreite (Advance Width) pro Glyphe bereitstellt, die Sie selbst aufsummieren könnten. Glyphenmetriken hängen vom Schriftartprogramm, von der Kodierung und davon ab, wie PDFium das Schriftbild lädt, und es gibt keinen öffentlichen Aufruf, der Ihnen den Vorschub jedes Zeichens in einer Zeichenfolge übergibt. Der Begrenzungsrahmen eines echten Textobjekts hingegen wird von derselben Maschinerie berechnet, die die Glyphen zum Zeichnen anordnen würde, sodass er die tatsächliche gerenderte Ausdehnung widerspiegelt und nicht nur eine Annäherung. Der Bau eines wegwerfbaren Objekts und das Auslesen seiner Grenzen ist die zuverlässigste Messung, die die Bibliothek liefern 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

Der Begrenzungsrahmen wird als vier Kanten zurückgegeben: links, unten, rechts und oben, und 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 ein Zweiundsiebzigstel eines Zolls (Inch) ist, derselbe Koordinatenraum, in dem Sie Text auf der Seite positionieren. In diesem Stadium ist keine versteckte Geräteeinheit und kein Pixel involviert. Eine Breite von 36 bedeutet ein halbes Zoll der Seite, unabhängig von der endgültigen Rendering-Auflösung

Die vertikale Achse verläuft so, wie PDF sie definiert, wobei Y nach oben hin zunimmt, 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, da eine Bewegung nach unten auf der Seite eine Bewegung hin zu einem kleineren Y bedeutet. Wenn Ihr Ziel ein Bildschirm und kein Papier ist, konvertieren Sie Benutzereinheiten mithilfe der Anzeigeauflösung in Gerätepixel: Ein Wert in Benutzereinheiten, multipliziert mit den DPI und geteilt durch 72, ergibt Pixel, sodass eine in Punkten (Points) festgelegte Spaltenbreite mit einem gemessenen Durchlauf abgeglichen werden kann, bevor Sie entscheiden, wo der Umbruch erfolgt

Was bei degenerierter Eingabe passiert

Die Funktionen sind so geschrieben, dass sie stillschweigend fehlschlagen. Wenn kein Dokument geöffnet ist oder das Textobjekt nicht erstellt werden kann, ist das Ergebnis eine Ausdehnung von null und nicht eine ausgelöste Ausnahme (Exception). Breite und Höhe werden zu Beginn auf null initialisiert und erst überschrieben, sobald ein Begrenzungsrahmen erfolgreich zurückgelesen wurde. Eine leere Zeichenfolge, ein fehlendes Dokument, eine Schriftart, die die Bibliothek nicht in ein Objekt auflösen kann – all dies gibt null zurück, anstatt eine Ausnahme auszulösen

Diese Entscheidung hält eine Messschleife einfach, da eine Schleife, die über Tausende von Wörtern läuft, nicht der richtige Ort für eine Ausnahmebehandlung (Exception Handling) bei jeder Iteration ist. Die Kehrseite ist, dass der Aufrufer die Prüfung übernimmt. Eine Breite von null ist ein Wächter (Sentinel), keine Tatsache über den Text, daher muss Code, der durch eine gemessene Breite dividiert oder einen positiven Wert annimmt, sich gegen null absichern, bevor er ihm vertraut. Behandeln Sie null als "konnte nicht messen" und der Vertrag ist klar; ignorieren Sie es, und eine degenerierte Eingabe wird stillschweigend zu einem Layout mit einer Spalte überlappender Glyphen

Ein gieriger (greedy) Zeilenumbruch basierend auf der Messung

Mit einer Breitenfunktion in der Hand ist der Zeilenumbruch eine kurze gierige Schleife. Sie teilen den Absatz in Wörter auf, behalten eine aktuelle Zeile bei, und für jedes Wort messen Sie, wie die Zeile aussehen würde, wenn Sie dieses Wort anfügen würden. Solange die Testzeile noch in die Spaltenbreite passt, fügen Sie weiter hinzu; wenn sie überlaufen würde, geben Sie die aktuelle Zeile mit AddText aus und beginnen eine neue mit dem Wort, das nicht gepasst hat. Die Akkumulation erfolgt 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<string>;
  Line, Trial: WideString;
  I: Integer;
  Y: Double;
begin
  Words := string(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(Line, Font, FontSize, X, Y);   // 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(Line, Font, FontSize, X, Y);      // flush the final line
end;

Die Schleife misst die Testzeile, anstatt jedes Wort zu messen und zu summieren, da die Breite einer Zeile nicht die Summe der Breiten ihrer Wörter ist. Leerzeichen zwischen Wörtern tragen dazu bei, und ein gemessener Durchlauf erfasst dies direkt. Die gierige Regel, so viele Wörter einzufügen, wie die Spalte zulässt, und beim letzten passenden Wort umzubrechen, ist dieselbe Regel, die die Lücke zwischen einem rohen AddText und einem echten Absatz schließt. Der Zeichenaufruf war nie der schwierige Teil. Die Messung, die ihm vorausgehen muss, ist es, und genau das ist es, was der Helper bietet

Wo dies reinpasst

Die Messung ist die Schicht zwischen der Inhaltsgenerierung und dem Rendering, daher passt sie auf natürliche Weise zum Rest eines von Grund auf neu erstellten Dokumenten-Workflows. Wenn Sie überhaupt erst Seiten zusammenstellen und Text platzieren, finden Sie die Grundlagen unter Erstellen von PDF-Dokumenten von Grund auf neu 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 die Zeichenfolge, weil Metriken vom Schriftbild abhängen, zeigt Ihnen Analysieren von PDF-Schriftarteigenschaften mit der PDFium-Komponente in Delphi, wie die Bibliothek die Schriftartinformationen liefert, die diese Begrenzungsrahmen steuern. Beide bauen auf derselben Anbindung auf, der PDFium-Komponente für Delphi und Lazarus, bei der der Messungs-Helper zusammen mit den in diesem Blog beschriebenen Dokument-, Seiten- und Text-APIs geliefert wird