Tehnički članak

Performanse ekstrakcije stranica iz HotPDF-a u Delphiju

Dvije minute za kopiranje tri stranice iz PDF-a od 40 stranica nije problem ugađanja performansi. To je signal da se koristi pogrešan API put. Kada sam prvi put vidio ovo vrijeme na primjeru kopiranja stranica komponente HotPDF Component, moj je instinkt bio prvo pogledati strukturu dokumenta, a tek onda kod. Pokazalo se da je taj redoslijed bitan

Što je zapravo bilo sporo

Predmetni PDF bio je referentni dokument od 40 stranica s netrivijalnim stablom stranica: više prijelaznih /Pages čvorova umjesto jednog ravnog niza. Izvorni kod primjera pozivao je LoadFromFile, a zatim gradio novi dokument s BeginDoc, petljao po odabranim brojevima stranica i u svakoj iteraciji ponovno učitavao izvorni dokument s diska da bi izvukao stranicu. To je puni trošak parsiranja pomnožen s onoliko stranica koliko želite. Datoteka od 12 MB pristupila je disku šest puta za ekstrakciju od tri stranice jer nitko nije provjerio treba li datoteka ostati otvorena tijekom iteracija

Drugi čimbenik bio je nevidljiv u kodu: LoadFromFile komponente HotPDF rješava cijelu tablicu unakrsnih referenci i dekomprimira svaki tok objekata prilikom učitavanja. To je ispravno ponašanje za dokument koji se spremate izmijeniti, ali to je više posla nego što trebate ako želite samo broj stranica i podskup stranica. Za pristup strukturi samo za čitanje, DAOpenFileReadOnly izbjegava deserijalizaciju cijelog stabla objekata, što je važno kod komprimiranih datoteka s velikim slikovnim resursima

Nijedno od ovoga nije bug u biblioteci. U oba slučaja pozivatelji biraju API dizajniran za jedan posao i koriste ga za drugi

Korištenje InsertPagesFromDocument za ekstrakciju stranica

Pravi put za kopiranje niza stranica iz jednog HotPDF dokumenta u drugi je InsertPagesFromDocument, pozvan nakon LoadFromFile na izvoru. Izvor učitavate jednom, odredište učitavate ili kreirate jednom, premještate stranice i spremate. Izvor ostaje u memoriji tijekom svih umetanja stranica:

procedure ExtractPages(const SourceFile, DestFile: string;
  const PageRange: string);
var
  Source, Dest: THotPDF;
begin
  Source := THotPDF.Create(nil);
  Dest   := THotPDF.Create(nil);
  try
    // Učitaj izvor jednom: potpuno parsiranje događa se ovdje i samo ovdje
    Source.LoadFromFile(SourceFile);

    // Izradi minimalni odredišni dokument
    Dest.FileName := DestFile;
    Dest.BeginDoc;

    // Kopiraj traženi raspon; '1-3' umeće stranice od 1 do 3
    // počevši od pozicije 1 u odredištu
    Dest.InsertPagesFromDocument(Source, PageRange, 1);

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

Parametar PageRange prihvaća isti format kao i primjer iz komandne linije: zarezima odvojen popis brojeva stranica ili raspona kao što su '1-3' ili '1,5,7-9'. Stranice su indeksirane od 1. InsertPagesFromDocument kopira tokove sadržaja, rječnike resursa i geometriju stranice bez diranja metapodataka, knjižnih oznaka ili ugrađenih privitaka datoteka osim ako se ne referenciraju s kopiranih stranica. Za ekstrakciju tri stranice iz dokumenta od 40 stranica, to je mali radni skup

Vrijeme izvođenja na istoj datoteci od 12 MB koja je prije trajala dvije minute: manje od 1,5 sekunde s ovim obrascem. Većina tog vremena odlazi na jedan poziv LoadFromFile. Struktura dokumenta je irelevantna nakon što se tablica objekata prvi put riješi

Kada je LoadFromFile previše: Direct File API

Ako trebate samo izbrojati stranice, pregledati informacije o dokumentu ili kopirati datoteku bez diranja njezinog sadržaja, Direct File API u potpunosti izbjegava cjelokupno parsiranje. DAOpenFileReadOnly mapira tablicu unakrsnih referenci bez dekompresije tokova objekata, tako da je broj stranica O(veličina xref-a) umjesto O(veličina datoteke):

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 je kopija koja čuva bajtove, nema ponovne serijalizacije
      Pdf.DACopyFile(FileName, 'archive-copy.pdf');
    finally
      Pdf.DACloseFile(Handle);
    end;
  finally
    Pdf.Free;
  end;
end;

Upozorenje: DAOpenFileReadOnly prihvaća parametar lozinke, ali se vraća na puno parsiranje za kriptirane ulaze, jer dekripcija zahtijeva stablo objekata kako bi riješila rječnik enkripcije. Ako su vaše izvorne datoteke kriptirane, prvo ih dekriptirajte pomoću DecryptFile kako biste dobili nekriptiranu kopiju, a zatim je otvorite pomoću Direct File API-ja. Funkcija DecryptFile na razini datoteke koristi izravni AES-256 put ponovnog pisanja za standardnu enkripciju i brža je od LoadFromFile praćenog sa SaveLoadedDocument za velike datoteke, jer ne gradi cijeli objektni model u memoriji

Memorija tijekom obrade velikih serija (large-batch)

Skupni poslovi (batch jobs) koji obrađuju desetke datoteka u petlji imaju obrazac koji izgleda točno, ali nakuplja memoriju: kreiranje THotPDF unutar petlje, pozivanje LoadFromFile, odrađivanje posla, pozivanje Free. To je strukturno u redu. Problem nastaje kada unutarnji rad alocira privremene objekte, hvata iznimke i ostavlja te privremene objekte živima na putanjama pogreške. Delphijev upravitelj memorijom se ne sažima, tako da stotinu curenja memorije zbog grešaka u serijskom pokretanju može gurnuti memoriju dovoljno visoko da uspori alokaciju za sve ostalo

Rješenje nije egzotično. Svaki THotPDF i svaki posredni TStream ili TBitmap koji sudjeluje u PDF radu pripada u try/finally blok gdje je Free posljednja naredba. Postavite lokalne pokazivače na nil prije try tako da grana finally može sigurno koristiti if Assigned(x) then x.Free kada inicijalizacija djelomično ne uspije. Ovo je standardna Delphi disciplina vlasništva i to je cijela priča za ovu klasu problema

Još jedna stvar koju treba provjeriti u serijskim kontekstima: AddImage registrira slike u internoj listi koja postoji tijekom životnog vijeka THotPDF instance. Ako ponovno koristite jednu instancu kroz mnogo dokumenata višekratnim pozivanjem LoadFromFile, registracije slika iz ranijih dokumenata ostaju u popisu. Ili stvorite svježu instancu za svaki dokument ili pozovite putanju za brisanje liste slika između dokumenata

Mjerenje prije bilo kakvih promjena

Prije nego posegnete za bilo kojim od ovih obrazaca, mjerite. Delphijev TStopwatch iz System.Diagnostics omotava QueryPerformanceCounter i dovoljno je precizan za profiliranje u stvarnom vremenu ulaza/izlaza datoteka. Omotajte samo LoadFromFile i pogledajte koliko mu je vremena potrebno. Ako je to 90% ukupnog vremena, rješenje je Direct File API ili smanjenje broja parsiranja iste datoteke. Ako je ispod 20%, usko grlo je negdje drugdje i lovite pogrešnu stvar

Ekstrakcija od dvije minute s početka ovog posta pokazala se u potpunosti kao obrazac ponovljenog učitavanja. Struktura dokumenta nije pridonijela ničemu; ravno stablo stranica izvodilo bi se na isti način. Prelazak na jedan LoadFromFile praćen jednim pozivom InsertPagesFromDocument smanjio je to na 1,3 sekunde na istom hardveru bez diranja ičega drugog

API za manipulaciju stranicama prikazan ovdje dio je komponente HotPDF Component za Delphi i C++Builder