Τεχνικό Άρθρο

Σχόλια FDF στο Delphi: διόρθωση του σιωπηλού μηδενός

Πριν το v3.539.30, το TPDFlib.ImportAnnotationsFromFDFString στο losLab PDF Library επέστρεφε τον αριθμό των εγγραφών σχολίων FDF που είχε κάνει parse χωρίς να προσθέσει καμία στο έγγραφο: κάθε εγγραφή μετριόταν, κάθε εγγραφή πετιόταν. Από το v3.539.30 ο FDF importer διαβάζει keys με οποιαδήποτε σειρά, κάνει parse το /Rect σωστά και ανεξάρτητα από locale, και ο αντίστοιχος exporter γράφει το πραγματικό /Rect του σχολίου, οπότε ένα export, μια εισαγωγή και ένα δεύτερο export βγάζουν byte-πανομοιότυπο FDF. Η υπόλοιπη σημείωση εξηγεί πώς ένα λάθος αρχικό offset παρήγαγε ένα τέλειο σιωπηλό failure, ποια τρία άλλα ελαττώματα κρύβονταν πίσω του, και πώς να ελέγξετε μόνοι σας μια εισαγωγή αντί να εμπιστεύεστε την τιμή επιστροφής

Το σενάριο είναι συνηθισμένο. Ένας reviewer σχολιάζει ένα συμβόλαιο, τα σχόλια ταξιδεύουν ως αρχείο FDF (το Acrobat το λέει Export Comments), και η υπηρεσία σας στο Delphi τα συγχωνεύει σε ένα καθαρό αντίγραφο με ImportAnnotationsFromFDF. Η κλήση επιστρέφει 7, το log λέει «7 comments imported», η δουλειά βγαίνει πράσινη, και το PDF εξόδου δεν έχει κανένα σχόλιο. Τίποτα δεν πετάχτηκε, τίποτα δεν προειδοποίησε, και ο αριθμός έμοιαζε πειστικός γιατί ήταν το αληθινό πλήθος των εγγραφών του αρχείου. Αυτό είναι το χειρότερο σχήμα που μπορεί να πάρει ένα bug: μια συνάρτηση του οποίου το μόνο σήμα επιτυχίας είναι ένας μετρητής που υπολογίζεται ανεξάρτητα από τη δουλειά που ισχυρίζεται ότι αναφέρει

Γιατί το ImportAnnotationsFromFDFString αναφέρει επιτυχία αλλά δεν προσθέτει τίποτα;

Ο importer διάβαζε κάθε /Subtype ως κενό string, και ο helper που δημιουργεί το σχόλιο τερματίζει νωρίς σε κενό subtype ενώ ο caller αυξάνει το αποτέλεσμα ούτως ή άλλως. Ο finder keys επέστρεφε τη θέση αμέσως μετά το /Subtype, που είναι το whitespace πριν την τιμή. Το ReadName ξεκινούσε από αυτό το κενό και σταματούσε στον πρώτο χαρακτήρα whitespace, οπότε σταματούσε πριν διαβάσει οτιδήποτε. Το AddAnnotationToPage αρνείται να χτίσει σχόλιο χωρίς subtype, που είναι η σωστή αμυντική επιλογή μεμονωμένα, αλλά ήταν procedure χωρίς τιμή επιστροφής, και το Inc(Result) καθόταν έξω από αυτό. Κάθε guard ήταν λογικός από μόνος του· μαζί μετέτρεπαν το «τίποτα δεν δούλεψε» σε «όλα δούλεψαν». Το fix κάνει το ReadName να προσπερνά whitespace, να απαιτεί το αρχικό / ενός PDF name object, και να σταματά σε οποιονδήποτε delimiter, συμπεριλαμβανομένων των [, ( και ), ώστε τα /Subtype/Text και /Subtype /Text να δίνουν και τα δύο Text

Το ImportAnnotationsFromFDFString του PDFlibPas βρήκε το /Subtype, ξεκίνησε το ReadName πάνω στο whitespace μετά το key ώστε να επιστρέψει κενό όνομα, το AddAnnotationToPage τερμάτισε για το λείπον subtype, και ο caller αύξησε το αποτέλεσμα ούτως ή άλλως, αναφέροντας επτά εισαγμένα σχόλια χωρίς να προσθέσει κανένα στο έγγραφο
Κάθε guard ήταν λογικός μεμονωμένα· μαζί μετέτρεπαν το «τίποτα δεν δούλεψε» σε «όλα δούλεψαν», γι' αυτό η τιμή επιστροφής δεν πρέπει ποτέ να είναι το μόνο που ελέγχει ένα τεστ εισαγωγής

Η τιμή επιστροφής χρειαζόταν προσοχή ακόμα και μετά από εκείνο το fix. Ως το v3.539.39, το ImportAnnotationsFromFDFString αύξανε ακόμα το αποτέλεσμά του για κάθε καλοσχηματισμένο dictionary στον πίνακα /Annots, συμπεριλαμβανομένων εγγραφών της οποίας το 0-based /Page ήταν εκτός εύρους ή της οποίας έλειπε το /Subtype, που και οι δύο παραλείπονται. Από το PDFlibPas v3.539.40, το ImportAnnotationsFromFDFString και το ImportAnnotationsFromFDF επιστρέφουν τον αριθμό των σχολίων που πραγματικά προστέθηκαν, όπως η εισαγωγή XFDF: ο FDF helper AddAnnotationToPage τώρα επιστρέφει Boolean και ο μετρητής κινείται μόνο σε επιτυχία. Η μέτρηση του εγγράφου παραμένει ο ισχυρότερος έλεγχος, γιατί ισχύει και σε παλαιότερες εκδόσεις, οπότε το σκίτσο παρακάτω συγκρίνει το AnnotationCount σε κάθε σελίδα πριν και μετά την εισαγωγή

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);   // ανά επιλεγμένη σελίδα, με τα widgets
  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   // ίσα από το 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;

Τρία ακόμα ελαττώματα πίσω από το πρώτο

Η διόρθωση μόνο του subtype θα είχε φέρει στην επιφάνεια τρία ακόμα bugs στην ίδια συνάρτηση, το καθένα αόρατο μόνο και μόνο επειδή κανένα σχόλιο δεν έφτανε ποτέ σε σελίδα. Πρώτον, το ReadNumber έπαιρνε τη θέση του ως value parameter, οπότε η ανάγνωση των τεσσάρων αριθμών /Rect στη σειρά διάβαζε το ίδιο σημείο τέσσερις φορές, και δεν προσπερνούσε το αρχικό [, οπότε στην πράξη δεν διάβαζε τίποτα απολύτως. Δεύτερον, το FindKey μοιραζόταν έναν μόνο κέρσορα που προχωρούσε μπροστά σε όλα τα lookups. Ο exporter γράφει /Subtype, /Rect, /Page, /Contents, /T, /Subj, αλλά ο importer έψαχνε με τη σειρά /Subtype, /Contents, /T, /Subj, /Page, /Rect· μόλις ο κέρσορας είχε περάσει το /Contents, η αναζήτηση για /Page και /Rect έτρεχε πέρα από την τρέχουσα εγγραφή και είτε δεν έβρισκε τίποτα είτε ταίριαζε τα keys του επόμενου σχολίου. Η βιβλιοθήκη δεν μπορούσε να διαβάσει το δικό της output. Τρίτον, οι αριθμοί περνούσαν από το PLStrToFloat, που ακολουθεί το δεκαδικό διαχωριστικό του συστήματος. Το ISO 32000-1 §12.7.7 ορίζει το FDF ως σύνταξη PDF αντικειμένων, και τα dictionary keys στο PDF δεν έχουν σειρά (§7.3.7), οπότε οποιοσδήποτε FDF parser υποθέτει σειρά keys είναι λάθος εκ κατασκευής, όποιο κι αν είναι το εργαλείο που παρήγαγε το αρχείο

Ο επιδιορθωμένος importer φράσσει πρώτα κάθε εγγραφή. Το FindDictEnd περπατά από το ανοίγον << μέχρι το αντίστοιχό του >>, παρακολουθώντας εμφωλευμένα dictionaries και προσπερνώντας τα σώματα literal strings μαζί με τα backslash escapes τους, ώστε ένα >> μέσα σε ένα σχόλιο όπως (see section >> 4) δεν μπορεί να κλείσει την εγγραφή νωρίς. Κάθε αναζήτηση key ξεκινά μετά στο δικό της ξεκίνημα της εγγραφής και φράσσεται στο τέλος της, κάτι που κάνει τη σειρά keys άσχετη και σταματά ένα σχόλιο από το να δανειστεί το /Page άλλου. Το ταίριασμα key δέχεται επίσης delimiter αμέσως μετά το όνομα, επειδή το /Contents(Hi) είναι εξίσου έγκυρο με το /Contents (Hi), ενώ ο κανόνας του word boundary εμποδίζει το /Subj να ταιριάξει την αρχή του /Subtype και το /T να ταιριάξει το /Type. Το ReadNumber τώρα παίρνει τη θέση του ως var parameter, προσπερνά whitespace και [, και κάνει parse με το PLTryStrToFloatInvariant, που αποτυγχάνει ήπια σε κακοσχηματισμένο token αντί να πετάει exception. Αν αποτύχει οποιοσδήποτε από τους τέσσερις αριθμούς του ορθογωνίου, και οι τέσσερις γυρνούν στο μηδέν αντί να βγάλουν μισοδιαβασμένο ορθογώνιο

Το FindDictEnd του PDFlibPas τώρα φράσσει κάθε σχόλιο FDF από το ανοίγον << μέχρι το αντίστοιχο >>, ώστε κάθε αναζήτηση key να ξεκινά από την αρχή της εγγραφής και σταματά στο τέλος της, και το ReadNumber παίρνει var θέση, προσπερνά την αγκύλη και κάνει parse με PLTryStrToFloatInvariant
Ο κοινόχρηστος κέρσορας δεν μπορούσε να διαβάσει το δικό του export της βιβλιοθήκης: μόλις περνούσε το /Contents, οι αναζητήσεις /Page και /Rect έπεφταν στα keys του επόμενου σχολίου, οπότε η σειρά των keys δεν επιτρέπεται πια να μετράει

Γιατί τα round-trips του FDF μετακινούσαν κάθε σχόλιο κατά το ύψος του;

Ο παλιός exporter έγραφε ορθογώνιο σε λάθος μοντέλο συντεταγμένων. Το /Rect ενός σχολίου είναι [llx lly urx ury] στο default user space (ISO 32000-1 §12.5.2, με τα ορθογώνια ορισμένα στο §7.9.5), και το FDF κουβαλά τον ίδιο πίνακα. Το ExportAnnotationsToFDFString, όμως, καλούσε το GetAnnotRectEx, που αναφέρει Left, Top, Width και Height στις drawing συντεταγμένες της βιβλιοθήκης, τον χώρο που ελέγχει το SetOrigin, και τα σειριοποιούσε ως [L T L+W T+H]. Ο importer, μόλις άρχισε να δουλεύει, έγραφε αυτές τις τέσσερις τιμές αναλλοίωτες πίσω ως PDF ορθογώνιο, οπότε η πάνω ακμή έπεφτε εκεί που ανήκε η κάτω αριστερή γωνία, και κάθε round trip ανέβαζε το σχόλιο κατά το δικό του ύψος. Ο exporter τώρα αντιγράφει τους δικούς του αριθμούς /Rect του σχολίου, τρία δεκαδικά, διαχωριστικό τελεία, χωρίς εκθέτη, και γυρίζει στο υπολογισμένο ορθογώνιο μόνο όταν ο αποθηκευμένος πίνακας λείπει ή δεν έχει τέσσερις αριθμούς

Το PDFlibPas σειριοποιούσε παλιά το FDF /Rect ως left, top, width, height σε drawing συντεταγμένες, οπότε η εισαγωγή εκείνων των τεσσάρων αριθμών πίσω ως llx lly urx ury έριχνε την πάνω ακμή εκεί που ανήκε η κάτω αριστερή γωνία και ανέβαζε κάθε σχόλιο κατά το ύψος του σε κάθε round trip
Ο exporter τώρα αντιγράφει τους δικούς του αριθμούς /Rect του σχολίου — τρία δεκαδικά, διαχωριστικό τελεία, χωρίς εκθέτη — και το regression test συγκρίνει ένα δεύτερο export byte προς byte με το πρώτο

Το regression test που καρφώνει αυτή τη συμπεριφορά αξίζει αντιγραφή, γιατί κάνει assert πάνω στο έγγραφο και σε ένα δεύτερο export, όχι πάνω στην τιμή επιστροφής του importer. Προσέξτε το αναμενόμενο πλήθος 2: το AddNoteAnnotation δημιουργεί ένα σχόλιο Text μαζί με το Popup του, και τα δύο ταξιδεύουν. Το τεστ τρέχει επίσης το export και την εισαγωγή κάτω από κόμμα ως δεκαδικό διαχωριστικό, και εκεί ζει το άλλο μισό της ιστορίας

var
  Source, Target: TPDFlib;
  FDF: AnsiString;
  OldSep: Char;
begin
  Source := TPDFlib.Create;
  Target := TPDFlib.Create;
  try
    Source.NewPages(1);                     // τώρα δύο σελίδες
    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 := ',';   // προσομοίωση γερμανικού ή γαλλικού desktop
    try
      FDF := Source.ExportAnnotationsToFDFString;   // συνεχίζει να γράφει /Rect [50.5 ...
      Target.ImportAnnotationsFromFDFString(FDF);
    finally
      FormatSettings.DecimalSeparator := OldSep;
    end;

    Target.SelectPage(2);
    Assert(Target.AnnotationCount = 2);           // η σημείωση και το popup της
    Assert(Target.GetAnnotType(1) = 'Text');
    Assert(Target.ExportAnnotationsToFDFString = Source.ExportAnnotationsToFDFString);
  finally
    Target.Free;
    Source.Free;
  end;
end;

Έχετε καθαρά τι κουβαλά το μονοπάτι FDF. Ο importer ξαναχτίζει κάθε εγγραφή ως dictionary με /Type, /Subtype, /Rect, /Contents, /T και /Subj· χρώμα, flags, στυλ border, popup συνδέσεις και appearance streams δεν ανήκουν σε αυτή τη διαδρομή, και ο exporter παραλείπει τα σχόλια Widget επειδή τα form fields ανήκουν στις μεθόδους form-data. Ο ευρύτερος χάρτης του ποια δεδομένα ταξιδεύουν μέσω ποιου μεθόδου βρίσκεται στην επισκόπηση του ανταλλαγής form δεδομένων FDF, XFDF και XFA, και αν θέλετε να εξετάσετε τι έφτασε πραγματικά, οι readers ανά δείκτη όπως το GetAnnotType, το GetAnnotTitle και το GetAnnotContentsEx καλύπτονται στο introspection outline, σχολίων και ενεργειών

Πώς διαβάζετε αρχεία FDF και XFDF με κόμμα ως δεκαδικό από παλιότερα exports;

Για το FDF η απάντηση είναι σαφής: το κόμμα δεν είναι delimiter στη σύνταξη PDF, οπότε ένα αριθμητικό token που περιέχει ακριβώς ένα κόμμα και καμία τελεία μπορεί να είναι μόνο ένα δεκαδικό γραμμένο σε μηχανή με κόμμα στο locale. Παλαιότερες εκδόσεις όντως έγραφαν τέτοια αρχεία, για παράδειγμα /Rect [10,500 20,250 40,750 60,125], και το νέο ReadNumber μετατρέπει εκείνο το μοναδικό κόμμα σε τελεία πριν το parse. Ένα token με δύο κόμματα, ή με κόμμα και τελεία, απορρίπτεται αντί να μαντεύεται. Ο reader δεν καταναλώνει ούτε notation εκθέτη, που ταιριάζει με το ISO 32000-1 §7.3.3: οι αριθμοί PDF δεν τον χρησιμοποιούν ποτέ

Το XFDF είναι δυσκολότερο, επειδή στα XML attributes το κόμμα είναι ο διαχωριστής. Το πρότυπο XFDF (ISO 19444-1) γράφει rect="50.5,80.25,70.75,100.125" και dashes="4,2", ενώ το v3.539.28 και παλαιότερα, σε σύστημα με κόμμα στο locale, έγραφαν rect="50,500 80,250 70,750 100,125" και opacity="0,600", και απέτυχαν επίσης με EConvertError όταν διάβαζαν ένα πρότυπο opacity="0.6". Από το v3.539.29 και οι δύο κατευθύνσεις είναι invariant, και το legacy σχήμα αναγνωρίζεται από το XFDFNormalizeLegacyDecimals μόνο όταν το attribute χωρίζεται σε whitespace σε ακριβώς τον αναμενόμενο αριθμό tokens (τέσσερα για rect, ένα για opacity και width) και κάθε token έχει τη μορφή ψηφία-κόμμα-ψηφία. Ένα πρότυπο rect δεν ταιριάζει ποτέ: είναι είτε ένα token με τρία κόμματα είτε tokens που τελειώνουν σε κόμμα. Το dashes μένει σκόπιμα απ' έξω, επειδή το 4,2 μπορεί να είναι δύο μήκη παύλας ή ένα legacy 4.2, και κανένας κανόνας δεν μπορεί να τα ξεχωρίσει

const
  // Keys εκτός σειράς exporter, με δεκαδικά κόμματος από παλιότερο export σε locale με κόμμα
  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;               // ένα φρέσκο έγγραφο έχει μία σελίδα
  try
    Lib.ImportAnnotationsFromFDFString(LegacyFDF);
    Assert(Lib.AnnotationCount = 1);
    Assert(Lib.GetAnnotTitle(1) = 'Alpha');
    // Ξαναεξάγεται ως XFDF με δεκαδικά τελείας: rect="10.500 20.250 40.750 60.125"
    Writeln(Lib.ExportAnnotationsToXFDFString);
  finally
    Lib.Free;
  end;
end;

Τι πρέπει πραγματικά να ελέγχει ένα τεστ εισαγωγής σχολίων;

Ένα χρήσιμο τεστ εισαγωγής κάνει assert πάνω στην κατάσταση του εγγράφου-στόχου, ποτέ μόνο πάνω σε ό,τι λέει ο importer για τον εαυτό του. Τίποτα στη σουίτα τεστ δεν έλεγχε το AnnotationCount μετά από εισαγωγή FDF, και η τιμή επιστροφής, ο μόνος αριθμός που κοιτούσε κανείς, ήταν ο ένας αριθμός που το bug άφηνε ανέπαφο. Τρεις έλεγχοι θα είχαν πιάσει κάθε ελάττωμα που περιγράφεται εδώ: το πλήθος σχολίων στην αναμενόμενη σελίδα, ένα πεδίο διαβασμένο πίσω μέσω GetAnnotType ή GetAnnotContentsEx, και ένα δεύτερο export συγκρινόμενο byte προς byte με το πρώτο. Η ίδια πειθαρχία ισχύει για οποιοδήποτε API ξαναγράφει μαζικά τη δομή του εγγράφου, συμπεριλαμβανομένης της ενοποίησης πεδίων που περιγράφεται στο συγχώνευση διπλότυπων form fields: ελέγξτε το προκύπτον δέντρο, όχι ένα επιστρεφόμενο σύνολο. Οι μέθοδοι σχολίων FDF και XFDF, με τις παραλλαγές τους για αρχείο και string, κυκλοφορούν στο losLab PDF Library for Delphi και C++Builder, και v3.539.30 ή νεότερη είναι η έκδοση που θέλετε αν τα σχόλια πρέπει να επιβιώσουν του ταξιδιού, v3.539.40 ή νεότερη αν ο επιστρεφόμενος μετρητής πρέπει να ταιριάζει με ό,τι προστέθηκε