Technischer Artikel

RTF-zu-PDF-Konvertierung in Delphi mit losLab PDF Library

RTF gibt es lange genug, dass es an Stellen auftaucht, an denen niemand es geplant hat: Legacy-Reportgeneratoren, Mail-Merge-Pipelines, juristische Dokumentenarchive, die älter sind als moderne Textverarbeitungen. Die Konvertierung on the fly nach PDF ist eine wiederkehrende Anforderung, und der Ansatz, der unter Windows tatsächlich funktioniert, ist kein eigener RTF-Parser, sondern der Rendering-Pfad, den Windows selbst bereits über TRichEdit und EM_FORMATRANGE mitbringt. Die losLab PDF Library stellt einen virtuellen Device Context bereit, der sich direkt in diese Pipeline einfügt

Der Mechanismus: virtueller DC und EM_FORMATRANGE

Rich-Edit-Steuerelemente können ihren Inhalt für jeden Device Context paginieren, nicht nur für einen physischen Drucker. Die Botschaft EM_FORMATRANGE weist das Steuerelement an, einen Bereich von Zeichen in einen gegebenen DC zu layouten, und liefert die Position des letzten Zeichens zurück, das noch hineingepasst hat. Ruft man sie wiederholt auf und rückt cpMin jedes Mal weiter, erhält man eine Ausgabe Seite für Seite. GetCanvasDC der losLab PDF Library stellt einen speicherinternen DC bereit, dimensioniert auf jede Seitengröße, die man angibt; nach dem Hineinrendern einer Seite erfasst LoadFromCanvasDC das Ergebnis als neues einseitiges PDF-Dokument. Das ist die ganze Pipeline

Eines gehört von Anfang an richtig gemacht: Das TRichEdit-Steuerelement muss auf die Ziel-Seitengröße dimensioniert sein. Ist das Steuerelement kleiner oder größer als die DC-Maße, passt die Paginierung nicht zu dem, was am Ende im PDF landet. Für A4-Ausgaben besteht der Standardweg darin, die Pixelmaße des Steuerelements vor dem Laden der RTF-Datei auf 210 x 297 mm bei demselben DPI zu setzen, das man später an LoadFromCanvasDC übergibt, mit derselben Millimeter-zu-Pixel-Umrechnung, mit der man auch den DC dimensioniert

PDF: RTF-zu-PDF-Pipeline: ein dimensioniertes Rich-Edit-Steuerelement paginiert seinen Text über EM_FORMATRANGE in einen virtuellen Canvas-DC, und jeder Durchgang wird als eine PDF-Seite erfasst
EM_FORMATRANGE legt einen Bereich von RTF-Text in den virtuellen Canvas-DC, und LoadFromCanvasDC erfasst das Ergebnis, wiederholt bis das letzte Zeichen erreicht ist

Delphi-Implementierung

Das Folgende verwendet die Import-Unit PDFlibAX_TLB, den Type-Library-Import für die ActiveX-Edition der Bibliothek. Das Formular beherbergt ein TRichEdit und einen Button; der OnCreate-Handler des Formulars dimensioniert das Steuerelement und lädt das RTF, und der Button-Klick treibt die Konvertierungsschleife an

unit MainUnit;

interface

uses
  Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms,
  Dialogs, StdCtrls, ComCtrls, PDFlibAX_TLB, ActiveX;

type
  TForm1 = class(TForm)
    RichEdit1: TRichEdit;
    Button1: TButton;
    procedure FormCreate(Sender: TObject);
    procedure Button1Click(Sender: TObject);
  private
    function PrintRtfBox(hDc: HDC; rtfBox: TRichEdit;
      FirstChar: Integer): Integer;
  end;

var
  Form1: TForm1;
  PdfDoc: IPDFlibrary;

implementation

{$R *.dfm}

procedure TForm1.FormCreate(Sender: TObject);
begin
  PdfDoc := CoPDFlibrary.Create;
  // Steuerelement auf A4 bei Bildschirm-DPI dimensionieren, damit die Paginierung zum DC passt
  RichEdit1.Width  := MulDiv(210, Screen.PixelsPerInch * 10, 254);
  RichEdit1.Height := MulDiv(297, Screen.PixelsPerInch * 10, 254);
  RichEdit1.Lines.LoadFromFile(
    ExtractFilePath(Application.ExeName) + 'document.rtf');
end;

procedure TForm1.Button1Click(Sender: TObject);
var
  Dc: HDC;
  PageNumber, LastChar, PdfDocId: Integer;
begin
  PageNumber := 1;
  LastChar   := 0;
  repeat
    // Virtuellen DC in A4-Größe holen
    Dc := PdfDoc.GetCanvasDC(
      MulDiv(210, Screen.PixelsPerInch * 10, 254),
      MulDiv(297, Screen.PixelsPerInch * 10, 254));
    // Nächste Seite des RTF-Inhalts in den DC rendern
    LastChar := PrintRtfBox(Dc, RichEdit1, LastChar);
    // DC-Inhalt als PDF-Dokument erfassen
    PdfDoc.LoadFromCanvasDC(Screen.PixelsPerInch, 0);
    PdfDocId := PdfDoc.SelectedDocument;
    PdfDoc.SaveToFile(
      ExtractFilePath(Application.ExeName)
      + 'Output' + IntToStr(PageNumber) + '.pdf');
    PdfDoc.RemoveDocument(PdfDocId);
    Inc(PageNumber);
  until LastChar = 0;
  // Dem Steuerelement erlauben, die für den DC gecachten Formatierungsdaten freizugeben
  SendMessage(RichEdit1.Handle, EM_FORMATRANGE, 0, 0);
end;

function TForm1.PrintRtfBox(hDc: HDC; rtfBox: TRichEdit;
  FirstChar: Integer): Integer;
var
  Fr: TFormatRange;
  Dpi, PageWidthTw, PageHeightTw, NextCharPosition: Integer;
begin
  // rc und rcPage stehen in Twips (1/1440 Zoll), nicht in Pixeln: die
  // Seitengröße mit demselben DPI umrechnen, das DC und Steuerelement dimensioniert hat
  Dpi          := Screen.PixelsPerInch;
  PageWidthTw  := MulDiv(rtfBox.Width,  1440, Dpi);
  PageHeightTw := MulDiv(rtfBox.Height, 1440, Dpi);

  Fr.hdc         := hDc;
  Fr.hdcTarget   := hDc;
  Fr.rcPage      := Rect(0, 0, PageWidthTw, PageHeightTw);
  Fr.rc          := Fr.rcPage;  // auf der ganzen Seite zeichnen; für Ränder verkleinern
  Fr.chrg.cpMin  := FirstChar;
  Fr.chrg.cpMax  := -1;

  NextCharPosition :=
    SendMessage(rtfBox.Handle, EM_FORMATRANGE, 1, LPARAM(@Fr));
  if NextCharPosition < Length(rtfBox.Text) then
    Result := NextCharPosition
  else
    Result := 0;  // meldet die letzte Seite
end;

end.

Was die Schleife tut

PrintRtfBox füllt die TFormatRange-Struktur und übergibt sie per SendMessage an das Rich-Edit-Steuerelement. Das Steuerelement rendert Zeichen ab cpMin, hört auf, wenn der DC voll ist, und liefert die Position des ersten Zeichens zurück, das nicht mehr hineingepasst hat. Erreicht oder überschreitet der Rückgabewert die Gesamtlänge des Textes, ist jedes Zeichen gerendert, die Funktion liefert null, und damit endet die repeat...until-Schleife

Jeder Durchgang erzeugt eine PDF-Datei namens Output1.pdf, Output2.pdf und so weiter. Will man stattdessen ein einziges mehrseitiges Dokument, können AddToFileList plus MergeFileList die Einzelseiten-Dateien nachträglich zusammenführen, oder man baut die Schleife so um, dass jedes neue Seiten-Dokument per MergeDocument in das erste eingefaltet wird. Das Muster oben, je Durchgang SaveToFile gefolgt von RemoveDocument, hält den Speicherhöchststand bei dem einer Seite Inhalt begrenzt, was bei sehr langen RTF-Dateien zählt

Größenangaben, an denen es leicht hakt

Das DPI-Argument von LoadFromCanvasDC sagt der Bibliothek, wie viele Ihrer Pixel einen Zoll ergeben: Die Seite kommt so breit heraus wie Canvas-Breite geteilt durch DPI mal 72 Punkte, und für die Höhe gilt dasselbe. Wenn man das vermasselt, erscheint der Text in der Ausgabe in der falschen Größe, obwohl das Bild auf dem Bildschirm korrekt aussieht

Die Einheit von rc und rcPage ist das Detail, das die meisten Delphi-Beispiele zu dieser Technik falsch haben. EM_FORMATRANGE liest beide Rechtecke in Twips, 1/1440 Zoll, obwohl der DC, in den gezeichnet wird, in Pixeln rechnet. Füllt man sie mit Pixelwerten, setzt das Steuerelement den Text auf eine Seite, die bei 96 DPI fünfzehnmal zu klein ist: eine Handvoll Wörter pro Durchgang und eine Schleife, die aus einem zehnseitigen Dokument hunderte fast leere PDFs macht. Die Umrechnung mit MulDiv(Pixels, 1440, Dpi), mit dem DPI, das den DC dimensioniert hat, setzt den Seitenumbruch dorthin, wo die Fläche wirklich endet. rcPage ist die volle Seite; rc ist der Bereich, den Text belegen darf, also entstehen Ränder, indem man rc darin verkleinert, ebenfalls in Twips. Ist die Schleife fertig, sagt ein letztes EM_FORMATRANGE mit nil als lParam dem Steuerelement, dass es die Formatierungsdaten freigeben soll, die es für den DC gecacht hat

PDF: Verschachtelte rcPage- und rc-Rechtecke für EM_FORMATRANGE, beide in Twips angegeben, umgerechnet aus Canvas-Pixeln bei dem DPI, das den virtuellen DC dimensioniert hat
rcPage ist die ganze Seite und rc der Bereich, den Text nutzen darf; beide gehen in Twips an EM_FORMATRANGE, während der DC darunter in Pixeln bleibt

Schließlich muss das Steuerelement bereits an ein sichtbares Formularfenster angebunden sein, wenn FormCreate läuft, damit sein Fenster-Handle vor dem ersten SendMessage-Aufruf gültig ist. Ein TRichEdit, das zur Laufzeit dynamisch erzeugt wurde, braucht einen expliziten HandleNeeded-Aufruf, bevor die Rendering-Schleife beginnt, falls das Formular noch nicht gezeigt wurde

Fonts und RTF-Funktionen behandeln

Weil das Rendern von der Windows-Rich-Edit-Engine erledigt wird, folgt der Font-Ersatz denselben Regeln wie bei Anzeige und Druck. Fonts, die die RTF-Datei referenziert und die auf der Maschine installiert sind, werden originalgetreu gerendert; fehlende Fonts werden lautlos ersetzt, was Zeilenlängen und Paginierung verschieben kann. Für die Batch-Konvertierung im Produktivbetrieb lohnt es sich, das explizit zu testen: Laden Sie ein Dokument mit jeder Schriftart, die Ihre RTF-Quellen verwenden, und prüfen Sie, dass die Seitenzahl der Ausgabe mit der aus einer manuellen Druckvorschau übereinstimmt

Tabellen, eingebettete Bilder und die meisten Rich-Text-Formatierungsfunktionen funktionieren ohne zusätzliche Behandlung, weil Rich Edit sie nativ rendert. Der eine Bereich, der überraschen kann, ist Text mit benutzerdefiniertem Absatzabstand oder Erstzeilen-Einzug in Twips: Rich Edit misst in Twips (1/1440 Zoll), derselben Einheit wie die Rechtecke in TFormatRange, während der DC darunter in Pixeln beim aktuellen DPI rechnet. Das Steuerelement bildet das eine beim Zeichnen auf das andere ab, aber wenn Sie das RTF programmatisch erzeugen, sollten Sie prüfen, dass auch Ihre Randwerte in Twips sind

DPI-Bewusstsein und High-DPI-Displays

Auf einem Display mit 150 % Skalierung (144 DPI) liefert die Millimeter-zu-Pixel-Umrechnung eine größere Pixelzahl als auf einem 100-%-Display. Die PDF Library merkt sich, welche Pixelmaße man an GetCanvasDC übergibt, und rechnet mit dem DPI-Argument von LoadFromCanvasDC die physische Seitengröße im PDF zurück. Solange der DPI-Wert, den man übergibt, derselbe ist, mit dem man Millimeter in Pixel umgerechnet hat, stimmt die Seitengröße der Ausgabe unabhängig von der Display-Skalierung

Ist Ihre Anwendung DPI-unaware (der alte Standard), skaliert Windows den Bildschirm-DC, und Ihre Pixelrechnungen gehen auf High-DPI-Maschinen schief. Die einfachste Abhilfe ist, DPI-Bewusstsein im Anwendungsmanifest zu deklarieren; die Anwendung bekommt dann echte Geräte-Pixel, und der Wert Screen.PixelsPerInch, den das Beispiel an LoadFromCanvasDC übergibt, meldet die echte Display-DPI, statt 100 % Skalierung anzunehmen

Ausgabestruktur: eine Datei pro Seite oder ein zusammengeführtes Dokument

Die Schleife oben schreibt jede Seite in eine separate PDF-Datei. Ob das das Richtige ist, hängt von der Weiterverarbeitung ab. Report-Systeme brauchen oft einzelne Seiten, weil sie das endgültige Dokument später durch Zusammenführen oder Umsortieren von Seiten zusammenbauen. Will man von Anfang an ein einzelnes PDF, kann die Bibliothek es in einer Sitzung aufbauen: Die ID des Dokuments behalten, das der erste LoadFromCanvasDC-Aufruf erzeugt, und in jedem späteren Durchgang es per SelectDocument auswählen und statt SaveToFile MergeDocument mit der Dokument-ID der neuen Seite aufrufen, dann einmal nach dem Schleifenende speichern. Das vermeidet die Zwischendateien und ist die richtige Struktur für die meisten Szenarien mit einem Dokument

PDF: Entscheidung zwischen dem Schreiben einer PDF-Datei pro Durchgang für die spätere Zusammenführung und dem Aufbau eines einzelnen mehrseitigen PDFs durch Anhängen von Seiten innerhalb der Schleife
Dieselbe Rendering-Schleife speist entweder separate Output-Dateien oder ein wachsendes Dokument und tauscht Flexibilität bei der Weiterverarbeitung gegen Zwischendateien ein

Bei großen RTF-Dateien lohnt es sich, in der Schleife eine Fortschrittsrückmeldung einzubauen, denn die Konvertierungsgeschwindigkeit ist ungefähr proportional zur Seitenzahl, und ein 200-Seiten-Dokument kann ein paar Sekunden dauern. Die repeat...until-Struktur lässt sich leicht erweitern: den Zeichen-Offset in einem Fortschrittsbalken-Update nach jedem Durchgang verfolgen, mit LastChar geteilt durch die Gesamtzeichenzahl aus RichEdit1.GetTextLen

Die hier gezeigten Methoden GetCanvasDC und LoadFromCanvasDC sind Teil der losLab PDF Library für Delphi und C++Builder