A PDF Library for Delphi v3.539.18 e v3.539.20 corrige duas maneiras pelas quais um save de PDF que não muda nada ainda podia corromper os metadados do documento: quando /CreationDate e /ModDate referenciavam o mesmo objeto string, a atualização automática de ModDate reescrevia os dois, e quando o objeto XMP era criado antes de o stream /Metadata original ser lido, um pacote padrão substituía o original. As correções substituem referências de dicionário em vez de mutar objetos compartilhados, e capturam o pacote existente antes da inicialização lazy do XMP
O cenário é a operação menos interessante que uma biblioteca de PDF faz: carregar um arquivo, salvar com outro nome, não tocar em nada no meio. As páginas renderizavam idênticas antes e depois. Os hashes dos content streams batiam. O arquivo passava em toda verificação que tínhamos, e ainda assim estava errado em dois lugares que nenhum renderer jamais te mostraria. Os dois defeitos ficavam no caminho de read-modify-write pelo qual passa toda edição de verdade, então qualquer save já bastava para disparar — e ambos só apareceram quando um segundo parser, independente, comparou a semântica não visual dos dois arquivos
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 a biblioteca estava atualizando o objeto em vez da chave. A ISO 32000-1 §7.3.10 permite que qualquer valor de dicionário seja uma referência indireta, e nada na §14.3.3 Tabela 317 diz que o valor sob /CreationDate precisa ser um objeto diferente do valor sob /ModDate. Um produtor que escreveu o mesmo timestamp duas vezes na criação pode, perfeitamente dentro da lei, apontar as duas chaves para um único 2728 0 R — que é exatamente o que um documento de design em CJK do nosso corpus local fazia
O gatilho é a data de modificação automática. A menos que UserModDate esteja setado, SaveToFile chama SetInfo('ModDate', ...) com a hora atual antes de escrever, o que chega em SetRawInfo. O antigo SetRawInfo procurava o objeto sob a chave e, se encontrasse um TPDFString, chamava SetTo nele. Isso é uma escrita in-place em qualquer objeto que a chave resolva naquele momento, e quando esse objeto é compartilhado, o /CreationDate agora também reporta a hora do save. O documento continua abrindo, imprimindo e renderizando pixel a pixel como antes, então uma suíte de regressão visual passa sem piscar
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 correção em TPDFDocument.SetRawInfo é pequena e o princípio por trás dela é geral: atualizar uma entrada de dicionário substitui a referência dessa entrada, nunca o objeto que ela por acaso resolvia. O código novo lê o TPDFStringMode existente para que uma string hex continue hex e uma string literal continue literal, e então adiciona uma string nova de FStructure.NewString(Value, StringMode) sob a chave. Dois outros detalhes importam tanto quanto a mudança principal. O ramo antigo para uma entrada com valor de stream limpava o stream com SetTo('') antes de substituí-lo, o que teria esvaziado o valor para toda outra chave que ainda apontasse para aquele stream, então essa limpeza foi removida. E o objeto substituído não é apagado, porque a estrutura é dona dele e outras referências ainda podem precisar dele
// Antes: mutava qualquer objeto que a chave resolvesse naquele momento
if Obj is TPDFString then
TPDFString(Obj).SetTo(Value);
// Depois: mantém a representação e substitui só a referência desta chave
StringMode := smLiteral;
if Obj is TPDFString then
StringMode := TPDFString(Obj).StringMode;
ID.Add(Key, FStructure.NewString(Value, StringMode));
O teste de regressão em Tests\SharedInfoSemantics.inc constrói o alias de propósito, em vez de depender de um arquivo de corpus: 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 de SetInformation agora enuncia a garantia numa frase: atualizar um campo do Info substitui apenas 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 devolve XMP.SaveToString em vez de decodificar o stream /Metadata do catalog. Vários call sites inicializavam de forma lazy com XMP := TPDFlibXMP.Create; XMP.LoadFromString(GetMetadata);, o que se lê naturalmente e está errado: quando GetMetadata roda, o XMP já está atribuído, então a "fonte" que está sendo carregada é o pacote padrão serializado de um objeto criado uma linha antes. O pacote original, com seu dc:creator, namespaces customizados e qualquer identificação de padrão, nunca chega ao objeto e é sobrescrito no save. A mesma data de modificação automática já basta para disparar isso, porque SetInfo inicializa o XMP antes de tocar no Info dictionary, para que xmp:ModifyDate ande junto com /ModDate. Repare no que esse defeito se esconde: a comparação do Info dictionary do primeiro bug passa, já que /Author e /Title em /Info ficam intocados. Só a árvore XMP mudou, e só uma checagem que faça parse e compare essa árvore percebe
// 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 correção faz duas coisas. O TPDFDocument.EnsureXMP agora captura Source := GetMetadata antes de TPDFlibXMP.Create, e toda inicialização lazy no documento foi trocada por uma chamada a 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 SetXMPProperty já passavam pelo EnsureXMP, e GetXMPProperty lê via GetDocumentMetadata, então a superfície inteira compartilha uma única ordem de inicialização. Uma cópia correta de uma sequência de três linhas vale mais que dez cópias que por acaso concordam hoje
Duas armadilhas menores encontradas 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 pode carregar. O código antigo a removia apagando caracteres até chegar em <?xpacket. A ISO 16684-1 §7.3.2 torna o wrapper xpacket opcional, e um produtor que escreve um elemento <x:xmpmeta> puro está dentro do padrão, então num pacote desses o loop apagava o documento inteiro, que era válido. O serializador agora localiza o ?> de fechamento da declaração e remove só ele. O Tests\XMPRetentionSemantics.inc roda sua checagem de retenção duas vezes, uma com o wrapper e outra sem ele, e verifica que um marcador de namespace customizado e o autor original sobrevivem a SetInfo, GetMetadata, SaveToString e um reload. A segunda armadilha era um símbolo de preprocessador: a sincronização Info-para-XMP em SetInfo estava guardada por NOVCL, que é definido em builds Free Pascal, mas o backend XMP é condicionado pelo sistema operacional, não pelo framework, já que PDFlibXMP.pas define NO_XMP só quando OS_WINDOWS está ausente. Um build Lazarus para Windows portanto tinha um objeto XMP funcionando e um SetInfo que pulava a atualização dele em silêncio. A guarda agora é NO_XMP, então uma aplicação Free Pascal para Windows ganha 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 te dá. O KeepModDate é a escolha certa para um passo de pass-through cuja saída deve descrever a mesma revisão da entrada, e é a escolha errada para qualquer coisa que de fato edite conteúdo, porque a §14.3.3 espera que /ModDate reflita a modificação mais recente. Ele também não corrige retroativamente uma biblioteca que muta objetos compartilhados; só evita a única escrita que expunha o defeito. As duas correções acima são o que torna um save comum seguro, e 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 os dois defeitos deixam toda página e todo content stream byte a byte idênticos. A checagem que os pegou é um snapshot semântico não visual feito por um parser independente, que não compartilha código com a biblioteca testada, do arquivo de origem e do arquivo salvo, seguido de uma comparação estrutural. O snapshot cobre o Info dictionary com /ModDate excluído, a árvore de outline com cada bookmark resolvido para um número de página em vez de um número de objeto, named destinations e destinos de link resolvidos do mesmo jeito, valores de campos de formulário, bytes de anexos como hashes, e o pacote XMP lido como árvore em vez de comparado como texto. Números de objeto deliberadamente não fazem parte disso, já que uma reescrita completa renumera tudo e uma comparação baseada neles reportaria ruído
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 trazia XMP nenhum não é penalizado por ganhar um pacote. O que a checagem não afirma é igualmente explícito: reter um pacote existente não diz nada sobre se esse pacote é schema-valid ou se o documento atende ao PDF/UA ou a qualquer parte do PDF/A. São perguntas separadas, com ferramentas separadas, e confundir "os metadados sobreviveram" com "os metadados estão em conformidade" é como o primeiro bug se escondeu por tanto tempo. Do lado da biblioteca, as duas regressões agora rodam em toda passada de alvo, em Delphi Win32 e Win64 e Free Pascal Win32 e Win64, e a comparação semântica é condição de aprovação para o benchmark de corpus de documentos reais
Se você trabalha no nível abaixo dessas correções, a mecânica de como um save reescreve objetos está em incremental updates e save 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ão, que é o outro lugar onde uma data obsoleta ou reescrita engana o leitor. A visão do lado do reparo do mesmo par Info e XMP, em que as duas metades são feitas para concordar em vez de apenas preservadas, está em converter para PDF/A e reparar 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