Dois minutos para copiar três páginas de um PDF de 40 páginas não é um problema de otimização de desempenho. É um sinal de que está a ser utilizado o caminho de API errado. Quando vi pela primeira vez este tempo num exemplo de cópia de páginas do Componente HotPDF, o meu instinto foi analisar primeiro a estrutura do documento e depois o código. Essa ordem revelou-se importante.
O que estava realmente lento
O PDF em causa era um documento de referência de 40 páginas com uma árvore de páginas não trivial: múltiplos nós /Pages intermédios em vez de uma única matriz simples. O código de exemplo original chamava LoadFromFile, construía depois um novo documento com BeginDoc, percorria os números de página selecionados em ciclo e, em cada iteração, voltava a carregar o documento de origem do disco para extrair uma página. Isto equivale ao custo total de análise multiplicado pelo número de páginas pretendidas. Um ficheiro de 12 MB acedeu ao disco seis vezes numa extração de três páginas, porque ninguém verificou se o ficheiro precisava de permanecer aberto entre iterações.
O segundo fator era invisível no código: o LoadFromFile do HotPDF resolve toda a tabela de referências cruzadas e descomprime todos os fluxos de objetos no carregamento. Este é o comportamento correto para um documento que se pretende modificar, mas representa mais trabalho do que o necessário se apenas se quer a contagem de páginas e um subconjunto de páginas. Para acesso apenas de leitura à estrutura, o DAOpenFileReadOnly evita desserializar toda a árvore de objetos, o que é relevante em ficheiros comprimidos com recursos de imagem volumosos.
Nenhum destes casos é um erro da biblioteca. Em ambos, quem chama escolhe a API concebida para uma tarefa e utiliza-a para uma diferente.
Usar InsertPagesFromDocument para extração de páginas
O caminho correto para copiar um intervalo de páginas de um documento HotPDF para outro é InsertPagesFromDocument, chamado após LoadFromFile na origem. Carrega-se a origem uma vez, carrega-se ou cria-se o destino uma vez, movem-se as páginas e guarda-se. A origem permanece em memória durante todas as inserções de páginas:
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 o exemplo de linha de comandos: uma lista separada por vírgulas de números de página ou intervalos como '1-3' ou '1,5,7-9'. As páginas são indexadas a partir de 1. O InsertPagesFromDocument copia fluxos de conteúdo, dicionários de recursos e geometria de página sem alterar metadados, marcadores ou anexos de ficheiros incorporados, a não ser que sejam referenciados nas páginas copiadas. Para uma extração de três páginas de um documento de 40, trata-se de um conjunto de trabalho reduzido.
Tempo de execução com o mesmo ficheiro de 12 MB que anteriormente demorava dois minutos: menos de 1,5 segundos com este padrão. A maior parte desse tempo corresponde à única chamada a LoadFromFile. A estrutura do documento torna-se irrelevante assim que a tabela de objetos é resolvida pela primeira vez.
Quando o LoadFromFile é excessivo: a API de Ficheiro Direto
Se apenas precisa de contar páginas, inspecionar informações do documento ou copiar um ficheiro sem alterar o seu conteúdo, a API de Ficheiro Direto evita a análise completa por inteiro. O DAOpenFileReadOnly mapeia a tabela de referências cruzadas sem descomprimir os fluxos de objetos, pelo que a contagem de páginas é O(tamanho da xref) em vez de O(tamanho do ficheiro):
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: o DAOpenFileReadOnly aceita um parâmetro de palavra-passe, mas recorre a uma análise completa para entradas encriptadas, porque a desencriptação exige a árvore de objetos para resolver o dicionário de encriptação. Se os ficheiros de origem estiverem encriptados, desencripte-os primeiro com DecryptFile para obter uma cópia não encriptada e depois abra-a com a API de Ficheiro Direto. A função DecryptFile ao nível do ficheiro segue um caminho de reescrita direta AES-256 para encriptação padrão e é mais rápida do que LoadFromFile seguido de SaveLoadedDocument em ficheiros grandes, porque não constrói o modelo de objetos completo em memória.
Memória durante o processamento em lote de grande volume
Os trabalhos em lote que processam dezenas de ficheiros em ciclo apresentam um padrão com aparência correta mas que acumula memória: criar THotPDF dentro do ciclo, chamar LoadFromFile, efetuar o trabalho e chamar Free. Estruturalmente, está correto. O problema surge quando o trabalho interno aloca objetos temporários, apanha exceções e deixa esses objetos temporários ativos nos caminhos de erro. O gestor de memória do Delphi não compacta a memória, pelo que centenas de fugas nos caminhos de erro ao longo de uma execução em lote podem elevar a memória ao ponto de tornar a alocação mais lenta para tudo o resto.
A solução não é complexa. Cada THotPDF e cada TStream ou TBitmap intermédio que participa no trabalho com PDF deve estar num bloco try/finally onde Free é a última instrução. Defina os apontadores locais para nil antes do try para que o ramo finally possa usar if Assigned(x) then x.Free com segurança quando a inicialização falha a meio. Esta é a disciplina padrão de propriedade em Delphi e representa a solução completa para esta classe de problema.
Mais uma coisa a verificar em contextos de lote: o AddImage regista imagens numa lista interna que persiste durante o tempo de vida da instância THotPDF. Se reutilizar uma única instância em muitos documentos chamando LoadFromFile repetidamente, os registos de imagens de documentos anteriores ficam na lista. Crie uma instância nova por documento ou invoque o caminho de limpeza da lista de imagens entre documentos.
Medir antes de alterar qualquer coisa
Antes de recorrer a qualquer destes padrões, meça. O TStopwatch do Delphi de System.Diagnostics encapsula o QueryPerformanceCounter e é suficientemente preciso para perfis de relógio de parede de E/S de ficheiros. Envolva apenas o LoadFromFile e veja quanto tempo representa. Se for 90% do tempo total, a solução é a API de Ficheiro Direto ou reduzir o número de vezes que analisa o mesmo ficheiro. Se for inferior a 20%, o estrangulamento está noutro lado e está a perseguir o problema errado.
A extração de dois minutos que deu origem a este artigo revelou-se inteiramente devida ao padrão de carregamento repetido. A estrutura do documento não contribuiu em nada; uma árvore de páginas simples teria corrido da mesma forma. Mudar para um único LoadFromFile seguido de uma chamada a InsertPagesFromDocument reduziu o tempo para 1,3 segundos no mesmo hardware sem alterar mais nada.
A API de manipulação de páginas apresentada aqui faz parte do Componente HotPDF para Delphi e C++Builder.