Technisch artikel

FDF-annotatie-import in Delphi: de stille nul opgelost

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

PDFlibPas ImportAnnotationsFromFDFString vond /Subtype, startte ReadName op de witruimte na de key zodat hij een lege naam teruggaf, AddAnnotationToPage stopte bij de ontbrekende subtype, en de aanroeper hoogde het resultaat toch op, met zeven geïmporteerde comments als rapport terwijl er geen enkele aan het document werd toegevoegd
Elke beveiliging was op zich redelijk; samen veranderden ze er werkte niets in alles werkte, en daarom mag de return value nooit het enige zijn dat een importtest controleert

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

PDFlibPas FindDictEnd begrenst elke FDF-annotatie nu van de openingse << tot de bijbehorende >>, zodat elke key-lookup opnieuw begint op de start van het item en stopt op zijn einde, en ReadNumber neemt een var-positie, slaat de bracket over en parst met PLTryStrToFloatInvariant
De gedeelde cursor kon de eigen export van de library niet lezen: eenmaal voorbij /Contents liepen de zoektochten naar /Page en /Rect de keys van de volgende annotatie in, dus key-volgorde mag er niet meer toe doen

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

PDFlibPas serialiseerde FDF /Rect vroeger als left, top, width, height in tekencoördinaten, zodat het terugimporteren van die vier getallen als llx lly urx ury de bovenrand liet landen waar de linkerbenedenhoek hoorde en elke annotatie bij elke round-trip om zijn eigen hoogte omhoog verschoof
De exporter kopieert nu de eigen /Rect-nummers van de annotatie — drie decimalen, punt als scheidingsteken, geen exponent — en de regression test vergelijkt een tweede export byte voor byte met de eerste

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