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

Αντιγραφή PDF αντικειμένων σε Delphi: cycles και crashes

Συνενώστε δύο PDF με το χέρι, μετακινήστε ένα απλό page object στο έγγραφο στόχο, και η αντιγραφή πέφτει κατευθείαν σε access violation. Το PDFlibPas το διορθώνει στο CopyForeignObject: αντιγράφει σε βάθος ένα indirect object μαζί με όλο το reference closure του, και επιλύει κυκλικές οπίσθιες αναφορές όπως το /Parent σε null αντί να συνεχίζει αναδρομικά

Γιατί σκάει η αντιγραφή μιας σελίδας μεταξύ εγγράφων;

Επειδή ένα page tree PDF είναι δέντρο μόνο αν το διαβάσετε προς τα κάτω. Περιηγηθείτε το όπως ένας αναδρομικός copier, ακολουθώντας κάθε τιμή σε κάθε λεξικό, και το page dictionary σας δίνει το /Parent, που δείχνει πίσω στον κόμβο /Pages απ' όπου ήρθατε, και εκείνος ο κόμβος σας δίνει το /Kids, που δείχνει πίσω στη σελίδα. Το ISO 32000-1 §7.7.3 κάνει το /Parent υποχρεωτικό σε κάθε κόμβο page tree εκτός από τη ρίζα, οπότε αυτό δεν είναι ένα malformed αρχείο που μπορείτε να απορρίψετε — είναι το κανονικό σχήμα κάθε εγγράφου που θα σας παραδώσουν ποτέ

Το δεύτερο μισό του προβλήματος είναι η αρίθμηση. Τα indirect objects αναγνωρίζονται από object number τοπικό σε ένα αρχείο (ISO 32000-1 §7.3.10), οπότε ένα αντικείμενο που σύρεται από το έγγραφο Α στο έγγραφο Β πρέπει να ξανααριθμηθεί, και κάθε αναφορά σε αυτό μέσα στο αντιγραμμένο closure πρέπει να ξανααριθμηθεί με τον ίδιο τρόπο, αλλιώς δύο αναφορές που έδειχναν σε μια κοινή γραμματοσειρά τώρα δείχνουν σε δύο άσχετα πράγματα. Αυτή η επαναρίθμηση είναι η ίδια δουλειά που κάνει ένα γρήγορο merge σε επίπεδο byte, και αξίζει να διαβάσετε τα δύο παράλληλα: το byte-level reference shifting για γρήγορο PDF merge το λύνει μεταφράζοντας ολόκληρα αρχεία, ενώ μια αντιγραφή σε επίπεδο αντικειμένου πρέπει να το λύσει μία ακμή τη φορά

Γιατί μια αντιγραφή PDF μεταξύ εγγράφων σε Delphi θέλει προσοχή: το page dictionary και ο κόμβος /Pages του κλείνουν κύκλο μέσω /Parent και /Kids, ένα closure γραμματοσειράς τρέχει προς τα κάτω και τερματίζει, και το PDFlibPas ξαναχαρτογραφεί κάθε object number τοπικό στο αρχείο
Το page tree κλείνει βρόχο μέσω /Parent και /Kids ενώ τα content closures τερματίζουν, και κάθε αντιγραμμένο object number πρέπει να ξαναχαρτογραφηθεί στην πορεία

Τι αντιγράφει πραγματικά το CopyForeignObject του PDFlibPas

Το TPDFlib.CopyForeignObject(SourceDocumentID, ObjectNumber) κλωνοποιεί ένα indirect object και ό,τι φτάνει από αυτό — εμφωλευμένα dictionaries, arrays, strings, names, numbers, και streams με τα dictionaries τους ανέπαφα — στο τρέχον επιλεγμένο έγγραφο, και επιστρέφει μη μηδενικό handle στη νέα indirect reference. Οι source object numbers ξαναχαρτογραφούνται μέσω ενός ζωντανού map που κρατιέται για τη διάρκεια της κλήσης, οπότε ένα αντικείμενο που φτάνεται δύο φορές στο closure κλωνοποιείται μία φορά και μοιράζεται δύο. Επιστρέφει μηδέν, χωρίς exception, όταν το source document ID είναι άγνωστο, όταν η πηγή είναι το ίδιο το επιλεγμένο έγγραφο, ή όταν το ObjectNumber είναι κάτω από 1

var
  Lib: TPDFlib;
  SourceDoc, TargetDoc, Handle: Integer;
begin
  Lib := TPDFlib.Create;
  try
    TargetDoc := Lib.NewDocument;
    if Lib.LoadFromFile('source.pdf', '') <> 1 then
      Exit;                              // το LoadFromFile επιστρέφει 1 σε επιτυχία
    SourceDoc := Lib.SelectedDocument;   // το load επέλεξε ό,τι φόρτωσε
    Lib.SelectDocument(TargetDoc);       // η αντιγραφή στοχεύει το επιλεγμένο έγγραφο
    Handle := Lib.CopyForeignObject(SourceDoc, 12);
    if Handle = 0 then
      raise Exception.Create('cross-document copy rejected');
  finally
    Lib.Free;
  end;
end;

Δύο λεπτομέρειες τσιμπούν τους ανθρώπους στην πρώτη εκτέλεση. Το LoadFromFile απαντά 1 ή 0, όχι document ID, οπότε το handle που χρειάζεστε έρχεται από το SelectedDocument αμέσως μετά το load· και η αντιγραφή γράφει πάντα σε ό,τι έκανε τελευταίο τρέχον το SelectDocument, ποτέ στο έγγραφο από το οποίο φορτώσατε. Εσωτερικά η αναδρομή κουβαλά και σκληρό όριο βάθους 64, που είναι δίχτυ ασφαλείας ενάντια σε παθολογική εμφώλευση, όχι ο μηχανισμός που χειρίζεται cycles — ο χειρισμός cycles είναι ξεχωριστός και σκόπιμος

Γιατί η κράτηση μιας αντιστοίχισης Nil δεν σπάει τον κύκλο;

Επειδή το Nil στον πίνακα αντιστοίχισης σημαίνει δύο διαφορετικά πράγματα ταυτόχρονα, και ο κώδικας δεν τα ξεχωρίζει. Η προφανής άμυνα ενάντια σε κύκλο είναι να προστεθεί η εγγραφή map πριν την αναδρομή στο αντικείμενο, ώστε ό,τι επιστρέφει πίσω να βρίσκει την εγγραφή και να σταματά. Αλλά η εγγραφή δεν μπορεί να κρατήσει ακόμα τον πραγματικό στόχο — ο στόχος δεν υπάρχει μέχρι να γραφτεί το closure από κάτω του — οπότε κρατά Nil, και η αναζήτηση που υποτίθεται ότι πιάνει την οπίσθια ακμή διαβάζει Nil και συμπεραίνει ότι το αντικείμενο δεν χαρτογραφήθηκε ποτέ

// Σπασμένο: ένας δεσμευμένος στόχος Nil είναι αδιακρίτως από «δεν έχει χαρτογραφηθεί ακόμα»
NewRef := FindMapped(SrcRef.ObjNum);
if not Assigned(NewRef) then
begin
  SetLength(Map, Length(Map) + 1);
  Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
  Map[High(Map)].Target := nil;          // δεσμευμένο, ακόμα Nil
  NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
  Map[High(Map)].Target := NewRef;       // γεμίζει μόνο στην έξοδο
end;

Ακολουθήστε το μέσα στον βρόχο σελίδων. Ο κλώνος της σελίδας φτάνει στο /Parent, κάνει αναδρομή στον κόμβο /Pages, που φτάνει στο /Kids, που κάνει αναδρομή πίσω στη σελίδα — της οποίας η δεσμευμένη εγγραφή διαβάζει ακόμα Nil, οπότε κλωνοποιείται δεύτερη φορά, και τρίτη, κάθε επίπεδο σπρώχνοντας φρέσκο frame και φρέσκο μισοχτισμένο αντικείμενο. Αυτό που παρατηρείτε δεν είναι ούτε καθαρό stack overflow: τα εξωτερικά frames κάθονται σε αναφορές των οποίων οι στόχοι δεν ανατέθηκαν ποτέ, οπότε η πρώτη εγγραφή μέσα από μία από αυτές τις θέσεις είναι access violation κάπου που δεν μοιάζει καθόλου με την αντιγραφή σελίδας που την προκάλεσε

Γιατί η κράτηση Nil στόχου map δεν σταματά τον κύκλο σε μια αντιγραφή PDFlibPas μεταξύ εγγράφων: η αναζήτηση δεν ξεχωρίζει δεσμευμένη εγγραφή από αχαρτογράφητη, οπότε ο copier κατεβαίνει μέσα από όλο και βαθύτερα μισοχτισμένα frames μέχρι μια εγγραφή να σκάσει
Επειδή ένας στόχος Nil απαντά δύο διαφορετικά ερωτήματα ταυτόχρονα, η οπίσθια ακμή δεν αναγνωρίζεται ποτέ και η σελίδα κλωνοποιείται ξανά σε κάθε πέρασμα

Η διόρθωση: μια ρητή κατάσταση in-progress

Η επισκευή είναι να πάψει η υπερφόρτωση του Nil και να τεθεί το ερώτημα απευθείας. Μια εγγραφή map της οποίας ο στόχος δεν έχει ανατεθεί ακόμα σημαίνει αυτό το αντικείμενο κλωνοποιείται αυτή τη στιγμή, και ένα predicate InProgress τεστάρει ακριβώς αυτό πριν τρέξει η συνηθισμένη αναζήτηση. Όταν είναι true, η ακμή είναι κύκλος πίσω σε πρόγονο του τρέχοντος κλώνου, και το PDFlibPas βγάζει null object για αυτήν αντί να την ακολουθήσει

// Μια εγγραφή map με στόχο Nil σηματοδοτεί κλώνο σε εξέλιξη
function InProgress(Num: Integer): Boolean;
var
  I: Integer;
begin
  Result := False;
  for I := 0 to High(Map) do
    if (Map[I].SourceObjNum = Num) and (not Assigned(Map[I].Target)) then
      Exit(True);
end;

// ... μέσα στο CloneObject, για μια indirect reference:
if InProgress(SrcRef.ObjNum) then
  Exit(FStructure.NewNull);              // κυκλική οπίσθια ακμή, μην κάνεις αναδρομή
NewRef := FindMapped(SrcRef.ObjNum);
if not Assigned(NewRef) then
begin
  SrcInd := SourceDoc.FindObj(SrcRef.ObjNum, SrcRef.GenNum);
  if (not Assigned(SrcInd)) or (not Assigned(SrcInd.Obj)) then
    Exit(FStructure.NewNull);            // κρεμασμένη αναφορά πηγής
  SetLength(Map, Length(Map) + 1);
  Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
  Map[High(Map)].Target := nil;          // δέσμευση, μετά αναδρομή
  NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
  Map[High(Map)].Target := NewRef;       // γέμισμα
end;
Exit(NewRef);

Αυτό είναι ασφαλές να γενικευτεί μόνο λόγω ενός δομικού γεγονότος για το PDF: οι κύκλοι στο γράφημα αντικειμένων εμφανίζονται σε back-links, όχι στις ακμές περιεχομένου. Το /Parent στο page tree και το /Prev σε μια αλυσίδα outline δείχνουν προς τα πάνω ή προς τα πίσω σε κάτι ήδη επισκεφθέν· το closure μιας γραμματοσειράς, ενός image XObject ή ενός form XObject τρέχει προς τα κάτω και τερματίζει. Οπότε μια αντιγραφή font descriptor, χρωματικού χώρου ή shading dictionary δεν επηρεάζεται από την αντικατάσταση null — τίποτα σε αυτά τα closures δεν πέφτει ποτέ στο InProgress. Το κόστος, ειπωμένο ευθέως, είναι ότι η κυκλική ακμή δεν επιβιώνει της αντιγραφής. Ένα page dictionary κλωνοποιημένο έτσι έρχεται με /Parent ως null object, που το ISO 32000-1 §7.3.9 θεωρεί ισοδύναμο με την απουσία εγγραφής, οπότε η αντιγραμμένη σελίδα είναι έγκυρο αντικείμενο που δεν ανήκει σε κανένα page tree μέχρι να το συνδέσετε εσείς στον κόμβο /Pages στόχο και να διορθώσετε το /Count. Ένα αντιγραμμένο outline item χάνει το /Prev με τον ίδιο τρόπο και θέλει την αλυσίδα αδελφών ξαναχτισμένη. Αυτό είναι το τίμιο εμπόριο: το CopyForeignObject σας δίνει σωστό closure και αφήνει το δομικό re-parenting στον καλούντα, που είναι το ίδιο όριο μέσα στο οποίο δουλεύει η αντικατάσταση σελίδων με διατήρηση object numbers

Η διόρθωση στο CopyForeignObject του PDFlibPas για Delphi: ένα ρητό τεστ InProgress τρέχει πριν την αναζήτηση map, μια κυκλική οπίσθια ακμή γίνεται null object, και ο καλών ξανασυνδέει την αντιγραμμένη σελίδα στο page tree στόχο κατόπιν
Μια ρητή κατάσταση in-progress αντικαθιστά το υπερφορτωμένο Nil, οπότε η οπίσθια ακμή επιλύεται σε null και ο καλών μένει με μία δομική επισκευή να κάνει

Γιατί η εγγραφή map πρέπει να δεσμευτεί πριν το NewObjRef

Μια προφανής εναλλακτική θα προσπερνούσε όλο τον χορό του in-progress: δέσμευση πρώτα ενός κενού shell αντικειμένου, καταχώρηση του πραγματικού αριθμού του στο map, και γέμισμα του shell μόλις κλωνοποιηθούν τα παιδιά. Αυτό δεν δουλεύει εδώ, γιατί το TPDFIndObj.Obj είναι read-only και το περιεχόμενό του δεν αντικαθίσταται μετά την κατασκευή — δεν υπάρχει shell να γεμίσετε. Ο αριθμός και το περιεχόμενο αποφασίζονται μαζί από το NewObjRef, που σημαίνει ότι η εγγραφή map πρέπει να δημιουργηθεί πριν την αναδρομική κλήση και να συμπληρωθεί μετά από αυτήν, και το διάστημα ανάμεσα στις δύο στιγμές είναι ακριβώς αυτό που πρέπει να καλύψει το InProgress. Μια συνέπεια που αξίζει να ξέρετε πριν κάνετε diff της εξόδου: επειδή το NewObjRef τρέχει αφού γραφτεί το closure των παιδιών, η αρίθμηση στον στόχο βγαίνει από τα κάτω προς τα πάνω, και τα object numbers δεν θα καθρεφτίσουν τη σειρά της πηγής. Τίποτα στη μορφή αρχείου δεν νοιάζεται, αλλά μια σύγκριση byte ενάντια σε χειροποίητη προσδοκία ναι. Αν μια εκτέλεση αφήσει αντικείμενα που αποφασίσατε να μην συνδέσετε πουθενά, είναι unreferenced και όχι κατεστραμμένα, και η συλλογή mark-and-sweep απρόσιτων αντικειμένων PDF είναι το εργαλείο που τα ξεκαθαρίζει πριν την αποθήκευση

Το regression που καλύπτει αυτό θέλει μία λεπτομέρεια που εκπλήσσει όσους γράφουν τεστ ενάντια στο TPDFlib: ο constructor κρατά ήδη default έγγραφο, οπότε το DocumentCount ξεκινά από 1 και ένα fixture δύο εγγράφων πρέπει να assert >= 2, όχι = 2. Πλάι στην επιτυχή αντιγραφή, το τεστ καρφώνει τις τρεις απορρίψεις — άγνωστο source ID, το επιλεγμένο έγγραφο ως πηγή του εαυτού του, και object number μηδέν — όλες επιστρέφουν 0 αντί να πετάνε, γιατί ένας βρόχος merge είναι κακό μέρος για να ανακαλύψετε ότι μια guard clause πετάει

Πού χωράει αυτό σε μια γραμμή merge

Η αντιγραφή σε επίπεδο αντικειμένου είναι το primitive που αρπάζετε όταν η συγχώνευση ολόκληρων αρχείων είναι πολύ χοντρή: το να σηκώσετε ένα font programme από ένα template, να τραβήξετε ένα απλό form XObject σε έγγραφο σφραγίδας, ή να μετακινήσετε μια annotation με τα appearance streams της μεταξύ αρχείων χωρίς να σύρετε μαζί την υπόλοιπη σελίδα. Το PDFlibPas το εκθέτει ως μία κλήση σε φορτωμένα έγγραφα, και μπορείτε να δείτε πώς κάθεται με το υπόλοιπο low-level object API στην αναφορά της PDFlibPas Delphi PDF Library