Technisch artikel

Bouw een PDF-viewer in Delphi met de PDFium-component

Een PDF-viewer in Delphi komt neer op twee componenten en de bedrading (wiring) ertussen. TPdf is eigenaar van het document: het opent het bestand, decodeert (decrypt) het en beantwoordt vragen over het aantal pagina's en de metadata. TPdfView is het visuele besturingselement (visual control) dat pagina's op het scherm schildert en het scrollen, de zoom en de pagina bekijkt waar de gebruiker momenteel naar kijkt. De PDFium-component omhult dezelfde rendering-engine die wordt meegeleverd in Chrome, dus de tekens, anti-aliasing en kleur die u op het canvas krijgt, komen overeen met wat uw gebruikers al in hun browser zien. Het werk zit niet in het renderen. Het zit in het verbinden van het documentobject met de weergave (view), het laden zonder te crashen op een beschadigd of met een wachtwoord beveiligd bestand, en het geven van de handvol besturingselementen aan de gebruiker die een viewer 'af' (finished) laten voelen: de pagina omslaan, de zoom wijzigen en de pagina passend maken voor het venster

Dit artikel doorloopt die opbouw in de volgorde waarin u deze daadwerkelijk bouwt. Alles hierin rendert één enkele pagina tegelijk, wat de meeste documentworkflows ook willen. Als u pagina's in één continu scrollende kolom gestapeld wilt hebben, is dat een andere lay-outbeslissing en niet het pad dat hier wordt bewandeld

TPdf koppelen aan TPdfView

Plaats (drop) een TPdf en een TPdfView op het formulier en vertel de weergave vervolgens welk document moet worden weergegeven. Die enkele toewijzing (assignment) is de volledige link tussen het niet-visuele document en het besturingselement dat het tekent

Delphi PDF-viewerarchitectuur waarin TPdf het document bezit, TPdfView het schildert, en één property-toewijzing ze koppelt bovenop de PDFium-DLL
TPdf bezit het document terwijl TPdfView het tekent, en één enkele toewijzing verbindt de twee via de gedeelde PDFium-engine
procedure TFormMain.FormCreate(Sender: TObject);
begin
  // Pdf en PdfView zijn tijdens het ontwerp op het formulier geplaatst.
  PdfView.Pdf := Pdf;                 // de view tekent wat dit document ook bevat
  PdfView.FitMode := pfmFitWidth;     // begint de gebruiker met een zinvolle zoom
end;

Voordat dit alles wordt uitgevoerd, moet de native PDFium-bibliotheek zich op de machine bevinden. De PDFium-component roept pdfium32.dll of pdfium64.dll aan, afhankelijk van uw doelplatform (target platform), en het document weigert simpelweg te openen als de DLL niet kan worden gevonden. Lever de bijbehorende DLL mee naast uw executable, of plaats deze op een plek waar de systeemlader (system loader) deze zal vinden. De versies (builds) met V8-ondersteuning bestaan alleen voor PDF's die JavaScript bevatten dat u wilt uitvoeren, wat een eenvoudige viewer niet doet. Grijp dus naar de standaard-DLL tenzij u een concrete reden hebt om dat niet te doen

Een document laden zonder de invoer te vertrouwen

De eerste ingeving is om het laden in een try/except-blok te verpakken en een gegenereerde uitzondering (exception) te behandelen als een mislukking. Dat instinct is hier verkeerd, en het fout doen levert een viewer op die er prima uitziet totdat iemand er een kapot bestand aan overhandigt. Het instellen van Active := True genereert geen foutmelding (raise) bij een mislukte laadpoging. De PDFium-component vangt de interne fout op en laat Active op False staan. De enige eerlijke manier om te weten of het document is geopend, is dus om de eigenschap (property) terug te lezen nadat u deze hebt ingesteld

Laadbeslissingsstroom voor een Delphi PDFium-viewer waarbij het zetten van Active nooit een exceptie opwerpt, een stille false een verkeerd wachtwoord of een beschadigd bestand betekent, en één wachtwoordherhaling volgt
Activering geeft nooit een exception bij faling, dus de viewer leest Active terug en beantwoordt een stille false met één hernieuwde poging met wachtwoord
procedure TFormMain.OpenDocument(const FileName: string);
begin
  Pdf.FileName := FileName;
  Pdf.Active := True;                 // genereert nooit een uitzondering; bij mislukking blijft Active = False
  if not Pdf.Active then
  begin
    ShowMessage('Could not open ' + FileName);
    Exit;
  end;
  PdfView.PageNumber := 1;            // de view houdt zijn eigen huidige pagina bij
  UpdatePageLabel;
end;

Twee dingen verdienen de aandacht. Het eerste is dat PageNumber op beide objecten bestaat en dat die twee onafhankelijk zijn. Pdf.PageNumber is de notie van het document van een huidige pagina; PdfView.PageNumber is de pagina die het besturingselement (control) daadwerkelijk weergeeft, en dit is degene die u instelt om de gebruiker door het bestand te verplaatsen. Het instellen van het ene object verplaatst het andere niet, dus een viewer stuurt altijd de eigenschap van de weergave (view) aan. Het tweede is de 1-gebaseerde indexering: pagina's lopen van 1 tot Pdf.PageCount, niet vanaf 0. Dit zet iedereen op het verkeerde been die gewend is aan zero-based arrays

Een gecodeerd (encrypted) bestand afhandelen

Gecodeerde documenten integreren in hetzelfde laadpad. Als het open-wachtwoord is ingesteld vóór activering, decodeert (decrypts) het document naarmate het opent. Als het onjuist is of ontbreekt, blijft Active op False staan, precies zoals het geval is voor een corrupt bestand. Het herstel (recovery) is dus om om een wachtwoord te vragen en de activering opnieuw te proberen

procedure TFormMain.OpenWithPassword(const FileName: string);
var
  Password: string;
begin
  Pdf.FileName := FileName;
  Pdf.Active := True;
  if not Pdf.Active then
  begin
    if InputQuery('Password required', 'Password:', Password) then
    begin
      Pdf.Password := Password;       // moet worden ingesteld vóór Active := True
      Pdf.Active := True;
    end;
    if not Pdf.Active then
    begin
      ShowMessage('Unable to open the document.');
      Exit;
    end;
  end;
  PdfView.PageNumber := 1;
end;

Omdat de fout stilzwijgend is voor zowel een onjuist wachtwoord als een beschadigd bestand, kunt u de twee niet onderscheiden op basis van Active alleen. In de praktijk is dat acceptabel voor een viewer: de gebruiker geeft óf het juiste wachtwoord op, óf merkt dat het bestand niet wil openen, en het bericht leest in beide gevallen hetzelfde

Door het document bladeren (Paging)

Wanneer het document open is, is navigatie een kwestie van rekenen met PdfView.PageNumber, begrensd door Pdf.PageCount. Het enige echte werk is klemmen (clamping), zodat de knoppen de pagina nooit buiten het bereik duwen en de eerste en laatste knoppen uitgeschakeld blijven aan de uiteinden van het bestand

procedure TFormMain.GoToPage(NewPage: Integer);
begin
  if not Pdf.Active then
    Exit;
  if NewPage < 1 then
    NewPage := 1
  else if NewPage > Pdf.PageCount then
    NewPage := Pdf.PageCount;
  PdfView.PageNumber := NewPage;
  UpdatePageLabel;
end;

// de vier navigatieknoppen komen elk neer op één aanroep
procedure TFormMain.FirstClick(Sender: TObject);  begin GoToPage(1); end;
procedure TFormMain.PrevClick(Sender: TObject);   begin GoToPage(PdfView.PageNumber - 1); end;
procedure TFormMain.NextClick(Sender: TObject);   begin GoToPage(PdfView.PageNumber + 1); end;
procedure TFormMain.LastClick(Sender: TObject);   begin GoToPage(Pdf.PageCount); end;

Een "ga naar pagina N"-tekstvak is dezelfde GoToPage-aanroep die wordt gevoed door een geparseerd geheel getal (integer). De 'clamp' dekt het geval waarin de gebruiker 9999 typt in een bestand van tien pagina's. Behoud UpdatePageLabel als de enige plek die "Pagina 3 van 12" schrijft, zodat de weergave (readout) nooit uit de pas (out of sync) gaat lopen met wat de view toont

Zoom: expliciete percentages en weergavemodi (fit modes)

Zoomen met TPdfView komt in twee smaken (flavors) die op elkaar inwerken. Het begrijpen van de interactie maakt het verschil tussen een zoomregelaar die zich gedraagt en een regelaar die de gebruiker tegenwerkt. De directe route is de Zoom-eigenschap, een percentage waarbij 100 staat voor werkelijke grootte. De andere route is FitMode, wat de weergave (view) vertelt om de zoom voor u te bereken en deze te blijven herberekenen zodra de grootte van het venster verandert

Zoom- en FitMode-interactie in een PDFium Delphi-viewer waarbij het toewijzen van een exacte Zoom FitMode wist naar pfmNone en het kiezen van een fit-modus de zoom teruggeeft aan de view
Een exacte zoom toewijzen wist de fit mode, en een fit mode kiezen geeft de zoomberekening terug aan de view
// vaste vergrotingen
PdfView.Zoom := 100;     // werkelijke grootte
PdfView.Zoom := 50;      // half
PdfView.Zoom := 200;     // dubbel

// laat de view de pagina aanpassen aan het venster, en blijft dat doen bij formaatwijziging
PdfView.FitMode := pfmFitWidth;   // paginabreedte vult het besturingselement
PdfView.FitMode := pfmFitPage;    // hele pagina zichtbaar
PdfView.FitMode := pfmActualSize; // 1:1 met de punten van het document

Hier is het onderdeel dat mensen op het verkeerde been zet. Het rechtstreeks toewijzen van Zoom reset FitMode naar pfmNone. Dat is correct gedrag, geen bug (fout): op het moment dat de gebruiker exact 150% kiest, kan de weergave niet langer ook "aan breedte aanpassen" (fit to width) in acht nemen, omdat de twee verzoeken in strijd zijn. De consequentie voor uw UI (gebruikersinterface) is dat een zoom-in-knop en een 'aan pagina aanpassen'-knop staten zijn die elkaar uitsluiten. De werkbalk (toolbar) moet de actieve modus zichtbaar maken. Wanneer de gebruiker op 'aan pagina aanpassen' klikt, stelt u FitMode in. Wanneer er op een numerieke zoom wordt geklikt, stelt u Zoom in en laat u deze uit zichzelf de weergavemodus wissen

Als u de 'fit'-waarde (fit value) liever zelf wilt berekenen, bijvoorbeeld om een zoomschuifregelaar te voeden met het huidige aanpassingspercentage, dan geven de per-page helpers (helpers per pagina) u de cijfers zonder de modus te wijzigen. PageWidthZoom[N], PageZoom[N] en ActualSizeZoom[N] retourneren het percentage dat pagina N aan de breedte zou aanpassen, in zijn geheel passend zou maken of op ware grootte zou weergeven

// vul een zoom-uitlezing met de aan-breedte-aanpassen-waarde van de huidige pagina
var
  FitPercent: Double;
begin
  FitPercent := PdfView.PageWidthZoom[PdfView.PageNumber];
  ZoomEdit.Text := Format('%.0f%%', [FitPercent]);
end;

Wat een afgewerkte (finished) viewer daadwerkelijk nodig heeft

De bovenstaande viewer is een paar dozijn regels lang en doet al de taak die een documentworkflow nodig heeft: een bestand openen, een beschadigd (bad) exemplaar overleven, een pagina tonen, navigeren (move) tussen pagina's en de vergroting (magnification) handmatig of passend veranderen. PDFium doet the harde werk in stilte. Ingesloten lettertypen (embedded fonts) lossen zich op, annotaties en formuliervelden worden geverfd (painted) op de plek waar het document ze plaatst. De pagina die u ziet, is bovendien identiek aan de pagina die een Chrome-gebruiker zou zien, omdat het dezelfde engine is die beide tekent

Vanuit deze basis zijn de toevoegingen eerder incrementeel dan structureel. Tekstselectie en zoeken worden afgelezen van dezelfde tekstlaag die PDFium reeds opbouwt. Metadata zoals Pdf.Title en Pdf.Author is slechts één ingelezen eigenschap verwijderd. Rotatie en grijstinten (grayscale) zijn renderopties die u doorgeeft (passes) wanneer u een pagina naar een bitmap tekent. Geen van deze zaken verandert de ruggengraat (spine) die u hier hebt, namelijk het documentobject, de view (weergave) en de laad-en-navigeer flow (load-then-navigate flow) die ze met elkaar verbindt. Krijg die ruggengraat goed op orde en de rest is decoratie

De doorlopend gebruikte componenten TPdf en TPdfView maken deel uit van de PDFium-component voor Delphi en C++Builder, die de volledige viewer-referentie op zijn productpagina (product page) draagt