Artigo Técnico

Por que um save no-op de PDF pode corromper Info e XMP

Um save de PDF que não muda nada ainda assim pode corromper metadados do documento de duas maneiras: quando /CreationDate e /ModDate referenciam o mesmo objeto string, uma atualização automática de ModDate que escreve nesse objeto reescreve os dois, e quando o objeto XMP é criado antes da leitura do stream /Metadata original, um pacote padrão substitui o original. A PDF Library for Delphi substitui referências do dicionário em vez de mutar objetos compartilhados, e captura o pacote existente antes da inicialização preguiçosa do XMP

Salvar é a operação menos interessante que uma biblioteca de PDF executa: carregar um arquivo, salvá-lo com outro nome, não tocar em nada no meio do caminho. As páginas renderizam de forma idêntica antes e depois, e os hashes de content stream batem. Um arquivo pode passar por todas essas verificações e ainda estar errado em dois lugares que nenhum renderizador jamais mostraria para você. As duas armadilhas ficam no caminho read-modify-write por que passa toda edição de verdade, então qualquer save já basta para dispará-las, e só um segundo parser, independente, comparando a semântica não visual dos dois arquivos as revela

Por que salvar um PDF muda o CreationDate dele?

Porque o document information dictionary pode referenciar um mesmo objeto string indireto a partir de duas chaves, e um writer que atualiza o objeto em vez da chave muda os dois. A ISO 32000-1 §7.3.10 permite que qualquer valor de dicionário seja uma referência indireta, e nada na Tabela 317 do §14.3.3 diz que o valor sob /CreationDate precisa ser um objeto diferente do valor sob /ModDate. Um produtor que gravou o mesmo timestamp duas vezes na criação pode, perfeitamente dentro da lei, apontar as duas chaves para um único 2728 0 R, e arquivos reais fazem exatamente isso

O gatilho é a data de modificação automática. A menos que UserModDate esteja definido, o SaveToFile chama SetInfo('ModDate', ...) com o horário corrente antes de gravar, o que chega ao SetRawInfo. Um SetRawInfo ingênuo procura o objeto sob a chave e, se encontra um TPDFString, chama SetTo nele. Isso é uma escrita in-place no objeto em que a chave resolve naquele momento, e quando esse objeto é compartilhado, o /CreationDate passa a reportar o horário do salvamento também. O documento continua abrindo, imprimindo e renderizando pixel a pixel como antes, então uma suíte de regressão visual passa sem piscar

Mutação de string do Shared Info no PDFlibPas: /CreationDate e /ModDate referenciam legalmente um mesmo objeto string 2728 0 R, e um SetTo in-place no objeto em que a chave resolve reescreveria as duas datas com o horário do salvamento, então o SetRawInfo adiciona uma string nova sob a chave preservando o modo hex string
Atualizar uma entrada do dicionário substitui a referência da entrada em vez de mutar o objeto compartilhado, então uma única escrita automática de ModDate não consegue alterar o CreationDate, e o objeto superado fica preservado para outras referências
var
  Lib: TPDFlib;
  Before, After: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.LoadFromFile('design.pdf', '');
    Before := Lib.GetInformation(7);          // 7 = CreationDate (criação), 8 = ModDate (modificação)
    Lib.SaveToFile('design-resaved.pdf');
    Lib.LoadFromFile('design-resaved.pdf', '');
    After := Lib.GetInformation(7);
    if Before <> After then
      Log('a save that changed nothing rewrote CreationDate');
  finally
    Lib.Free;
  end;
end;

A regra no TPDFDocument.SetRawInfo é pequena e o princípio por trás dela é geral: atualizar uma entrada do dicionário substitui a referência da entrada, nunca o objeto em que ela resolveu por acaso. O código lê o TPDFStringMode existente para que uma string hex continue hex e uma string literal continue literal, e depois adiciona uma string nova de FStructure.NewString(Value, StringMode) sob a chave. Dois outros detalhes importam tanto. Uma entrada cujo valor é stream é substituída sem limpar o stream antigo antes, porque SetTo('') nela esvaziaria o valor para todas as outras chaves que ainda apontam para esse stream. E o objeto superado não é apagado, porque a estrutura é dona dele e outras referências podem ainda precisar dele

// Ingênuo: muta o objeto em que a chave resolve no momento
if Obj is TPDFString then
  TPDFString(Obj).SetTo(Value);

// Correto: mantém a representação e troca só a referência dessa chave
StringMode := smLiteral;
if Obj is TPDFString then
  StringMode := TPDFString(Obj).StringMode;
ID.Add(Key, FStructure.NewString(Value, StringMode));

Um teste para isso deve construir o aliasing de propósito, em vez de depender de um arquivo que por acaso compartilhe objetos: uma string hex referenciada pelas duas chaves de data, uma string direta compartilhada por /Title e /Subject, um stream compartilhado por /Author e /Keywords. Depois de atualizar uma chave de cada par, a outra precisa continuar lendo o valor original e a string atualizada precisa continuar hex. A referência pública do SetInformation afirma a garantia em uma frase: atualizar um campo do Info substitui só aquele campo, mesmo quando outros campos referenciam o mesmo objeto

Por que um pacote XMP existente é substituído pelos padrões?

Por causa da ordem de duas linhas. O TPDFDocument.GetMetadata tem um caminho rápido: quando o campo XMP já está atribuído, ele retorna XMP.SaveToString em vez de decodificar o stream /Metadata do catálogo. Uma inicialização preguiçosa escrita como XMP := TPDFlibXMP.Create; XMP.LoadFromString(GetMetadata); parece natural e está errada: quando o GetMetadata roda, o XMP já está atribuído, então a "fonte" sendo carregada é o pacote padrão serializado de um objeto criado uma linha antes. O pacote original, com o dc:creator dele, namespaces personalizados e qualquer identificação de standards, nunca chega ao objeto e é sobrescrito no save. A mesma data de modificação automática basta para dispará-lo, porque o SetInfo inicializa o XMP antes de tocar no dicionário Info para que xmp:ModifyDate acompanhe o /ModDate. Note atrás do que essa armadilha se esconde: a comparação do dicionário Info do primeiro defeito passa, já que /Author e /Title no /Info ficam intocados. Só a árvore XMP mudou, e só uma verificação que interpreta e compara essa árvore percebe

Ordem de inicialização preguiçosa do XMP no PDFlibPas: criar o objeto XMP antes de chamar GetMetadata faz o caminho rápido serializar um pacote padrão e descartar dc:creator, namespaces personalizados e a identificação de standards, enquanto capturar Source antes de TPDFlibXMP.Create carrega o stream /Metadata original do catálogo
Qualquer save pode disparar a troca, porque o SetInfo inicializa o XMP para manter xmp:ModifyDate em sincronia com /ModDate, então toda inicialização preguiçosa no documento passa por um único EnsureXMP que captura o pacote existente antes de criar o objeto
// Errado: GetMetadata agora serializa o objeto criado na linha anterior
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(GetMetadata);

// Certo: capture primeiro o stream /Metadata, depois crie e carregue
Source := GetMetadata;
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(Source);

A PDF Library for Delphi mantém essa ordem em um único lugar. O TPDFDocument.EnsureXMP captura Source := GetMetadata antes do TPDFlibXMP.Create, e toda inicialização preguiçosa no documento passa por ele: SetInfo, SetXMPInformation, GetXMPInformation, os setters de modo PDF/A, PDF/X, PDF/E, PDF/VT, PDF/VCR e PDF/UA, e o caminho de reparo de metadados. Pontos de entrada públicos como o SetXMPProperty também passam pelo EnsureXMP, e o GetXMPProperty lê via GetDocumentMetadata, então toda a superfície compartilha uma única ordem de inicialização. Uma única cópia correta de uma sequência de três linhas vale mais que dez cópias que por acaso estão de acordo hoje

Duas armadilhas menores no mesmo caminho

O serializador XMP no Windows usa o writer XML da plataforma, que emite uma declaração XML que o pacote não deve carregar. O jeito óbvio de removê-la, apagar caracteres até chegar ao <?xpacket, falha em entrada válida: a ISO 16684-1 §7.3.2 torna o wrapper xpacket opcional, e um produtor que grava um elemento <x:xmpmeta> puro está dentro do padrão, então em tal pacote esse laço apaga o documento inteiro e válido. O serializador localiza o ?> de fechamento da declaração e remove só isso. Uma verificação de retenção que vale a pena copiar roda duas vezes, uma com o wrapper e outra sem ele, e afirma que um marcador de namespace personalizado e o autor original sobrevivem a SetInfo, GetMetadata, SaveToString e a um reload. A segunda armadilha é um símbolo de preprocessor. NOVCL é definido em builds Free Pascal, mas o backend de XMP é controlado pelo sistema operacional, não pelo framework, já que o PDFlibXMP.pas define NO_XMP apenas quando OS_WINDOWS está ausente. Proteger a sincronização Info-para-XMP no SetInfo com NOVCL deixaria um build Lazarus no Windows com um objeto XMP funcional e um SetInfo que silenciosamente pula a atualização dele. O guard é o NO_XMP, então uma aplicação Free Pascal no Windows recebe a mesma sincronização que o Delphi

Como manter o ModDate original num save de pass-through?

Defina KeepModDate em TPDFlibSaveOptions e salve via SaveToFileOptions. A opção seta UserModDate durante a chamada, e o SaveToFile então pula o timestamp automático, que é também o passo que inicializa o objeto XMP de forma lazy. Um documento cujos metadados você nunca tocou, e para o qual nenhum modo de conformidade foi habilitado, mantém tanto seu Info dictionary quanto seu stream /Metadata como carregados. Chamar SetInformation(8, ...) tem o mesmo efeito de forma permanente, porque definir a data de modificação você mesmo a marca como controlada pelo usuário

var
  Options: TPDFlibSaveOptions;
begin
  FillChar(Options, SizeOf(Options), 0);
  Options.OptimizeContentStreams := True;
  Options.PackObjectStreams := True;
  Options.KeepModDate := True;      // sem /ModDate automático, sem init lazy do XMP
  if Lib.SaveToFileOptions('design-resaved.pdf', Options) <> 1 then
    Log(Format('save failed, LastErrorCode=%d', [Lib.LastErrorCode]));
end;

Seja honesto sobre o que isso compra. O KeepModDate é a escolha certa para um passo de pass-through cuja saída deve descrever a mesma revisão que a entrada, e é a escolha errada para qualquer coisa que edita conteúdo de verdade, porque o §14.3.3 espera que o /ModDate reflita a modificação mais recente. Ele também não substitui o tratamento correto de referências, já que evita apenas a única escrita que dispara a atualização automática. Substituir referências em vez de mutar objetos compartilhados, e capturar o pacote antes da inicialização, é o que torna um save comum seguro; a opção é o que torna um no-op deliberado honesto

Como verificar que um save não mudou nada além do ModDate?

Nem com pixels nem com hashes de stream, porque as duas armadilhas deixam toda página e todo content stream byte a byte idênticos. A verificação que as pega é um snapshot semântico não visual feito por um parser independente, que não compartilha código com a biblioteca sob teste, do arquivo de origem e do arquivo salvo, seguido de uma comparação estrutural. O snapshot cobre o dicionário Info com /ModDate excluído, a árvore de outline com cada bookmark resolvido para um número de página em vez de número de objeto, destinos nomeados e alvos de links resolvidos da mesma forma, valores de campos de formulário, bytes de anexos como hashes, e o pacote XMP interpretado como árvore em vez de comparado como texto. Números de objeto ficam de fora de propósito, já que uma reescrita completa renumera tudo e uma comparação baseada neles reportaria ruído

Verificação semântica não visual de saves no PDFlibPas: um parser independente, sem código compartilhado, tira um snapshot do dicionário Info sem /ModDate, páginas de outline e destinos, valores de campos de formulário, hashes de anexos e a árvore XMP, e compara origem e arquivo salvo descartando /ModDate, xmp:ModifyDate e xmp:MetadataDate como mudanças esperadas
Pixels e hashes de stream continuam byte a byte idênticos nas duas armadilhas, então a comparação trabalha sobre semântica resolvida em vez de números de objeto, e o metadado sobrevivente é relatado honestamente como retido, não como válido no schema ou em conformidade com PDF/UA e PDF/A

As exclusões importam tanto quanto as inclusões. /ModDate, xmp:ModifyDate e xmp:MetadataDate devem mudar e são descartados antes da comparação; um arquivo cuja origem não tinha XMP algum não é penalizado por ganhar um pacote. O que a verificação não afirma é igualmente explícito: reter um pacote existente não diz nada sobre se esse pacote é válido no schema ou se o documento atende PDF/UA ou qualquer parte do PDF/A. São perguntas separadas com ferramentas separadas, e confundir "o metadado sobreviveu" com "o metadado está em conformidade" é como uma reescrita de data compartilhada pode ficar escondida por muito tempo

Se você trabalha no nível abaixo dessas regras, a mecânica de como um save reescreve objetos está coberta em atualizações incrementais e salvamento append-only, que é o único modo de save em que um objeto compartilhado simplesmente fica onde estava, e em níveis de modificação e diff de revisões, que é o outro lugar em que uma data velha ou reescrita engana o leitor. A visão do lado do reparo desse mesmo par Info e XMP, em que as duas metades são feitas para concordar em vez de apenas preservadas, está em conversão para PDF/A e reparo de metadados

A PDF Library for Delphi é uma biblioteca PDF nativa em Pascal para Delphi, C++Builder e Lazarus, e o caminho de read-modify-write descrito aqui é o mesmo pelo qual passa toda edição no seu próprio processo, então as garantias acima valem tanto se você salva uma vez quanto mil vezes por dia — veja a página do produto PDF Library for Delphi para os compiladores e plataformas suportados