Înainte de v3.539.30, TPDFlib.ImportAnnotationsFromFDFString din losLab PDF Library întorcea numărul de intrări de adnotări FDF pe care le parsezase, fără să adauge niciuna în document: fiecare intrare era numărată, fiecare intrare era aruncată. Din v3.539.30, importatorul FDF citește cheile în orice ordine, parsează /Rect corect și independent de locale, iar exporter-ul pereche scrie /Rect-ul real al adnotării, astfel încât un export, un import și un al doilea export produc FDF identic octet cu octet. Restul notei de față explică cum un singur offset de pornire greșit a produs un eșec tăcut perfect, care trei alte defecte se ascundeau în spatele lui și cum să verificați singuri un import în loc să aveți încredere în valoarea întoarsă
Scenariul e banal. Un reviewer adnotează un contract, comentariile călătoresc ca fișier FDF (Acrobat îi spune Export Comments), iar serviciul vostru Delphi le îmbină într-o copie curată cu ImportAnnotationsFromFDF. Apelul întoarce 7, logul scrie „7 comments imported”, jobul devine verde, iar PDF-ul de ieșire n-are niciun comentariu. Nimic ridicat, nimic avertizat, iar numărul părea plauzibil pentru că era chiar numărul real de intrări din fișier. Asta e cea mai urâtă formă pe care o poate lua un bug: o funcție al cărei unic semnal de succes e un contor calculat independent de munca pe care pretinde că o raportează
De ce raporta ImportAnnotationsFromFDFString succes fără să adauge nimic?
Importatorul citea fiecare /Subtype ca un șir gol, iar helper-ul care creează adnotarea ieșea devreme pe un subtype gol, în timp ce apelantul incrementa oricum rezultatul. Căutătorul de chei întorcea poziția imediat după /Subtype, adică whitespace-ul din fața valorii. ReadName pornea de la acel spațiu și se oprea la primul caracter de whitespace, deci se oprea înainte să citească ceva. AddAnnotationToPage refuză să construiască o adnotare fără subtype, ceea ce e alegerea defensivă corectă luată izolat, dar era o procedură fără valoare de retur, iar Inc(Result) stătea în afara ei. Fiecare gard era rezonabil de unul singur; împreună au transformat „nimic nu a mers” în „totul a mers”. Corecția face ca ReadName să sară peste whitespace, să ceară /-ul de început al unui name object PDF și să se oprească la orice delimiter, inclusiv [, ( și ), astfel încât /Subtype/Text și /Subtype /Text să dea amândouă Text
Valoarea de retur merita grijă chiar și după corecția asta. Până la v3.539.39, ImportAnnotationsFromFDFString își incrementa în continuare rezultatul pentru fiecare dicționar bine format din tabloul /Annots, inclusiv pentru intrări al căror /Page pe zero-based era în afara intervalului sau cărora le lipsea /Subtype, ambele cazuri fiind omise. Din PDFlibPas v3.539.40, ImportAnnotationsFromFDFString și ImportAnnotationsFromFDF întorc numărul de adnotări efectiv adăugate, ca importul XFDF: helper-ul FDF AddAnnotationToPage întoarce acum un Boolean, iar contorul se mișcă doar pe succes. Măsuratul documentului rămâne totuși verificarea mai puternică, pentru că ține și pe versiunile mai vechi, deci schița de mai jos compară AnnotationCount pe fiecare pagină înainte și după 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); // pe pagina selectată, cu widget-uri
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 // egale din 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;
Încă trei defecte în spatele primului
Repararea doar a subtype-ului ar fi expus trei bug-uri suplimentare în aceeași funcție, fiecare invizibil până atunci doar pentru că nicio adnotare nu ajunsese vreodată pe o pagină. Întâi, ReadNumber își primea poziția ca parametru de tip valoare, deci citirea celor patru numere /Rect în secvență citea același loc de patru ori, și nu sărea peste [-ul de deschidere, deci în practică nu citea nimic. Al doilea, FindKey partaja un singur cursor care mergea doar înainte între toate căutările. Exporter-ul scrie /Subtype, /Rect, /Page, /Contents, /T, /Subj, dar importatorul căuta în ordinea /Subtype, /Contents, /T, /Subj, /Page, /Rect; odată ce cursorul trecuse de /Contents, căutarea lui /Page și /Rect alerga peste intrarea curentă și fie nu găsea nimic, fie potrivea cheile adnotării următoare. Biblioteca nu-și putea citi propriul output. Al treilea, numerele treceau prin PLStrToFloat, care urmează separatorul zecimal al sistemului. ISO 32000-1 §12.7.7 definește FDF ca sintaxă de obiecte PDF, iar cheile de dicționar în PDF nu au ordine (§7.3.7), deci orice parser FDF care presupune o ordine a cheilor e greșit prin construcție, indiferent ce unealtă a produs fișierul
Importatorul reparat mărginește întâi fiecare intrare. FindDictEnd merge de la <<-ul de deschidere până la >>-ul lui pereche, ținând evidența dicționarelor imbricate și sărind peste corpurile de șiruri literale cu escape-urile lor cu backslash, deci un >> din interiorul unui comentariu precum (see section >> 4) nu poate încheia intrarea devreme. Fiecare căutare de cheie pornește apoi de la începutul propriu al intrării și e limitată la capătul lui, ceea ce face ordinea cheilor irelevantă și împiedică o adnotare să împrumute /Page-ul alteia. Potrivirea cheii acceptă și un delimiter imediat după nume, pentru că /Contents(Hi) e la fel de valid ca /Contents (Hi), în timp ce regula limitei de cuvânt împiedică /Subj să potrivească începutul lui /Subtype și /T să potrivească /Type. ReadNumber își ia acum poziția ca parametru var, sare peste whitespace și peste [, și parsează cu PLTryStrToFloatInvariant, care eșuează moale pe un token malformat în loc să ridice excepție. Dacă oricare dintre cele patru numere de dreptunghi eșuează, toate patru cad înapoi la zero în loc să producă un dreptunghi citit pe jumătate
De ce drumurile dus-întors FDF mutau fiecare adnotare cu propria ei înălțime?
Vechiul exporter scria un dreptunghi în modelul de coordonate greșit. /Rect-ul unei adnotări e [llx lly urx ury] în user space implicit (ISO 32000-1 §12.5.2, cu dreptunghiurile definite în §7.9.5), iar FDF cară același tablou. ExportAnnotationsToFDFString, însă, apela GetAnnotRectEx, care raportează Left, Top, Width și Height în coordonatele de desen ale bibliotecii, spațiul controlat de SetOrigin, și le serializa ca [L T L+W T+H]. Importatorul, odată ce a funcționat, scria cele patru valori înapoi întocmai ca dreptunghi PDF, deci muchia de sus ateriza acolo unde aparținea colțul din stânga-jos, iar fiecare dus-întors muta adnotarea în sus cu propria ei înălțime. Exporter-ul copiază acum numerele /Rect ale adnotării, cu trei zecimale, separator punct, fără exponent, și cade pe dreptunghiul calculat doar când tabloul stocat lipsește sau nu are patru numere
Testul de regresie care fixează asta merită copiat, pentru că assertează pe document și pe un al doilea export, nu pe valoarea întoarsă de importator. Remarcați numărul așteptat de 2: AddNoteAnnotation creează o adnotare Text plus Popup-ul ei, și ambele călătoresc. Testul rulează de asemenea exportul și importul sub un separator zecimal virgulă, iar acolo locuiește cealaltă jumătate a poveștii de față
var
Source, Target: TPDFlib;
FDF: AnsiString;
OldSep: Char;
begin
Source := TPDFlib.Create;
Target := TPDFlib.Create;
try
Source.NewPages(1); // acum două pagini
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 := ','; // simulează un desktop german sau francez
try
FDF := Source.ExportAnnotationsToFDFString; // scrie în continuare /Rect [50.5 ...
Target.ImportAnnotationsFromFDFString(FDF);
finally
FormatSettings.DecimalSeparator := OldSep;
end;
Target.SelectPage(2);
Assert(Target.AnnotationCount = 2); // nota și popup-ul ei
Assert(Target.GetAnnotType(1) = 'Text');
Assert(Target.ExportAnnotationsToFDFString = Source.ExportAnnotationsToFDFString);
finally
Target.Free;
Source.Free;
end;
end;
Fiți clari în privința a ceea ce cară ruta FDF. Importatorul reconstruiește fiecare intrare ca un dicționar cu /Type, /Subtype, /Rect, /Contents, /T și /Subj; culoarea, flag-urile, stilul bordurii, legăturile popup și appearance stream-urile nu fac parte din ruta asta, iar exporter-ul sare peste adnotările Widget pentru că câmpurile de formular aparțin metodelor de form-data. Harta mai largă a ce date călătoresc prin ce metodă e în trecerea în revistă a schimbului de date de formulare FDF, XFDF și XFA, iar dacă trebuie să inspectați ce a ajuns de fapt acolo, cititoarele per index precum GetAnnotType, GetAnnotTitle și GetAnnotContentsEx sunt acoperite în introspecția outline-urilor, adnotărilor și acțiunilor
Cum citiți fișiere FDF și XFDF cu zecimale cu virgulă din exporturi mai vechi?
Pentru FDF răspunsul e neechivoc: o virgulă nu e delimiter în sintaxa PDF, deci un token de număr care conține exact o virgulă și niciun punct nu poate fi decât un zecimal scris pe o mașină cu locale cu virgulă. Versiunile mai vechi scriau asemenea fișiere, de exemplu /Rect [10,500 20,250 40,750 60,125], iar noul ReadNumber transformă virgula aceea unică în punct înainte de parsare. Un token cu două virgule, sau cu o virgulă și un punct, e respins în loc să fie ghicit. Cititorul nu consumă nici notația cu exponent, ceea ce se potrivește cu ISO 32000-1 §7.3.3: numerele PDF nu o folosesc niciodată
XFDF e mai greu, pentru că în atribute XML virgula e separatorul. XFDF standard (ISO 19444-1) scrie rect="50.5,80.25,70.75,100.125" și dashes="4,2", în timp ce v3.539.28 și mai vechi, pe un sistem cu locale cu virgulă, scriau rect="50,500 80,250 70,750 100,125" și opacity="0,600", și eșuau de asemenea cu EConvertError la citirea unui opacity="0.6" standard. Din v3.539.29 ambele direcții sunt invariante, iar forma legacy e recunoscută de XFDFNormalizeLegacyDecimals doar când atributul se desparte pe whitespace în exact numărul așteptat de token-uri (patru pentru rect, câte unul pentru opacity și width) și fiecare token are forma cifre-virgulă-cifre. Un rect standard nu se potrivește niciodată: e ori un singur token cu trei virgule, ori token-uri care se termină în virgulă. dashes e lăsat în mod deliberat în pace, pentru că 4,2 ar putea fi două lungimi de liniuțe sau un 4.2 legacy, și nicio regulă nu le poate deosebi
const
// Chei în afara ordinii exporter-ului, plus zecimale cu virgulă dintr-un export vechi
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 document proaspăt are o pagină
try
Lib.ImportAnnotationsFromFDFString(LegacyFDF);
Assert(Lib.AnnotationCount = 1);
Assert(Lib.GetAnnotTitle(1) = 'Alpha');
// Re-exportat ca XFDF cu zecimale cu punct: rect="10.500 20.250 40.750 60.125"
Writeln(Lib.ExportAnnotationsToXFDFString);
finally
Lib.Free;
end;
end;
Ce ar trebui să asserteze de fapt un test de import de adnotări?
Un test de import util assertează pe starea documentului țintă, niciodată doar pe ce spune importatorul despre el însuși. Nimic din suita de test nu verifica AnnotationCount după un import FDF, iar valoarea întoarsă, singurul număr pe care îl privea cineva, era exact numărul pe care bug-ul l-a lăsat intact. Trei aserțiuni ar fi prins fiecare defect descris aici: numărul de adnotări pe pagina așteptată, un câmp citit înapoi prin GetAnnotType sau GetAnnotContentsEx și un al doilea export comparat octet cu octet cu primul. Aceeași disciplină se aplică oricărui API care rescrie structura documentului în bloc, inclusiv consolidării de câmpuri descrise în combinarea câmpurilor de formular duplicate: verificați arborele rezultat, nu un total întors. Metodele de adnotări FDF și XFDF, cu variantele lor pe fișier și pe șir, se livrează în losLab PDF Library for Delphi and C++Builder, iar v3.539.30 sau mai nou e versiunea de rulat dacă comentariile trebuie să supraviețuiască drumului, v3.539.40 sau mai nou dacă numărul întors trebuie să se potrivească cu ce a fost adăugat