Teknisk artikkel

FDF-annotasjonsimport i Delphi: å fikse den stille nullen

Før v3.539.30 returnerte TPDFlib.ImportAnnotationsFromFDFString i losLab PDF Library antallet FDF-annotasjonsoppføringer den hadde parset, mens den la ingen av dem inn i dokumentet: hver oppføring ble telt, hver oppføring ble kastet. Siden v3.539.30 leser FDF-importøren nøkler i vilkårlig rekkefølge, parser /Rect riktig og uavhengig av locale, og den tilhørende eksportøren skriver annotasjonens ekte /Rect, så en eksport, en import og en andre eksport gir byteidentisk FDF. Resten av dette notatet forklarer hvordan ett feil startoffset ga en perfekt stille feil, hvilke tre andre defekter som gjemte seg bak den, og hvordan du sjekker en import selv i stedet for å stole på returverdien

Scenarioet er helt vanlig. En korrekturleser kommenterer opp en kontrakt, kommentarene reiser som en FDF-fil (Acrobat kaller det Export Comments), og Delphi-tjenesten din fletter dem inn i en ren kopi med ImportAnnotationsFromFDF. Kallet returnerer 7, loggen sier «7 comments imported», jobben blir grønn, og utdata-PDF-en har ingen kommentarer i det hele tatt. Ingenting reist, ingen advarsel, og tallet så plausibelt ut fordi det var det ekte antallet oppføringer i filen. Det er den verste formen en bug kan ta: en funksjon hvis eneste suksesssignal er en teller som beregnes uavhengig av arbeidet den hevder å rapportere

Hvorfor rapporterte ImportAnnotationsFromFDFString suksess men la inn ingenting?

Importøren leste hver /Subtype som en tom streng, og hjelperen som oppretter annotasjonen, avslutter tidlig på en tom subtype mens kalleren øker resultatet likevel. Nøkkelsøkeren returnerte posisjonen rett etter /Subtype, som er whitespace-en foran verdien. ReadName startet på det mellomrommet og stoppet ved det første whitespace-tegnet, så den stoppet før den leste noe som helst. AddAnnotationToPage nekter å bygge en annotasjon uten subtype, som er det riktige defensive valget isolert sett, men den var en prosedyre uten returverdi, og Inc(Result) satt utenfor den. Hver vakt var fornuftig alene; sammen forvandlet de «ingenting fungerte» til «alt fungerte». Fiksen gjør at ReadName hopper over whitespace, krever den ledende /-en i et PDF name-objekt og stopper ved ethvert skilletegn, inkludert [, ( og ), så både /Subtype/Text og /Subtype /Text gir Text

PDFlibPas ImportAnnotationsFromFDFString fant /Subtype, startet ReadName på whitespace-en etter nøkkelen så den returnerte et tomt navn, AddAnnotationToPage avsluttet på den manglende subtypen, og kalleren økte resultatet likevel, rapporterte syv importerte kommentarer mens den la ingen inn i dokumentet
Hver vakt var fornuftig isolert sett; sammen forvandlet de ingenting fungerte til alt fungerte, og det er derfor returverdien aldri må være det eneste en importtest sjekker

Returverdien fortjente omsorg selv etter den fiks-en. Gjennom v3.539.39 økte ImportAnnotationsFromFDFString fortsatt resultatet for hver velformet ordbok i /Annots-arrayen, inkludert oppføringer hvis 0-baserte /Page var utenfor området eller hvis /Subtype manglet, noe som begge hoppes over. Siden PDFlibPas v3.539.40 returnerer ImportAnnotationsFromFDFString og ImportAnnotationsFromFDF antallet annotasjoner som faktisk ble lagt inn, som XFDF-importen: FDF-hjelperen AddAnnotationToPage returnerer nå en Boolean, og telleren flytter seg bare ved suksess. Å måle dokumentet er fortsatt den sterkere sjekken, fordi den også holder på eldre versjoner, så skissen under sammenligner AnnotationCount på hver side før og etter 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 valgt side, widgeter inkludert
  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   // likt siden 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 flere defekter bak den første

Å fikse bare subtypen ville ha avslørt tre ytterligere buger i samme funksjon, som hver hadde vært usynlige bare fordi ingen annotasjon noen gang nådde en side. For det første tok ReadNumber posisjonen sin som value-parameter, så å lese de fire /Rect-tallene i rekkefølge leste samme sted fire ganger, og den hoppet ikke over åpningens [, så i praksis leste den ingenting i det hele tatt. For det andre delte FindKey én fremoverbevegelig cursor mellom alle oppslagene. Eksportøren skriver /Subtype, /Rect, /Page, /Contents, /T, /Subj, men importøren søkte i rekkefølgen /Subtype, /Contents, /T, /Subj, /Page, /Rect; når cursoren først hadde passert /Contents, rant søket etter /Page og /Rect forbi gjeldende oppføring og fant enten ingenting eller matchet neste annotasjons nøkler. Biblioteket klarte ikke å lese sitt eget output. For det tredje gikk tallene gjennom PLStrToFloat, som følger systemets desimalseparator. ISO 32000-1 §12.7.7 definerer FDF som PDF-objektsyntaks, og ordboknøkler i PDF er uordnede (§7.3.7), så enhver FDF-parser som antar en nøkkelrekkefølge, er feil av konstruksjon, uansett hvilket verktøy som produserte filen

Den reparerte importøren avgrenser hver oppføring først. FindDictEnd går fra den åpnende << til sin matchende >>, sporer nestede ordbøker og hopper over literal-strengkropper med deres backslash-escapes, så en >> inne i en kommentar som (see section >> 4) ikke kan avslutte oppføringen tidlig. Hvert nøkkeloppslag starter så på oppføringens egen start og er begrenset til dens slutt, noe som gjør nøkkelrekkefølgen irrelevant og stopper én annotasjon fra å låne en annens /Page. Nøkkelmatchen godtar også et skilletegn rett etter navnet, fordi /Contents(Hi) er like gyldig som /Contents (Hi), mens ordgrenseregelen holder /Subj fra å matche starten på /Subtype og /T fra å matche /Type. ReadNumber tar nå posisjonen sin som var-parameter, hopper over whitespace og [, og parser med PLTryStrToFloatInvariant, som feiler mykt på en misdannet token i stedet for å reise. Feiler noen av de fire rektangeltallene, faller alle fire tilbake til null i stedet for å produsere et halvlest rektangel

PDFlibPas FindDictEnd avgrenser nå hver FDF-annotasjon fra sin åpnende << til matchende >>, så hvert nøkkeloppslag starter på nytt ved oppføringens start og stopper ved dens slutt, og ReadNumber tar en var-posisjon, hopper over klammen og parser med PLTryStrToFloatInvariant
Den delte cursoren klarte ikke å lese bibliotekets egen eksport: når den først hadde passert /Contents, rant søkene etter /Page og /Rect inn i neste annotasjons nøkler, så nøkkelrekkefølgen får ikke lenger bety noe

Hvorfor forskjøv en FDF-rundtur hver annotasjon med sin egen høyde?

Den gamle eksportøren skrev et rektangel i feil koordinatmodell. En annotasjons /Rect er [llx lly urx ury] i default user space (ISO 32000-1 §12.5.2, med rektangler definert i §7.9.5), og FDF bærer samme array. ExportAnnotationsToFDFString kalte imidlertid GetAnnotRectEx, som rapporterer Left, Top, Width og Height i bibliotekets tegnecoordinater, rommet som SetOrigin styrer, og serialiserte dem som [L T L+W T+H]. Importøren, når den først fungerte, skrev de fire verdiene rett tilbake som et PDF-rektangel, så toppkanten landet der nedre venstre hjørne hørte hjemme, og hver rundtur flyttet annotasjonen opp med sin egen høyde. Eksportøren kopierer nå annotasjonens egne /Rect-tall, tre desimaler, punktumseparator, ingen eksponent, og faller bare tilbake til det beregnede rektangelet når det lagrede arrayet mangler eller ikke har fire tall

PDFlibPas serialiserte FDF /Rect som left, top, width, height i tegnecoordinater, så import av de fire tallene tilbake som llx lly urx ury landet toppkanten der nedre venstre hjørne hørte hjemme og flyttet hver annotasjon opp med sin egen høyde på hver rundtur
Eksportøren kopierer nå annotasjonens egne /Rect-tall — tre desimaler, punktumseparator, ingen eksponent — og regresjonstesten sammenligner en andre eksport byte for byte med den første

Regresjonstesten som fester dette, er verdt å kopiere, fordi den hevder på dokumentet og på en andre eksport, ikke på importørens returverdi. Merk det forventede antallet 2: AddNoteAnnotation oppretter en Text-annotasjon pluss sin Popup, og begge reiser. Testen kjører også eksporten og importen under et komma som desimalseparator, og det er der den andre halvdelen av denne historien bor

var
  Source, Target: TPDFlib;
  FDF: AnsiString;
  OldSep: Char;
begin
  Source := TPDFlib.Create;
  Target := TPDFlib.Create;
  try
    Source.NewPages(1);                     // nå to sider
    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 := ',';   // simuler et tysk eller fransk skrivebord
    try
      FDF := Source.ExportAnnotationsToFDFString;   // skriver fortsatt /Rect [50.5 ...
      Target.ImportAnnotationsFromFDFString(FDF);
    finally
      FormatSettings.DecimalSeparator := OldSep;
    end;

    Target.SelectPage(2);
    Assert(Target.AnnotationCount = 2);           // notatet og popup-en
    Assert(Target.GetAnnotType(1) = 'Text');
    Assert(Target.ExportAnnotationsToFDFString = Source.ExportAnnotationsToFDFString);
  finally
    Target.Free;
    Source.Free;
  end;
end;

Vær klar over hva FDF-stien bærer med seg. Importøren bygger hver oppføring på nytt som en ordbok med /Type, /Subtype, /Rect, /Contents, /T og /Subj; farge, flagg, kantstil, popup-lenker og appearance-streams er ikke en del av denne ruten, og eksportøren hopper over Widget-annotasjoner fordi skjemafeltene hører til form-data-metodene. Det bredere kartet over hvilke data som reiser gjennom hvilken metode, finner du i oversikten over FDF-, XFDF- og XFA-formdatautveksling, og hvis du trenger å inspisere hva som faktisk ankom, dekkes per-indeks-leserne som GetAnnotType, GetAnnotTitle og GetAnnotContentsEx i outline-, annotasjons- og action-introspeksjon

Hvordan leser du FDF- og XFDF-filer med kommadeimaler fra eldre eksporter?

For FDF er svaret entydig: et komma er ikke et skilletegn i PDF-syntaks, så en token som inneholder nøyaktig ett komma og ingen punktum, kan bare være et desimaltall skrevet på en komma-locale-maskin. Tidligere versjoner skrev slike filer, for eksempel /Rect [10,500 20,250 40,750 60,125], og den nye ReadNumber gjør det eneste kommaet om til et punktum før parsing. En token med to kommaer, eller et komma og et punktum, avvises i stedet for å gjettes på. Leseren konsumerer heller ikke eksponentnotasjon, noe som samsvarer med ISO 32000-1 §7.3.3: PDF-numre bruker den aldri

XFDF er vanskeligere, for i XML-attributter er kommaet separatoren. Standard XFDF (ISO 19444-1) skriver rect="50.5,80.25,70.75,100.125" og dashes="4,2", mens v3.539.28 og tidligere, på et komma-locale-system, skrev rect="50,500 80,250 70,750 100,125" og opacity="0,600", og dessuten feilet med EConvertError ved lesing av en standard opacity="0.6". Siden v3.539.29 er begge retninger invariante, og den gamle formen gjenkjennes av XFDFNormalizeLegacyDecimals bare når attributten splittes på whitespace til nøyaktig det forventede antallet token (fire for rect, én for opacity og width) og hver token har formen siffer-komma-siffer. Et standard rect matcher aldri: det er enten én token med tre kommaer eller token som slutter med et komma. dashes er med vilje latt i fred, for 4,2 kan være to streklengder eller en gammel 4.2, og ingen regel kan skille dem

const
  // Nøkler i annen rekkefølge enn eksportøren, pluss kommadeimaler fra en eldre komma-locale-eksport
  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;               // et ferskt dokument har én side
  try
    Lib.ImportAnnotationsFromFDFString(LegacyFDF);
    Assert(Lib.AnnotationCount = 1);
    Assert(Lib.GetAnnotTitle(1) = 'Alpha');
    // Re-eksportert som XFDF med punktum som desimaltegn: rect="10.500 20.250 40.750 60.125"
    Writeln(Lib.ExportAnnotationsToXFDFString);
  finally
    Lib.Free;
  end;
end;

Hva bør en annotasjonsimporttest egentlig hevde?

En nyttig importtest hevder på tilstanden til måldokumentet, aldri bare på hva importøren sier om seg selv. Ingenting i testpakken sjekket AnnotationCount etter en FDF-import, og returverdien, det eneste tallet noen så på, var det eneste tallet bug-en lot stå intakt. Tre påstander ville ha fanget hver defekt beskrevet her: annotasjonsantallet på den forventede siden, ett felt lest tilbake gjennom GetAnnotType eller GetAnnotContentsEx, og en andre eksport sammenlignet byte for byte med den første. Samme disiplin gjelder enhver API som omskriver dokumentstruktur i bulk, inkludert feltkonsolideringen beskrevet i fletting av duplikate skjemafelt: sjekk det resulterende treet, ikke en returnert total. FDF- og XFDF-annotasjonsmetodene, med sine fil- og strengvarianter, følger med losLab PDF Library for Delphi og C++Builder, og v3.539.30 eller nyere er versjonen å kjøre hvis kommentarene skal overleve turen, v3.539.40 eller nyere hvis den returnerte tellingen må matche det som ble lagt inn