Teknisk artikel

FDF-annoteringsimport i Delphi: att fixa den tysta nollan

Före v3.539.30 returnerade TPDFlib.ImportAnnotationsFromFDFString i losLab PDF Library antalet FDF-annoteringposter den hade parsat, medan den inte lade till en enda av dem i dokumentet: varje post räknades, varje post kastades. Sedan v3.539.30 läser FDF-importören nycklar i valfri ordning, parsar /Rect korrekt och oberoende av locale, och den matchande exportören skriver annoteringens verkliga /Rect, så en export, en import och en andra export ger byte-identisk FDF. Resten av denna not förklarar hur en felaktig startoffset producerade ett perfekt tyst misslyckande, vilka tre andra defekter som gömde sig bakom den, och hur du kontrollerar en import själv i stället för att lita på returvärdet

Scenariot är helt vanligt. En granskare prickar av i ett kontrakt, kommentarerna färdas som en FDF-fil (Acrobat kallar det Export Comments), och din Delphi-tjänst slår ihop dem med en ren kopia via ImportAnnotationsFromFDF. Anropet returnerar 7, loggen säger "7 kommentarer importerade", jobbet blir grönt, och utdata-PDF:en saknar alla kommentarer. Inget kastades, inget varnade, och siffran såg rimlig ut för den var det sanna antalet poster i filen. Det är den värsta form en bugg kan ta: en funktion vars enda framgångssignal är en räknare som beräknas oberoende av arbetet den påstår sig rapportera

Varför rapporterade ImportAnnotationsFromFDFString framgång men lade till ingenting?

Importören läste varje /Subtype som en tom sträng, och hjälparen som skapar annoteringen avslutar tidigt vid en tom subtyp, medan anroparen ändå ökar resultatet. Nyckelsökaren returnerade positionen direkt efter /Subtype, alltså blanktecknet före värdet. ReadName började på det blanktecknet och stannade vid första blanktecknet, så den stannade innan den läst något alls. AddAnnotationToPage vägrar bygga en annotering utan subtyp, vilket är det korrekta defensiva valet i isolering, men det var en procedur utan returvärde, och Inc(Result) satt utanför den. Varje vakt var rimlig för sig; tillsammans omvandlade de "ingenting fungerade" till "allting fungerade". Fixen gör så att ReadName hoppar över blanksteg, kräver det inledande / hos ett PDF-name-objekt och stannar vid valfri avgränsare, inklusive [, ( och ), så att både /Subtype/Text och /Subtype /Text ger Text

PDFlibPas ImportAnnotationsFromFDFString hittade /Subtype, startade ReadName på blanktecknet efter nyckeln så att den returnerade ett tomt namn, AddAnnotationToPage avslutade vid den saknade subtypen, och anroparen ökade resultatet ändå och rapporterade sju importerade kommentarer medan ingen lades till i dokumentet
Varje vakt var rimlig för sig; tillsammans omvandlade de ingenting fungerade till allting fungerade, vilket är därför returvärdet aldrig får vara det enda ett importtest kontrollerar

Returvärdet förtjänade omsorg även efter den fixen. Genom v3.539.39 ökade ImportAnnotationsFromFDFString fortfarande sitt resultat för varje välbildad ordbok i /Annots-arrayen, inklusive poster vars nollbaserade /Page var utanför intervallet eller vars /Subtype saknades, vilket båda hoppas över. Sedan PDFlibPas v3.539.40 returnerar ImportAnnotationsFromFDFString och ImportAnnotationsFromFDF antalet annoteringar som faktiskt lades till, precis som XFDF-importen: FDF-hjälparen AddAnnotationToPage returnerar nu en Boolean och räknaren rör sig bara vid framgång. Att mäta dokumentet är fortfarande den starkare kontrollen, eftersom den också gäller äldre versioner, så skissen nedan jämför AnnotationCount på varje sida före och efter importen

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 vald sida, widgetar inkluderade
  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   // lika sedan 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;

Tre fler defekter bakom den första

Att fixa bara subtypen hade exponerat tre ytterligare buggar i samma funktion, som var och en bara varit osynlig för att ingen annotering någonsin nådde en sida. För det första tog ReadNumber sin position som value-parameter, så att läsning av de fyra /Rect-talen i sekvens läste samma ställe fyra gånger, och den hoppade inte över inledande [, så i praktiken läste den ingenting alls. För det andra delade FindKey en enda framåtrörande kursor mellan alla uppslagningar. Exportören skriver /Subtype, /Rect, /Page, /Contents, /T, /Subj, men importören sökte i ordningen /Subtype, /Contents, /T, /Subj, /Page, /Rect; när kursorn väl passerat /Contents gick sökningen efter /Page och /Rect förbi den aktuella posten och fann antingen ingenting eller matchade nästa annoterings nycklar. Biblioteket kunde inte läsa sin egen utdata. För det tredje gick talen genom PLStrToFloat, som följer systemets decimaltecken. ISO 32000-1 §12.7.7 definierar FDF som PDF-objektsyntax, och ordboksnycklar i PDF är oordnade (§7.3.7), så varje FDF-parser som antar en nyckelordning är fel av konstruktion, oavsett vilket verktyg som producerade filen

Den lagade importören avgränsar varje post först. FindDictEnd går från inledande << till dess matchande >>, håller reda på nästlade ordböcker och hoppar över literala strängkroppar med deras omvända snedstreck-escape, så att en >> inuti en kommentar som (see section >> 4) inte kan avsluta posten i förtid. Varje nyckeluppslagning börjar sedan vid postens egen start och begränsas till dess slut, vilket gör nyckelordningen irrelevant och hindrar en annotering från att låna en annans /Page. Nyckelmatchningen accepterar också en avgränsare direkt efter namnet, eftersom /Contents(Hi) är lika giltig som /Contents (Hi), medan ordgränsregeln hindrar /Subj från att matcha början av /Subtype och /T från att matcha /Type. ReadNumber tar nu sin position som en var-parameter, hoppar över blanksteg och [ och parsar med PLTryStrToFloatInvariant, som fallerar mjukt på en felformad token i stället för att kasta. Om något av de fyra rektangeltalen fallerar faller alla fyra tillbaka till noll i stället för att ge en halvläst rektangel

PDFlibPas FindDictEnd avgränsar nu varje FDF-annotering från dess inledande << till det matchande >>, så att varje nyckeluppslagning börjar om vid postens start och stannar vid dess slut, och ReadNumber tar en var-position, hoppar över klamern och parsar med PLTryStrToFloatInvariant
Den delade kursorn kunde inte läsa bibliotekets egen export: när den väl passerat /Contents sprang sökningarna efter /Page och /Rect in i nästa annoterings nycklar, så nyckelordningen får inte längre spela någon roll

Varför försköts varje annotering med sin egen höjd vid FDF-roundtrips?

Den gamla exportören skrev en rektangel i fel koordinatmodell. En annoterings /Rect är [llx lly urx ury] i default user space (ISO 32000-1 §12.5.2, med rektanglar definierade i §7.9.5), och FDF bär samma array. ExportAnnotationsToFDFString anropade däremot GetAnnotRectEx, som rapporterar Left, Top, Width och Height i bibliotekets ritkoordinater, det rum som SetOrigin styr, och serialiserade dem som [L T L+W T+H]. Importören, när den väl fungerade, skrev tillbaka de fyra värdena ordagrant som en PDF-rektangel, så att överkanten landade där nedre vänstra hörnet hörde hemma och varje roundtrip flyttade annoteringen uppåt med sin egen höjd. Exportören kopierar nu annoteringens egna /Rect-tal, tre decimaler, punkt som avgränsare, ingen exponent, och faller tillbaka till den beräknade rektangeln bara när den lagrade arrayen saknas eller inte är fyra tal

PDFlibPas serialiserade tidigare FDF /Rect som left, top, width, height i ritkoordinater, så att import av de fyra talen tillbaka som llx lly urx ury landade överkanten där nedre vänstra hörnet hörde hemma och flyttade varje annotering uppåt med sin egen höjd vid varje roundtrip
Exportören kopierar nu annoteringens egna /Rect-tal — tre decimaler, punkt som avgränsare, ingen exponent — och regressionstestet jämför en andra export byte för byte med den första

Regressionstestet som låser detta är värt att kopiera, eftersom det gör sina påståenden mot dokumentet och mot en andra export, inte mot importörens returvärde. Notera det förväntade antalet 2: AddNoteAnnotation skapar en Text-annotering plus dess Popup, och båda två färdas. Testet kör också export och import under ett komma-decimaltecken, och det är där den andra halvan av denna historia bor

var
  Source, Target: TPDFlib;
  FDF: AnsiString;
  OldSep: Char;
begin
  Source := TPDFlib.Create;
  Target := TPDFlib.Create;
  try
    Source.NewPages(1);                     // nu två sidor
    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 := ',';   // simulera ett tyskt eller franskt skrivbord
    try
      FDF := Source.ExportAnnotationsToFDFString;   // skriver fortfarande /Rect [50.5 ...
      Target.ImportAnnotationsFromFDFString(FDF);
    finally
      FormatSettings.DecimalSeparator := OldSep;
    end;

    Target.SelectPage(2);
    Assert(Target.AnnotationCount = 2);           // noten och dess popup
    Assert(Target.GetAnnotType(1) = 'Text');
    Assert(Target.ExportAnnotationsToFDFString = Source.ExportAnnotationsToFDFString);
  finally
    Target.Free;
    Source.Free;
  end;
end;

Var tydlig med vad FDF-vägen bär med sig. Importören bygger upp varje post som en ordbok med /Type, /Subtype, /Rect, /Contents, /T och /Subj; färg, flaggor, kantstil, popup-länkar och appearance streams är inte en del av den rutten, och exportören hoppar över Widget-annoteringar eftersom formulärfält hör hemma hos formulärdatametoderna. Den vidare kartan över vilken data som färdas genom vilken metod finns i översikten om FDF-, XFDF- och XFA-formulärdatautbyte, och om du behöver inspektera vad som faktiskt anlänt täcks per-index-läsarna som GetAnnotType, GetAnnotTitle och GetAnnotContentsEx i introspektion av dispositioner, annoteringar och actions

Hur läser du FDF- och XFDF-filer med komma-decimaler från äldre exporter?

För FDF är svaret entydigt: ett komma är ingen avgränsare i PDF-syntax, så en tal-token som innehåller exakt ett komma och ingen punkt kan bara vara en decimal skriven på en maskin med komma-locale. Tidigare versioner skrev faktiskt sådana filer, till exempel /Rect [10,500 20,250 40,750 60,125], och den nya ReadNumber förvandlar det enskilda kommat till en punkt före parsning. En token med två komman, eller med ett komma och en punkt, avvisas i stället för att gissas fram. Läsaren konsumerar inte heller exponentnotation, vilket stämmer med ISO 32000-1 §7.3.3: PDF-tal använder den aldrig

XFDF är knepigare, för i XML-attribut är kommat avgränsaren. Standard-XFDF (ISO 19444-1) skriver rect="50.5,80.25,70.75,100.125" och dashes="4,2", medan v3.539.28 och tidigare, på ett system med komma-locale, skrev rect="50,500 80,250 70,750 100,125" och opacity="0,600", och dessutom fallerade med EConvertError vid läsning av en standard opacity="0.6". Sedan v3.539.29 är båda riktningarna invarianta, och den äldre formen känns igen av XFDFNormalizeLegacyDecimals bara när attributet delas på blanksteg till exakt det förväntade antalet token (fyra för rect, ett för opacity och width) och varje token har formen siffror-komma-siffror. En standard-rect matchar aldrig: den är antingen en token med tre komman eller token som slutar med komma. dashes lämnas medvetet orörd, eftersom 4,2 kan vara två strecklängder eller ett äldre 4.2, och ingen regel kan skilja dem åt

const
  // Nycklar i omvänd exporterordning, plus komma-decimaler från en äldre 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;               // ett nytt dokument har en sida
  try
    Lib.ImportAnnotationsFromFDFString(LegacyFDF);
    Assert(Lib.AnnotationCount = 1);
    Assert(Lib.GetAnnotTitle(1) = 'Alpha');
    // Återexporterad som XFDF med punkt-decimaler: rect="10.500 20.250 40.750 60.125"
    Writeln(Lib.ExportAnnotationsToXFDFString);
  finally
    Lib.Free;
  end;
end;

Vad ska ett annoteringsimporttest egentligen göra påståenden om?

Ett användbart importtest gör sina påståenden om måldokumentets tillstånd, aldrig bara om vad importören säger om sig själv. Ingenting i testsviten kontrollerade AnnotationCount efter en FDF-import, och returvärdet, det enda tal någon tittade på, var det enda tal buggen lämnade intakt. Tre påståenden hade fångat varje defekt som beskrivs här: annoteringsantalet på den förväntade sidan, ett fält läst tillbaka via GetAnnotType eller GetAnnotContentsEx, och en andra export jämförd byte för byte med den första. Samma disciplin gäller alla API:er som skriver om dokumentstruktur i bulk, inklusive fältsammanfogningen som beskrivs i att slå ihop duplicerade formulärfält: kontrollera det resulterande trädet, inte en returnerad summa. FDF- och XFDF-annoteringsmetoderna, med sina fil- och strängvarianter, följer med i losLab PDF Library for Delphi and C++Builder, och v3.539.30 eller senare är versionen att köra om kommentarer måste överleva färden, v3.539.40 eller senare om det returnerade antalet måste matcha det som lades till