Quando o HotPDF Delphi Component carrega um ficheiro PDF 1.5 com o LoadFromFile, não faz o parse dos objetos empacotados dentro de contentores /Type /ObjStm. Regista onde vive cada membro comprimido e só o lê quando algo o pede. Esse invariante lazy é o que mantém o tempo de carregamento proporcional àquilo em que realmente toca, e é também a razão pela qual uma reescrita completa tem de fazer um trabalho extra antes de sair um único byte: expandir todos os membros que ainda não foram lidos, porque a reescrita está prestes a deitar fora os contentores onde esses membros vivem
O sintoma que motivou esta nota é fácil de descrever e desagradável de depurar. Carregue um ficheiro cujos tipos de letra, espaços de cor e árvore de estrutura estão em object streams, passe-o pelo par de geração BeginDoc e EndDoc, e a saída abre sem queixas. A contagem de páginas está certa, o texto é visível nas páginas que verifica por amostragem. Depois um colega abre a página 40 e o corpo do texto sai num tipo de letra substituído, ou o comando Extract Text devolve disparate onde antes havia uma substituição ActualText. Nada rebentou. O writer limitou-se a serializar um objeto que nunca foi carregado, e um objeto não carregado serializa-se como nada
O que guarda afinal o LoadFromFile para um objeto comprimido?
Para cada entrada de cross-reference de tipo 2, o LoadFromFile guarda um pequeno record em FCompactObjects: o número de objeto, o índice do stream contentor na tabela de contentores, a posição do membro dentro desse stream, e um ponteiro ParsedObject que começa a nil. O contentor em si é localizado, desencriptado se o documento estiver encriptado, e inflado, mas os corpos dos membros ficam como bytes. A ISO 32000-1 §7.5.7 define o layout do contentor que torna isto possível: um cabeçalho de pares número-de-objeto e offset, e depois os corpos dos membros concatenados após o /First, para que qualquer membro individual possa ser recortado sem tocar nos vizinhos
O EnsureCompressedObjectLoaded é o único caminho que transforma um record num objeto. Encontra o record pelo número de objeto e, se o ParsedObject já estiver definido, devolve esse objeto em cache e conta um cache hit. Caso contrário, recarrega o contentor se este tiver sido descartado, calcula o intervalo de bytes do membro a partir da tabela de offsets, entrega ao parser uma vista sem cópia desse recorte, e guarda o resultado de volta no record. A partir daí o objeto é indireto, transporta o seu número de objeto real, e está registado no índice de objetos do documento como qualquer objeto lido do corpo do ficheiro. O catálogo, o dicionário de informação, a raiz da árvore de páginas e os objetos de página passam por este caminho no carregamento porque a navegação precisa deles. Os tipos de letra, os espaços de cor, os dicionários ExtGState e os elementos de estrutura não, e ficam como records até uma renderização de página ou uma reescrita lhes tocar
Pode observar isto de fora. O GetLoadedObjectStreamCacheInfo reporta quantos contentores existem, quantos membros foram indexados, e quantos desses já foram lidos:
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 ficheiro com muita estrutura, o terceiro número é uma fração pequena do segundo logo a seguir ao carregamento. Essa diferença é o objetivo todo do carregamento lazy, e é também exatamente o conjunto de objetos a que uma reescrita completa tem de voltar
Porque é que uma reescrita completa perde tipos de letra que uma gravação incremental mantém?
Uma reescrita completa descarta os contentores /ObjStm e /XRef do ficheiro de origem e re-serializa o grafo de objetos de raiz, pelo que qualquer membro cujo ParsedObject ainda seja nil fica sem representação na saída. Uma atualização incremental nunca tem este problema, porque acrescenta objetos novos depois dos bytes originais e deixa os contentores antigos no lugar para a secção de cross-reference anterior os endereçar. A diferença não está em como os dois modos tratam os tipos de letra. Está em se os contentores originais sobrevivem para serem lidos pelo visualizador seguinte
A correção vive no SaveToStream, o serializador que o EndDoc conduz quer defina FileName quer OutputStream. Antes de despachar para qualquer ramo de writer, percorre FCompactObjects e chama EnsureCompressedObjectLoaded em cada entrada. Se um membro não puder ser carregado, a gravação levanta erro em vez de continuar, porque uma reescrita que deixa cair silenciosamente um dicionário de tipo de letra é pior do que uma que para. A expansão tem de estar nesse nível, acima dos ramos clássico, empacotado e linearizado, e acima da poda de streams estruturais recarregados da via linearizada. Uma versão anterior expandia os membros apenas dentro do SaveLoadedDocument, o que cobria o vocabulário de documento carregado e falhava por completo o vocabulário de geração. Um LoadFromFile seguido de BeginDoc, edições de página e EndDoc ia direto ao writer com todos os membros intocados ainda por ler
// Ambas as vias de reescrita expandem agora os membros compactos antes de qualquer writer correr.
// Via do documento carregado:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.SaveLoadedDocument('quarterly-rewritten.pdf');
// Via de geração sobre um ficheiro 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 primeiro cada entrada de FCompactObjects
Os membros em cache mantêm tudo o que lhes fez. Um objeto que foi lido, editado e marcado como sujo antes da gravação é devolvido da cache com as suas edições, e um membro que apagou mantém o seu estado de eliminação ao longo de gravações repetidas. A passagem de expansão é idempotente por construção: só preenche posições nil
Porque é que verificações de píxeis em três páginas falham o caso do ActualText
Os elementos de estrutura são onde este bug se esconde durante mais tempo. Uma entrada ActualText numa sequência de conteúdo marcado, definida na ISO 32000-1 §14.9.4, substitui os glifos 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 a desenhar-se corretamente, a primeira, a do meio e a última página comparam píxeis com a origem, e a regressão só aparece quando alguém corre extração de texto ou um leitor de ecrã. Um teste de reescrita que só desenha páginas não é um teste de reescrita para PDF com tags. Compare também o texto extraído e a árvore de estrutura
Como é que uma palavra-passe de utilizador vazia muda o carregamento?
Uma palavra-passe de utilizador vazia significa na mesma que o ficheiro está encriptado, e os object streams num ficheiro desses são texto cifrado até a chave do ficheiro ser recuperada. A ISO 32000-1 §7.6.3.4 Algoritmo 2 deriva essa chave a partir da palavra-passe, da entrada /O, de /P e do primeiro identificador de documento, e o HotPDF tem de o correr contra a string vazia antes de a passagem de tipo 2 poder inflar um único contentor. É por isso que o BeginDoc sobre um documento encriptado carregado chama o DecryptLoadedDocument com uma palavra-passe vazia antes de qualquer outra coisa: o grafo de objetos tem de ser autenticado e desencriptado antes de uma reescrita poder começar, independentemente de o chamador tencionar proteger a saída. A encriptação da saída é uma decisão separada, conduzida pelas definições de proteção do chamador, e o BeginDoc restaura essas definições depois da passagem de desencriptação, para que uma entrada encriptada não se transforme silenciosamente numa saída encriptada
A política dos contentores é lida do dicionário /Encrypt antes de se tentar qualquer palavra-passe. Para /V 1 e 2 todos os streams são encriptados com a chave do ficheiro. Para crypt filters, o HotPDF resolve o /StmF através de /CF: um filtro Identity ou um /CFM de None significa contentores em texto simples, enquanto V2 e AESV2 significam contentores encriptados. A resposta aterra em FReloadObjectStreamsEncrypted, e importa para um caso específico. Quando os contentores são texto simples mas as strings não, os membros transportam strings encriptadas que têm de ser desencriptadas individualmente, por isso o MaterializeMembersOfPlaintextObjectStreams expande cada membro compacto antes da passagem de desencriptação por objeto. Não faz nada quando a política ainda não é conhecida nem quando os próprios contentores foram encriptados, porque os membros de um contentor encriptado já foram desencriptados com ele e nunca podem ser desencriptados duas vezes
O que acontece quando um contentor não pode ser desencriptado?
Um contentor cuja desencriptação falha fica em quarentena, não é fatal. A passagem de tipo 2 regista uma entrada THPDFObjStmQuarantineInfo em FObjStmQuarantine com o número de objeto do contentor, um THPDFObjStmQuarantineReason, uma string de diagnóstico, e a lista dos números de objeto dos membros que a cross-reference tinha encaminhado para lá. O osqrDecryptFailed é levantado em quatro situações distintas: não se conseguiu resolver nenhum crypt filter, a desencriptação AES-256 ou AES-GCM levantou erro, a desencriptação RC4 ou AES-128 legada levantou erro, ou não existe nenhuma chave de ficheiro utilizável. Os contentores independentes continuam a carregar, pelo que um documento com um contentor danificado abre na mesma e desenha na mesma todas as páginas que não dependem dele
A lista de quarentena sobrevive ao fallback do parser. Se o carregamento primário da cross-reference falhar e o HotPDF reconstruir a tabela de objetos varrendo o ficheiro, o flag de encriptação da primeira tentativa pode não sobreviver a essa reconstrução, mas os registos de quarentena sobrevivem. É por isso que o BeginDoc verifica a lista de quarentena em vez do flag de encriptação: num documento carregado percorre FObjStmQuarantine e levanta erro na primeira entrada osqrDecryptFailed, nomeando o contentor e pedindo um recarregamento com uma palavra-passe válida. Uma reescrita que prosseguisse para lá desse ponto escreveria os membros que o contentor devia conter como objetos vazios e reportaria sucesso. Pode correr a mesma verificação por si, mais cedo e com a sua própria política, através dos acessores públicos:
var
Info: THPDFObjStmQuarantineInfo;
I: Integer;
begin
Pdf.LoadFromFile('vendor-form.pdf'); // palavra-passe de utilizador 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;
As outras razões de quarentena cobrem as falhas não criptográficas: um contentor que não é um stream, um dicionário em falta, um /N ou /First inválido, um tamanho de stream fora do intervalo aceite, uma falha de descompressão, um /First a apontar para lá dos dados, ou um corpo de membro que descodificou mas não fez parse. Vale a pena registá-las no momento da ingestão, já que cada uma nomeia exatamente os membros que lhe vão faltar a jusante
Porque é que uma reescrita precisa do token numérico original?
O HotPDF guarda todos os objetos numéricos como um 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 uma ida e volta por binário de 24 bits e um formatador genérico. Pior, um valor como 0.7 não é representável num Single de todo; é lido para o float mais próximo, e reformatar esse float pode produzir 0.69999999 ou um vizinho arredondado, conforme o ciclo 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 chega para falhar uma comparação de píxeis contra a origem e, em fronteiras de gradiente, chega para se ver
O THPDFNumericObject.RememberSourceToken resolve isto para o caso não modificado. O parser chama-o com o token em bruto logo depois de atribuir Value; o método só aceita tokens feitos de dígitos, no máximo um ponto decimal e um sinal inicial opcional, e guarda o token juntamente com o valor a que correspondia em FSourceValue. A propriedade SourceToken devolve o texto guardado apenas enquanto Value continuar igual a FSourceValue. Mude o número e o token evapora-se, pelo que um valor modificado passa sempre pela via de formatação existente e nunca emite texto obsoleto. O SaveNumericObject verifica primeiro o SourceToken e escreve-o tal e qual quando está presente, e só recorre aos ramos de inteiro, de referência a espaço de cor e de fracionário para números que foram criados ou editados em memória
O invariante é pequeno e vale a pena enunciá-lo com clareza: um número em que não tocou é escrito com os bytes com que foi lido, e um número em que tocou é escrito pelo próprio formatador do HotPDF. Os membros compactos beneficiam disto da mesma forma que os objetos do corpo do ficheiro, já que o EnsureCompressedObjectLoaded corre o mesmo parser sobre o recorte do membro. A formatação numérica em si, e a sua independência do locale do processo, é abordada em o artigo sobre formatação de números PDF invariante ao locale no HotPDF
Testar uma via de reescrita contra object streams
Três verificações apanham todas as falhas descritas acima, e nenhuma delas precisa do Acrobat. Primeiro, compare IndexedObjectCount com MaterializedObjectCount depois da gravação; numa reescrita completa têm de ser iguais, e qualquer diferença é um membro que foi largado. Segundo, extraia o texto e enumere a árvore de estrutura em ambos os ficheiros, não só desenhe as páginas, para que um ActualText perdido ou um elemento de estrutura perdido apareça como diff. Terceiro, carregue a saída com uma instância nova e afirme que GetLoadedQuarantinedObjStmCount é zero, o que também prova que o writer não produziu um contentor que o leitor não consegue abrir. As combinações de crypt filter que decidem o FReloadObjectStreamsEncrypted estão descritas em o 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á em o guia sobre object streams e atualizações incrementais
O carregamento lazy de membros, a passagem de expansão antes do writer, a quarentena de desencriptação e a preservação dos tokens de origem saem todos no HotPDF Delphi Component para Delphi e C++Builder. A página do produto liga a referência da API, caso queira seguir o GetLoadedObjectStreamCacheInfo e os acessores de quarentena contra o seu próprio pipeline de ingestão