Artigo Técnico

Mesclando Arquivos PDF no Delphi com o Componente PDFium

Um laço de merge que passa 1 como posição de inserção em toda chamada produz um arquivo com o número certo de páginas e os documentos em ordem reversa. O último arquivo da lista acaba na página 1, porque posição 1 significa "inserir antes da primeira página atual", e cada origem nova empurra as anteriores mais para trás. Nada se perde, e é exatamente por isso que o erro sobrevive a uma checagem rápida de contagem de páginas e aparece quando alguém realmente lê o pacote

O laço página a página com ImportPages

O PDFium Component constrói um PDF mesclado no Delphi a partir de duas instâncias de TPdf: um destino criado vazio com CreateDocument, e uma origem que abre cada arquivo de entrada por vez. TPdf.ImportPages(Pdf, Range, PageNumber) copia as páginas nomeadas em Range do Pdf para o destino e retorna True em caso de sucesso. O PageNumber tem base 1 e por padrão é 1. Ele nomeia a posição de destino em que a primeira página importada pousa, então 1 precede e PageCount + 1 anexa

O jeito mais limpo de manter os arquivos na ordem da lista é parar de controlar um cursor e perguntar ao destino onde fica o fim dele. Um contador InsertAt manual também funciona, mas precisa ser avançado pelo número de páginas realmente importadas, e esse número é fácil de errar quando intervalos entram em cena. PdfDest.PageCount + 1 está sempre certo:

procedure MergeFileList(const FileList: TStrings; const OutputPath: string);
var
  PdfDest, PdfSrc: TPdf;
  I: Integer;
begin
  PdfDest := TPdf.Create(nil);
  PdfSrc  := TPdf.Create(nil);
  try
    PdfDest.CreateDocument;
    for I := 0 to FileList.Count - 1 do
    begin
      PdfSrc.FileName := FileList[I];
      PdfSrc.Active   := True;            // não levanta exceção em falha
      if not PdfSrc.Active then
        raise Exception.CreateFmt('Cannot open %s', [FileList[I]]);

      // posição com base 1: PageCount + 1 anexa depois da última página
      if not PdfDest.ImportPages(PdfSrc,
        '1-' + IntToStr(PdfSrc.PageCount), PdfDest.PageCount + 1) then
        raise Exception.CreateFmt('Page import failed for %s', [FileList[I]]);

      PdfSrc.Active := False;             // FileName só pode mudar enquanto inativo
    end;

    if not PdfDest.SaveAs(OutputPath) then
      raise Exception.Create('Cannot write ' + OutputPath);
  finally
    PdfSrc.Free;
    PdfDest.Free;
  end;
end;

Duas checagens nesse laço carregam peso real. Definir Active := True nunca levanta exceção: se o arquivo está ausente, danificado ou precisa de senha, o TPdf captura o erro de carga e deixa o Active em False. O componente fica em um estado fechado consistente e um formulário não quebra com um arquivo ruim. Um laço de merge que pula a checagem, porém, simplesmente produz uma saída mais curta. A segunda checagem é o resultado Booleano do ImportPages, que volta False quando a string de intervalo não interpreta ou a posição passa de PageCount + 1. Note também que FileName e Password só podem ser atribuídos com o componente inativo, razão pela qual a origem é fechada no fim de cada passada

Loop de mesclagem Delphi com o PDFium Component: cada arquivo de origem é aberto, copiado por ImportPages na posição InsertAt, e o documento de destino é gravado uma única vez com SaveAs
ImportPages pousa cada origem na posição InsertAt, e um arquivo ausente ou danificado falha silenciosamente a menos que Active seja verificado

Strings de intervalo: o que o parser aceita

O argumento de intervalo é uma lista separada por vírgulas de páginas únicas e trechos fechados, numerados a partir de 1 no documento de origem. '1-3' pega as três primeiras páginas, '2,4,6' pega três específicas, e '1,3-5' pega uma capa mais um resumo de três páginas. Os números sempre se referem à origem; onde essas páginas caem no destino é decidido só pelo argumento de posição

Trechos abertos são onde as pessoas tropeçam. Uma string como '7-' parece "página 7 até o fim", mas o parser de intervalos do PDFium exige as duas pontas de um trecho e rejeita a string inteira. O mesmo vale para um trecho invertido ('5-3'), um número de página 0, ou qualquer página além da contagem de páginas da origem. Um item ruim falha a chamada inteira: o ImportPages retorna False e nenhuma página daquela string é copiada. Por isso o laço acima constrói '1-' + IntToStr(PdfSrc.PageCount) em vez de escrever '1-'

Quando você já tem os índices de página em um array, o ImportPagesByIndex(Source, PageIndices, InsertAt) pula a etapa da string. Fique de olho na mudança de convenção, porém: tanto os índices quanto o InsertAt têm base zero ali, então anexar é InsertAt = PdfDest.PageCount e a seleção capa-mais-resumo vira [0, 2, 3, 4]. Passar um array vazio importa todas as páginas

Existe um merge de uma chamada só para uma lista de arquivos?

Sim. O TPdf.MergeFiles(Sources, Options, Report) recebe um array de registros TPdfBatchMergeSource, cada um construído com TPdfBatchMergeSource.FromFile(FileName, PageRange, Password), abre um de cada vez e preenche um TPdfBatchMergeReport com o que aconteceu. Um PageRange vazio significa todas as páginas. A sintaxe de intervalo é o mesmo formato de vírgulas-e-trechos, e um intervalo malformado vira um erro por arquivo no relatório em vez de um pulo silencioso

Os padrões de TPdfBatchMergeOptions.Default valem a pena conhecer antes de mudar qualquer um. CreateDestination é True, então um destino inativo é criado para você. InsertAt é -1, significando anexar; qualquer outro valor é um índice de base zero, batendo com o ImportPagesByIndex e não com o ImportPages. ErrorPolicy é bmepStop e RollbackOnCancelOrFailure é True, então um arquivo ilegível encerra a execução e remove todas as páginas que ela tinha adicionado. CopyViewerPreferences copia as preferências de visualizador da primeira origem que importar com sucesso

procedure BuildBundle(const OutputPath: string; Log: TStrings);
var
  Dest: TPdf;
  Sources: TPdfBatchMergeSources;
  Options: TPdfBatchMergeOptions;
  Report: TPdfBatchMergeReport;
  I: Integer;
begin
  SetLength(Sources, 3);
  Sources[0] := TPdfBatchMergeSource.FromFile('cover.pdf');
  Sources[1] := TPdfBatchMergeSource.FromFile('annual-report.pdf', '1,3-5');
  Sources[2] := TPdfBatchMergeSource.FromFile('appendix.pdf', '', 'user-password');

  Options := TPdfBatchMergeOptions.Default;
  Options.ErrorPolicy := bmepSkip;        // continue além dos arquivos ilegíveis

  Dest := TPdf.Create(nil);               // inactive: MergeFiles creates it
  try
    if not Dest.MergeFiles(Sources, Options, Report) then
      raise Exception.Create('Merge failed: ' + Report.ErrorMessage);

    for I := 0 to High(Report.Issues) do
      Log.Add(Format('%s: %s',
        [Report.Issues[I].FileName, Report.Issues[I].MessageText]));
    Log.Add(Format('%d pages from %d of %d files',
      [Report.ImportedPageCount, Report.ImportedSourceCount, Report.SourceCount]));

    Dest.SaveAs(OutputPath);
  finally
    Dest.Free;
  end;
end;

Qual política de erro usar depende do que um arquivo ausente significa para a saída. Para um pacote de contrato ou uma leva de faturas, um pacote sem uma das partes é um documento errado: mantenha o bmepStop padrão com rollback. Troque para bmepSkip só em coleções de melhor esforço, como um resumo semanal de relatórios, e então trate o bmrsCompletedWithErrors no Report.Status como algo que uma pessoa precisa examinar. Escolha o que escolher, leia o Report.Issues: cada entrada carrega o índice da origem, o nome do arquivo e a mensagem, o que responde à pergunta "qual arquivo foi o culpado" que o laço escrito à mão precisa responder com as próprias exceções dele

Duas opções a mais importam em execuções longas. OnProgress e CancellationToken deixam uma UI mostrar o arquivo atual e parar a execução, e uma execução cancelada sofre rollback como uma que falhou. ResourcePolicy tem como padrão bmrpSourceScoped, que importa as páginas selecionadas de cada origem em uma chamada nativa para que fontes e imagens compartilhadas por essas páginas sejam copiadas uma vez. bmrpChunked importa em lotes de PageChunkSize páginas, o que dá pontos de cancelamento mais finos ao preço de recursos compartilhados serem copiados de novo a cada lote

O que uma página importada carrega, e o que fica na origem

Uma página importada traz tudo que é alcançável do dicionário de página dela: o content stream, as fontes, imagens e form XObjects nos resources, e as anotações dela, incluindo comentários, destaques e tinta. O que vive em nível de documento na origem não vem junto, porque a ISO 32000-1 o mantém fora da árvore de páginas

  • O document information dictionary (ISO 32000-1 §14.3.3) fica para trás, então o arquivo mesclado não tem nenhum Title, Author ou Keywords das origens. As propriedades Title e Author do TPdf são somente leitura, o que as torna úteis para registrar no log o que cada origem declarou antes de você fechá-la
  • Outlines (§12.3.3) são descartados sob o OutlinePolicy padrão bmopDiscard; bmopReject transforma uma origem com outline em um erro por arquivo. Para manter bookmarks, a unidade FPdfMerge adiciona o MergeFilesWithNavigation, que reconstrói outlines, renomeia destinos nomeados em conflito com um prefixo Imported_ por padrão e remapeia links entre páginas; o artigo sobre edição de outlines e remapeamento de páginas cobre como esses destinos são reescritos
  • Criptografia (§7.6) pertence ao arquivo de origem. Uma origem protegida abre com a Password dela, mas um destino criado pelo CreateDocument não é criptografado, e as páginas copiadas para ele também não. Se o arquivo mesclado precisa ser protegido, grave com SaveAsEncrypted em vez de SaveAs
O ImportPages do PDFium carrega conteúdo de página, fontes e anotações para um PDF mesclado, enquanto metadados Info, a árvore de campos AcroForm e a criptografia ficam para trás em cada arquivo de origem
O ImportPages move tudo o que está guardado com a página, enquanto metadados de nível de documento e a árvore de campos AcroForm ficam para trás

Por que os campos de formulário param de funcionar depois de um merge?

Campos de formulário ficam divididos entre dois níveis de um PDF. A anotação widget que desenha uma caixa de texto fica na página, enquanto o campo em si, com o nome e o valor dele, está listado no array /Fields do dicionário de formulário interativo do catálogo (ISO 32000-1 §12.7.2). Uma importação nativa de página copia o widget junto com as outras anotações de página, mas nada o adiciona à árvore de campos do destino. A caixa ainda desenha a última aparência dela. Um visualizador que constrói o modelo de formulários dele a partir do /AcroForm, porém, não tem campo nenhum a que ligar o widget, então o valor não pode ser editado e qualquer operação de formulário que percorre a árvore de campos o pula

O TPdfBatchMergeOptions.FormPolicy dá três respostas para um merge simples. bmfpPreserveNative, o padrão, mantém o resultado da importação nativa descrito acima. bmfpFlatten gera aparências e achata as páginas selecionadas de cada origem com formulário em memória antes de importá-las, então os valores viram conteúdo comum de página; o arquivo de origem no disco não é modificado. bmfpReject transforma qualquer origem com formulário AcroForm ou XFA em um erro por arquivo

Quando os campos precisam continuar preenchíveis, use o MergeFilesWithForms da unidade FPdfFormMerge. Ele importa o conteúdo das páginas, reconstrói os widgets a partir da árvore de campos de cada origem e enxerta esses campos no formulário de destino. Nomes de campos são a parte difícil. Sob o §12.7.3.2, dois campos com o mesmo nome totalmente qualificado são o mesmo campo e compartilham um valor, então um campo Total em uma fatura e um campo Total em um romaneio de entrega sobrescreveriam um ao outro. O CollisionPolicy padrão é pagcpReject, que para o merge no primeiro conflito:

uses
  PDFium, FPdfCompress, FPdfFormMerge;

procedure MergeFillableForms(Dest: TPdf;
  const Sources: TPdfBatchMergeSources; Log: TStrings);
var
  Options: TPdfFormMergeOptions;
  Report: TPdfFormMergeReport;
begin
  Options := TPdfFormMergeOptions.Default;
  // Nomes em conflito recebem o RenamePrefix ('Imported_' se você não mudar)
  Options.GraftOptions.CollisionPolicy := pagcpRename;

  if Dest.MergeFilesWithForms(Sources, Options, Report) then
    Log.Add(Format('%d fields grafted, %d renamed',
      [Report.GraftedFieldCount, Report.RenamedFieldCount]))
  else
    Log.Add('Form merge failed: ' + Report.ErrorMessage);
end;

O pagcpNamespace põe os campos de cada origem sob um prefixo próprio (Imported mais o número da origem), e o pagcpMergeCompatible junta campos de mesmo nome só quando os tipos e opções deles concordam. O MergeFilesWithForms funciona em origens e destinos AcroForm sem campos de assinatura. Ele recusa formulários XFA e arquivos assinados de cara, já que reescrever um arquivo assinado invalida o intervalo de bytes assinado descrito na ISO 32000-1 §12.8.1. Um destino aberto também precisa estar sem criptografia: mesclar em um arquivo protegido por senha falha, então adicione proteção depois com SaveAsEncrypted. Uma página selecionada duas vezes em uma origem, como em '1,1', precisa de um RepeatedPagePolicy explícito

O documento de destino só é substituído depois que o resultado preparado é reaberto e conferido. Nomes, valores e tipos de campos são comparados página a página, e um merge de formulários que falha deixa o Dest como estava. O artigo sobre enxertia de AcroForm aprofunda o tratamento de colisões

Gravando o documento mesclado

O SaveAs tem uma sobrecarga para arquivo e uma para TStream, ambas recebendo um TSaveOption opcional (padrão saNone) e um TPdfVersion (padrão pvUnknown, que deixa o cabeçalho para a biblioteca). Ambas retornam um Booleano, e em um job em lote esse resultado merece a mesma checagem que o Active. Fixe a versão com algo como pv17 apenas quando um sistema a jusante exige um cabeçalho específico

O trabalho inverso, cortar um arquivo em vários, usa as mesmas strings de intervalo e as mesmas posições de base 1; o artigo sobre divisão o percorre

O ImportPages, o MergeFiles e o merge ciente de formulários descrito aqui vêm com o PDFium Component, que traz essas operações de página para aplicações Delphi e C++Builder