Antes da v3.539.30, o TPDFlib.ImportAnnotationsFromFDFString da losLab PDF Library devolvia o número de entradas de anotações FDF que tinha interpretado sem adicionar nenhuma ao documento: cada entrada era contada, cada entrada era descartada. Desde a v3.539.30 que o importador FDF lê as chaves em qualquer ordem, interpreta o /Rect corretamente e sem depender da locale, e o exportador correspondente escreve o /Rect real da anotação, pelo que uma exportação, uma importação e uma segunda exportação produzem FDF byte a byte idêntico. O resto desta nota explica como um offset inicial errado produziu uma falha silenciosa perfeita, que outros três defeitos estavam escondidos atrás dele, e como verificar uma importação por si próprio em vez de confiar no valor de retorno
O cenário é banal. Um revisor anota um contrato, os comentários viajam num ficheiro FDF (o Acrobat chama-lhe Export Comments), e o seu serviço em Delphi funde-os numa cópia limpa com o ImportAnnotationsFromFDF. A chamada devolve 7, o log diz «7 comentários importados», o trabalho fica verde, e o PDF de saída não tem comentário nenhum. Nada levantado, nada avisado, e o número parecia plausível porque era a contagem verdadeira das entradas no ficheiro. Essa é a pior forma que um bug pode tomar: uma função cujo único sinal de sucesso é um contador calculado independentemente do trabalho que diz reportar
Porque é que o ImportAnnotationsFromFDFString reportava sucesso sem adicionar nada?
O importador lia todos os /Subtype como uma string vazia, e o helper que cria a anotação sai cedo perante um subtype vazio enquanto o chamador incrementa o resultado na mesma. O procurador de chaves devolvia a posição imediatamente a seguir a /Subtype, que é o espaço em branco antes do valor. O ReadName começava nesse espaço e parava no primeiro espaço em branco, por isso parava antes de ler qualquer coisa. O AddAnnotationToPage recusa construir uma anotação sem subtype, o que é a escolha defensiva certa isoladamente, mas era uma procedure sem valor de retorno, e o Inc(Result) estava fora dela. Cada guarda era razoável por si; em conjunto converteram «nada funcionava» em «tudo funcionava». A correção faz o ReadName saltar o espaço em branco, exigir a / inicial de um name object PDF e parar em qualquer delimitador, incluindo [, ( e ), por isso /Subtype/Text e /Subtype /Text dão ambos Text
O valor de retorno merecia cuidado mesmo depois dessa correção. Até à v3.539.39, o ImportAnnotationsFromFDFString continuava a incrementar o resultado por cada dicionário bem formado na matriz /Annots, incluindo entradas cujo /Page base zero estava fora do intervalo ou cujo /Subtype faltava, coisas ambas que são saltadas. Desde o PDFlibPas v3.539.40 que o ImportAnnotationsFromFDFString e o ImportAnnotationsFromFDF devolvem o número de anotações efetivamente adicionadas, como a importação XFDF: o helper FDF AddAnnotationToPage agora devolve um Boolean e o contador só anda em caso de sucesso. Medir o documento continua a ser a verificação mais forte, porque também vale nas versões antigas, por isso o esboço abaixo compara o AnnotationCount em cada página antes e depois da importação
function TotalAnnotations(Lib: TPDFlib): Integer;
var
Page, Saved: Integer;
begin
Result := 0;
Saved := Lib.SelectedPage;
for Page := 1 to Lib.PageCount do
if Lib.SelectPage(Page) = 1 then
Inc(Result, Lib.AnnotationCount); // por página selecionada, widgets incluídos
Lib.SelectPage(Saved);
end;
var
Lib: TPDFlib;
Before, Reported, Added: Integer;
begin
Lib := TPDFlib.Create;
try
Lib.LoadFromFile('contract.pdf', '');
Before := TotalAnnotations(Lib);
Reported := Lib.ImportAnnotationsFromFDF('review-comments.fdf');
Added := TotalAnnotations(Lib) - Before;
if Added <> Reported then // iguais desde a v3.539.40
Writeln(Format('Importer reported %d, %d landed on a page', [Reported, Added]));
Lib.SaveToFile('contract-reviewed.pdf');
finally
Lib.Free;
end;
end;
Mais três defeitos por trás do primeiro
Corrigir o subtype por si só teria exposto mais três bugs na mesma função, cada um deles invisível apenas porque anotação nenhuma chegava a uma página. Primeiro, o ReadNumber recebia a posição como parâmetro de valor, por isso ler os quatro números do /Rect em sequência lia o mesmo sítio quatro vezes, e não saltava o [ de abertura, por isso na prática não lia nada. Segundo, o FindKey partilhava um único cursor que só avançava por todas as procuras. O exportador escreve /Subtype, /Rect, /Page, /Contents, /T, /Subj, mas o importador procurava pela ordem /Subtype, /Contents, /T, /Subj, /Page, /Rect; mal o cursor passasse o /Contents, a procura por /Page e /Rect corria para além da entrada corrente e ou não encontrava nada ou casava com as chaves da anotação seguinte. A biblioteca não conseguia ler o próprio output. Terceiro, os números passavam pelo PLStrToFloat, que segue o separador decimal do sistema. A ISO 32000-1 §12.7.7 define o FDF como sintaxe de objetos PDF, e as chaves de dicionário em PDF não têm ordem (§7.3.7), por isso qualquer parser FDF que assuma uma ordem de chaves está errado por construção, seja qual for a ferramenta que produziu o ficheiro
O importador reparado limita primeiro cada entrada. O FindDictEnd caminha do << de abertura até ao >> que lhe corresponde, acompanhando os dicionários aninhados e saltando os corpos de strings literais com os seus escapes de barra invertida, por isso um >> dentro de um comentário como (see section >> 4) não pode acabar com a entrada cedo demais. Cada procura de chave começa então no início da própria entrada e fica limitada ao seu fim, o que torna a ordem das chaves irrelevante e impede uma anotação de pedir emprestado o /Page de outra. O casamento de chaves também aceita um delimitador logo a seguir ao nome, porque /Contents(Hi) é tão válido como /Contents (Hi), enquanto a regra da fronteira de palavra impede o /Subj de casar com o início do /Subtype e o /T de casar com /Type. O ReadNumber agora recebe a posição como parâmetro var, salta o espaço em branco e o [, e interpreta com o PLTryStrToFloatInvariant, que falha suavemente perante um token mal formado em vez de levantar exceção. Se qualquer um dos quatro números do retângulo falhar, os quatro recolhem a zero em vez de produzir um retângulo meio lido
Porque é que os round-trips FDF deslocavam cada anotação pela sua própria altura?
O exportador antigo escrevia um retângulo no modelo de coordenadas errado. O /Rect de uma anotação é [llx lly urx ury] no user space predefinido (ISO 32000-1 §12.5.2, com retângulos definidos em §7.9.5), e o FDF transporta a mesma matriz. O ExportAnnotationsToFDFString, porém, chamava o GetAnnotRectEx, que reporta Left, Top, Width e Height nas coordenadas de desenho da biblioteca, o espaço que o SetOrigin controla, e serializava-os como [L T L+W T+H]. O importador, logo que passou a funcionar, escrevia esses quatro valores de volta tal e qual como um retângulo PDF, por isso o topo ficava onde pertencia o canto inferior esquerdo e cada round trip subia a anotação pela sua própria altura. O exportador agora copia os números do próprio /Rect da anotação, três decimais, separador por ponto, sem expoente, e só recorre ao retângulo calculado quando a matriz guardada falta ou não tem quatro números
O teste de regressão que prende isto vale a pena copiar, porque afirma sobre o documento e sobre uma segunda exportação, não sobre o valor de retorno do importador. Repare na contagem esperada de 2: o AddNoteAnnotation cria uma anotação Text mais o seu Popup, e ambos viajam. O teste também corre a exportação e a importação sob um separador decimal por vírgula, que é onde mora a outra metade desta história
var
Source, Target: TPDFlib;
FDF: AnsiString;
OldSep: Char;
begin
Source := TPDFlib.Create;
Target := TPDFlib.Create;
try
Source.NewPages(1); // agora duas páginas
Source.SelectPage(2);
Source.AddNoteAnnotation(50.5, 60.25, 0, 80, 80, 120, 60,
'Reviewer', 'Check this', 0.25, 0.5, 0.75, 0);
Target.NewPages(1);
OldSep := FormatSettings.DecimalSeparator;
FormatSettings.DecimalSeparator := ','; // simular um desktop alemão ou francês
try
FDF := Source.ExportAnnotationsToFDFString; // continua a escrever /Rect [50.5 ...
Target.ImportAnnotationsFromFDFString(FDF);
finally
FormatSettings.DecimalSeparator := OldSep;
end;
Target.SelectPage(2);
Assert(Target.AnnotationCount = 2); // a nota e o seu popup
Assert(Target.GetAnnotType(1) = 'Text');
Assert(Target.ExportAnnotationsToFDFString = Source.ExportAnnotationsToFDFString);
finally
Target.Free;
Source.Free;
end;
end;
Seja claro quanto ao que o caminho FDF transporta. O importador reconstrói cada entrada como um dicionário com /Type, /Subtype, /Rect, /Contents, /T e /Subj; cor, flags, estilo de contorno, ligações de popup e appearance streams não fazem parte desta rota, e o exportador salta as anotações Widget porque os campos de formulário pertencem aos métodos de form-data. O mapa mais amplo de que dados viajam por que método está na visão geral de intercâmbio de dados de formulário FDF, XFDF e XFA, e se precisar de inspecionar o que efetivamente chegou, os leitores por índice como o GetAnnotType, o GetAnnotTitle e o GetAnnotContentsEx estão cobertos em introspeção de outlines, anotações e ações
Como ler ficheiros FDF e XFDF com decimais por vírgula de exportações antigas?
No FDF a resposta é inequívoca: uma vírgula não é um delimitador na sintaxe PDF, por isso um token numérico que contenha exatamente uma vírgula e nenhum ponto só pode ser um decimal escrito numa máquina com locale de vírgula. Versões anteriores escreviam de facto esses ficheiros, por exemplo /Rect [10,500 20,250 40,750 60,125], e o novo ReadNumber transforma essa vírgula única num ponto antes de interpretar. Um token com duas vírgulas, ou uma vírgula e um ponto, é recusado em vez de adivinhado. O leitor também não consome notação de expoente, o que está de acordo com a ISO 32000-1 §7.3.3: os números PDF nunca a usam
O XFDF é mais difícil, porque nos atributos XML a vírgula é o separador. O XFDF standard (ISO 19444-1) escreve rect="50.5,80.25,70.75,100.125" e dashes="4,2", enquanto a v3.539.28 e anteriores, num sistema com locale de vírgula, escreviam rect="50,500 80,250 70,750 100,125" e opacity="0,600", e falhavam igualmente com EConvertError ao ler um opacity="0.6" standard. Desde a v3.539.29 que ambos os sentidos são invariantes, e a forma legada é reconhecida pelo XFDFNormalizeLegacyDecimals apenas quando o atributo se divide por espaço em branco no número exato de tokens esperado (quatro para rect, um para opacity e width) e cada token tem a forma dígitos-vírgula-dígitos. Um rect standard nunca casa: ou é um token com três vírgulas, ou são tokens que acabam em vírgula. O dashes fica deliberadamente de fora, porque 4,2 podia ser dois comprimentos de traço ou um 4.2 legado, e regra nenhuma os distingue
const
// Chaves fora da ordem do exportador, mais decimais por vírgula de uma exportação legada
LegacyFDF: AnsiString = '%FDF-1.2'#10'1 0 obj'#10'<< /FDF << /Annots ['#10 +
'<< /Rect [10,500 20,250 40,750 60,125] /Page 0 /Contents (First) ' +
'/Subtype /Text /T (Alpha) /Type /Annot >>'#10 +
'] >> >>'#10'endobj'#10'trailer'#10'<< /Root 1 0 R >>'#10'%%EOF'#10;
var
Lib: TPDFlib;
begin
Lib := TPDFlib.Create; // um documento novo tem uma página
try
Lib.ImportAnnotationsFromFDFString(LegacyFDF);
Assert(Lib.AnnotationCount = 1);
Assert(Lib.GetAnnotTitle(1) = 'Alpha');
// Reexportado como XFDF com decimais por ponto: rect="10.500 20.250 40.750 60.125"
Writeln(Lib.ExportAnnotationsToXFDFString);
finally
Lib.Free;
end;
end;
O que deve um teste de importação de anotações afirmar de facto?
Um teste de importação útil afirma sobre o estado do documento de destino, nunca apenas sobre o que o importador diz de si. Nada na suite de testes verificava o AnnotationCount depois de uma importação FDF, e o valor de retorno, o único número que alguém olhava, era precisamente o número que o bug deixou intacto. Três asserções teriam apanhado todos os defeitos aqui descritos: a contagem de anotações na página esperada, um campo lido de volta pelo GetAnnotType ou pelo GetAnnotContentsEx, e uma segunda exportação comparada byte a byte com a primeira. A mesma disciplina vale para qualquer API que reescreva a estrutura do documento em bloco, incluindo a consolidação de campos descrita em fundir campos de formulário duplicados: verifique a árvore resultante, não um total devolvido. Os métodos de anotações FDF e XFDF, com as suas variantes de ficheiro e de string, vêm na losLab PDF Library for Delphi e C++Builder, e a v3.539.30 ou posterior é a versão a correr se os comentários têm de sobreviver à viagem, a v3.539.40 ou posterior se a contagem devolvida tem de corresponder ao que foi adicionado