Technisch artikel

HotPDF pagina-extractie prestaties in Delphi

Twee minuten om drie pagina's uit een PDF van 40 pagina's te kopiëren is geen prestatieprobleem om bij te tuningen. Het is een signaal dat het verkeerde API-pad wordt gebruikt. Toen ik deze timing voor het eerst zag op pagina-kopieercode gebouwd op de HotPDF Delphi Component, was mijn instinct eerst naar de documentstructuur te kijken en pas daarna naar de code. Die volgorde bleek precies verkeerd

Wat er daadwerkelijk traag was

De PDF in kwestie was een referentiedocument van 40 pagina's met een niet-triviale paginaboom: meerdere tussenliggende /Pages-knooppunten in plaats van een enkele platte array. De code riep LoadFromFile aan, bouwde vervolgens een nieuw document op met BeginDoc, liep in een lus over geselecteerde paginanummers en laadde bij elke iteratie het brondocument opnieuw vanaf schijf om een pagina op te halen. Dat is de volledige parseerkost vermenigvuldigd met het aantal pagina's dat je wilt. Een bestand van 12 MB raakte zes keer de schijf voor een extractie van drie pagina's, omdat niemand had gekeken of het bestand tussen de iteraties door open moest blijven

De tweede bijdragende factor was onzichtbaar in de code: LoadFromFile van HotPDF bouwt het in-memory objectmodel waarop de bewerkings-API werkt. Dat is het juiste gedrag voor een document dat je gaat wijzigen, maar het is meer werk dan je nodig hebt als je alleen het aantal pagina's en een subset van pagina's wilt. Voor alleen-lezen toegang tot de structuur vermijdt DAOpenFileReadOnly de deserialisatie van de volledige objectboom, wat uitmaakt bij gecomprimeerde bestanden met grote afbeeldingsbronnen

Beide komen neer op het kiezen van de API die voor één taak is ontworpen en die voor een andere gebruiken

Diagram dat herhaalde LoadFromFile-aanroepen binnen een extractielus afzet tegen één parse gevolgd door ExtractPagesToFile voor snelle PDF-pagina-extractie in Delphi
De bron bij elke iteratie herladen vermenigvuldigt de kosten van de volledige parse en maakt van een kopie van drie pagina's een taak van twee minuten. Eén parse gevolgd door een enkele extractieaanroep brengt hetzelfde werk terug op ongeveer 1,3 seconden

ExtractPagesToFile gebruiken voor pagina-extractie

Het juiste pad om een reeks pagina's uit een geladen HotPDF-document naar een nieuw bestand te kopiëren is ExtractPagesToFile, aangeroepen na LoadFromFile op de bron. Je laadt de bron eenmaal in, en de aanroep bouwt het doeldocument, kopieert de geselecteerde pagina's erin en schrijft het naar schijf, allemaal vanuit de kopie die al in het geheugen staat:

procedure ExtractPages(const SourceFile, DestFile: string;
  const PageRange: string);
var
  Source: THotPDF;
begin
  Source := THotPDF.Create(nil);
  try
    // Laad de bron eenmalig: de volledige parse gebeurt hier en alleen hier
    if Source.LoadFromFile(SourceFile) <= 0 then
      raise Exception.Create('Source PDF has no pages');

    // Schrijf het gevraagde bereik ('1-3', '1,5,7-9') weg naar een nieuw bestand
    Source.ExtractPagesToFile(DestFile, PageRange);
  finally
    Source.Free;
  end;
end;

De parameter PageRange accepteert hetzelfde formaat als het opdrachtregelvoorbeeld: een lijst van paginanummers of -bereiken gescheiden door komma's, zoals '1-3' of '1,5,7-9'. Pagina's zijn 1-based. ExtractPagesToFile kopieert elke geselecteerde pagina samen met de content streams en bronnen waarnaar hij verwijst, dus voor een extractie van drie pagina's uit een document van 40 pagina's blijft de werkset klein

Timing op hetzelfde 12 MB-bestand dat eerder twee minuten duurde: ongeveer 1,3 seconde met dit patroon. Het merendeel van die tijd is de eenmalige LoadFromFile-aanroep. De documentstructuur is irrelevant zodra de objectentabel de eerste keer is opgelost

Wanneer LoadFromFile te veel is: de Direct File API

Als je alleen pagina's hoeft te tellen, documentinformatie wilt inspecteren of een bestand wilt kopiëren zonder de inhoud aan te raken, dan vermijdt de Direct File API de volledige parse volledig. DAOpenFileReadOnly brengt de kruisverwijzingstabel in kaart (maps) zonder objectstreams te decomprimeren, dus het tellen van pagina's is O(xref-grootte) in plaats van O(bestandsgrootte):

procedure InspectPDF(const FileName: string);
var
  Pdf: THotPDF;
  Handle, PageCount: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Handle := Pdf.DAOpenFileReadOnly(FileName, '');
    if Handle <= 0 then
      Exit;
    try
      PageCount := Pdf.DAGetPageCount(Handle);
      Writeln('Pages: ', PageCount);

      // DACopyFile is een byte-behoudende kopie, zonder her-serialisatie
      Pdf.DACopyFile(FileName, 'archive-copy.pdf');
    finally
      Pdf.DACloseFile(Handle);
    end;
  finally
    Pdf.Free;
  end;
end;

Het voorbehoud: DAOpenFileReadOnly accepteert een wachtwoordparameter maar valt terug op een volledige parse voor versleutelde (encrypted) invoer, omdat decodering vereist dat de objectboom de versleutelingsdictionary oplost. Als je bronbestanden zijn versleuteld, ontsleutel (decrypt) ze dan eerst met DecryptFile om een niet-versleutelde kopie te krijgen, en open die vervolgens met de Direct File API. De functie DecryptFile op bestandsniveau (file-level) neemt een direct AES-256-herschrijfpad voor standaardversleuteling en is bij grote bestanden sneller dan LoadFromFile gevolgd door SaveLoadedDocument, omdat het niet het volledige in-memory objectmodel opbouwt

Beslissingsdiagram dat kiest tussen het volledige HotPDF-bewerkingspad en de alleen-lezen Direct File API, met decryptie eerst afgehandeld voor versleutelde invoer
Alleen-lezen taken horen op het direct file access-pad, waar paginatellen meeschaalt met de cross-reference tabel in plaats van de bestandsgrootte. Versleutelde invoer valt terug op een volledige parse tenzij eerst ontsleuteld

Geheugen tijdens verwerking in grote batches

Batch-taken die tientallen bestanden in een lus verwerken, hebben een patroon dat er correct uitziet, maar geheugen ophoopt: THotPDF aanmaken in de lus, LoadFromFile aanroepen, werk verrichten, Free aanroepen. Dat is structureel in orde. Het probleem ontstaat wanneer de interne taken 'scratch objects' (tijdelijke objecten) toewijzen, uitzonderingen opvangen en die 'scratch objects' in leven laten op foutpaden (error paths). Het geheugenbeheer van Delphi wordt niet gecomprimeerd (does not compact), dus honderd lekken via foutpaden in een batchuitvoering kunnen het geheugen zodanig opstuwen dat de toewijzing voor al het andere vertraagt

De oplossing is niet exotisch. Elke THotPDF en elke tussentijdse TStream of TBitmap die deelneemt aan PDF-werk hoort thuis in een try/finally-blok, waarbij Free de laatste statement is. Zet lokale pointers op nil vóór de try, zodat de finally-tak if Assigned(x) then x.Free veilig kan gebruiken wanneer de initialisatie halverwege mislukt. Dit is de standaard eigendomsdiscipline in Delphi en dit vormt het volledige verhaal voor deze categorie van problemen

Nog iets om te weten in batchcontexten: AddImage registreert elke afbeelding bij het document dat wordt opgebouwd, en HotPDF geeft die registraties vrij zodra EndDoc dat bestand afrondt. Het register volgt de BeginDoc/EndDoc-cyclus, niet LoadFromFile, dus het nette patroon voor een batch is één THotPDF-instantie per document: aanmaken, het werk doen, opslaan, vrijgeven

Meten voordat je iets verandert

Voordat je naar een van deze patronen grijpt: meten. Delphi's TStopwatch uit System.Diagnostics wikkelt QueryPerformanceCounter in en is nauwkeurig genoeg voor wandklokprofilering (wall-clock profiling) van bestands-I/O. Wikkel LoadFromFile afzonderlijk in en kijk hoeveel tijd dit in beslag neemt. Als het 90% van de totale tijd beslaat, dan is de oplossing de Direct File API of het verminderen van het aantal keren dat je hetzelfde bestand parseert. Zit het onder de 20%, dan bevindt de bottleneck zich ergens anders en ben je op het verkeerde spoor

De extractie van twee minuten waar deze post mee begon, bleek geheel toe te schrijven aan het herhaalde-laadpatroon. De documentstructuur droeg daar niets aan bij; een platte paginaboom zou op dezelfde manier gelopen hebben. Overschakelen naar een eenmalige LoadFromFile gevolgd door één ExtractPagesToFile-aanroep bracht de tijd terug op ongeveer 1,3 seconde op dezelfde hardware, zonder ook maar iets anders aan te raken

De API voor paginamanipulatie die hier getoond wordt, maakt deel uit van de HotPDF Delphi Component voor Delphi en C++Builder