Teknisk artikel

HotPDF-sideekstraktionsydeevne i Delphi

To minutter til at kopiere tre sider ud af en PDF på 40 sider er ikke et problem med ydeevnetuning. Det er et signal om, at den forkerte API-sti bliver brugt. Da jeg første gang så denne timing på en prøve af HotPDF Component sidekopiering, var mit instinkt at se på dokumentstrukturen først og koden derefter. Den rækkefølge viste sig at have betydning

Hvad der faktisk var langsomt

Den pågældende PDF var et referencedokument på 40 sider med et ikke-trivielt sidetræ: flere mellemliggende /Pages-knudepunkter i stedet for et enkelt fladt array. Den originale prøvekode kaldte LoadFromFile, byggede derefter et nyt dokument med BeginDoc, sløjfede over valgte sidenumre og indlæste ved hver iteration kildedokumentet igen fra disken for at hente en side. Det er den fulde parseomkostning ganget med hvor mange sider, du vil have. En 12 MB fil ramte disken seks gange for en tre-siders ekstraktion, fordi ingen kiggede på, om filen skulle forblive åben på tværs af iterationer

Den anden bidragyder var usynlig i koden: HotPDF's LoadFromFile løser hele krydsreferencetabellen og dekomprimerer hver objektstrøm ved indlæsning. Det er den rigtige adfærd for et dokument, du er ved at ændre, men det er mere arbejde, end du har brug for, hvis du kun vil have sideantal og et undersæt af sider. For skrivebeskyttet adgang til struktur undgår DAOpenFileReadOnly at deserialisere det fulde objekttræ, hvilket har betydning på komprimerede filer med store billedressourcer

Ingen af disse er en biblioteksfejl. Begge er opkaldere, der vælger den API, der er designet til én opgave, og bruger den til en anden

Brug af InsertPagesFromDocument til sideekstraktion

Den rigtige vej til at kopiere en række sider fra et HotPDF-dokument til et andet er InsertPagesFromDocument, kaldet efter LoadFromFile på kilden. Du indlæser kilden én gang, indlæser eller opretter destinationen én gang, flytter siderne og gemmer. Kilden forbliver i hukommelsen på tværs af alle sideindsættelserne:

procedure ExtractPages(const SourceFile, DestFile: string;
  const PageRange: string);
var
  Source, Dest: THotPDF;
begin
  Source := THotPDF.Create(nil);
  Dest   := THotPDF.Create(nil);
  try
    // Load source once: full parse happens here and only here
    Source.LoadFromFile(SourceFile);

    // Build a minimal destination document
    Dest.FileName := DestFile;
    Dest.BeginDoc;

    // Copy the requested range; '1-3' inserts pages 1 through 3
    // starting at position 1 in the destination
    Dest.InsertPagesFromDocument(Source, PageRange, 1);

    Dest.EndDoc;
  finally
    Source.Free;
    Dest.Free;
  end;
end;

Parameteren PageRange accepterer samme format som kommandolinjeprøven: en kommasepareret liste over sidenumre eller -områder såsom '1-3' eller '1,5,7-9'. Sider er 1-baserede. InsertPagesFromDocument kopierer indholdsstrømme, ressourceordbøger og sidegeometri uden at berøre metadata, bogmærker eller indlejrede filvedhæftninger, medmindre der refereres til dem fra de kopierede sider. For en ekstraktion på tre sider fra et dokument på 40 sider er det et lille arbejdssæt

Timing på den samme 12 MB fil, der tidligere kørte i to minutter: under 1,5 sekunder med dette mønster. Det meste af den tid er det enkelte LoadFromFile-kald. Dokumentstrukturen er irrelevant, når objekttabellen er løst første gang

Når LoadFromFile er for meget: Direct File API

Hvis du kun har brug for at tælle sider, inspicere dokumentoplysninger eller kopiere en fil uden at røre indholdet, undgår Direct File API den fulde parse fuldstændigt. DAOpenFileReadOnly kortlægger krydsreferencetabellen uden at dekomprimere objektstrømme, så sideantal er O(xref-størrelse) snarere end O(filstørrelse):

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 a byte-preserving copy, no re-serialization
      Pdf.DACopyFile(FileName, 'archive-copy.pdf');
    finally
      Pdf.DACloseFile(Handle);
    end;
  finally
    Pdf.Free;
  end;
end;

Forbeholdet: DAOpenFileReadOnly accepterer en adgangskodeparameter, men falder tilbage til et fuldt parse for krypterede input, fordi dekryptering kræver objekttræet for at løse krypteringsordbogen. Hvis dine kildefiler er krypterede, skal du dekryptere dem først med DecryptFile for at få en ukrypteret kopi, og derefter åbne den med Direct File API. Funktionen DecryptFile på filniveau tager en direkte AES-256-omskrivningssti til standardkryptering og er hurtigere end LoadFromFile efterfulgt af SaveLoadedDocument for store filer, fordi den ikke bygger den fulde in-memory objektmodel

Hukommelse under store batchbehandlinger

Batchjobs, der behandler snesevis af filer i en løkke, har et mønster, der ser korrekt ud, men akkumulerer hukommelse: oprettelse af THotPDF inde i løkken, kald af LoadFromFile, udførelse af arbejde, kald af Free. Det er strukturelt fint. Problemet er, når det indre arbejde tildeler scratch-objekter, fanger undtagelser og lader disse scratch-objekter leve på fejlstier. Delphis hukommelsesmanager komprimerer ikke, så hundrede fejlstilækager på tværs af en batchkørsel kan skubbe hukommelsen højt nok til at bremse tildeling for alt andet

Rettelsen er ikke eksotisk. Hver THotPDF og hver mellemliggende TStream eller TBitmap, der deltager i PDF-arbejde, hører til i en try/finally-blok, hvor Free er den sidste erklæring. Indstil lokale pointere til nil før try, så finally-grenen kan bruge if Assigned(x) then x.Free sikkert, når initialisering mislykkes delvist. Dette er standard Delphi-ejerskabsdisciplin, og det er den fulde historie for denne klasse af problemer

Endnu en ting at tjekke i batchkontekster: AddImage registrerer billeder i en intern liste, der vedvarer i hele THotPDF-instansens levetid. Hvis du genbruger en enkelt instans på tværs af mange dokumenter ved gentagne gange at kalde LoadFromFile, forbliver billedregistreringer fra tidligere dokumenter på listen. Opret enten en frisk instans pr. dokument eller kalde billedlistens rydningssti mellem dokumenter

Måling før ændring af noget

Før du griber efter nogen af disse mønstre, skal du måle. Delphis TStopwatch fra System.Diagnostics ombryder QueryPerformanceCounter og er nøjagtig nok til vægursprofilering af fil-I/O. Ombryd LoadFromFile alene og se, hvor længe den står for. Hvis det er 90% af den samlede tid, er rettelsen Direct File API eller at reducere, hvor mange gange du parser den samme fil. Hvis det er under 20%, er flaskehalsen et andet sted, og du jagter den forkerte ting

De to minutters ekstraktion, der startede dette indlæg, viste sig udelukkende at være mønstret for gentagen belastning. Dokumentstrukturen bidrog ikke med noget; et fladt sidetræ ville have kørt på samme måde. Skift til et enkelt LoadFromFile efterfulgt af et InsertPagesFromDocument-kald bragte det til 1,3 sekunder på den samme hardware uden at røre ved andet

Sidenmanipulations-API'en vist her er en del af HotPDF Component til Delphi og C++Builder