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 daartussen. TPdf is de eigenaar van het document: hij opent het bestand, ontsleutelt het en beantwoordt vragen over het aantal pagina's en de metadata. TPdfView is het visuele control dat pagina's op het scherm tekent en het scrollen, de zoom en de pagina waar de gebruiker naar kijkt voor zijn rekening neemt. PDFium Component werkt met dezelfde rendering-engine die ook in Chrome zit, dus de glyphs, de anti-aliasing en de kleur op uw canvas komen overeen met wat uw gebruikers al in hun browser zien. Het werk zit niet in het renderen. Het zit in het documentobject aan de view koppelen, in laden zonder te crashen op een beschadigd of met wachtwoord beveiligd bestand, en in de gebruiker dat handjevol controls geven die een viewer af maken: naar de volgende pagina, de zoom aanpassen, de pagina passend maken in het venster

Dit artikel loopt die opbouw door in de volgorde waarin u hem werkelijk bouwt. Alles hier rendert één pagina tegelijk, en dat is wat de meeste documentworkflows willen. Heeft u pagina's nodig die in één doorlopend scrollende kolom onder elkaar staan, dan is dat een andere lay-outkeuze en niet het pad dat hier wordt gevolgd

TPdf aan TPdfView koppelen

Zet een TPdf en een TPdfView op het formulier en vertel de view daarna welk document hij moet tonen. Die ene toewijzing is de hele verbinding tussen het niet-visuele document en het control dat het tekent

Architectuur van een Delphi PDF-viewer waarin TPdf het document bezit, TPdfView het tekent en één property-toewijzing ze bovenop de PDFium-DLL met elkaar verbindt
TPdf bezit het document en TPdfView tekent het; éé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 draait, moet de native PDFium-bibliotheek op de machine staan. PDFium Component laadt pdfium.dll op zowel Win32 als Win64; de twee builds delen die bestandsnaam en worden geleverd in de aparte mappen DLLs\Win32 en DLLs\Win64, en het document weigert simpelweg te openen als de DLL niet te vinden is of zijn bitness niet overeenkomt met uw executable. Lever de DLL die bij uw doelplatform hoort naast de executable mee, of zet hem waar de systeemlader hem vindt. De builds met V8 bestaan alleen voor PDFs met JavaScript dat u wilt uitvoeren, en een kale viewer doet dat niet, dus grijp 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 het laden in een try/except te pakken en een exception als mislukking te behandelen. Die ingeving is hier verkeerd, en hem volgen levert een viewer op die er prima uitziet totdat iemand hem een kapot bestand geeft. Active := True zetten geeft geen exception bij een mislukte laadpoging. PDFium Component vangt de interne fout af en laat Active netjes op False staan, dus de enige eerlijke manier om te weten of het document geopend is, is de property teruglezen nadat u hem hebt gezet

Beslisstroom voor het laden in een Delphi PDFium-viewer waarbij het zetten van Active nooit een exception opwerpt, een stille false een verkeerd wachtwoord of een beschadigd bestand betekent en één nieuwe poging met wachtwoord volgt
Activering geeft bij faling nooit een exception, dus de viewer leest Active terug en beantwoordt een stille false met één nieuwe 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
  PdfView.Active := True;             // zolang de view niet actief is houdt hij geen pagina vast
  UpdatePageLabel;
end;

De view heeft een eigen Active-schakelaar, en zolang die niet True is houdt het control geen pagina vast: alleen het document openen en PageNumber zetten laat een lege viewer achter. Nog twee dingen verdienen aandacht. Ten eerste bestaat PageNumber op beide objecten en zijn die twee onafhankelijk. Pdf.PageNumber is de huidige pagina volgens het document; PdfView.PageNumber is de pagina die het control werkelijk toont, en dat is degene die u zet om de gebruiker door het bestand te laten bewegen. De ene zetten verplaatst de andere niet, dus een viewer stuurt altijd de property van de view aan. Ten tweede de nummering vanaf 1: pagina's lopen van 1 tot Pdf.PageCount en niet vanaf 0, wat iedereen op het verkeerde been zet die gewend is aan zero-based arrays

Een versleuteld bestand afhandelen

Versleutelde documenten volgen hetzelfde laadpad. Staat het open-wachtwoord vóór de activering gezet, dan ontsleutelt het document terwijl het opent; is het wachtwoord verkeerd of ontbreekt het, dan blijft Active op False staan, precies zoals bij een corrupt bestand. Het herstel is daarom 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;
  PdfView.Active := True;
end;

Omdat de mislukking voor zowel een verkeerd wachtwoord als een beschadigd bestand stil verloopt, kunt u de twee niet uit elkaar houden op Active alleen. In de praktijk is dat acceptabel voor een viewer: de gebruiker geeft of het juiste wachtwoord, of hij leert dat het bestand niet opent, en het bericht leest beide keren hetzelfde

Door het document bladeren

Met het document open is navigatie rekenwerk op PdfView.PageNumber, begrensd door Pdf.PageCount. Het enige echte werk is het klemzetten, zodat de knoppen de pagina nooit uit het bereik duwen en de eerste en laatste knop 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 tekstvak "ga naar pagina N" is dezelfde GoToPage-aanroep, gevoed uit een geparsed geheel getal, en de klem dekt het geval waarin de gebruiker 9999 typt in een bestand van tien pagina's. Houd UpdatePageLabel de enige plek die "Pagina 3 van 12" schrijft, zodat de uitlezing nooit uit de pas loopt met wat de view toont

Zoom: expliciete percentages en fit-modi

Zoom op TPdfView komt in twee smaken die op elkaar inwerken, en de interactie doorgronden is het verschil tussen een zoomregelaar die meewerkt en één die tegen de gebruiker ingaat. De directe route is de property Zoom, een percentage waarbij 100 werkelijke grootte betekent. De andere route is FitMode, waarmee u de view de zoom voor u laat berekenen en telkens opnieuw laat berekenen als het venster van grootte verandert

Interactie tussen Zoom en FitMode in een PDFium Delphi-viewer waarbij het toewijzen van een exacte Zoom FitMode naar pfmNone wist en het kiezen van een fit-modus de zoom teruggeeft aan de view
Een exacte zoom toewijzen wist de fit-modus en een fit-modus 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 gaat het mis bij veel mensen. Zoom rechtstreeks toewijzen reset FitMode naar pfmNone. Dat is correct gedrag en geen bug: zodra de gebruiker exact 150% kiest, kan de view niet tegelijk nog aan de breedte blijven passen, want de twee verzoeken spreken elkaar tegen. Het gevolg voor uw UI is dat een zoomknop en een knop voor passend maken toestanden zijn die elkaar uitsluiten, en de werkbalk moet de actieve modus zichtbaar maken. Klikt de gebruiker op passend maken, zet dan FitMode; klikt hij op een numerieke zoom, zet dan Zoom en laat die zelf de fit-modus wissen

Wilt u de fit-waarde liever zelf berekenen, bijvoorbeeld om een zoomschuif te vullen met het huidige fit-percentage, dan geven de per-page helpers u de getallen zonder de modus te veranderen. PageWidthZoom[N], PageZoom[N] en ActualSizeZoom[N] geven het percentage terug dat pagina N op de breedte past, heel passend maakt of op werkelijke grootte rendert

// 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 afgeronde viewer werkelijk nodig heeft

De viewer hierboven is een paar dozijn regels en doet al wat een documentworkflow nodig heeft: een bestand openen, een kapot bestand overleven, een pagina tonen, tussen pagina's springen en de vergroting met de hand of via fit aanpassen. PDFium doet de lastige delen stilletjes. Ingesloten lettertypen lossen netjes op, annotaties en formuliervelden verschijnen waar het document ze neerzet, en de pagina die u ziet is dezelfde als die een Chrome-gebruiker zou zien, want dezelfde engine tekent beide

Vanaf deze basis zijn de uitbreidingen incrementeel in plaats van structureel. Tekstselectie en zoeken lezen uit dezelfde tekstlaag die PDFium al opbouwt; metadata zoals Pdf.Title en Pdf.Author is één property-lezing verwijderd; rotatie en grijstinten zijn renderopties die u meegeeft wanneer u een pagina naar een bitmap tekent. Geen van die zaken raakt aan de ruggengraat die u hier hebt: het documentobject, de view en de stroom van eerst laden en dan navigeren die ze verbindt. Zit die ruggengraat goed, dan is de rest decoratie

De TPdf- en TPdfView-componenten die hier overal worden gebruikt maken deel uit van PDFium Component voor Delphi en C++Builder, waar de volledige viewer-referentie op de productpagina staat