Artigo Técnico

Reescrever PDF no Delphi com membros lazy de ObjStm

Quando o HotPDF Delphi Component carrega um arquivo PDF 1.5 com LoadFromFile, ele não faz parse dos objetos empacotados dentro de containers /Type /ObjStm. Ele registra onde cada membro comprimido vive e só o interpreta quando algo pede por ele. Essa invariante lazy é o que mantém o tempo de load proporcional ao que você de fato toca, e é também a razão pela qual uma reescrita completa tem de fazer um trabalho extra antes de qualquer byte sair: expandir todo membro que ainda está sem parse, porque a reescrita está prestes a jogar fora os containers em que esses membros vivem

O sintoma que motivou esta nota é fácil de descrever e desagradável de depurar. Carregue um arquivo cujas fontes, color spaces e structure tree ficam em object streams, passe-o pelo par de geração BeginDoc e EndDoc, e a saída abre sem reclamar. A contagem de páginas está certa, o texto aparece nas páginas que você confere por amostragem. Aí um colega abre a página 40 e o corpo do texto renderiza numa fonte substituída, ou o comando Extract Text devolve lixo onde antes havia uma substituição ActualText. Nada travou. O writer simplesmente serializou um objeto que nunca foi carregado, e um objeto não carregado serializa como nada

O que o LoadFromFile guarda de fato para um objeto comprimido?

Para toda entrada de cross-reference tipo 2, o LoadFromFile guarda um record pequeno em FCompactObjects: o número do objeto, o índice do stream que o contém na tabela de containers, a posição do membro dentro desse stream e um ponteiro ParsedObject que começa como nil. O container em si é localizado, decriptado se o documento for criptografado, e inflado, mas os corpos dos membros ficam como bytes. A ISO 32000-1 §7.5.7 define o layout de container que torna isso possível: um header de pares de número de objeto e offset, e então os corpos dos membros concatenados depois de /First, de modo que qualquer membro isolado pode ser recortado sem tocar nos vizinhos

O EnsureCompressedObjectLoaded é o único caminho que transforma um record em objeto. Ele encontra o record pelo número de objeto e, se ParsedObject já estiver setado, devolve aquele objeto em cache e conta um cache hit. Caso contrário, recarrega o container se ele foi descartado, calcula a faixa de bytes do membro a partir da tabela de offsets, entrega ao parser uma view zero-copy dessa fatia e guarda o resultado de volta no record. Daí em diante o objeto é indireto, carrega seu número de objeto real e fica registrado no índice de objetos do documento como qualquer objeto que tenha sido lido do corpo do arquivo. O catalog, o info dictionary, a raiz da page tree e os objetos de página passam por esse caminho no load porque a navegação precisa deles. Fontes, color spaces, dicionários ExtGState e elementos de estrutura não, e eles ficam como records até que uma renderização de página ou uma reescrita os toque

Como o HotPDF Delphi Component guarda um membro comprimido antes de interpretá-lo: o record FCompactObjects mantém o número de objeto, o índice do container, o índice do membro e um ponteiro ParsedObject nil, enquanto o EnsureCompressedObjectLoaded transforma um record em objeto registrado por meio de cache hits, recargas de container, recorte pela tabela de offsets e parsing zero-copy
O LoadFromFile deixa os corpos dos membros /ObjStm como bytes e só os interpreta quando um leitor pede, então o tempo de load acompanha o que você toca — catalog e page tree chegam cedo, enquanto fontes, color spaces e elementos de estrutura ficam como records

Você consegue observar isso de fora. O GetLoadedObjectStreamCacheInfo reporta quantos containers existem, quantos membros foram indexados e quantos deles já foram interpretados até agora:

var
  Pdf: THotPDF;
  Info: THPDFObjectStreamCacheInfo;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('tagged-report.pdf');
    if Pdf.GetLoadedObjectStreamCacheInfo(Info) then
      Writeln(Format('%d containers, %d members indexed, %d parsed so far',
        [Info.ContainerCount, Info.IndexedObjectCount,
         Info.MaterializedObjectCount]));
  finally
    Pdf.Free;
  end;
end;

Num arquivo com muita estrutura o terceiro número é uma fração pequena do segundo logo depois do load. Essa diferença é o ponto inteiro do lazy loading, e também é exatamente o conjunto de objetos que uma reescrita completa precisa ir buscar depois

Por que uma reescrita completa descarta fontes que um save incremental preserva?

Uma reescrita completa descarta os containers /ObjStm e /XRef do arquivo de origem e reserializa o grafo de objetos do zero, então todo membro cujo ParsedObject ainda é nil fica sem representação na saída. Uma atualização incremental nunca tem esse problema, porque ela anexa objetos novos depois dos bytes originais e deixa os containers antigos no lugar para a seção de cross-reference anterior endereçar. A diferença não está em como os dois modos tratam fontes. Está em se os containers originais sobrevivem para serem lidos pelo próximo viewer

A correção mora no SaveToStream, o serializador que o EndDoc aciona quer você defina FileName ou OutputStream. Antes de despachar para qualquer ramo de writer, ele percorre FCompactObjects e chama EnsureCompressedObjectLoaded em toda entrada. Se um membro não puder ser carregado, o save levanta erro em vez de continuar, porque uma reescrita que descarta em silêncio um dicionário de fonte é pior que uma que para. A expansão tem de ficar nesse nível, acima dos ramos classic, packed e linearized, e acima da poda de streams estruturais recarregados da rota linearized. Uma versão anterior expandia membros só dentro do SaveLoadedDocument, o que cobria o vocabulário de documento carregado e perdia o vocabulário de geração por completo. LoadFromFile seguido de BeginDoc, edições de página e EndDoc ia direto ao writer com todo membro intocado ainda sem parse

Onde fica a expansão da reescrita completa no HotPDF: o SaveToStream percorre toda entrada de FCompactObjects por EnsureCompressedObjectLoaded antes de despachar para o writer classic, packed ou linearized, então tanto o vocabulário de SaveLoadedDocument quanto o de LoadFromFile mais BeginDoc mais EndDoc serializam objetos totalmente interpretados em vez de records nil
Uma atualização incremental anexa depois dos bytes originais e mantém os containers antigos legíveis, mas uma reescrita completa os descarta — uma passada de expansão acima de todo ramo de writer é o que impede uma fonte ou um elemento de estrutura não carregado de serializar como nada
// Os dois vocabulários de reescrita agora expandem membros compactos antes de qualquer writer rodar.
// Caminho de documento carregado:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.SaveLoadedDocument('quarterly-rewritten.pdf');

// Caminho de geração sobre um arquivo carregado:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.FileName := 'quarterly-stamped.pdf';
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 9);
Pdf.CurrentPage.TextOut(40, 20, 0, 'Reviewed 2026-09-11');
Pdf.EndDoc;   // O SaveToStream materializa toda entrada de FCompactObjects primeiro

Membros em cache mantêm tudo que você fez com eles. Um objeto que foi interpretado, editado e marcado como sujo antes do save volta do cache com suas edições, e um membro que você excluiu mantém o estado de exclusão ao longo de saves repetidos. A passada de expansão é idempotente por construção: ela só preenche slots nil

Por que checagens de pixel em três páginas deixam passar o caso do ActualText

Elementos de estrutura são onde esse bug se esconde por mais tempo. Uma entrada ActualText numa sequência de marked content, definida na ISO 32000-1 §14.9.4, substitui os glyphs para extração e acessibilidade, mas não afeta a renderização. Se o elemento de estrutura vive num object stream e a reescrita o perde, a página continua sendo desenhada corretamente, a primeira, a do meio e a última página comparam pixel a pixel com a origem, e a regressão só aparece quando alguém roda extração de texto ou um leitor de tela. Um teste de reescrita que só renderiza páginas não é um teste de reescrita para PDF com tags. Compare também o diff do texto extraído e da structure tree

Como uma senha de usuário vazia muda o load?

Uma senha de usuário vazia ainda significa que o arquivo está criptografado, e object streams num arquivo desses são ciphertext até a file key ser recuperada. A ISO 32000-1 §7.6.3.4 Algoritmo 2 deriva essa chave a partir da senha, da entrada /O, de /P e do primeiro identificador de documento, e o HotPDF tem de rodá-lo contra a string vazia antes que a passada tipo 2 consiga inflar um único container. É por isso que o BeginDoc sobre um documento criptografado carregado chama DecryptLoadedDocument com senha vazia antes de qualquer outra coisa: o grafo de objetos precisa ser autenticado e decriptado antes de uma reescrita começar, independentemente de o chamador pretender proteger a saída. A criptografia de saída é uma decisão separada, guiada pelas configurações de proteção do chamador, e o BeginDoc restaura essas configurações depois da passada de decriptação, para que uma entrada criptografada não vire uma saída criptografada em silêncio

A política de container é lida do dicionário /Encrypt antes de qualquer senha ser tentada. Para /V 1 e 2 todo stream é criptografado com a file key. Para crypt filters, o HotPDF resolve /StmF via /CF: um filtro Identity ou um /CFM de None significa containers em texto puro, enquanto V2 e AESV2 significam containers criptografados. A resposta cai em FReloadObjectStreamsEncrypted, e ela importa para um caso específico. Quando os containers são texto puro mas as strings não são, os membros carregam strings criptografadas que precisam ser decriptadas individualmente, então o MaterializeMembersOfPlaintextObjectStreams expande todo membro compacto antes da passada de decriptação por objeto. Ele não faz nada quando a política ainda não é conhecida e nada quando os próprios containers foram criptografados, porque membros de um container criptografado já foram decriptados junto com ele e nunca podem ser decriptados duas vezes

O que acontece quando um container não pode ser decriptado?

Um container que falha na decriptação é posto em quarentena, não é fatal. A passada tipo 2 registra uma entrada THPDFObjStmQuarantineInfo em FObjStmQuarantine com o número de objeto do container, um THPDFObjStmQuarantineReason, uma string de diagnóstico e a lista dos números de objeto dos membros que o cross-reference tinha roteado para dentro dele. O osqrDecryptFailed é levantado para quatro situações distintas: nenhum crypt filter pôde ser resolvido, a decriptação AES-256 ou AES-GCM levantou erro, a decriptação legada RC4 ou AES-128 levantou erro, ou não existe file key utilizável de jeito nenhum. Containers independentes continuam carregando, então um documento com um container danificado ainda abre e ainda renderiza toda página que não dependa dele

Como a quarentena de decriptação do HotPDF funciona num PDF carregado: um container cuja decriptação levanta erro é registrado como THPDFObjStmQuarantineInfo com motivo osqrDecryptFailed e os números de objeto dos seus membros, containers independentes continuam carregando, e o BeginDoc levanta erro na primeira entrada falha antes que uma reescrita possa reportar sucesso
Os registros de quarentena sobrevivem ao fallback do parser e o BeginDoc os checa por nome em vez de pela flag de criptografia, então um documento com um container danificado ainda abre, enquanto o caminho de reescrita para em vez de escrever objetos vazios

A lista de quarentena sobrevive ao fallback do parser. Se o load principal de cross-reference falha e o HotPDF reconstrói a tabela de objetos varrendo o arquivo, a flag de criptografia da primeira tentativa pode não sobreviver a essa reconstrução, mas os registros de quarentena sobrevivem. É por isso que o BeginDoc checa a lista de quarentena em vez da flag de criptografia: num documento carregado ele percorre FObjStmQuarantine e levanta erro na primeira entrada osqrDecryptFailed, nomeando o container e pedindo um reload com senha válida. Uma reescrita que prosseguisse além desse ponto escreveria os membros que o container deveria guardar como objetos vazios e reportaria sucesso. Você pode rodar a mesma checagem por conta própria, mais cedo e com a sua política, pelos acessores públicos:

var
  Info: THPDFObjStmQuarantineInfo;
  I: Integer;
begin
  Pdf.LoadFromFile('vendor-form.pdf');   // senha de usuário vazia
  for I := 0 to Pdf.GetLoadedQuarantinedObjStmCount - 1 do
    if Pdf.GetLoadedQuarantinedObjStmInfo(I, Info) and
       (Info.Reason = osqrDecryptFailed) then
      raise Exception.CreateFmt(
        'Object stream %d is unreadable (%s); %d members unresolved',
        [Info.ContainerObjNum, String(Info.Diagnostic),
         Length(Info.MemberObjNums)]);
  // seguro para reescrever a partir daqui
end;

Outros motivos de quarentena cobrem as falhas não criptográficas: um container que não é stream, um dicionário ausente, um /N ou /First inválido, um tamanho de stream fora da faixa aceita, uma falha de descompressão, um /First apontando para além dos dados, ou um corpo de membro que decodificou mas não deu parse. Vale registrar esses casos na ingestão, já que cada um nomeia exatamente os membros que vão faltar mais adiante

Por que uma reescrita precisa do token numérico original?

O HotPDF armazena todo objeto numérico como Single, e um Single não consegue reproduzir o texto de origem de um número real. A ISO 32000-1 §7.3.3 permite que um writer emita 0.750000, .75 ou 0.75 para o mesmo valor, e nenhum deles sobrevive inalterado a um round trip por binário de 24 bits e um formatador genérico. Pior, um valor como 0.7 não é representável num Single de jeito nenhum; ele é lido como o float mais próximo, e reformatar esse float pode produzir 0.69999999 ou um vizinho arredondado, dependendo do loop de dígitos. Numa cor de preenchimento ou numa constante de transparência /CA, isso é uma diferença de uma unidade num canal de 8 bits, o que já basta para falhar uma comparação de pixel contra a origem e, em fronteiras de gradiente, basta para se ver

O THPDFNumericObject.RememberSourceToken resolve isso para o caso não modificado. O parser o chama com o token cru logo depois de atribuir Value; o método aceita apenas tokens feitos de dígitos, no máximo um ponto decimal e um sinal inicial opcional, e guarda o token junto com o valor a que ele correspondia em FSourceValue. A propriedade SourceToken devolve o texto guardado só enquanto Value ainda é igual a FSourceValue. Mude o número e o token evapora, então um valor modificado sempre passa pelo caminho de formatação existente e nunca emite texto obsoleto. O SaveNumericObject checa SourceToken primeiro e o escreve literalmente quando presente, e só cai nos ramos de inteiro, referência de color space e fracionário para números que foram criados ou editados em memória

A invariante é pequena e vale enunciar sem rodeios: um número que você não tocou é escrito com os bytes com que foi lido, e um número que você tocou é escrito pelo formatador do próprio HotPDF. Membros compactos se beneficiam disso do mesmo jeito que os objetos do corpo, já que o EnsureCompressedObjectLoaded roda o mesmo parser sobre a fatia do membro. A formatação de número em si, e sua independência do locale do processo, está coberta no artigo sobre formatação invariant de números PDF no HotPDF

Testar um caminho de reescrita contra object streams

Três checagens pegam toda falha descrita acima, e nenhuma delas exige o Acrobat. Primeiro, compare IndexedObjectCount com MaterializedObjectCount depois do save; numa reescrita completa os dois precisam ser iguais, e qualquer diferença é um membro que foi descartado. Segundo, extraia o texto e enumere a structure tree nos dois arquivos, não apenas os renderize, para que um ActualText perdido ou um elemento de estrutura perdido apareçam como diff. Terceiro, carregue a saída com uma instância nova e verifique que GetLoadedQuarantinedObjStmCount é zero, o que também prova que o writer não produziu um container que o reader não consegue abrir. As combinações de crypt filter que decidem FReloadObjectStreamsEncrypted estão detalhadas no artigo sobre as políticas StmF, StrF e EFF. O lado do writer desta história, como emitir object streams e quando preferir uma atualização incremental a uma reescrita, está no guia de object streams e atualizações incrementais

O carregamento lazy de membros, a passada de expansão antes do writer, a quarentena de decriptação e a preservação do token de origem vêm todos no HotPDF Delphi Component para Delphi e C++Builder. A página do produto traz o link da referência de API caso você queira rastrear GetLoadedObjectStreamCacheInfo e os acessores de quarentena contra o seu próprio pipeline de ingestão