Technical Article

Производителност при извличане на страници с HotPDF в Delphi

Две минути за копиране на три страници от PDF файл с общо 40 страници не е проблем за оптимизиране на производителността. Това е сигнал, че се използва грешен път в API. Когато за първи път видях това време в примерен код за копиране на страници с HotPDF компонента, инстинктът ми беше да погледна първо структурата на документа и след това кода. Оказа се, че този ред е от значение.

Какво всъщност забавяше процеса

Въпросният PDF беше референтен документ от 40 страници с нетривиално дърво на страниците: множество междинни /Pages възли вместо един плосък масив. Оригиналният примерен код извикваше LoadFromFile, след което изграждаше нов документ с BeginDoc, въртеше се в цикъл по избраните номера на страници и при всяка итерация зареждаше изходния документ отново от диска, за да вземе съответната страница. Това означава, че разходът за пълен анализ се умножава по броя на страниците, които искате да извлечете. Файл с размер 12 MB четеше диска шест пъти за извличане на три страници, защото никой не беше помислил дали файлът трябва да остане отворен между итерациите.

Вторият фактор беше невидим в кода: методът LoadFromFile на HotPDF обработва цялата таблица с препратки (cross-reference table) и декомпресира всеки поток от обекти при зареждане. Това е правилното поведение за документ, който предстои да модифицирате, но е излишна работа, ако искате само броя на страниците или подмножество от страници. За достъп само за четене до структурата, DAOpenFileReadOnly избягва десериализацията на пълното дърво от обекти, което е важно при компресирани файлове с големи ресурси от изображения.

Нито едно от тези неща не е бъг в библиотеката. И в двата случая извикващият код избира API, предназначен за една задача, и го използва за друга.

Използване на InsertPagesFromDocument за извличане на страници

Правилният път за копиране на диапазон от страници от един HotPDF документ в друг е InsertPagesFromDocument, извикан след LoadFromFile на източника. Зареждате източника веднъж, зареждате или създавате целевия документ веднъж, премествате страниците и записвате. Източникът остава в паметта по време на всички вмъквания на страници:

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;

Параметърът PageRange приема същия формат като примера за команден ред: списък с разделител запетая от номера на страници или диапазони, като '1-3' или '1,5,7-9'. Индексирането на страниците започва от 1. InsertPagesFromDocument копира потоците от съдържание, речниците на ресурсите и геометрията на страниците, без да докосва метаданни, отметки или прикачени файлове, освен ако те не са реферирани от копираните страници. За извличане на три страници от документ с 40 страници, това е малък работен набор от данни.

Времето за изпълнение на същия 12 MB файл, което преди отнемаше две минути: под 1.5 секунди с този модел. По-голямата част от това време отива за еднократното извикване на LoadFromFile. Структурата на документа престава да бъде фактор, след като таблицата с обекти бъде обработена първия път.

Когато LoadFromFile е твърде тежък: Direct File API

Ако трябва само да преброите страниците, да проверите информацията за документа или да копирате файл, без да докосвате съдържанието му, Direct File API избягва напълно пълния анализ. DAOpenFileReadOnly картографира таблицата с препратки, без да декомпресира потоците от обекти, така че броят на страниците се изчислява за време O(размер на xref) вместо O(размер на файла):

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;

Предупреждение: DAOpenFileReadOnly приема параметър за парола, но преминава към пълен анализ при шифровани файлове, тъй като дешифрирането изисква дървото от обекти за извличане на речника за шифроване. Ако изходните ви файлове са шифровани, първо ги дешифрирайте с DecryptFile, за да получите нешифровано копие, и след това го отворете с Direct File API. Функцията DecryptFile на ниво файл използва директен път за презаписване с AES-256 за стандартно шифроване и е по-бърза от LoadFromFile, последван от SaveLoadedDocument за големи файлове, тъй като не изгражда пълния модел на обекти в паметта.

Памет по време на обработка на големи партиди

Пакетните задачи, които обработват десетки файлове в цикъл, често имат модел, който изглежда правилен, но натрупва памет: създаване на THotPDF вътре в цикъла, извикване на LoadFromFile, извършване на работата и извикване на Free. Това е структурно правилно. Проблемът възниква, когато вътрешната работа разпределя временни обекти, улавя изключения и оставя тези временни обекти активни при възникване на грешки. Мениджърът на паметта на Delphi не извършва автоматично уплътняване, така че стотина теча по пътя на грешките по време на пакетно изпълнение могат да повишат паметта достатъчно, за да забавят разпределянето на памет за всичко останало.

Решението не е сложно. Всеки THotPDF и всеки междинен TStream или TBitmap, който участва в работата с PDF, трябва да бъде в блок try/finally, където Free е последният оператор. Задайте локалните указатели на nil преди блока try, така че клонът finally да може безопасно да използва if Assigned(x) then x.Free, ако инициализацията се провали по средата. Това е стандартната дисциплина за управление на собствеността в Delphi и напълно решава този клас проблеми.

Още едно нещо, което трябва да проверите в пакетни контексти: AddImage регистрира изображения в допълнителен вътрешен списък, който съществува през целия живот на инстанцията на THotPDF. Ако използвате повторно една и съща инстанция за множество документи чрез последователно извикване на LoadFromFile, регистрациите на изображения от предишните документи остават в списъка. Или създавайте нова инстанция за всеки документ, или изчиствайте списъка с изображения между документите.

Измерване преди всяка промяна

Преди да приложите някой от тези модели, направете измерване. Класът TStopwatch на Delphi от System.Diagnostics обвива QueryPerformanceCounter и е достатъчно точен за профилиране на файловия входно-изходен процес (I/O). Измерете само LoadFromFile и вижте какъв процент от времето заема. Ако е 90% от общото време, решението е Direct File API или намаляване на броя анализи на един и същ файл. Ако е под 20%, тясното място е другаде и се фокусирате върху грешното нещо.

Двуминутното извличане, с което започна тази статия, се оказа изцяло причинено от модела на многократно зареждане. Структурата на документа не допринасяше с нищо â€?едно плоско дърво на страниците би работило по същия начин. Преминаването към еднократно извикване на LoadFromFile, последвано от едно извикване на InsertPagesFromDocument, намали времето до 1.3 секунди на същия хардуер, без да се променя нищо друго.

API за манипулиране на страници, показано тук, е част от HotPDF компонента за Delphi и C++Builder.