Artigo Técnico

Mesclar PDFs rapidamente no Delphi com deslocamento de referências em bytes

Concatenar PDFs parece barato. O conteúdo das páginas já está paginado, as fontes já estão incorporadas, as imagens já estão comprimidas. Em princípio, uma mesclagem é apenas trabalho administrativo: renumerar os objectos à medida que entram e escrever os bytes de referência actualizados. O problema é que o PDF não funciona assim no detalhe, porque os objectos indirectos carregam IDs que atravessam o ficheiro inteiro

PDFlibPas é um motor PDF nativo em Object Pascal para Delphi e C++Builder, e o seu percurso de mesclagem rápida existe para saltar essa volta sempre que isso é comprovadamente seguro. A ideia é estreita, mas compensa em conjuntos de documentos inteiros: quando o conteúdo de um objecto de um ficheiro seguinte pode ser reutilizado sem reescrita, a engine copia os bytes tal como estão e limita-se a deslocar as referências apontadas por eles

Porque a renumeração de objectos é o verdadeiro custo de uma mesclagem

Cada PDF traz o seu próprio espaço de numeração de objectos. O ficheiro A tem o objecto 1, o objecto 2 e assim por diante; o ficheiro B tem o seu próprio objecto 1, objecto 2, e assim sucessivamente. Não pode despejar os objectos de B no ficheiro A sem alterações, porque os números colidiriam. E não basta mudar só os números que vê no topo: os números de objecto aparecem dentro de streams, anotações, árvores de estrutura, formulários e catálogos. Mesclar é, no fundo, reescrever apontadores, não copiar páginas

Esse deslocamento é o verdadeiro trabalho semântico da mesclagem do corpo. As correcções da page tree e a fusão do AcroForm são edições pequenas e limitadas a um punhado de objectos. O trabalho pesado é reescrever referências em milhares de objectos, e é aí que um caminho byte a byte ganha tempo quando pode provar que nada mudou

Quando reutilizar bytes de origem é comprovadamente seguro

O caminho byte a byte só é usado quando se verificam três condições para o objecto que está a ser copiado de um documento seguinte. Se qualquer uma falhar, o objecto regressa ao percurso completo de descodificação e reserialização, pelo que a correcção não depende de um atalho optimista

  • Doc2.IsChangedObject(X) é False. Se o motor de mesclagem já alterou o objecto em memória, por exemplo um objecto de página cujo /Parent foi redireccionado, a árvore em memória é a fonte da verdade e os bytes originais já não servem
  • Reader2.IsStream(X) é True. Apenas streams são candidatos ao caminho byte a byte; dicionários e outros objectos pequenos são reconstruídos de forma normal
  • O stream não pode conter /StructTreeRoot quando a preservação da árvore de estrutura está desligada. Esse é o único caso em que a cópia directa é descartada por semântica de tagged PDF

A decisão vive no ciclo de cópia por objecto. Quando as três verificações passam, os bytes do objecto seguem directamente para ShiftIndRefsInSource e depois para o writer; caso contrário, os bytes são rejeitados e o objecto é reconstruído com o caminho normal

ObjectData := '';
if not Doc2.IsChangedObject(X) then
begin
  ObjectData := FastMergeObjectSource(Reader2, X);
  if (PLPos('stream', ObjectData) > 0) or
     ((not PreserveStructTree) and (PLPos('/StructTreeRoot', ObjectData) > 0)) or
     ((not PreserveStructTree) and (PLPos('/StructElem', ObjectData) > 0)) then
    ObjectData := ''                                  // fall back to decode
  else
    ObjectData := ShiftIndRefsInSource(ObjectData, Offset);
end;

if ObjectData <> '' then
  Writer.AddObject(X + Offset, Doc2.GetGenNum(X), ObjectData)
else
begin
  Obj := Doc2.GetObject(X, TempStruct);              // full parse path
  // ... null out struct-tree objects, ShiftIndRef, Obj.Output ...
end;

Um ObjectData vazio é o sinal de que o caminho rápido recusou o objecto. Esse único sentinela mantém os percursos rápido e lento alinhados: existe exactamente um ponto onde a decisão é tomada, e exactamente uma fuga para o fallback

A máquina de estados de deslocamento de referências e os seus casos-limite

Uma reescrita de referências indirectas é traiçoeira, porque R e sequências de dígitos aparecem em todo o PDF em contextos que não são referências. ShiftIndRefsInSource é um pequeno scanner manual que percorre o objecto já serializado, localiza referências indirectas verdadeiras e soma um offset ao número de objecto, deixando todo o resto intocado

A correcção do scanner depende de reconhecer os contextos em que uma sequência com aparência de referência tem de ser deixada em paz. Estas são as fronteiras mais fáceis de falhar, e cada uma é tratada explicitamente:

  • Strings literais delimitadas por ( e ) são copiadas verbatim, com seguimento da profundidade de aninhamento e respeito pelo escape com barra invertida, para que um parêntesis escapado não estrague a contagem. Uma string como (see object 3 0 R for details) continua a ser texto, não uma referência
  • Strings hexadecimais entre < e > são igualmente deixadas sozinhas. Mesmo que a sequência de dígitos pareça uma referência, ali é apenas dado bruto codificado
  • Comentários começados por % são copiados até à quebra de linha e nunca são analisados como estrutura PDF. O scanner salta-os sem hesitar

O núcleo desse teste rigoroso lê quase exactamente como a frase da especificação descreve:

if (P <= N) and (Source[P] = 'R') and
   ((P = N) or PLIsPdfWhite(Source[P + 1]) or PLIsPdfDelimiter(Source[P + 1])) then
  Obj1 := PLStrToIntDef(PLCopy(Source, I, E1 - I), -1);

if Obj1 >= 0 then
begin
  AppendStr(PLIntToStr(Obj1 + Offset));   // shifted object number
  AppendBytes(E1, P - E1);                 // original whitespace + generation
  AppendBytes(P, 1);                       // the 'R'
end;

Só o número do objecto é reescrito; o número da geração e o whitespace exacto entre tokens são copiados tal e qual, pelo que a saída é byte-identical à entrada excepto pelo único inteiro que tinha de mudar. Isso é importante porque preserva a validade de assinaturas, filtros e consumidores sensíveis a espaços

Porque os bookmarks não podiam reutilizar AppendOutline

Mesclar os bookmarks de vários documentos numa única árvore de outline parece um trabalho para o auxiliar AppendOutline, que já sabe enxertar os bookmarks de topo de um documento noutro. É o instrumento errado quando está a combinar documentos inteiros, porque cada documento traz a sua própria ordem de objectos e o seu próprio conjunto de referências ajustadas

O caminho rápido resolve isso com uma injecção em duas fases, orientada por metadados, que nunca volta a percorrer o reader. A primeira passagem por todas as entradas recolhe, por documento, o objecto raiz da outline e os números de geração, o primeiro e o último número de objecto, e os pontos onde os bookmarks começam. A segunda passagem escreve os novos objectos já com as referências deslocadas e o outline remendado em simultâneo

A invariante de alinhamento dos offsets que junta tudo

Tanto o deslocamento de referências como a injecção de bookmarks dependem de uma única invariante aritmética, e ela é a suposição mais frágil de toda a concepção. Uma referência injectada num documento seguinte é escrita como o alvo global menos o número de objecto inicial desse documento, para que o novo objecto aponte para o lugar certo depois de o desvio ser aplicado

Resiste, porque há uma propriedade na forma como as mesclagens de páginas e formulários funcionam: AddPages, AddFields e AddFieldFonts só modificam os objectos existentes do primeiro documento, nunca acrescentam novos. Assim, a contagem de objectos do primeiro documento continua fixa, e o deslocamento dos documentos seguintes pode ser calculado de forma determinística

Três pontos de entrada sobre um único motor

O caminho rápido não é um fork do código de mesclagem. Na mesma linha de trabalho, o motor ao nível dos bytes foi factorizado numa única rotina interna, MergeFileListInternal(ListName, OutputFileName, PreserveStructTree, StrictMode), e as funções públicas só variam os parâmetros que importa expor

  • MergeFileListFast chama o motor com a preservação da árvore de estrutura desligada - o caminho mais leve, que abandona a árvore tagged PDF para que o percurso por bytes se aplique ao maior número possível de objectos
  • MergeFileList chama-o com a preservação ligada, para que a tagged PDF tree sobreviva sempre que o objecto possa ser copiado tal como está
  • MergeFileListStrict mantém a mesma estrutura mas desactiva os atalhos tolerantes, útil quando quer medir exactamente o que o writer faz sem optimizações defensivas

Juntar os percursos também permitiu reconstruir a mesclagem normal de um ciclo par-a-par O(N^2) - mesclar o ficheiro um com o dois, depois esse resultado com o três, e assim sucessivamente, reanalisando o acumulador crescente a cada passo - para uma única passagem sobre a lista de ficheiros. Isso removeu um segundo conjunto de caminhos quase iguais, e com ele uma classe de divergências difíceis de diagnosticar

Uma nota honesta sobre o comportamento da árvore de estrutura, porque isso deu cabo do conjunto de testes. O "drop" do caminho rápido não é total: remove a referência do catálogo do primeiro documento para /StructTreeRoot, mas o próprio objecto da árvore de estrutura continua no ficheiro se alguma parte anterior o tiver deixado alcançável. Isso é suficiente para os consumidores que só seguem o catálogo, mas não é uma purga completa do objecto

Quando escolher cada caminho

O caminho por bytes é uma optimização de throughput para montar muitos documentos quando não precisa de preservar a tagged PDF structure tree - bundling de relatórios, lotes de extratos, concatenação em massa. Medido em mesclagens repetidas sobre conjuntos grandes, o ganho vem de evitar descodificar e reescrever objectos que já estavam correctos. O caminho lento continua a ser o padrão quando a estrutura etiquetada importa mais do que o tempo total

As rotinas de mesclagem e as suas variantes rápida e estrita fazem parte da PDFlibPas Delphi PDF Library, cuja documentação inclui a referência completa para a API de listas de ficheiros e para as opções de mesclagem aqui descritas