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
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
TitleeAuthordoTPdfsã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
OutlinePolicypadrãobmopDiscard;bmopRejecttransforma uma origem com outline em um erro por arquivo. Para manter bookmarks, a unidadeFPdfMergeadiciona oMergeFilesWithNavigation, que reconstrói outlines, renomeia destinos nomeados em conflito com um prefixoImported_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
Passworddela, mas um destino criado peloCreateDocumentnão é criptografado, e as páginas copiadas para ele também não. Se o arquivo mesclado precisa ser protegido, grave comSaveAsEncryptedem vez deSaveAs
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