Technischer Artikel

HotPDF TextOut in Delphi: Größe, Stil, Rotation und Abstand

Ein String bei (72, 72) landet einen Zoll unter der oberen Seitenkante, was jeden erwischt, der PDF von der Content-Stream-Seite gelernt hat, wo y = 72 einen Zoll über der Unterkante sitzt. Die zweite Überraschung kommt ein paar hundert Zeilen später. Ein für US Letter gesetzter Bericht kommt mit einer ersten Seite von 612 mal 792 Punkten zurück und jede Seite danach mit 595 mal 842, weil die Zeile, die das Papierformat gesetzt hat, genau einmal gelaufen ist

Beide Effekte haben dieselbe Wurzel. Text in einem PDF ist eine kurze Folge von Zustandsänderungen (Schriftart und Größe, Zeichenabstand, Wortabstand, eine Textmatrix für Position und Rotation), gefolgt von einem einzigen Show-Text-Operator, und ein High-level-Textaufruf ist ein dünner Wrapper über diese Sequenz. Der Aufruf liest den Zustand. Er setzt ihn nie für Sie zurück, und eine neue Seite startet aus einem festen Satz von Defaults statt aus dem, womit die vorherige Seite geendet hat

Wo TextOut (X, Y) auf der Seite platziert

HotPDF zeichnet Text mit THPDFPage.TextOut(X, Y: Single; angle: Extended; Text: WideString), erreicht als Pdf.CurrentPage.TextOut. X und Y werden von der oberen linken Ecke der Seite gemessen, mit Y nach unten wachsend, und bei der Standard-Resolution von 72 ist eine Einheit ein Punkt, 1/72 Zoll. Die Seite rechnet jedes Y in den PDF-User-Space um, der von unten nach oben läuft, indem sie es von der Seitenhöhe abzieht. Der Winkel ist in Grad, und weil der Text ein WideString ist, geht Unicode direkt durch. Eine zweite Überladung nimmt ein PWORD plus eine Länge für Code, der bereits UTF-16-Buffer hält

Die Seitengeometrie steht ebenfalls fest. BeginDoc legt die erste Seite an, und jede Seite, die BeginDoc oder AddPage erzeugt, startet als A4, 595 mal 842 Punkte, mit Arial 12 als aktueller Schriftart. US Letter (612 mal 792) ist eine Zuweisung entfernt über CurrentPage.Size := Letter, aber diese Zuweisung gehört zur Seite, also muss sie für jede neue Seite wiederholt werden

Standardmäßig ist das Y, das Sie übergeben, die Oberkante des Texts, und die Grundlinie sitzt eine Schriftversalhöhe darunter. TextFromBaseLine(True) schaltet die Seite auf Grundlinienpositionierung um. Schalten Sie es für alles ein, was auf einer Zeile Größen mischt. Im Oberkante-Modus hat ein 9-Punkt-Währungscode neben einem 14-Punkt-Betrag seine Oberkante auf der Höhe der Betragsoberkante, steht also sichtbar zu hoch; im Grundlinienmodus sitzen beide Läufe auf derselben Zeile, so wie ein Schriftsetter sie gesetzt hätte. Der letzte Block des nächsten Beispiels macht genau das

Größe und Stil stecken in SetFont

TextOut hat keinen Parameter für Größe oder Stil. SetFont(FontName, FontStyle, ASize, FontCharset, IsVertical) trägt all das, wobei die letzten beiden Argumente auf ANSI_CHARSET und horizontalen Text defaulten, und was es setzt, bleibt für jeden folgenden TextOut in Kraft bis zum nächsten SetFont. Die meiste Verwirrung am ersten Tag geht auf das zurück. Eine Zeile kommt fett heraus, weil drei Aufrufe zuvor etwas [fsBold] gesetzt hat und nichts es gelöscht hat

var
  W: Single;
begin
  Pdf.BeginDoc;                                     // erste Seite: A4, Arial 12
  Pdf.CurrentPage.TextFromBaseLine(True);           // jedes Y darunter ist eine Grundlinie

  Pdf.CurrentPage.SetFont('Times New Roman', [], 24);
  Pdf.CurrentPage.TextOut(72, 72, 0, 'Quarterly Report');        // 24 pt normal

  Pdf.CurrentPage.SetFont('Times New Roman', [fsBold], 12);
  Pdf.CurrentPage.TextOut(72, 104, 0, 'Revenue');                // 12 pt fett

  Pdf.CurrentPage.SetFont('Times New Roman', [fsItalic], 11);
  Pdf.CurrentPage.TextOut(72, 120, 0, 'figures in thousands');   // 11 pt kursiv

  Pdf.CurrentPage.SetFont('Courier New', [fsBold, fsUnderline], 10);
  Pdf.CurrentPage.TextOut(72, 136, 0, '+18.4% YoY');             // Stile kombinieren

  // Gemischte Größen auf einer Grundlinie: messen, solange der 14-pt-Schnitt aktuell ist
  Pdf.CurrentPage.SetFont('Arial', [fsBold], 14);
  W := Pdf.CurrentPage.GetWideTextWidth('1,284.50');
  Pdf.CurrentPage.TextOut(72, 160, 0, '1,284.50');
  Pdf.CurrentPage.SetFont('Arial', [], 9);
  Pdf.CurrentPage.TextOut(72 + W + 3, 160, 0, 'EUR');            // 3 pt Abstand
  ...
end;

Das Stil-Argument ist ein gewöhnliches TFontStyles-Set, also ist [] normal und [fsBold, fsItalic] fett kursiv. fsUnderline und fsStrikeOut werden als dünne gefüllte Balken unter beziehungsweise durch den Lauf gezeichnet, und sie folgen dem Lauf, wenn er rotiert wird. Die Größe ist in Punkten, dieselbe Einheit wie die Koordinaten, was die vertikale Arithmetik einfach hält: 11-Punkt-Fließtext mit einem 16-Punkt-Schritt lässt etwa fünf Punkte Luft zwischen den Zeilen. Einen automatischen Zeilenvorschub gibt es nicht. Jedes Y müssen Sie selbst berechnen, was für einen Absatz mühsam und für ein Formular, dessen Felder an festen Positionen sitzen, genau richtig ist

GetWideTextWidth misst mit der Schriftart, die im Moment des Aufrufs aktuell ist, also muss im letzten Block die Messung passieren, während der 14-Punkt-Fett-Schnitt noch gesetzt ist. Verschieben Sie sie unter das zweite SetFont, und der 9-Punkt-Suffix landet auf den letzten Ziffern des Betrags

Der Schriftartname wird gegen die auf dem Rechner installierten Schriftarten aufgelöst, der die PDF baut, und ein Schnitt, der auf einem Entwickler-Desktop auflöst, kann auf einem Build-Server fehlen, also hängt der Schnitt in der fertigen Datei davon ab, welche Maschine sie erzeugt hat. Die Skript-Abdeckung ist die andere Hälfte. Ein nur-lateinischer Schnitt, der Kyrillisch oder CJK zeichnen soll, produziert Kästchen für fehlende Glyphen, und das ist eine Eigenschaft der Fontdatei, nicht des Aufrufs. Wenn ein String Skripte mischt, die kein einzelner Schnitt abdeckt, nimmt TextOutWithFallback dieselben vier Argumente, schaltet Lauf für Lauf auf andere Schnitte um für Zeichen, die der primären Schriftart fehlen, und stellt die ursprüngliche Schriftart danach wieder her

HotPDF TextOut-Seite, die Arial, Times New Roman und Courier New mit normalen, fetten und kursiven Stilen über mehrere Zeichensätze hinweg gerendert zeigt

Wie rotiert das angle-Argument den Text?

Ein Winkel ungleich null dreht den Lauf um diese Gradzahl gegen den Uhrzeigersinn um seinen Anker, und der Anker ist die obere linke Ecke des Texts im eigenen Bezugsrahmen des Texts. Der Anker bewegt sich nicht. Nur die Richtung, in die die Glyphen marschieren, ändert sich, also platziert die Koordinate, die ein horizontales Label gesetzt hat, auch seinen gedrehten Zwilling. Rotierte Läufe behalten diesen Oberkante-Anker sogar, wenn die Seite im Grundlinienmodus ist; TextFromBaseLine verschiebt nur horizontale Läufe

Diagramm der HotPDF-TextOut-Rotationsgeometrie: Läufe bei 0, 45 und 90 Grad, gegen den Uhrzeigersinn um einen festen Anker schwenkend, mit Delphi-TextOut-Aufrufbeispielen für Buchrückenbeschriftungen, Wasserzeichen und schräge Spaltenköpfe
Ein Winkel ungleich null dreht den Lauf gegen den Uhrzeigersinn um seinen unbeweglichen Anker, die obere linke Ecke des Textes, und gemeinsame Grundlinien rücken X genau so weiter, wie horizontale Zeilen Y rücken
var
  W, AxisMidY: Single;
begin
  AxisMidY := 400;                                // Mitte der Y-Achse des Diagramms

  // Ein 90-Grad-Lauf liest sich von unten nach oben und wächst von Y aus nach oben,
  // also bedeutet Zentrieren auf der Achse, eine halbe Breite unter der Mitte zu beginnen
  Pdf.CurrentPage.SetFont('Arial', [fsBold], 11);
  W := Pdf.CurrentPage.GetWideTextWidth('Units sold');
  Pdf.CurrentPage.TextOut(40, AxisMidY + W / 2, 90, 'Units sold');

  // Ein diagonales Wasserzeichen
  Pdf.CurrentPage.SetFont('Arial', [fsBold], 60);
  Pdf.CurrentPage.TextOut(150, 250, 45, 'DRAFT');

  // Um 60 Grad gekippte Spaltenköpfe: dasselbe Y, X in Schritten
  Pdf.CurrentPage.SetFont('Arial', [], 9);
  Pdf.CurrentPage.TextOut(120, 600, 60, 'Q1 actual');
  Pdf.CurrentPage.TextOut(160, 600, 60, 'Q2 actual');
end;

Neunzig Grad deckt Achsenbeschriftungen und Rückentitel ab. Bei 90 Grad stehen die Glyphen rechts vom X des Ankers, eine Zeilenhöhe breit, und der Lauf klettert um seine eigene Breite zur Seitenoberkante hinauf. Deshalb addiert die Zentrierung die halbe Breite zu Y, statt sie zu subtrahieren, und deshalb sitzt ein Label, das auf den Mittelpunkt selbst verankert ist, komplett darüber. Sechzig Grad eignen sich für Spaltenköpfe und lassen ein langes Label über einer schmalen Spalte stehen, ohne in den Nachbarn überzulaufen, und 45 Grad ist der übliche Wasserzeichen-Winkel. Läufe, die sich eine Grundlinie teilen, bekommen dasselbe Y und ein gestuftes X, genau wie gestapelte horizontale Zeilen sich ein X teilen und Y stufen

Warum kommt die zweite Seite als A4 in Arial 12 heraus?

AddPage liefert den Index der neuen Seite und macht sie aktuell, und die neue Seite startet aus den Defaults: A4, Arial 12, Oberkante-Positionierung, null Zeichen- und Wortabstand. Nichts von der vorherigen Seite wird übernommen. Dieses Design hält jede Seite in sich abgeschlossen, denn der Content-Stream einer PDF-Seite startet ohnehin mit einem frischen Grafikzustand, aber es bedeutet, dass Layout-Code, der die Seite einmal am Anfang des Dokuments konfiguriert, auf Seite zwei stillschweigend Papierformat und Schriftart wechselt. Der Fix: Legen Sie jede seitenspezifische Einstellung in eine Routine und rufen Sie sie nach BeginDoc und nach jedem AddPage auf

HotPDF: US-Letter-Seitendiagramm mit Zeilenpositionsarithmetik: ein linker Rand, eine obere Startlinie und ein festes Leading in Schritten Richtung einer Unterrandgrenze, daneben die Layoutschleife mit benannten Konstanten samt manueller AddPage-Sicherung und SetFont-Reset
Benannte Anker machen aus einer Spalte magischer Zahlen Arithmetik: Das laufende Y pro Zeile um Leading vorrücken und die Unterkante selbst mit AddPage absichern
procedure StartReportPage(Pdf: THotPDF);
begin
  Pdf.CurrentPage.Size := Letter;               // eine neue Seite startet als A4
  Pdf.CurrentPage.TextFromBaseLine(True);       // eine neue Seite startet im Oberkante-Modus
  Pdf.CurrentPage.SetFont('Arial', [], 11);     // eine neue Seite startet mit Arial 12
end;

procedure WriteReport(Pdf: THotPDF; Lines: TStrings);
const
  LeftMargin   = 72;   // 1 Zoll
  TopLine      = 72;   // erste Grundlinie, 1 Zoll von oben
  BottomMargin = 72;
  Leading      = 16;
var
  I: Integer;
  Y: Single;
begin
  StartReportPage(Pdf);
  Y := TopLine;
  for I := 0 to Lines.Count - 1 do
  begin
    if Y > Pdf.CurrentPage.Height - BottomMargin then   // 720 auf Letter
    begin
      Pdf.AddPage;
      StartReportPage(Pdf);
      Y := TopLine;
    end;
    Pdf.CurrentPage.TextOut(LeftMargin, Y, 0, Lines[I]);
    Y := Y + Leading;
  end;
end;

Unter TextOut steckt kein Flow-Layout, und ein Y hinter der Seitenunterkante ist für den Content-Stream weiterhin eine gültige Koordinate: Der Text wird geschrieben, er fällt nur außerhalb der Media-Box und kein Viewer zeigt ihn an. Deshalb prüft die Schleife Y vor jeder Zeile und öffnet selbst eine neue Seite. Die Grenze aus CurrentPage.Height zu lesen statt 720 hart zu kodieren, hält die Schleife korrekt, wenn jemand den Helfer später zurück auf A4 umstellt, wo derselbe Ein-Zoll-Rand die Grenze auf 770 legt

Die schnellste Prüfung ist die, die der Druckdialog eines Readers machen würde. Öffnen Sie die Ausgabe, gehen Sie auf Seite zwei und schauen Sie in die Dokumenteigenschaften: Eine Letter-Seite meldet 8,5 mal 11 Zoll, eine A4-Seite etwa 8,27 mal 11,69. Stimmen erste und zweite Seite nicht überein, lebt eine seitenspezifische Einstellung außerhalb des Helfers

Zeichen- und Wortabstand sind ebenfalls Seitenzustand

Manchmal ist der String richtig und die Breite falsch: eine Überschrift, die eine feste Linie überspannen soll, ein Code, der mit luftigeren Ziffern besser liest, eine Zeile Labels, deren Abstände geöffnet werden müssen. PDF hat dafür in ISO 32000-1 zwei Textstatus-Parameter, den Zeichenabstand Tc (§9.3.2), nach jeder Glyphe addiert, und den Wortabstand Tw (§9.3.3), an jedem Leerzeichen addiert. HotPDF setzt sie mit SetCharacterSpacing(Spacing: Single) und SetWordSpacing(Spacing: Single), in Punkten bei der Standard-Resolution, und wie die Schriftart bleiben sie in Kraft, bis sie geändert werden

HotPDF: Zeichenabstand Tc verteilt Zusatzraum gleichmäßig nach jedem Glyphen von SUMMARY, gegenüber Wortabstand Tw, der ihn nur an Leerzeichen sammelt; eingebunden in den Set-, Draw-, Reset-Zyklus, der verhindert, dass Abstandszustand in spätere Absätze sickert
Tc verteilt seine Anpassung auf jede Glyphe, während Tw nur auf das Leerzeichen wirkt, und das Zurücksetzen in der nächsten Zeile hält den Zeichenzustand sauber

Beim Wortabstand steht eine Falle in der Spezifikation. §9.3.3 wendet Tw nur auf das Einzelbyte-Zeichen 32 an und ausdrücklich nicht auf ein Byte 32 innerhalb eines Mehrbyte-Codes, also tut der Operator für sich genommen nichts für Text, der mit Zwei-Byte-Glyphen-Codes geschrieben ist, so werden eingebettete Unicode-Schriftarten normalerweise geschrieben. HotPDF schreibt Unicode-Läufe auf diese Weise, und auf diesem Pfad wendet es den Wortabstand als explizite Positionsverschiebungen nach jedem Leerzeichen-Glyph an, also verbreitert SetWordSpacing die Lücken so oder so. Der Trigger bleibt das schlichte Leerzeichen, U+0020. Ein chinesischer oder japanischer Lauf ohne ASCII-Leerzeichen bekommt überhaupt keinen Zusatzraum, und ein ideografisches Leerzeichen (U+3000) wird als gewöhnliche Glyphe behandelt

Der Zeichenabstand ist das richtige Werkzeug, wenn eine Überschrift eine Zielbreite erreichen muss, denn er verteilt die Änderung gleichmäßig, statt sie an den Leerzeichen zu bündeln. GetWideTextWidth zählt den aktuell wirksamen Zeichen- und Wortabstand mit, also messen Sie mit beiden auf null und leiten dann den Abstand ab:

const
  Heading = 'SUMMARY';
  Target  = 200;        // Breite der Linie, die die Überschrift überspannen soll, in Punkten
var
  Natural, Tc: Single;
begin
  Pdf.CurrentPage.SetFont('Arial', [fsBold], 14);
  Pdf.CurrentPage.SetCharacterSpacing(0);       // messen ohne übrig gebliebenen Abstand
  Natural := Pdf.CurrentPage.GetWideTextWidth(Heading);

  // Tc kommt hinter jede Glyphe, aber sichtbar sind nur die Lücken zwischen den Glyphen
  Tc := (Target - Natural) / (Length(Heading) - 1);

  Pdf.CurrentPage.SetCharacterSpacing(Tc);      // negative Werte ziehen zusammen
  Pdf.CurrentPage.TextOut(72, 72, 0, Heading);
  Pdf.CurrentPage.SetCharacterSpacing(0);       // vor dem Fließtext zurücksetzen

  Pdf.CurrentPage.SetWordSpacing(6);
  Pdf.CurrentPage.SetFont('Arial', [], 11);
  Pdf.CurrentPage.TextOut(72, 100, 0, 'Name Department Extension');
  Pdf.CurrentPage.SetWordSpacing(0);
end;

Die Reset-Zeilen zählen mehr als die Set-Zeilen. Lassen Sie einen 4-Punkt-Zeichenabstand nach einer Überschrift stehen, und jeder Absatz darunter erbt die Streckung, was sich als vage falsches Lesen äußert, das ein schnelles Gegenlesen übersteht und dann im Druck auftaucht. Setzen Sie den Wert, zeichnen Sie den einen Lauf, der ihn braucht, und nullen Sie ihn in der nächsten Zeile, sodass kein späterer Code wissen muss, was ein früherer Abschnitt getan hat. Dieselbe Seite bietet SetHorizontalScaling (Tz, standardmäßig 100) zum Stauchen eines Laufs ohne Höhenänderung und SetTextRenderingMode mit Werten wie trStroke für Konturtext, beide persistieren auf exakt dieselbe Weise

HotPDF TextOut-Seite, die horizontale Textskalierung, Zeichenabstand, Wortabstand und die Rendermodi Füllen gegenüber Kontur vergleicht

Wenn das Ihre erste Ausgabeseite ist, behandelt das HotPDF Hello World-Beispiel Dokumentaufbau und Speichern. Für Text, der eine Spalte randvoll füllen muss, hat die vollständige Justierung von PDF-Text ein eigenes Walkthrough, und von oben nach unten laufendes CJK ist in vertikales Textlayout in HotPDF abgedeckt. TextOut, SetFont, die Abstandsaufrufe und die Seitengrößen-Einstellungen oben kommen alle mit der HotPDF Delphi Component für Delphi und C++Builder