A PDF Library for Delphi v3.539.18 e v3.539.20 corrigem duas formas de uma gravação de PDF que não muda nada conseguir corromper os metadados do documento: quando /CreationDate e /ModDate referenciavam o mesmo objeto string, a atualização automática do ModDate reescrevia ambos, e quando o objeto XMP era criado antes de o stream /Metadata original ser lido, um pacote predefinido substituía o original. As correções substituem referências de dicionário em vez de alterar objetos partilhados, e capturam o pacote existente antes da inicialização lazy do XMP
Gravar é a operação menos interessante que uma biblioteca de PDF faz: carregar um ficheiro, guardá-lo com outro nome, não tocar em nada pelo meio. As páginas desenhavam-se de forma idêntica antes e depois. Os hashes dos content streams coincidiam. O ficheiro passava todas as verificações que tínhamos, e continuava errado em dois sítios que nenhum renderizador lhe mostraria. Os dois defeitos estavam no caminho de leitura-modificação-escrita por onde passa qualquer edição real, pelo que qualquer gravação chegava para os acionar, e ambos só foram encontrados quando um segundo parser independente comparou a semântica não visual dos dois ficheiros
Porque é que gravar um PDF altera o seu CreationDate?
Porque o dicionário de informação do documento pode referenciar um único objeto string indireto a partir de duas chaves, e a biblioteca estava a atualizar 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 em /CreationDate tem de ser um objeto diferente do valor em /ModDate. Um produtor que escreveu o mesmo timestamp duas vezes no momento da criação pode, com toda a legalidade, apontar as duas chaves para um único 2728 0 R, que foi exatamente o que fez um documento de design CJK do nosso corpus local
O gatilho é a data de modificação automática. A menos que UserModDate esteja definido, o SaveToFile chama SetInfo('ModDate', ...) com a hora atual antes de escrever, o que chega ao SetRawInfo. O antigo SetRawInfo procurava o objeto sob a chave e, se encontrasse um TPDFString, chamava-lhe SetTo. Isso é uma escrita no local sobre o objeto para o qual a chave resolve naquele momento, e quando esse objeto é partilhado, o /CreationDate passa também a reportar a hora da gravação. O documento continua a abrir, a imprimir e a desenhar píxel a píxel como antes, por isso uma suite de regressão visual passa sem pestanejar
var
Lib: TPDFlib;
Before, After: WideString;
begin
Lib := TPDFlib.Create;
try
Lib.LoadFromFile('design.pdf', '');
Before := Lib.GetInformation(7); // 7 = CreationDate, 8 = ModDate
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 no 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 para o qual ela por acaso resolvia. O novo código lê o TPDFStringMode existente para que uma string hexadecimal continue hexadecimal e uma string literal continue literal, e depois acrescenta uma string nova a partir de FStructure.NewString(Value, StringMode) sob a chave. Dois outros detalhes importam tanto como a mudança principal. O ramo antigo para uma entrada com valor de stream limpava o stream com SetTo('') antes de o substituir, o que teria esvaziado o valor para todas as outras chaves que ainda apontassem para esse stream, por isso essa limpeza desapareceu. E o objeto substituído não é eliminado, porque a estrutura é dona dele e outras referências podem ainda precisar dele
// Antes: alterar o objeto para o qual a chave resolve naquele momento
if Obj is TPDFString then
TPDFString(Obj).SetTo(Value);
// Depois: manter a representação, substituir só a referência desta chave
StringMode := smLiteral;
if Obj is TPDFString then
StringMode := TPDFString(Obj).StringMode;
ID.Add(Key, FStructure.NewString(Value, StringMode));
A regressão em Tests\SharedInfoSemantics.inc constrói o alias de propósito em vez de depender de um ficheiro do corpus: uma string hexadecimal referenciada pelas duas chaves de data, uma string direta partilhada por /Title e /Subject, um stream partilhado por /Author e /Keywords. Depois de atualizar uma chave de cada par, a outra tem de continuar a ler o valor original e a string atualizada tem de continuar hexadecimal. A referência pública do SetInformation passa agora a enunciar a garantia numa frase: atualizar um campo do Info substitui apenas esse campo, mesmo quando outros campos referenciam o mesmo objeto
Porque é que um pacote XMP existente é substituído por valores predefinidos?
Por causa da ordem de duas linhas. O TPDFDocument.GetMetadata tem um caminho rápido: quando o campo XMP já está atribuído, devolve XMP.SaveToString em vez de descodificar o stream /Metadata do catálogo. Vários locais de chamada inicializavam de forma lazy com XMP := TPDFlibXMP.Create; XMP.LoadFromString(GetMetadata);, o que se lê com naturalidade e está errado: quando o GetMetadata corre, o XMP já está atribuído, pelo que a «fonte» que está a ser carregada é o pacote predefinido serializado de um objeto criado uma linha antes. O pacote original, com o seu dc:creator, namespaces personalizados e qualquer identificação de normas, nunca chega ao objeto e é sobrescrito na gravação. A mesma data de modificação automática chega para o acionar, porque o SetInfo inicializa o XMP antes de tocar no dicionário Info, para que o xmp:ModifyDate acompanhe o /ModDate. Repare no que este defeito esconde: a comparação do dicionário Info do primeiro bug passa, já que /Author e /Title em /Info ficam intactos. Só a árvore XMP mudou, e só uma verificação que faça o parse e compare essa árvore dá por isso
// Errado: GetMetadata serializa agora o objeto criado na linha anterior
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(GetMetadata);
// Certo: capturar primeiro o stream /Metadata e só depois criar e carregar
Source := GetMetadata;
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(Source);
A correção faz duas coisas. O TPDFDocument.EnsureXMP captura agora Source := GetMetadata antes de TPDFlibXMP.Create, e toda a inicialização lazy do documento foi substituída 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 reparação de metadados. Pontos de entrada públicos como o SetXMPProperty já passavam pelo EnsureXMP, e o GetXMPProperty lê através do GetDocumentMetadata, portanto toda a superfície partilha uma única ordem de inicialização. Uma cópia correta de uma sequência de três linhas vale mais do que dez cópias que por acaso concordam hoje
Duas armadilhas mais pequenas encontradas no mesmo caminho
O serializador XMP no Windows usa o escritor XML da plataforma, que emite uma declaração XML que o pacote não pode transportar. O código antigo removia-a apagando caracteres até chegar a <?xpacket. A ISO 16684-1 §7.3.2 torna o wrapper xpacket opcional, e um produtor que escreva um elemento <x:xmpmeta> puro está dentro da norma, por isso, num pacote desses, o ciclo apagava o documento inteiro, que era válido. O serializador localiza agora o ?> de fecho da declaração e remove apenas isso. O Tests\XMPRetentionSemantics.inc corre a sua verificação de retenção duas vezes, uma com o wrapper e outra com ele cortado, e afirma que um marcador de namespace personalizado e o autor original sobrevivem ao SetInfo, ao GetMetadata, ao SaveToString e a uma recarga. A segunda armadilha era um símbolo de pré-processador: a sincronização de Info para XMP no SetInfo estava protegida por NOVCL, que está definido nos builds Free Pascal, mas o backend XMP é condicionado pelo sistema operativo, não pelo framework, já que o PDFlibXMP.pas só define NO_XMP quando OS_WINDOWS está ausente. Um build Lazarus para Windows tinha portanto um objeto XMP a funcionar e um SetInfo que silenciosamente se esquecia de o atualizar. A proteção é agora NO_XMP, pelo que uma aplicação Free Pascal para Windows recebe a mesma sincronização que o Delphi
Como se mantém o ModDate original numa gravação de passagem?
Defina KeepModDate em TPDFlibSaveOptions e grave através do SaveToFileOptions. A opção define UserModDate durante a chamada, e o SaveToFile salta então o timestamp automático, que é também o passo que inicializa o objeto XMP de forma lazy. Um documento cujos metadados nunca tocou, e para o qual não foi ativado nenhum modo de conformidade, mantém o seu dicionário Info e o seu stream /Metadata tal como foram carregados. Chamar SetInformation(8, ...) tem o mesmo efeito de forma permanente, porque definir a data de modificação você mesmo marca-a como controlada pelo utilizador
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 quanto ao que isto lhe dá. O KeepModDate é a escolha certa para um passo de passagem cuja saída deve descrever a mesma revisão da entrada, e é a escolha errada para tudo o que edite conteúdo a sério, porque a §14.3.3 espera que o /ModDate reflita a modificação mais recente. Também não corrige retroativamente uma biblioteca que altera objetos partilhados; apenas evita a única escrita que expunha o defeito. As duas correções acima são o que torna uma gravação normal segura, e a opção é o que torna honesto um no-op deliberado
Como se verifica que uma gravação só mudou o ModDate?
Nem com píxeis nem com hashes de stream, porque os dois defeitos deixam todas as páginas e todos os content streams byte a byte idênticos. A verificação que os apanhou é um retrato semântico não visual feito por um parser independente, que não partilha código com a biblioteca em teste, a partir do ficheiro de origem e do ficheiro gravado, seguido de uma comparação estrutural. O retrato cobre o dicionário Info com o /ModDate excluído, a árvore de outline com cada marcador resolvido para um número de página em vez de um número de objeto, destinos com nome e alvos de links resolvidos da mesma forma, valores de campos de formulário, bytes de anexos como hashes, e o pacote XMP lido como árvore em vez de comparado como texto. Os números de objeto estão deliberadamente fora dele, já que uma reescrita completa renumera tudo e uma comparação assente neles reportaria ruído
As exclusões são tão importantes como as inclusões. /ModDate, xmp:ModifyDate e xmp:MetadataDate devem mudar e são descartados antes da comparação; um ficheiro cuja origem não tinha XMP nenhum 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 esse pacote ser válido face ao schema nem sobre o documento cumprir PDF/UA ou qualquer parte PDF/A. São perguntas separadas com ferramentas separadas, e confundir «os metadados sobreviveram» com «os metadados estão conformes» é como o primeiro bug se escondeu tanto tempo. Do lado da biblioteca, as duas regressões correm agora em cada passagem visada em Delphi Win32 e Win64 e Free Pascal Win32 e Win64, e a comparação semântica é condição de aprovação no benchmark do corpus de documentos reais
Se trabalha ao nível abaixo destas correções, a mecânica de como uma gravação reescreve objetos é abordada nas atualizações incrementais e na gravação em modo append, que é o único modo de gravação em que um objeto partilhado fica simplesmente onde estava, e nos níveis de modificação e no diff de revisões, que é o outro sítio onde uma data obsoleta ou reescrita engana um leitor. A vista do lado da reparação sobre o mesmo par Info e XMP, onde as duas metades são obrigadas a concordar em vez de meramente preservadas, está em converter para PDF/A e reparar metadados
A PDF Library for Delphi é uma biblioteca PDF em Pascal nativo para Delphi, C++Builder e Lazarus, e o caminho de leitura-modificação-escrita aqui descrito é o mesmo por onde passa cada edição no seu próprio processo, pelo que as garantias acima se aplicam quer grave uma vez quer mil vezes por dia — veja a página do produto PDF Library for Delphi para conhecer os compiladores e plataformas suportados