Artigo Técnico

Importar anotações FDF no Delphi: corrigir o zero silencioso

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 ImportAnnotationsFromFDFString do PDFlibPas encontrava o /Subtype, iniciava o ReadName no espaço em branco depois da chave, de modo a devolver um nome vazio, o AddAnnotationToPage saía por falta de subtype, e o chamador incrementava o resultado na mesma, reportando sete comentários importados sem adicionar nenhum ao documento
Cada guarda era razoável isoladamente; em conjunto converteram nada funcionava em tudo funcionava, e é por isso que o valor de retorno nunca deve ser a única coisa que um teste de importação verifica

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

O FindDictEnd do PDFlibPas limita agora cada anotação FDF do << de abertura ao >> correspondente, por isso cada procura de chave recomeça no início da entrada e para no seu fim, e o ReadNumber recebe uma posição var, salta o parêntese reto e interpreta com o PLTryStrToFloatInvariant
O cursor partilhado não conseguia ler a própria exportação da biblioteca: mal passasse o /Contents, as procuras de /Page e /Rect caíam nas chaves da anotação seguinte, por isso a ordem das chaves deixou de poder fazer diferença

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 PDFlibPas serializava o /Rect do FDF como left, top, width, height nas coordenadas de desenho, por isso importar esses quatro números de volta como llx lly urx ury punha o topo onde pertencia o canto inferior esquerdo e subia cada anotação pela sua própria altura a cada round trip
O exportador agora copia os números do próprio /Rect da anotação — três decimais, separador por ponto, sem expoente — e o teste de regressão compara uma segunda exportação byte a byte com a primeira

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