Technisch artikel

RTF naar PDF-conversie in Delphi met de losLab PDF Library

RTF bestaat al lang genoeg dat het opduikt op plekken waar niemand het gepland had: legacy rapportgeneratoren, mail merge-pijplijnen, juridische documentarchieven van vóór de moderne tekstverwerkers. Het ter plekke naar PDF converteren is een terugkerende eis, en de aanpak die op Windows werkelijk werkt is geen dedicated RTF-parser maar de renderroute die Windows zelf al aanbiedt via TRichEdit en EM_FORMATRANGE. De losLab PDF Library stelt een virtuele device context beschikbaar die direct in die pijplijn past

Het mechanisme: virtuele DC en EM_FORMATRANGE

Rich Edit-besturingselementen kunnen hun inhoud pagineren voor elke device context, niet alleen een fysieke printer. Het bericht EM_FORMATRANGE zegt tegen het besturingselement dat hij een bereik van tekens in een gegeven DC moet inpassen, en geeft de positie van het laatste teken terug dat hij kwijt kon. Roep hem herhaaldelijk aan en schuif cpMin telkens door, en u krijgt uitvoer pagina voor pagina. De GetCanvasDC van de losLab PDF Library levert een DC in het geheugen met de pagina-afmetingen die u opgeeft; nadat u een pagina erin heeft gerenderd, legt LoadFromCanvasDC het resultaat vast als een nieuw PDF-document van één pagina. Dat is de hele pijplijn

Eén ding moet meteen goed: het TRichEdit-besturingselement moet worden ingesteld op de afmetingen van de doelpagina. Is het besturingselement kleiner of groter dan de DC-afmetingen, dan loopt de paginering niet gelijk met wat er uiteindelijk in de PDF belandt. Voor A4-uitvoer is de standaardaanpak de pixeldimensies van het besturingselement gelijk te stellen aan 210 x 297 mm bij dezelfde DPI die u later aan LoadFromCanvasDC doorgeeft, vóór het laden van het RTF-bestand, met dezelfde millimeter-naar-pixelconversie waarmee u de DC instelt

PDF: RTF naar PDF-pijplijn: een ingesteld Rich Edit-besturingselement pagineert zijn tekst via EM_FORMATRANGE in een virtuele canvas-DC en elke passe wordt als één PDF-pagina vastgelegd
EM_FORMATRANGE past een bereik RTF-tekst in de virtuele canvas-DC in en LoadFromCanvasDC legt het resultaat vast, herhaald tot het laatste teken is bereikt

Delphi-implementatie

Het volgende gebruikt de importunit PDFlibAX_TLB, de type library-import voor de ActiveX-editie van de library. Het formulier herbergt een TRichEdit en een knop; de OnCreate-handler van het formulier stelt het besturingselement in en laadt de RTF, en de klik op de knop stuurt de conversielus aan

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;
  // Stel het besturingselement op A4 in bij scherm-DPI zodat de paginering met de DC meevalt
  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
    // Verkrijg een virtuele DC met A4-formaat
    Dc := PdfDoc.GetCanvasDC(
      MulDiv(210, Screen.PixelsPerInch * 10, 254),
      MulDiv(297, Screen.PixelsPerInch * 10, 254));
    // Render de volgende pagina RTF-inhoud in de DC
    LastChar := PrintRtfBox(Dc, RichEdit1, LastChar);
    // Leg de inhoud van de DC vast als een PDF-document
    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;
  // Laat het besturingselement de formatteringsdata die hij voor de DC cachte vrijgeven
  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 en rcPage zijn in twips (1/1440 inch), geen pixels: zet de
  // paginagrootte om met dezelfde DPI die de DC en het besturingselement instelde
  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;  // teken op de hele pagina; verklein voor marges
  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;  // geeft de laatste pagina aan
end;

end.

Wat de lus doet

PrintRtfBox vult de structuur TFormatRange en geeft deze via SendMessage door aan het Rich Edit-besturingselement. Het besturingselement rendert tekens vanaf cpMin, stopt zodra de DC vol is, en retourneert de positie van het eerste teken dat niet meer paste. Wanneer de retourwaarde gelijk is aan of groter is dan de totale tekstlengte, is elk teken gerenderd en retourneert de functie nul, waarmee de repeat...until-lus wordt beëindigd

Elke iteratie levert één PDF-bestand op met de naam Output1.pdf, Output2.pdf, enzovoort. Wilt u liever één document met meerdere pagina’s, dan kan AddToFileList plus MergeFileList de per-pagina-bestanden achteraf samenvoegen, of u bouwt de lus om zodat elk nieuw pagina-document in het eerste wordt gevouwen met MergeDocument. Het patroon hierboven met per iteratie SaveToFile gevolgd door RemoveDocument houdt het piekgeheugen begrensd op de inhoud van één pagina, wat telt bij heel lange RTF-bestanden

Dimensiedetails waar mensen over struikelen

Het DPI-argument van LoadFromCanvasDC vertelt de library hoeveel van uw pixels een inch zijn: de pagina komt eruit als canvasbreedte gedeeld door DPI keer 72 punten breed, en hetzelfde voor de hoogte. Zit het eruit naast, dan verschijnt tekst op de verkeerde grootte in de uitvoer terwijl het beeld op het scherm correct oogt

De eenheid van rc en rcPage is het detail waar de meeste Delphi-samples van deze techniek de mist in gaan. EM_FORMATRANGE leest beide rechthoeken in twips, 1/1440 inch, ook al werkt de DC waarin hij tekent in pixels. Vult u ze met pixelwaarden, dan zet het besturingselement tekst neer op een pagina die bij 96 DPI vijftien keer te klein is: een handvol woorden per passe, en een lus die een document van tien pagina’s omzet in honderden bijna lege PDF’s. Converteer met MulDiv(Pixels, 1440, Dpi), met de DPI die de DC instelde, dan valt de paginabreak waar de canvas werkelijk eindigt. rcPage is de volledige pagina; rc is het gebied dat tekst mag bezetten, dus marges komen uit het laten krimpen van rc erin, ook in twips. Als de lus klaar is, zegt nog één EM_FORMATRANGE met een nil lParam tegen het besturingselement dat hij de formatteringsdata die hij voor de DC cachte mag vrijgeven

PDF: Geneste rcPage- en rc-rechthoeken voor EM_FORMATRANGE, allebei gegeven in twips geconverteerd uit canvaspixels bij de DPI die de virtuele DC instelde
rcPage is de hele pagina en rc het gebied dat tekst mag gebruiken; allebei gaan in twips naar EM_FORMATRANGE, terwijl de DC eronder in pixels blijft

Tot slot moet het besturingselement al aan een zichtbaar formuliervenster zijn gekoppeld wanneer FormCreate wordt uitgevoerd, zodat de window handle geldig is vóór de eerste aanroep van SendMessage. Een TRichEdit die tijdens runtime dynamisch wordt aangemaakt, vereist een expliciete aanroep van HandleNeeded voordat de renderlus begint, als het formulier nog niet is getoond

Omgaan met lettertypen en RTF-functies

Omdat het renderen wordt uitgevoerd door de Windows Rich Edit-engine, volgt lettertypevervanging dezelfde regels die worden gebruikt voor weergave en afdrukken. Lettertypen die in het RTF-bestand worden aangeroepen en op de machine zijn geïnstalleerd, worden getrouw gerenderd; ontbrekende lettertypen worden stilzwijgend vervangen, wat regellengtes en paginering kan verschuiven. Voor batchconversie in productie is het de moeite waard dit expliciet te testen: laad een document met elk lettertype dat uw RTF-bronnen gebruiken en controleer of het aantal uitvoerpagina's overeenkomt met wat u verwacht op basis van een handmatig afdrukvoorbeeld

Tabellen, ingesloten afbeeldingen en de meeste Rich Text-opmaakfuncties werken zonder extra behandeling, omdat Rich Edit ze native rendert. Het ene gebied dat kan verrassen is tekst met eigen alinea-afstand of eerste-regel-inspringingen uitgedrukt in twips: Rich Edit meet in twips (1/1440 inch), dezelfde eenheid als de rechthoeken in TFormatRange, terwijl de DC eronder in pixels werkt bij de huidige DPI. Het besturingselement mapt het ene op het andere tijdens het tekenen, maar als u de RTF programmatisch opbouwt, controleert u best dat uw marge waarden ook in twips zijn

DPI-bewustzijn en schermen met hoge DPI

Op een scherm dat draait op 150% schaling (144 DPI) geeft de millimeter-naar-pixelconversie een groter aantal pixels dan op een scherm met 100%. De PDF Library registreert welke pixelafmetingen u ook aan GetCanvasDC doorgeeft en gebruikt het DPI-argument in LoadFromCanvasDC om de fysieke paginagrootte in de PDF terug te rekenen. Zolang de DPI-waarde die u doorgeeft dezelfde is waarmee u millimeters naar pixels hebt omgezet, is de uitvoerpaginagrootte correct, ongeacht de schermschaling

Is uw toepassing DPI-onbewust (de oude standaard), dan schaalt Windows de scherm-DC en zijn uw pixelberekeningen fout op machines met hoge DPI. De eenvoudigste oplossing is DPI-bewustzijn declareren in het toepassingsmanifest; de toepassing ontvangt dan echte apparaatpixels, en de Screen.PixelsPerInch-waarde die de sample aan LoadFromCanvasDC doorgeeft rapporteert de echte scherm-DPI in plaats van 100% schaling te veronderstellen

Uitvoerstructuur: één bestand per pagina versus één gecombineerd document

De lus hierboven schrijft elke pagina weg naar een apart PDF-bestand. Of dat is wat u wilt hangt af van het gebruik verderop in de keten. Systemen voor rapportgeneratie hebben vaak afzonderlijke pagina’s nodig, omdat ze het uiteindelijke document later samenstellen door pagina’s samen te voegen of te herschikken. Wilt u vanaf het begin één PDF, dan laat de library u hem in één sessie bouwen: houd het ID van het document dat de eerste LoadFromCanvasDC-aanroep maakt vast, selecteer het bij elke volgende passe met SelectDocument en roep MergeDocument aan met het document-ID van de nieuwe pagina in plaats van SaveToFile, en sla daarna één keer op na het einde van de lus. Dit vermijdt de tussenliggende bestanden en is de juiste structuur voor de meeste conversiescenario’s met één document

PDF: Kiezen tussen het schrijven van één PDF-bestand per passe voor latere assemblage en het bouwen van één PDF met meerdere pagina's door pagina's binnen de lus toe te voegen
Dezelfde renderlus voedt of afzonderlijke Output-bestanden of één groeiend document, wat downstream-flexibiliteit inruilt tegen tussenbestanden

Bij grote RTF-bestanden is het de moeite waard om voortgangsfeedback aan de lus toe te voegen, aangezien de conversiesnelheid ruwweg evenredig is met het aantal pagina's en een document van 200 pagina's een paar seconden kan duren. De structuur repeat...until is eenvoudig uit te breiden: houd na elke iteratie de tekenoffset bij in een update van een voortgangsbalk, met LastChar gedeeld door het totale aantal tekens uit RichEdit1.GetTextLen

De hier getoonde methoden GetCanvasDC en LoadFromCanvasDc maken deel uit van de losLab PDF Library voor Delphi en C++Builder