Os object streams do PDF 1.5 empacotam muitos objetos indiretos pequenos num único contentor comprimido com Flate, e a losLab PDF Library emite-os numa gravação completa através da sua flag PackObjectStreams. O ganho é real: centenas de dicionários de página, tipo de letra e anotação, cada um a custar dezenas de bytes não comprimidos, colapsam num punhado de blobs comprimidos. O custo é que cada objeto empacotado precisa agora de uma cross-reference stream que o descreva
É nessa segunda metade que os escritores falham. Construir um contentor /ObjStm é aritmética; ensinar o mecanismo de cross-reference a apontar para lá dentro é um redesenho. Um escritor que produz um contentor perfeitamente válido e depois descreve os seus membros com offsets comuns de tipo 1 produziu um ficheiro que o Acrobat abre só o tempo suficiente para o declarar danificado. As duas funcionalidades são uma só, e este artigo cobre o lado da escrita de ambas, tal como definido em ISO 32000-1 §7.5.7 e §7.5.8
O que contém realmente um contentor ObjStm
Um object stream é um stream cujos bytes descodificados formam duas regiões concatenadas, e o ISO 32000-1 §7.5.7 dá ao dicionário exatamente três chaves relevantes para a construção. /Type /ObjStm identifica-o, /N dá o número de membros, e /First dá o comprimento em bytes da região de cabeçalho — ou seja, o offset a partir do qual começa o corpo. O cabeçalho são pares separados por espaços de número de objeto e offset; o corpo são os membros serializados uns a seguir aos outros, com cada offset medido a partir do início do corpo e não do início do payload descodificado. Ler um contentor totalmente descodificado torna isto óbvio: abaixo, /First é 14 porque as três linhas de cabeçalho ocupam catorze bytes, e o objeto 7 fica 55 bytes dentro do corpo porque o objeto 4 se serializou em 54 carateres 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 pertença são absolutas e ambas vêm diretamente do §7.5.7. Um objeto stream nunca pode ser membro, porque um stream transporta bytes em bruto que teriam de ficar aninhados dentro de outro stream. E um membro tem de ser um valor de objeto completo, nunca uma simples referência indireta — um objeto comprimido que é apenas 5 0 R cria uma indireção que o leitor não consegue resolver sem já saber para onde aponta. A losLab PDF Library filtra ambos os casos durante a recolha de candidatos, juntamente com o dicionário de encriptação e o objeto 0, e depois empacota o que sobrevive em grupos de 200 por contentor. Esse limite é uma decisão de acesso aleatório e não uma restrição da especificação: um leitor que quer apenas um membro tem de descomprimir o contentor inteiro, pelo que contentores demasiado grandes tornam as pesquisas pontuais caras
Porque é que os membros do ObjStm têm de usar entradas de cross-reference tipo 2?
Porque um objeto empacotado não tem offset de ficheiro para registar. O ISO 32000-1 §7.5.8 responde a isto com três tipos de entrada numa cross-reference stream binária: tipo 0 para objetos livres, tipo 1 para objetos comuns em uso guardados num offset de bytes, e tipo 2 para objetos comprimidos, cujos dois campos de dados guardam o número de objeto do contentor e o índice do membro dentro dele. Não há forma de exprimir um objeto empacotado na tabela xref clássica em texto simples, e é exatamente por isso que o PDF 1.5 introduziu as duas funcionalidades em conjunto
A ordenação que se segue faz tropeçar quase todas as primeiras implementações, incluindo a nossa. Os objetos comuns recebem entradas tipo 1. Os próprios contentores /ObjStm recebem entradas tipo 1, porque um contentor é um objeto stream indireto perfeitamente normal escrito num offset real. Só os membros recebem entradas tipo 2. E a própria cross-reference stream é um objeto indireto no ficheiro, pelo que precisa da sua própria entrada tipo 1 a apontar para o offset onde acabou de ser escrita — o mesmo offset que o startxref regista. Uma versão inicial do nosso escritor excluía os números de objeto dos contentores do ciclo de escrita em vez de excluir os membros, e o resultado foi um ficheiro com uma cross-reference stream e nenhum object stream: estruturalmente coerente, semanticamente vazio, rejeitado a jusante. O valor de /Size esconde um erro de contagem correspondente, já que é o número de objeto mais alto mais um, e a cross-reference stream é alocada como o número de objeto mais alto, pelo que também tem de ser contada
Dimensionar o array /W: porque quatro bytes não chegam
O array /W declara a largura em bytes de cada um dos três campos, e a losLab PDF Library escreve-o 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 tanto números de geração até 65535 como índices de membro. O campo 2 é aquele que não pode ser constante, porque transporta duas grandezas sem relação entre si: numa entrada tipo 1 é um offset em bytes limitado apenas pelo tamanho do ficheiro, enquanto numa entrada tipo 2 é um número de objeto de contentor e numa entrada tipo 0 é o próximo objeto livre na cadeia. Um campo 2 fixo em quatro bytes funciona bem até o ficheiro ultrapassar 4 GB, momento em que todos os offsets além da fronteira truncam silenciosamente e a tabela inteira se torna lixo. O escritor percorre por isso a tabela montada em busca do maior valor que qualquer posição de campo 2 alguma vez irá conter, incluindo o offset da própria cross-reference stream, e alarga 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
Assim que as larguras são conhecidas, o tamanho do payload é conhecido com exatidão, pelo que o escritor pré-aloca o buffer inteiro e preenche-o por índice; acrescentar entradas byte a byte a uma AnsiString torna a construção da tabela quadrática, o que ninguém nota numa fatura de dez páginas e toda a gente nota num documento com duzentos mil objetos. Dois outros pormenores mantêm os leitores estritos satisfeitos. /Index declara quais os intervalos de números de objeto que a tabela cobre, e para uma reescrita completa isso é simplesmente [0 N] sem lacunas. E cada posição que o escritor não emitiu de facto tem de assumir por defeito livre em vez de em uso: o objeto 0 encabeça a cadeia de livres, cada posição livre liga à seguinte, e uma posição que outrora continha um objeto eliminado mantém a sua geração incrementada em um. A nota complementar sobre segurança de memória ao analisar PDFs não fiáveis apresenta o mesmo argumento de limites pelo lado da leitura
Porque é que a cross-reference stream nunca pode ser encriptada?
Porque um leitor tem de a analisar antes de poder saber como decifrar seja o que for. A cross-reference stream é o que diz ao leitor onde vive o dicionário /Encrypt; se os seus bytes estivessem eles próprios encriptados, o leitor precisaria da chave do ficheiro para encontrar o objeto que descreve a chave do ficheiro. A losLab PDF Library impõe isto num único predicado: ShouldCryptStreamData devolve False sempre que o dicionário do stream transporta /Type /XRef, pelo que a exceção se mantém seja qual for o caminho que chega ao serializador
O contentor /ObjStm recebe o tratamento oposto, e a assimetria é deliberada. Um contentor é encriptado inteiro, com base no seu próprio número de objeto, tal como qualquer outro stream. Os seus membros não são encriptados individualmente — são empacotados na sua forma decifrada, e a única passagem sobre o contentor montado cobre-os, strings incluídas. Encriptar os membros duas vezes produz um ficheiro que se decifra em ciphertext, e como a camada exterior é bem-sucedida, a falha aparece como um erro de análise lá no fundo do grafo de objetos em vez de aparecer como falha de autenticação. Um objeto fica então completamente fora deste esquema: num documento encriptado o Catalog mantém-se como objeto direto de tipo 1 e nunca é empacotado, porque empacotá-lo obrigaria o carregador a descomprimir e decifrar um object stream para chegar à raiz do documento, antes de o contexto de decifragem que essa raiz ajuda a estabelecer estar totalmente construído
Ativar o empacotamento a partir do Delphi
O interrutor público é PackObjectStreams, exposto como campo em TPDFlibSaveOptions, como setter independente SetPackObjectStreams, e como propriedade no objeto de documento. Vem ativado por predefinição e é limitado automaticamente pela versão: o escritor só empacota quando o documento já é PDF 1.5 ou posterior, e chama a proteção interna de versão mínima para que um documento empacotado seja elevado para 1.5 em vez de ficar mal identificado. Após a gravação, GetLastSaveUsedObjectStreams reporta se a proteção de facto se abriu, o que é o assert que se quer num teste de regressão em vez de uma comparação de tamanho de 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 o empacotamento e o garbage collection. A análise de alcançabilidade tem de correr primeiro, porque um membro que sobrevive até um contentor arrasta o contentor consigo — se um objeto vivo é empacotado, o número do seu contentor é alcançável por definição, e varrer o contentor para fora deixa o membro sem forma de o localizar. Correr o coletor primeiro também significa que objetos mortos nunca chegam a entrar num contentor, o que é de onde vem o ganho de tamanho composto. O empacotamento complementa as outras alavancas de tamanho em vez de as substituir; o percurso sobre otimização do tamanho de ficheiro PDF e subsetting de tipos de letra cobre as alavancas que atuam sobre os payloads dos streams, onde os object streams atuam sobre a estrutura
Limites a conhecer antes de ativar
As gravações incrementais nunca empacotam. Uma atualização incremental acrescenta novos objetos e uma nova secção de cross-reference deixando as revisões anteriores fisicamente intactas, pelo que reempacotar objetos existentes em novos contentores tornaria órfãs as entradas tipo 1 que a revisão anterior ainda referencia; a losLab PDF Library desativa o empacotamento sempre que o modo de acrescento está ativo, e o artigo sobre atualizações incrementais e streaming em modo de acrescento cobre esse caminho na íntegra. Documentos abaixo de PDF 1.5 mantêm incondicionalmente a tabela de cross-reference em texto simples: um consumidor 1.4 não faz ideia do que /ObjStm significa, e promover silenciosamente um documento porque o escritor preferia um ficheiro mais pequeno seria a troca errada a fazer em nome do chamador. Uma chave opcional que deliberadamente não emitimos é /Extends, que o ISO 32000-1 §7.5.7 define para que um contentor possa nomear um predecessor e os leitores possam tratar uma cadeia de contentores como um grupo lógico. É genuinamente opcional, cada contentor que escrevemos é autónomo e descodificável de forma independente, e omiti-la remove uma classe de bugs de ciclos e de referências pendentes do escritor — ainda que os leitores devam, claro, continuar a respeitar /Extends quando o encontrarem em ficheiros de outros produtores
O empacotamento em object streams e a emissão de cross-reference streams fazem parte da losLab PDF Library para Delphi e C++Builder, a par do garbage collector e do otimizador de content streams com que se combinam; a página de produto contém a referência completa das opções de gravação