Artigo Técnico

Desempenho de Extração de Páginas do HotPDF em Delphi

Dois minutos para copiar três páginas de um PDF de 40 páginas não é um problema de ajuste de desempenho. É um sinal de que o caminho errado da API está sendo usado. Quando eu vi esse tempo de execução pela primeira vez em um exemplo de cópia de página do Componente HotPDF, meu instinto foi examinar a estrutura do documento primeiro e o código depois. E essa ordem se revelou importante

O que realmente estava lento

O PDF em questão era um documento de referência de 40 páginas com uma árvore de páginas não trivial: múltiplos nós intermediários /Pages em vez de um único array plano. O código de exemplo original chamava LoadFromFile e então, ao criar um novo documento com BeginDoc, repetia os números de página selecionados, carregando novamente a cada iteração o documento de origem do disco para extrair uma página. É o custo total da análise multiplicado por quantas páginas você quiser. Um arquivo de 12 MB atingiu o disco seis vezes para uma extração de três páginas porque ninguém analisou se o arquivo precisava ficar aberto entre as iterações

O segundo colaborador era invisível no código: o LoadFromFile do HotPDF resolve toda a tabela de referência cruzada e descompacta cada stream de objetos durante o carregamento. É o comportamento correto para um documento que você está prestes a modificar, mas é mais trabalho do que você precisa se tudo o que quer é a contagem de páginas e um subconjunto de páginas. Para o acesso de leitura à estrutura, o DAOpenFileReadOnly evita a desserialização de toda a árvore de objetos, o que faz diferença em arquivos compactados com recursos extensos de imagem

Nenhum desses é um bug de biblioteca. Em ambos os casos, a parte chamadora escolheu a API feita para um trabalho e a está usando para outro diferente

Usando InsertPagesFromDocument para extração de páginas

O caminho certo para copiar um intervalo de páginas de um documento do HotPDF para outro é InsertPagesFromDocument, chamado após LoadFromFile na origem. Você carrega a origem uma vez, carrega ou cria o destino uma vez, move as páginas e salva. A origem permanece na memória em todas as inserções de página:

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;

O parâmetro PageRange aceita o mesmo formato que a amostra de linha de comando: uma lista separada por vírgula de números ou intervalos de páginas como '1-3' ou '1,5,7-9'. As páginas são baseadas em 1. InsertPagesFromDocument copia os streams de conteúdo, os dicionários de recursos e a geometria de páginas sem afetar os metadados, marcadores ou anexos de arquivo embutidos a não ser que sejam citados a partir de páginas copiadas. Para uma extração de três páginas a partir de um documento com 40 páginas, este é um working set pequeno

Cronometragem no mesmo arquivo de 12 MB que antes demorava dois minutos: menos de 1,5 segundos com este padrão. A maior parte desse tempo vem da única chamada LoadFromFile. A estrutura do documento é irrelevante assim que a tabela de objetos é solucionada na primeira vez

Quando o LoadFromFile é muito pesado: a API Direta de Arquivo

Se você precisar apenas contar páginas, inspecionar as informações do documento ou copiar um arquivo sem tocar no conteúdo, a API Direta de Arquivo evitará que todo o parsing seja efetuado por completo. DAOpenFileReadOnly rastreia a tabela de referências cruzadas sem descompactar as sequências dos objetos para que o cômputo das páginas venha a ser O(tamanho do xref) e não O(tamanho do arquivo):

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;

A ressalva: DAOpenFileReadOnly aceita um parâmetro de senha, mas recorre a uma análise completa para entradas criptografadas, porque a descriptografia requer a árvore de objetos para resolver o dicionário de criptografia. Se os seus arquivos de origem forem criptografados, descriptografe-os primeiro com DecryptFile para obter uma cópia não criptografada, e então abra-a com a API Direta de Arquivo. A função DecryptFile no nível do arquivo utiliza um caminho direto de reescrita AES-256 para criptografia padrão e é mais rápida do que LoadFromFile seguido de SaveLoadedDocument para arquivos grandes, porque não constrói o modelo completo de objeto em memória

Memória durante o processamento em lote grande

Trabalhos em lote que processam dezenas de arquivos em um laço possuem um padrão que parece correto, mas acumula memória: criar THotPDF dentro do laço, chamar LoadFromFile, realizar o trabalho e chamar Free. Estruturalmente, isso está certo. O problema é quando o trabalho interno aloca objetos temporários (scratch objects), captura exceções e deixa esses objetos temporários vivos em caminhos de erro. O gerenciador de memória do Delphi não compacta, de modo que cem vazamentos por caminhos de erro ao longo de uma execução em lote podem elevar a memória a ponto de retardar a alocação de todo o resto

A solução não é exótica. Todo THotPDF e todo TStream ou TBitmap intermediário que participa do trabalho com PDF pertence a um bloco try/finally onde Free é a última instrução. Defina ponteiros locais como nil antes do try para que o bloco finally possa usar if Assigned(x) then x.Free de forma segura quando a inicialização falhar na metade do caminho. Esta é a disciplina de propriedade padrão do Delphi e resolve completamente este tipo de problema

Mais uma coisa a verificar em contextos de lote: AddImage registra imagens em uma lista interna que persiste durante o tempo de vida da instância THotPDF. Se você reutilizar uma única instância entre muitos documentos chamando LoadFromFile repetidamente, os registros de imagens dos documentos anteriores permanecerão na lista. Crie uma nova instância por documento ou chame o caminho de limpeza da lista de imagens entre os documentos

Medindo antes de mudar qualquer coisa

Antes de recorrer a qualquer um desses padrões, faça medições. O TStopwatch do Delphi em System.Diagnostics encapsula QueryPerformanceCounter e é preciso o suficiente para a criação de perfis de tempo de relógio de parede (wall-clock profiling) de E/S de arquivos. Envolva apenas LoadFromFile e veja quanto tempo ele consome. Se for 90% do tempo total, a correção será a API Direta de Arquivo ou reduzir o número de vezes em que você analisa o mesmo arquivo. Se for abaixo de 20%, o gargalo estará em algum lugar diverso, significando que o direcionamento não condiz

A extração de dois minutos que deu início a este post acabou sendo inteiramente devido ao padrão de carregamentos repetidos. A estrutura do documento não contribuiu em nada; uma árvore de páginas plana teria rodado da mesma forma. Mudar para um único LoadFromFile seguido de uma chamada InsertPagesFromDocument reduziu o tempo para 1,3 segundos no mesmo hardware sem alterar mais nada

A API de manipulação de páginas mostrada aqui é parte do Componente HotPDF para Delphi e C++Builder