Vóór v3.539.30 gaf TPDFlib.ImportAnnotationsFromFDFString in losLab PDF Library het aantal FDF-annotatie-items terug dat hij geparsed had, zonder er ook maar één aan het document toe te voegen: elk item werd geteld, elk item werd weggegooid. Sinds v3.539.30 leest de FDF-importer keys in willekeurige volgorde, parst hij /Rect correct en locale-onafhankelijk, en schrijft de bijbehorende exporter de echte /Rect van de annotatie, zodat een export, import en tweede export byte-identieke FDF opleveren. De rest van deze notitie laat zien hoe één verkeerde start-offset een perfecte stille mislukking produceerde, welke drie andere defecten erachter schuilden, en hoe u een import zelf controleert in plaats van op de return value te vertrouwen
Het scenario is alledaags. Een reviewer voorziet een contract van aantekeningen, de comments reizen als FDF-bestand (Acrobat noemt het Export Comments), en uw Delphi-service voegt ze samen in een schone kopie met ImportAnnotationsFromFDF. De aanroep geeft 7 terug, de log zegt "7 comments imported", de job wordt groen, en de uitvoer-PDF bevat geen enkele comment. Er werd niets gegooid, niets gemeld, en het getal leek geloofwaardig omdat het de echte telling van items in het bestand was. Dat is de ergste vorm die een bug kan aannemen: een functie waarvan het enige succes-signaal een teller is die onafhankelijk van het werk dat hij beweert te rapporteren wordt berekend
Waarom meldde ImportAnnotationsFromFDFString succes maar voegde niets toe?
De importer las elke /Subtype als een lege string, en de helper die de annotatie aanmaakt stopt vroegtijdig bij een lege subtype, terwijl de aanroeper het resultaat toch ophoogde. De key-finder gaf de positie direct na /Subtype terug, en dat is de witruimte vóór de waarde. ReadName begon op die spatie en stopte bij het eerste witruimteteken, dus hij stopte nog vóór hij iets gelezen had. AddAnnotationToPage weigert een annotatie zonder subtype te bouwen, op zich de juiste defensieve keuze, maar het was een procedure zonder returnwaarde, en Inc(Result) stond erbuiten. Elke beveiliging was op zich redelijk; samen veranderden ze "er werkte niets" in "alles werkte". De fix laat ReadName witruimte overslaan, de leading / van een PDF name object vereisen, en stoppen bij elke delimiter, waaronder [, ( en ), zodat /Subtype/Text en /Subtype /Text allebei Text opleveren
Ook ná die fix vroeg de return value om aandacht. Tot en met v3.539.39 hoogde ImportAnnotationsFromFDFString zijn resultaat nog op voor elke goed gevormde dictionary in de /Annots-array, inclusief items waarvan de zero-based /Page buiten bereik viel of waarvan de /Subtype ontbrak, en die worden allebei overgeslagen. Sinds PDFlibPas v3.539.40 geven ImportAnnotationsFromFDFString en ImportAnnotationsFromFDF het aantal daadwerkelijk toegevoegde annotaties terug, net als de XFDF-import: de FDF-helper AddAnnotationToPage geeft nu een Boolean terug en de teller beweegt alleen bij succes. Het meten van het document blijft de sterkere controle, omdat die ook op oudere versies klopt, dus de schets hieronder vergelijkt AnnotationCount op elke pagina vóór en na de 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 geselecteerde pagina, widgets inbegrepen
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 // gelijk sinds 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;
Nog drie defecten achter het eerste
Alleen de subtype repareren zou nog drie bugs in dezelfde functie blootgelegd hebben, die elk onzichtbaar waren gebleven alleen omdat er nooit een annotatie een pagina bereikte. Ten eerste nam ReadNumber zijn positie als value-parameter, dus het achter elkaar lezen van de vier /Rect-nummers las vier keer dezelfde plek, en hij sloeg de openings-[ niet over, dus in de praktijk las hij helemaal niets. Ten tweede deelde FindKey één vooruitlopende cursor over alle lookups. De exporter schrijft /Subtype, /Rect, /Page, /Contents, /T, /Subj, maar de importer zocht in de volgorde /Subtype, /Contents, /T, /Subj, /Page, /Rect; eenmaal de cursor voorbij /Contents was, liep de zoektocht naar /Page en /Rect voorbij het huidige item en vond ofwel niets ofwel de keys van de volgende annotatie. De library kon zijn eigen uitvoer niet lezen. Ten derde gingen getallen door PLStrToFloat, dat het decimale scheidingsteken van het systeem volgt. ISO 32000-1 §12.7.7 definieert FDF als PDF-objectsyntaxis, en dictionary-keys zijn in PDF ongeordend (§7.3.7), dus elke FDF-parser die een key-volgorde aanneemt is van constructie uit fout, ongeacht welke tool het bestand maakte
De gerepareerde importer begrenst eerst elk item. FindDictEnd loopt van de openings-<< naar de bijbehorende >>, houdt geneste dictionaries bij en slaat letterlijke string-inhouden met hun backslash-escapes over, zodat een >> binnen een comment zoals (see section >> 4) het item niet voortijdig kan afsluiten. Elke key-lookup begint daarna op de eigen start van het item en is beperkt tot zijn einde, wat key-volgorde irrelevant maakt en voorkomt dat één annotatie de /Page van een andere leent. De key-match accepteert ook een delimiter direct achter de naam, want /Contents(Hi) is net zo geldig als /Contents (Hi), terwijl de woordgrensregel voorkomt dat /Subj matcht op het begin van /Subtype en /T op /Type. ReadNumber neemt zijn positie nu als var-parameter, slaat witruimte en [ over, en parst met PLTryStrToFloatInvariant, die zacht faalt op een misvormd token in plaats van een exceptie te gooien. Als een van de vier rechthoeknummers faalt, vallen alle vier terug op nul in plaats van een halfgelezen rechthoek op te leveren
Waarom verschoof een FDF-round-trip elke annotatie om zijn eigen hoogte?
De oude exporter schreef een rechthoek in het verkeerde coördinatenmodel. De /Rect van een annotatie is [llx lly urx ury] in default user space (ISO 32000-1 §12.5.2, met rechthoeken gedefinieerd in §7.9.5), en FDF draagt dezelfde array. ExportAnnotationsToFDFString riep echter GetAnnotRectEx aan, die Left, Top, Width en Height rapporteert in de tekencoördinaten van de library, de ruimte die SetOrigin beheerst, en serialiseerde die als [L T L+W T+H]. De importer, eenmaal werkend, schreef die vier waarden ongewijzigd terug als PDF-rechthoek, dus de bovenrand kwam te liggen waar de linkerbenedenhoek hoorde, en elke round-trip verschoof de annotatie om zijn eigen hoogte omhoog. De exporter kopieert nu de eigen /Rect-nummers van de annotatie, drie decimalen, punt als scheidingsteken, geen exponent, en valt alleen terug op de berekende rechthoek als de opgeslagen array ontbreekt of geen vier nummers bevat
De regression test die dit vastzetet is het kopiëren waard, want hij doet asserties op het document en op een tweede export, niet op de return value van de importer. Let op de verwachte telling van 2: AddNoteAnnotation maakt een Text-annotatie plus zijn Popup aan, en allebei reizen mee. De test draait de export en import ook onder een komma als decimale scheidingsteken, en daar woont de andere helft van dit verhaal
var
Source, Target: TPDFlib;
FDF: AnsiString;
OldSep: Char;
begin
Source := TPDFlib.Create;
Target := TPDFlib.Create;
try
Source.NewPages(1); // nu twee pagina's
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 := ','; // simuleer een Duits of Frans bureaublad
try
FDF := Source.ExportAnnotationsToFDFString; // schrijft nog steeds /Rect [50.5 ...
Target.ImportAnnotationsFromFDFString(FDF);
finally
FormatSettings.DecimalSeparator := OldSep;
end;
Target.SelectPage(2);
Assert(Target.AnnotationCount = 2); // de notitie en zijn popup
Assert(Target.GetAnnotType(1) = 'Text');
Assert(Target.ExportAnnotationsToFDFString = Source.ExportAnnotationsToFDFString);
finally
Target.Free;
Source.Free;
end;
end;
Wees duidelijk over wat het FDF-pad meedraagt. De importer bouwt elk item weer op als dictionary met /Type, /Subtype, /Rect, /Contents, /T en /Subj; kleur, flags, borderstijl, popup-links en appearance streams horen niet bij deze route, en de exporter slaat Widget-annotaties over omdat formuliervelden bij de form-data-methoden horen. De bredere kaart van welke data via welke methode reist staat in het overzicht van FDF-, XFDF- en XFA-form data interchange, en als u wilt inspecteren wat er werkelijk aangekomen is, worden de per-index lezers zoals GetAnnotType, GetAnnotTitle en GetAnnotContentsEx behandeld in outline-, annotatie- en action-introspectie
Hoe leest u FDF- en XFDF-bestanden met komma-decimals uit oudere exports?
Voor FDF is het antwoord ondubbelzinnig: een komma is geen delimiter in PDF-syntaxis, dus een nummertoken dat precies één komma en geen punt bevat kan alleen een decimaal zijn die op een machine met komma-locale is geschreven. Eerdere versies schreven zulke bestanden echt, bijvoorbeeld /Rect [10,500 20,250 40,750 60,125], en de nieuwe ReadNumber zet die ene komma in een punt vóór het parsen. Een token met twee komma's, of een komma en een punt, wordt geweigerd in plaats van gokkend geaccepteerd. De lezer eet exponentnotatie ook niet op, wat past bij ISO 32000-1 §7.3.3: PDF-nummers gebruiken die nooit
XFDF is lastiger, want in XML-attributen is de komma het scheidingsteken. Standaard XFDF (ISO 19444-1) schrijft rect="50.5,80.25,70.75,100.125" en dashes="4,2", terwijl v3.539.28 en ouder, op een systeem met komma-locale, rect="50,500 80,250 70,750 100,125" en opacity="0,600" schreven, en bovendien faalden met EConvertError bij het lezen van een standaard opacity="0.6". Sinds v3.539.29 zijn beide richtingen invariant, en de legacy-vorm wordt door XFDFNormalizeLegacyDecimals alleen herkend als het attribuut op witruimte splitst in precies het verwachte aantal tokens (vier voor rect, één voor opacity en width) en elk token de vorm cijfers-komma-cijfers heeft. Een standaard rect matcht nooit: het is ofwel één token met drie komma's ofwel tokens die op een komma eindigen. dashes wordt met opzet met rust gelaten, want 4,2 kan twee dash-lengtes zijn of een legacy 4.2, en geen enkele regel kan die twee uit elkaar houden
const
// Keys in een afwijkende volgorde ten opzichte van de exporter, plus komma-decimals uit een oudere komma-locale-export
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; // een vers document heeft één pagina
try
Lib.ImportAnnotationsFromFDFString(LegacyFDF);
Assert(Lib.AnnotationCount = 1);
Assert(Lib.GetAnnotTitle(1) = 'Alpha');
// Opnieuw geëxporteerd als XFDF met punt-decimals: rect="10.500 20.250 40.750 60.125"
Writeln(Lib.ExportAnnotationsToXFDFString);
finally
Lib.Free;
end;
end;
Wat moet een annotatie-importtest eigenlijk controleren?
Een nuttige importtest doet asserties op de toestand van het doeldocument, nooit alleen op wat de importer over zichzelf zegt. Niets in de testsuite controleerde AnnotationCount na een FDF-import, en de return value, het enige getal waar iedereen naar keek, was precies het getal dat de bug intact liet. Drie asserties hadden elk hier beschreven defect gevangen: de annotatietelling op de verwachte pagina, één veld teruglezen via GetAnnotType of GetAnnotContentsEx, en een tweede export die byte voor byte met de eerste vergeleken wordt. Dezelfde discipline geldt voor elke API die de documentstructuur in bulk herschrijft, inclusief de veldconsolidatie uit het samenvoegen van dubbele formuliervelden: controleer de resulterende boom, niet een teruggegeven totaal. De FDF- en XFDF-annotatiemethoden, met hun file- en string-varianten, zitten in de losLab PDF Library for Delphi and C++Builder, en v3.539.30 of nieuwer is de versie om te draaien als comments de reis moeten overleven, v3.539.40 of nieuwer als de teruggegeven telling moet matchen wat er toegevoegd is