Articolo tecnico

Import FDF delle annotazioni in Delphi: lo zero silenzioso

Prima della v3.539.30, TPDFlib.ImportAnnotationsFromFDFString in losLab PDF Library restituiva il numero di voci di annotazione FDF che aveva analizzato senza aggiungerne nessuna al documento: ogni voce veniva contata, ogni voce veniva scartata. Dalla v3.539.30 l'importer FDF legge le chiavi in qualsiasi ordine, analizza /Rect in modo corretto e indipendente dal locale, e l'exporter corrispondente scrive il vero /Rect dell'annotazione, così un export, un import e un secondo export producono un FDF identico byte per byte. Il resto di questa nota spiega come un singolo offset di partenza sbagliato abbia prodotto un fallimento silenzioso perfetto, quali altri tre difetti si nascondessero dietro, e come controllare un import da te invece di fidarti del valore di ritorno

Lo scenario è ordinario. Un revisore annota un contratto, i commenti viaggiano come file FDF (Acrobat lo chiama Export Comments), e il tuo servizio Delphi li fonde in una copia pulita con ImportAnnotationsFromFDF. La chiamata restituisce 7, il log dice «7 comments imported», il job diventa verde, e il PDF di output non ha alcun commento. Niente eccezioni, niente avvisi, e il numero sembrava plausibile perché era il conteggio vero delle voci nel file. È la forma peggiore che un bug possa assumere: una funzione il cui unico segnale di successo è un contatore calcolato indipendentemente dal lavoro che dichiara di riportare

Perché ImportAnnotationsFromFDFString riportava successo senza aggiungere nulla?

L'importer leggeva ogni /Subtype come stringa vuota, e l'helper che crea l'annotazione usciva anticipatamente su un subtype vuoto mentre il chiamante incrementava comunque il risultato. Il cercatore di chiavi restituiva la posizione immediatamente dopo /Subtype, cioè lo spazio bianco prima del valore. ReadName partiva da quello spazio e si fermava al primo carattere di spazio bianco, quindi si fermava prima di leggere qualsiasi cosa. AddAnnotationToPage rifiuta di costruire un'annotazione senza subtype, che da sola è la scelta difensiva corretta, ma era una procedura senza valore di ritorno, e Inc(Result) stava fuori da essa. Ogni guardia era ragionevole presa da sola; insieme convertivano «non funzionava nulla» in «funzionava tutto». La correzione fa sì che ReadName salti gli spazi bianchi, richieda la / iniziale di un name object PDF e si fermi a qualsiasi delimitatore, compresi [, ( e ), così /Subtype/Text e /Subtype /Text producono entrambi Text

PDFlibPas ImportAnnotationsFromFDFString trovava /Subtype, avviava ReadName sullo spazio bianco dopo la chiave così restituiva un nome vuoto, AddAnnotationToPage usciva per il subtype mancante, e il chiamante incrementava comunque il risultato, riportando sette commenti importati senza aggiungerne nessuno al documento
Ogni guardia era ragionevole presa da sola; insieme convertivano non funzionava nulla in funzionava tutto, ed è per questo che il valore di ritorno non deve mai essere l'unica cosa che un test di import controlla

Il valore di ritorno meritava attenzione anche dopo quella correzione. Fino alla v3.539.39, ImportAnnotationsFromFDFString incrementava comunque il risultato per ogni dizionario ben formato nell'array /Annots, comprese le voci il cui /Page a base zero era fuori intervallo o il cui /Subtype era assente, e entrambe le categorie vengono saltate. Dalla PDFlibPas v3.539.40, ImportAnnotationsFromFDFString e ImportAnnotationsFromFDF restituiscono il numero di annotazioni realmente aggiunte, come l'import XFDF: l'helper FDF AddAnnotationToPage ora restituisce un Boolean e il contatore si muove solo in caso di successo. Misurare il documento resta il controllo più robusto, perché vale anche su versioni precedenti, quindi lo schema qui sotto confronta AnnotationCount su ogni pagina prima e dopo l'import

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);   // per la pagina selezionata, widget inclusi
  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   // uguali dalla 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;

Altri tre difetti dietro al primo

Correggere solo il subtype avrebbe messo in luce altri tre bug nella stessa funzione, ognuno dei quali era rimasto invisibile solo perché nessuna annotazione era mai arrivata su una pagina. Primo, ReadNumber prendeva la propria posizione come parametro per valore, quindi leggere i quattro numeri di /Rect in sequenza significava leggere lo stesso punto quattro volte, e non saltava la [ iniziale, quindi in pratica non leggeva niente affatto. Secondo, FindKey condivideva un unico cursore in avanti tra tutte le ricerche. L'exporter scrive /Subtype, /Rect, /Page, /Contents, /T, /Subj, ma l'importer cercava nell'ordine /Subtype, /Contents, /T, /Subj, /Page, /Rect; una volta che il cursore aveva superato /Contents, la ricerca di /Page e /Rect correva oltre la voce corrente e non trovava nulla o abbinava le chiavi dell'annotazione successiva. La libreria non riusciva a leggere il proprio output. Terzo, i numeri passavano per PLStrToFloat, che segue il separatore decimale di sistema. ISO 32000-1 §12.7.7 definisce FDF come sintassi di oggetti PDF, e le chiavi dei dizionari in PDF non hanno ordine (§7.3.7), quindi qualunque parser FDF che presuppone un ordine delle chiavi è sbagliato per costruzione, indipendentemente dallo strumento che ha prodotto il file

L'importer riparato delimita prima ogni voce. FindDictEnd cammina dal << iniziale fino al >> corrispondente, tracciando i dizionari annidati e saltando i corpi delle stringhe letterali con i loro escape a backslash, così un >> dentro un commento come (see section >> 4) non può chiudere la voce in anticipo. Ogni ricerca di chiave poi parte dall'inizio della voce stessa ed è limitata alla sua fine, il che rende irrilevante l'ordine delle chiavi e impedisce a un'annotazione di prendere in prestito il /Page di un'altra. Il match delle chiavi accetta anche un delimitatore subito dopo il nome, perché /Contents(Hi) è tanto valido quanto /Contents (Hi), mentre la regola del confine di parola impedisce a /Subj di abbinare l'inizio di /Subtype e a /T di abbinare /Type. ReadNumber ora prende la propria posizione come parametro var, salta spazi bianchi e [, e analizza con PLTryStrToFloatInvariant, che fallisce con delicatezza su un token malformato invece di sollevare un'eccezione. Se uno dei quattro numeri del rettangolo fallisce, tutti e quattro tornano a zero invece di produrre un rettangolo letto a metà

PDFlibPas FindDictEnd ora delimita ogni annotazione FDF dal suo << iniziale al >> corrispondente, così ogni ricerca di chiave riparte dall'inizio della voce e si ferma alla sua fine, e ReadNumber prende una posizione var, salta la parentesi e analizza con PLTryStrToFloatInvariant
Il cursore condiviso non riusciva a leggere l'export della libreria stessa: una volta superato /Contents, le ricerche di /Page e /Rect finivano sulle chiavi dell'annotazione successiva, quindi l'ordine delle chiavi non è più autorizzato a contare

Perché i round-trip FDF spostavano ogni annotazione della propria altezza?

Il vecchio exporter scriveva un rettangolo nel modello di coordinate sbagliato. Il /Rect di un'annotazione è [llx lly urx ury] in default user space (ISO 32000-1 §12.5.2, con i rettangoli definiti in §7.9.5), e FDF trasporta lo stesso array. ExportAnnotationsToFDFString, tuttavia, chiamava GetAnnotRectEx, che riporta Left, Top, Width e Height nelle coordinate di disegno della libreria, lo spazio che SetOrigin controlla, e li serializzava come [L T L+W T+H]. L'importer, una volta funzionante, riscriveva quei quattro valori verbatim come rettangolo PDF, quindi il bordo superiore atterrava dove apparteneva l'angolo inferiore sinistro e ogni round trip spostava l'annotazione in alto della propria altezza. L'exporter ora copia i numeri /Rect dell'annotazione stessa, tre decimali, separatore punto, niente esponente, e ripiega sul rettangolo calcolato solo quando l'array memorizzato è assente o non contiene quattro numeri

PDFlibPas serializzava FDF /Rect come left, top, width, height in coordinate di disegno, quindi reimportare quei quattro numeri come llx lly urx ury faceva atterrare il bordo superiore dove apparteneva l'angolo inferiore sinistro e spostava ogni annotazione in alto della propria altezza a ogni round trip
L'exporter ora copia i numeri /Rect dell'annotazione stessa — tre decimali, separatore punto, niente esponente — e il test di regression confronta un secondo export byte per byte con il primo

Il test di regression che blocca questo comportamento merita di essere copiato, perché asserisce sul documento e su un secondo export, non sul valore di ritorno dell'importer. Nota il conteggio atteso di 2: AddNoteAnnotation crea un'annotazione Text più il suo Popup, e entrambi viaggiano. Il test esegue anche export e import con un separatore decimale a virgola, ed è qui che vive l'altra metà di questa storia

var
  Source, Target: TPDFlib;
  FDF: AnsiString;
  OldSep: Char;
begin
  Source := TPDFlib.Create;
  Target := TPDFlib.Create;
  try
    Source.NewPages(1);                     // ora due pagine
    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 un desktop tedesco o francese
    try
      FDF := Source.ExportAnnotationsToFDFString;   // scrive comunque /Rect [50.5 ...
      Target.ImportAnnotationsFromFDFString(FDF);
    finally
      FormatSettings.DecimalSeparator := OldSep;
    end;

    Target.SelectPage(2);
    Assert(Target.AnnotationCount = 2);           // la nota e il suo popup
    Assert(Target.GetAnnotType(1) = 'Text');
    Assert(Target.ExportAnnotationsToFDFString = Source.ExportAnnotationsToFDFString);
  finally
    Target.Free;
    Source.Free;
  end;
end;

Sii chiaro su ciò che il percorso FDF trasporta. L'importer ricostruisce ogni voce come dizionario con /Type, /Subtype, /Rect, /Contents, /T e /Subj; colore, flag, stile del bordo, collegamenti popup e appearance stream non fanno parte di questa rotta, e l'exporter salta le annotazioni Widget perché i campi modulo appartengono ai metodi form-data. La mappa più ampia di quali dati viaggiano attraverso quale metodo è nella panoramica di interscambio dati di modulo FDF, XFDF e XFA, e se devi ispezionare cosa è effettivamente arrivato, i lettori per indice come GetAnnotType, GetAnnotTitle e GetAnnotContentsEx sono trattati in introspezione di outline, annotazioni e action

Come si leggono file FDF e XFDF con decimali a virgola da export più vecchi?

Per FDF la risposta è univoca: una virgola non è un delimitatore nella sintassi PDF, quindi un token numerico che contiene esattamente una virgola e nessun punto può essere solo un decimale scritto su una macchina con locale a virgola. Le versioni precedenti scrivevano davvero file simili, per esempio /Rect [10,500 20,250 40,750 60,125], e il nuovo ReadNumber trasforma quella singola virgola in un punto prima di analizzare. Un token con due virgole, o una virgola e un punto, viene rifiutato invece di essere indovinato. Il lettore non consuma nemmeno la notazione a esponente, il che corrisponde a ISO 32000-1 §7.3.3: i numeri PDF non la usano mai

XFDF è più difficile, perché negli attributi XML la virgola è il separatore. XFDF standard (ISO 19444-1) scrive rect="50.5,80.25,70.75,100.125" e dashes="4,2", mentre v3.539.28 e precedenti, su un sistema con locale a virgola, scrivevano rect="50,500 80,250 70,750 100,125" e opacity="0,600", e fallivano anche con EConvertError leggendo un opacity="0.6" standard. Dalla v3.539.29 entrambe le direzioni sono invarianti, e la forma legacy viene riconosciuta da XFDFNormalizeLegacyDecimals solo quando l'attributo si spezza sugli spazi bianchi in esattamente il numero atteso di token (quattro per rect, uno per opacity e width) e ogni token ha la forma cifre-virgola-cifre. Un rect standard non fa mai match: o è un token con tre virgole, o sono token che finiscono con una virgola. dashes è lasciato deliberatamente in pace, perché 4,2 potrebbe essere due lunghezze di tratteggio o un legacy 4.2, e nessuna regola può distinguerli

const
  // Chiavi fuori dall'ordine dell'exporter, più decimali a virgola da un export legacy
  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;               // un documento nuovo ha una pagina
  try
    Lib.ImportAnnotationsFromFDFString(LegacyFDF);
    Assert(Lib.AnnotationCount = 1);
    Assert(Lib.GetAnnotTitle(1) = 'Alpha');
    // Re-esportato come XFDF con decimali a punto: rect="10.500 20.250 40.750 60.125"
    Writeln(Lib.ExportAnnotationsToXFDFString);
  finally
    Lib.Free;
  end;
end;

Che cosa deve davvero asserire un test di import delle annotazioni?

Un test di import utile asserisce sullo stato del documento di destinazione, mai solo su ciò che l'importer dice di sé. Nulla nella suite di test controllava AnnotationCount dopo un import FDF, e il valore di ritorno, l'unico numero che qualcuno guardava, era proprio il numero che il bug aveva lasciato intatto. Tre asserzioni avrebbero colto ogni difetto descritto qui: il conteggio delle annotazioni sulla pagina attesa, un campo rileto attraverso GetAnnotType o GetAnnotContentsEx, e un secondo export confrontato byte per byte con il primo. La stessa disciplina vale per qualunque API che riscrive in blocco la struttura del documento, compresa la consolidazione dei campi descritta in unione dei campi modulo duplicati: controlla l'albero risultante, non un totale restituito. I metodi per le annotazioni FDF e XFDF, con le loro varianti su file e su stringa, sono inclusi nel losLab PDF Library for Delphi and C++Builder, e la v3.539.30 o successiva è la versione da usare se i commenti devono sopravvivere al viaggio, la v3.539.40 o successiva se il conteggio restituito deve corrispondere a ciò che è stato aggiunto