Antes da v3.539.30, o TPDFlib.ImportAnnotationsFromFDFString na losLab PDF Library retornava o número de entradas de anotações FDF que tinha interpretado sem adicionar nenhuma delas ao documento: toda entrada era contada, toda entrada era descartada. Desde a v3.539.30 o importador de FDF lê as chaves em qualquer ordem, interpreta a /Rect corretamente e de forma independente de locale, e o exportador correspondente grava a /Rect real da anotação, então um export, um import e um segundo export produzem FDF idêntico byte a byte. O resto desta nota explica como um offset inicial errado produziu uma falha silenciosa perfeita, quais três outros defeitos estavam escondidos atrás dele, e como verificar uma importação você mesmo em vez de confiar no valor de retorno
O cenário é banal. Um revisor marca um contrato, os comentários viajam como um arquivo FDF (o Acrobat chama de Export Comments), e seu serviço em Delphi os mescla numa cópia limpa com ImportAnnotationsFromFDF. A chamada retorna 7, o log diz "7 comments imported", o job fica verde, e o PDF de saída não tem comentário nenhum. Nada levantou exceção, nada avisou, e o número parecia plausível porque era a contagem real de entradas no arquivo. Essa é a pior forma que um bug pode tomar: uma função cujo único sinal de sucesso é um contador calculado independentemente do trabalho que ele alega reportar
Por que o ImportAnnotationsFromFDFString reportava sucesso sem adicionar nada?
O importador lia todo /Subtype como string vazia, e o helper que cria a anotação sai cedo com um subtype vazio enquanto o chamador incrementa o resultado mesmo assim. O localizador de chaves retornava a posição imediatamente depois de /Subtype, que é o whitespace antes do valor. O ReadName começava nesse espaço e parava no primeiro caractere de whitespace, então parava antes de ler qualquer coisa. O AddAnnotationToPage recusa construir uma anotação sem subtype, o que é a escolha defensiva certa isoladamente, mas ele era uma procedure sem valor de retorno, e o Inc(Result) ficava fora dele. Cada guarda era razoável por si só; juntas, elas convertiam "nada funcionou" em "tudo funcionou". O fix faz o ReadName pular whitespace, exigir a / inicial de um name object do PDF e parar em qualquer delimitador, incluindo [, ( e ), então /Subtype/Text e /Subtype /Text os dois produzem Text
O valor de retorno merecia cuidado mesmo depois desse fix. Até a v3.539.39, o ImportAnnotationsFromFDFString ainda incrementava o resultado dele para todo dicionário bem formado no array /Annots, incluindo entradas cujo /Page 0-based estava fora do intervalo ou cujo /Subtype faltava, ambos casos pulados. Desde a PDFlibPas v3.539.40, o ImportAnnotationsFromFDFString e o ImportAnnotationsFromFDF retornam o número de anotações de fato adicionadas, como o import de XFDF: o helper de FDF AddAnnotationToPage agora retorna um Boolean e o contador só se move no sucesso. Medir o documento continua sendo a checagem mais forte, porque também vale em versões antigas, então o esboço abaixo compara o AnnotationCount de 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 atrás do primeiro
Corrigir só o subtype teria exposto mais três bugs na mesma função, cada um invisível até então apenas porque anotação nenhuma jamais chegou a uma página. Primeiro, o ReadNumber recebia a posição dele como parâmetro de valor, então ler os quatro números de /Rect em sequência lia o mesmo lugar quatro vezes, e ele não pulava o [ de abertura, então na prática não lia nada mesmo. Segundo, o FindKey compartilhava um cursor que só avança entre todas as buscas. O exportador grava /Subtype, /Rect, /Page, /Contents, /T, /Subj, mas o importador buscava na ordem /Subtype, /Contents, /T, /Subj, /Page, /Rect; quando o cursor já tinha passado o /Contents, a busca por /Page e /Rect corria além da entrada atual e ou não encontrava nada ou casava com as chaves da anotação seguinte. A biblioteca não conseguia ler a própria saída dela. Terceiro, os números passavam pelo PLStrToFloat, que segue o separador decimal do sistema. A ISO 32000-1 §12.7.7 define FDF como sintaxe de objetos PDF, e chaves de dicionário em PDF são sem ordem (§7.3.7), então qualquer parser de FDF que assuma uma ordem de chaves está errado por construção, não importa qual ferramenta tenha produzido o arquivo
O importador reparado delimita cada entrada primeiro. O FindDictEnd caminha do << de abertura até o >> correspondente, acompanhando dicionários aninhados e pulando corpos de literal string com os escapes de barra invertida deles, então um >> dentro de um comentário como (see section >> 4) não pode encerrar a entrada cedo demais. Toda busca de chave então começa no início da própria entrada e fica limitada ao fim dela, o que torna a ordem das chaves irrelevante e impede uma anotação de pegar emprestado o /Page de outra. O match de chave também aceita um delimitador direto depois do nome, porque /Contents(Hi) é tão válido quanto /Contents (Hi), enquanto a regra de fronteira de palavra impede o /Subj de casar com o começo de /Subtype e o /T de casar com /Type. O ReadNumber agora recebe a posição dele como parâmetro var, pula whitespace e [, e interpreta com PLTryStrToFloatInvariant, que falha suave num token malformado em vez de levantar exceção. Se qualquer um dos quatro números do retângulo falha, os quatro caem para zero em vez de produzir um retângulo lido pela metade
Por que os round trips de FDF deslocavam cada anotação pela própria altura dela?
O exportador antigo gravava um retângulo no modelo de coordenadas errado. A /Rect de uma anotação é [llx lly urx ury] no user space default (ISO 32000-1 §12.5.2, com retângulos definidos no §7.9.5), e o FDF carrega o mesmo array. 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 quatro como [L T L+W T+H]. O importador, quando passou a funcionar, gravava esses quatro valores de volta literalmente como um retângulo PDF, então a borda de cima caía onde pertencia o canto inferior esquerdo e todo round trip subia a anotação pela própria altura dela. O exportador agora copia os próprios números de /Rect da anotação, três decimais, separador ponto, sem expoente, e cai no retângulo calculado só quando o array armazenado falta ou não tem quatro números
O teste de regressão que trava isso vale a pena copiar, porque ele faz asserções sobre o documento e sobre um segundo export, não sobre o valor de retorno do importador. Note a contagem esperada de 2: o AddNoteAnnotation cria uma anotação Text mais o Popup dela, e os dois viajam. O teste também roda o export e o import com separador decimal 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 são 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 := ','; // simula um desktop alemão ou francês
try
FDF := Source.ExportAnnotationsToFDFString; // ainda grava /Rect [50.5 ...
Target.ImportAnnotationsFromFDFString(FDF);
finally
FormatSettings.DecimalSeparator := OldSep;
end;
Target.SelectPage(2);
Assert(Target.AnnotationCount = 2); // a nota e o popup dela
Assert(Target.GetAnnotType(1) = 'Text');
Assert(Target.ExportAnnotationsToFDFString = Source.ExportAnnotationsToFDFString);
finally
Target.Free;
Source.Free;
end;
end;
Esteja claro sobre o que o caminho FDF carrega. O importador reconstrói cada entrada como um dicionário com /Type, /Subtype, /Rect, /Contents, /T e /Subj; cor, flags, estilo de borda, links de popup e appearance streams não fazem parte dessa rota, e o exportador pula anotações Widget porque form fields pertencem aos métodos de form data. O mapa mais amplo de quais dados viajam por qual método está na visão geral do intercâmbio de dados de formulário FDF, XFDF e XFA, e se você precisa inspecionar o que de fato chegou, os leitores por índice como GetAnnotType, GetAnnotTitle e GetAnnotContentsEx são cobertos em introspecção de outline, anotações e actions
Como ler arquivos FDF e XFDF com decimal vírgula vindos de exports antigos?
Para FDF a resposta é inequívoca: vírgula não é delimitador na sintaxe PDF, então um token de número que contém exatamente uma vírgula e nenhum ponto só pode ser um decimal escrito numa máquina com locale de vírgula. Versões antigas gravavam arquivos assim, por exemplo /Rect [10,500 20,250 40,750 60,125], e o novo ReadNumber transforma essa vírgula única em ponto antes de interpretar. Um token com duas vírgulas, ou com vírgula e ponto, é rejeitado em vez de chutado. O leitor também não consome notação de expoente, o que bate com a ISO 32000-1 §7.3.3: números de PDF nunca a usam
XFDF é mais difícil, porque em atributos XML a vírgula é o separador. O XFDF padrão (ISO 19444-1) grava 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, gravavam rect="50,500 80,250 70,750 100,125" e opacity="0,600", e ainda falhavam com EConvertError ao ler um opacity="0.6" padrão. Desde a v3.539.29 os dois sentidos são invariantes, e a forma legada é reconhecida pelo XFDFNormalizeLegacyDecimals só quando o atributo se divide por whitespace em exatamente o número esperado de tokens (quatro para rect, um para opacity e width) e todo token tem a forma dígitos-vírgula-dígitos. Um rect padrão nunca casa: ou é um token com três vírgulas, ou são tokens que terminam em vírgula. O dashes é deixado de lado de propósito, porque 4,2 pode ser dois comprimentos de tracejo ou um 4.2 legado, e regra nenhuma distingue os dois
const
// Chaves fora da ordem do exportador, mais decimais com vírgula de um export antigo
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 de ponto: rect="10.500 20.250 40.750 60.125"
Writeln(Lib.ExportAnnotationsToXFDFString);
finally
Lib.Free;
end;
end;
O que um teste de importação de anotações deve afirmar de fato?
Um teste de importação útil faz asserções sobre o estado do documento alvo, nunca só sobre o que o importador diz de si mesmo. Nada na suíte de testes conferia o AnnotationCount depois de um import de FDF, e o valor de retorno, o único número que alguém olhava, era justamente o número que o bug deixou intacto. Três asserções teriam pegado cada defeito descrito aqui: a contagem de anotações na página esperada, um campo lido de volta pelo GetAnnotType ou pelo GetAnnotContentsEx, e um segundo export comparado byte a byte com o primeiro. A mesma disciplina vale para qualquer API que reescreve a estrutura do documento em massa, incluindo a consolidação de campos descrita em mesclar form fields duplicados: confira a árvore resultante, não um total retornado. Os métodos de anotações FDF e XFDF, com as variantes de arquivo e de string deles, vêm na losLab PDF Library for Delphi e C++Builder, e a v3.539.30 ou mais recente é a versão a rodar se comentários precisam sobreviver à viagem, v3.539.40 ou mais recente se a contagem retornada precisa bater com o que foi adicionado