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/Parentfoi redireccionado, a árvore em memória é a fonte da verdade e os bytes originais já não servemReader2.IsStream(X)éTrue. Apenasstreamssão candidatos ao caminho byte a byte; dicionários e outros objectos pequenos são reconstruídos de forma normal- O
streamnão pode conter/StructTreeRootquando 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
MergeFileListFastchama 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 objectosMergeFileListchama-o com a preservação ligada, para que a tagged PDF tree sobreviva sempre que o objecto possa ser copiado tal como estáMergeFileListStrictmantém a mesma estrutura mas desactiva os atalhos tolerantes, útil quando quer medir exactamente o que owriterfaz 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