Artigo Técnico

Object Streams e Cross-Reference Streams de PDF em Delphi

Os object streams do PDF 1.5 empacotam muitos objetos indiretos pequenos em um único container comprimido com Flate, e a losLab PDF Library os emite em um full save através da flag PackObjectStreams. O ganho é real: centenas de dictionaries de página, fonte e anotação, cada um custando dezenas de bytes não comprimidos, colapsam em um punhado de blobs comprimidos. O custo é que todo objeto empacotado agora precisa de um cross-reference stream para descrevê-lo

É nessa segunda metade que os writers quebram. Construir um container /ObjStm é aritmética; ensinar o mecanismo de cross-reference a apontar para dentro dele é um redesign. Um writer que produz um container perfeitamente válido e depois descreve seus membros com deslocamentos comuns tipo 1 produziu um arquivo que o Acrobat vai abrir só o suficiente para declará-lo danificado. As duas features são uma única feature, e este artigo cobre o lado de escrita de ambas, conforme definido na ISO 32000-1 §7.5.7 e §7.5.8

O que um container ObjStm realmente contém

Um object stream é um stream cujos bytes decodificados são duas regiões concatenadas, e a ISO 32000-1 §7.5.7 dá ao dictionary exatamente três chaves que importam para a construção. /Type /ObjStm o identifica, /N dá o número de membros, e /First dá o comprimento em bytes da região de cabeçalho — equivalentemente, o deslocamento em que o corpo começa. O cabeçalho é pares separados por espaço em branco de número de objeto e deslocamento; o corpo são os membros serializados um após o outro, com cada deslocamento medido a partir do início do corpo, e não do início do payload decodificado. Ler um container totalmente decodificado torna isso óbvio: abaixo, /First é 14 porque as três linhas de cabeçalho ocupam quatorze bytes, e o objeto 7 fica 55 bytes dentro do corpo porque o objeto 4 serializou em 54 caracteres mais um separador

// Decoded payload of: 12 0 obj << /Type /ObjStm /N 3 /First 14
//                        /Filter /FlateDecode /Length 118 >> stream
4 0
7 55
9 90
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
<< /Type /ExtGState /CA 1 /ca 1 >>
[ 0 0 595 842 ]

Duas regras de associação são absolutas e ambas vêm direto da §7.5.7. Um stream object nunca pode ser membro, porque um stream carrega bytes brutos que teriam que ficar aninhados dentro de outro stream. E um membro precisa ser um valor de objeto completo, nunca uma referência indireta isolada — um objeto comprimido que é apenas 5 0 R cria uma indireção que o leitor não consegue resolver sem já saber para onde ela aponta. A losLab PDF Library filtra os dois casos durante a coleta de candidatos, junto com o encryption dictionary e o objeto 0, e depois empacota o que sobra em grupos de 200 por container. Esse limite é uma decisão de acesso aleatório, não um limite da especificação: um leitor que quer um único membro precisa inflar o container inteiro, então containers grandes demais tornam buscas pequenas caras

Por que os membros do ObjStm precisam usar entradas de cross-reference tipo 2?

Porque um objeto empacotado não tem deslocamento de arquivo para registrar. A ISO 32000-1 §7.5.8 responde isso com três tipos de entrada em um cross-reference stream binário: tipo 0 para objetos livres, tipo 1 para objetos comuns em uso armazenados em um deslocamento de bytes, e tipo 2 para objetos comprimidos, cujos dois campos de dado guardam o número do objeto container e o índice do membro dentro dele. Não há como expressar um objeto empacotado na tabela xref clássica em texto puro, e é exatamente por isso que o PDF 1.5 introduziu as duas features juntas

A ordenação que se segue trava quase toda primeira implementação, incluindo a nossa. Objetos comuns recebem entradas tipo 1. Os próprios containers /ObjStm recebem entradas tipo 1, porque um container é um stream object indireto perfeitamente normal escrito em um deslocamento real. Só os membros recebem entradas tipo 2. E o próprio cross-reference stream é um objeto indireto no arquivo, então precisa de sua própria entrada tipo 1 apontando para o deslocamento onde acabou de ser escrito — o mesmo deslocamento que o startxref registra. Uma versão inicial do nosso writer excluía números de objeto de container do loop de escrita em vez de excluir os membros, e o resultado foi um arquivo com um cross-reference stream e nenhum object stream: estruturalmente coerente, semanticamente vazio, rejeitado a jusante. O valor de /Size esconde um erro de off-by-one correspondente, já que é o maior número de objeto mais um e o cross-reference stream é alocado como o maior número de objeto, então ele também precisa ser contado

Dimensionando o array /W: por que quatro bytes não bastam

O array /W declara a largura em bytes de cada um dos três campos, e a losLab PDF Library o escreve como /W [1 Field2 Field3] com o campo 1 fixo em um byte para o código de tipo e o campo 3 fixo em dois bytes, o que cobre números de geração até 65535 e índices de membro igualmente. O campo 2 é o único que não pode ser constante, porque ele carrega duas quantidades sem relação: em uma entrada tipo 1 é um deslocamento de bytes limitado apenas pelo tamanho do arquivo, enquanto em uma entrada tipo 2 é um número de objeto container e em uma entrada tipo 0 é o próximo objeto livre na cadeia. Um campo 2 fixo em quatro bytes funciona bem até o arquivo cruzar 4 GB, ponto em que todo deslocamento além do limite trunca silenciosamente e a tabela inteira vira lixo. O writer, portanto, varre a tabela montada em busca do maior valor que qualquer slot do campo 2 vai conter, incluindo o deslocamento do próprio cross-reference stream, e amplia o campo até oito bytes

// Field 2 must hold the largest byte offset AND the largest
// ObjStm container number AND the largest free-chain target.
MaxField2Value := XRefStart;
for X := 0 to MaxObj do
begin
  if XRefTable[X].InUse and (XRefTable[X].ObjStrNum > 0) then
    Field2Value := XRefTable[X].ObjStrNum   // type-2: container number
  else
    Field2Value := XRefTable[X].ObjPos;     // type-1 offset / type-0 next-free
  if Field2Value > MaxField2Value then
    MaxField2Value := Field2Value;
end;

Field2 := 4;
while (Field2 < 8) and
      (MaxField2Value > ((Int64(1) shl (Field2 * 8)) - 1)) do
  Inc(Field2);
Field3 := 2;   // generation numbers and member indices both fit

Uma vez conhecidas as larguras, o tamanho do payload é conhecido com exatidão, então o writer pré-aloca o buffer inteiro e o preenche por índice; anexar entradas byte a byte em uma AnsiString torna a construção da tabela quadrática, o que ninguém percebe em uma fatura de dez páginas e todo mundo percebe em um documento com duzentos mil objetos. Dois outros detalhes mantêm leitores rigorosos satisfeitos. /Index declara quais intervalos de número de objeto a tabela cobre, e para uma reescrita completa isso é simplesmente [0 N] sem lacunas. E todo slot que o writer não emitiu de fato precisa assumir livre por padrão em vez de em uso: o objeto 0 encabeça a cadeia de livres, cada slot livre encadeia para o próximo, e um slot que já teve um objeto excluído mantém seu número de geração incrementado em um. A nota complementar sobre segurança de memória ao analisar PDFs não confiáveis faz o mesmo argumento de limites pelo lado da leitura

Por que o cross-reference stream nunca pode ser criptografado?

Porque um leitor precisa analisá-lo antes de saber como descriptografar qualquer coisa. O cross-reference stream é o que diz ao leitor onde vive o dictionary /Encrypt; se seus bytes fossem eles mesmos criptografados, o leitor precisaria da file key para encontrar o objeto que descreve a file key. A losLab PDF Library impõe isso em um único predicado: ShouldCryptStreamData retorna False sempre que o stream dictionary carrega /Type /XRef, então a isenção se mantém não importa qual caminho chegue ao serializer

O container /ObjStm recebe o tratamento oposto, e a assimetria é deliberada. Um container é criptografado inteiro, com chave baseada em seu próprio número de objeto, exatamente como qualquer outro stream. Seus membros não são criptografados individualmente — eles são empacotados em sua forma de texto puro descriptografada, e a única passagem sobre o container montado os cobre, strings incluídas. Criptografar os membros duas vezes produz um arquivo que descriptografa para texto cifrado, e como a camada externa é bem-sucedida, a falha aparece como um erro de parse lá no fundo do grafo de objetos em vez de como uma falha de autenticação. Um objeto então fica totalmente fora do esquema: em um documento criptografado, o Catalog é mantido como um objeto tipo 1 direto e nunca empacotado, porque empacotá-lo forçaria o loader a inflar e descriptografar um object stream para chegar à raiz do documento, antes que o contexto de descriptografia que a raiz ajuda a estabelecer esteja totalmente construído

Ativando o empacotamento a partir do Delphi

O switch público é PackObjectStreams, exposto como campo em TPDFlibSaveOptions, como o setter independente SetPackObjectStreams, e como propriedade no objeto documento. Vem habilitado por padrão e é restrito automaticamente por versão: o writer só empacota quando o documento já é PDF 1.5 ou posterior, e chama a guarda interna de versão mínima para que um documento empacotado seja elevado para 1.5 em vez de rotulado incorretamente. Depois do save, GetLastSaveUsedObjectStreams reporta se a restrição de fato foi liberada, o que é o assert que você quer em um teste de regressão em vez de uma comparação de tamanho em bytes

var
  Doc: TPDFlib;
  Options: TPDFlibSaveOptions;
begin
  Doc := TPDFlib.Create;
  try
    if Doc.LoadFromFile('report.pdf', '') <= 0 then
      Exit;

    Doc.SetInformation(0, '1.5');        // packing is gated on PDF 1.5+

    FillChar(Options, SizeOf(Options), 0);
    Options.CompressContent    := True;
    Options.GarbageCollect     := True;  // drop orphans before packing
    Options.PackObjectStreams  := True;

    if Doc.SaveToFileOptions('report-packed.pdf', Options) = 1 then
      if Doc.GetLastSaveUsedObjectStreams = 1 then
        Writeln('Saved with ObjStm containers and an xref stream');
  finally
    Doc.Free;
  end;
end;

A ordem importa entre empacotamento e garbage collection. A análise de alcançabilidade precisa rodar primeiro, porque um membro que sobrevive dentro de um container arrasta o container junto com ele — se um objeto vivo é empacotado, seu número de container é alcançável por definição, e varrer o container para fora deixa o membro sem como ser localizado. Rodar o coletor primeiro também significa que objetos mortos nunca entram em um container, e é daí que vem o ganho de tamanho que se acumula. O empacotamento complementa as outras alavancas de tamanho em vez de substituí-las; o passo a passo de otimização de tamanho de arquivo PDF e font subsetting cobre as alavancas que agem sobre payloads de stream, enquanto object streams agem sobre estrutura

Limites que vale a pena conhecer antes de ativar

Saves incrementais nunca empacotam. Um incremental update anexa novos objetos e uma nova seção de cross-reference deixando as revisões anteriores fisicamente intactas, então reempacotar objetos existentes em containers novos deixaria órfãs as entradas tipo 1 que a revisão anterior ainda referencia; a losLab PDF Library desativa o empacotamento sempre que o modo append está ativo, e o artigo sobre incremental updates e streaming em modo append cobre esse caminho por completo. Documentos abaixo do PDF 1.5 mantêm a tabela de cross-reference em texto puro incondicionalmente: um consumidor 1.4 não faz ideia do que /ObjStm significa, e promover silenciosamente um documento porque o writer preferiu um arquivo menor seria a troca errada a fazer em nome de quem chamou. Uma chave opcional que deliberadamente não emitimos é /Extends, que a ISO 32000-1 §7.5.7 define para que um container possa nomear um predecessor e leitores possam tratar uma cadeia de containers como um grupo lógico. Ela é genuinamente opcional, todo container que escrevemos é autocontido e decodificável independentemente, e omiti-la remove uma classe de bugs de ciclo e referência pendente do writer — embora leitores devam, é claro, ainda respeitar /Extends quando o encontrarem em arquivos de outros produtores

O empacotamento de object streams e a saída de cross-reference stream vêm como parte da losLab PDF Library para Delphi e C++Builder, junto com o garbage collector e o otimizador de content stream com os quais se combinam; a página de produto traz a referência completa de save-options