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

Αντικατάσταση Σελίδων PDF σε Delphi Χωρίς Bookmarks

Η αντικατάσταση της σελίδας 3 ενός υπογεγραμμένου συμβολαίου δεν θα έπρεπε να μετακινεί τον πίνακα περιεχομένων. Διαγραφή της παλιάς σελίδας, εισαγωγή της νέας, και κάθε σελιδοδείκτης που παλιά έδειχνε εκεί τώρα καταλήγει κάπου αλλού. Η βιβλιοθήκη PDFlibPas Delphi PDF το αποφεύγει αυτό κρατώντας το ίδιο το αντικείμενο σελίδας-στόχου και μεταφέροντας μόνο τις εγγραφές που μεταφέρουν οπτικό περιεχόμενο

Γιατί σπάνε οι σελιδοδείκτες μετά την αντικατάσταση μιας σελίδας PDF;

Οι σελιδοδείκτες σπάνε επειδή ένας προορισμός PDF ονομάζει μια σελίδα με έμμεση αναφορά αντικειμένου, όχι με αριθμό σελίδας. Το ISO 32000-1 §12.3.2.2 ορίζει έναν ρητό προορισμό ως πίνακα του οποίου το πρώτο στοιχείο είναι έμμεση αναφορά στο αντικείμενο σελίδας. Αν διαγραφεί εκείνο το αντικείμενο και προσαρτηθεί μια αντικατάσταση, η αναφορά μένει εκκρεμής: οι περισσότεροι viewers αντιδρούν ρίχνοντας τον αναγνώστη στη σελίδα 1, που είναι ακριβώς το σύμπτωμα που αναφέρουν οι χρήστες μετά από μια αντικατάσταση τύπου διαγραφή-και-μετά-εισαγωγή. Το page tree φαίνεται τέλειο, ο αριθμός σελίδων είναι σωστός, το rendering είναι σωστό, και ολόκληρο το επίπεδο πλοήγησης είναι σιωπηλά λάθος

Οι επώνυμοι προορισμοί (named destinations) δεν σώζουν την κατάσταση ούτε αυτοί. Η §12.3.2.3 δρομολογεί ένα όνομα μέσω του name tree /Dests στον document catalogue, αλλά το φύλλο στο οποίο επιλύεται εκείνο το όνομα εξακολουθεί να είναι ένας ρητός πίνακας προορισμού που κρατά την ίδια αναφορά σελίδας. Η ονομασία προσθέτει ένα επίπεδο έμμεσης παραπομπής πάνω από την αναφορά σελίδας, όχι γύρω από αυτήν. Η ίδια λογική καλύπτει το υπόλοιπο του διαδραστικού επιπέδου που περιγράφεται στην §12.5: μια annotation συνδέσμου φέρει ένα /Dest ή μια ενέργεια GoTo /A της οποίας το /D είναι εκείνος ο πίνακας, κάθε annotation μπορεί να φέρει μια εγγραφή /P που είναι έμμεση αναφορά στη σελίδα της, και ένα widget πεδίου φόρμας είναι annotation στην ίδια ακριβώς βάση. Μια αφελής εναλλαγή σελίδας αποσυνδέει τέσσερα υποσυστήματα ταυτόχρονα, και για να τα δει κανείς απαριθμημένα σε ένα πραγματικό αρχείο, το ίδιο γράφημα αντικειμένων είναι αυτό που διατρέχει η εσωτερική εξέταση outline και annotation

Ποιες εγγραφές σελίδας μεταφέρουν ταυτότητα και ποιες εμφάνιση

Ένα dictionary σελίδας αναμειγνύει δύο είδη εγγραφών, και μια αντικατάσταση επιτόπου πετυχαίνει ακριβώς όταν αυτά διαχωριστούν. Η πλευρά της εμφάνισης είναι πεπερασμένη και απαριθμήσιμη: /Contents, /Resources, τα πέντε page boxes /MediaBox, /CropBox, /BleedBox, /TrimBox και /ArtBox, συν /Rotate, /Group, /UserUnit και /BoxColorInfo. Αυτές οι έντεκα εγγραφές αποφασίζουν όλα όσα παράγει ένας rasteriser για τη σελίδα, και τίποτα άλλο στο αρχείο δεν τις δείχνει με όνομα

Η πλευρά της ταυτότητας είναι αυτό στο οποίο έχει δεσμευτεί το υπόλοιπο του εγγράφου: ο αριθμός αντικειμένου σελίδας και η generation, ο σύνδεσμος /Parent προς τα πίσω στο page tree, και το /Annots. Η PDFlibPas κρατά κάθε ένα από αυτά ανέγγιχτο. Η ReplacePageRanges εξαλείφει τις έντεκα οπτικές εγγραφές από το dictionary της σελίδας-στόχου και τις ξαναπροσθέτει από την εισαγόμενη σελίδα-πηγή, οπότε το αντικείμενο σελίδας-στόχου τροποποιείται επιτόπου αντί να αντικατασταθεί. Η δομή page tree που απαιτεί η §7.7.3 παραμένει επίσης byte-προς-byte ταυτόσημη σε σχήμα: η σειρά /Kids, το /Count, και κάθε επιζών /Parent είναι τα ίδια πριν και μετά, επειδή κανένας κόμβος δεν αποσυνδέθηκε ποτέ

Πώς αντικαθιστά η PDFlibPas μια σελίδα χωρίς επαναρίθμηση αντικειμένων;

Η κλήση δέχεται ένα έγγραφο-πηγή, μια σελίδα εκκίνησης-στόχο βασισμένη στο 1, μια έκφραση εύρους πηγής, και μια σημαία επιλογών. Και τα δύο έγγραφα πρέπει να είναι ανοιχτά στην ίδια instance, και το έγγραφο-στόχος είναι το επιλεγμένο. Επειδή ο αριθμός σελίδων του στόχου δεν αλλάζει ποτέ, το εύρος που ζητείται πρέπει να χωρά μέσα στο έγγραφο ξεκινώντας από το TargetStartPage, και αυτό ελέγχεται πριν δημιουργηθεί οτιδήποτε

var
  Lib: TPDFlib;
  TargetDoc, SourceDoc: Integer;
begin
  Lib := TPDFlib.Create;
  try
    // The document whose bookmarks and links must survive
    if Lib.LoadFromFile('contract-final.pdf', '') <> 1 then
      Exit;
    TargetDoc := Lib.SelectedDocument;

    // The revised clause page, rendered by whatever produced it
    if Lib.LoadFromFile('clause-7-revised.pdf', '') <> 1 then
      Exit;
    SourceDoc := Lib.SelectedDocument;

    Lib.SelectDocument(TargetDoc);
    // Source page 1 overwrites the visuals of target page 3.
    // Page count, page 3 object number, bookmarks and annotations are kept.
    if Lib.ReplacePageRanges(SourceDoc, 3, '1', 0) = 1 then
      Lib.SaveToFile('contract-final.pdf');
  finally
    Lib.Free;
  end;
end;

Εσωτερικά οι σελίδες-πηγή δεν μπορούν απλώς να διαβαστούν πέρα από τα όρια εγγράφου, επειδή κάθε έμμεση αναφορά μέσα τους ανήκει στην αρίθμηση αντικειμένων της πηγής. Έτσι το εύρος πηγής εισάγεται πρώτα με τον συνηθισμένο τρόπο, ως προσωρινές σελίδες προσαρτημένες μετά την τελευταία πραγματική σελίδα, κάτι που εκτελεί την πλήρη επαναχαρτογράφηση του γραφήματος αντικειμένων: content streams, γραμματοσειρές, XObjects, shadings και χρωματικοί χώροι επαναριθμούνται όλα στο έγγραφο-στόχο. Μόνο τότε αντιγράφονται οι έντεκα οπτικές εγγραφές από κάθε προσωρινή σελίδα στη σελίδα-στόχο της, και μόνο τότε οι προσωρινές σελίδες αποσυνδέονται από το page tree. Η δουλειά επαναχαρτογράφησης συμβαίνει εκεί όπου είναι φθηνή και ασφαλής, και η καταστροφική επεξεργασία περιορίζεται σε μια εναλλαγή σε επίπεδο dictionary πάνω σε σελίδες που ήδη υπάρχουν

Η διαδρομή διαγραφής που θα κατέστρεφε αυτό που μόλις μεταφέρθηκε

Η αφαίρεση αυτών των προσωρινών σελίδων είναι το βήμα που φαίνεται ασήμαντο και δεν είναι. Η συνηθισμένη διαδρομή διαγραφής σελίδας στη βιβλιοθήκη κάνει περισσότερα από απλή αποσύνδεση ενός κόμβου: συνδυάζει τα layers κάθε σελίδας που διαγράφεται, αδειάζει το πρώτο content stream, και ανακτά πόρους που καμία άλλη σελίδα δεν μοιράζεται. Αυτή είναι σωστή συμπεριφορά για μια πραγματική διαγραφή, και καταστροφική εδώ, επειδή τη στιγμή που αφαιρούνται οι προσωρινές σελίδες οι σελίδες-στόχοι ήδη αναφέρουν ακριβώς αυτά τα content streams και αντικείμενα πόρων. Το άδειασμά τους θα άφηνε κενή τη σελίδα που μόλις αντικαταστάθηκε, και η σάρωση πόρων θα συνέλεγε γραμματοσειρές και εικόνες που τώρα έχουν ζωντανό ιδιοκτήτη

Η διόρθωση είναι μια λειτουργία preserve-referenced-objects στην εσωτερική διαδρομή διαγραφής. Όταν είναι ενεργή, η διαγραφή παρακάμπτει τόσο τη σάρωση μη-κοινόχρηστων πόρων όσο και τον καθαρισμό content stream, και δεν κάνει τίποτα άλλο εκτός από την αποσύνδεση των σελίδων από το page tree και τη διόρθωση της λογιστικής του δέντρου. Τα μεταφερμένα αντικείμενα επιζούν με νέο ιδιοκτήτη, και η ιδιοκτησία αντικειμένων μετά την πράξη είναι αυτό που θα ζωγράφιζε κανείς σε έναν πίνακα: ένα content stream, μία σελίδα-ιδιοκτήτης, ένας αριθμός αντικειμένου που ποτέ δεν μετακινήθηκε. Οι σχετικοί κανόνες κύκλου ζωής για τη δημιουργία, διαγραφή και αναδιάταξη σελίδων καλύπτονται ξεχωριστά στις σημειώσεις για τις πράξεις κύκλου ζωής εγγράφου και σελίδας

Σειρά, διπλότυπα, και αποτυχία όλα-ή-τίποτα

Η σημαία επιλογών επιλέγει πώς ερμηνεύεται το εύρος πηγής. Το 0 ταξινομεί τους αναλυμένους αριθμούς σελίδων και αφαιρεί τα διπλότυπα, κάτι που είναι η λογική προεπιλογή όταν ο καλών περνά κάτι όπως '4-6,2' και απλώς σημαίνει εκείνες τις τέσσερις σελίδες. Το 1 διατηρεί τη σειρά που γράφτηκε και επιτρέπει μια σελίδα να επαναλαμβάνεται, οπότε το '2,1,2' σημαίνει γνησίως τρεις αντικαταστάσεις που προέρχονται από δύο σελίδες-πηγή. Η επικύρωση τρέχει πρώτη και τρέχει πλήρως: η σύνταξη εύρους, κάθε αριθμός σελίδας έναντι του αριθμού σελίδων πηγής, η ίδια η τιμή επιλογής, και η χωρητικότητα του στόχου ελέγχονται όλα πριν δημιουργηθεί ένα μόνο αντικείμενο. Μια απορριφθείσα κλήση θέτει το LastErrorCode σε 412, επαναφέρει την προηγουμένως επιλεγμένη σελίδα, και αφήνει το έγγραφο ακριβώς όπως ήταν

var
  Replaced: Integer;
begin
  Lib.SelectDocument(TargetDoc);
  // Options = 1: source order is preserved and repeats are allowed, so
  // target pages 5, 6 and 7 receive source pages 2, 1 and 2 respectively
  Replaced := Lib.ReplacePageRanges(SourceDoc, 5, '2,1,2', 1);
  if Replaced = 0 then
    raise Exception.CreateFmt('Replacement rejected, LastErrorCode = %d',
      [Lib.LastErrorCode]);
  // On success the selection is the first replaced page
  Assert(Lib.SelectedPage = 5);
end;

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

// Post-conditions worth asserting in a regression test
Lib.SelectPage(3);
// Geometry now comes from the source page
WriteLn(Format('%.2f x %.2f', [Lib.PageWidth, Lib.PageHeight]));
// Annotations that were already on target page 3 are still attached
WriteLn(Lib.AnnotationCount);
// The bookmark created before the replacement still resolves to page 3
WriteLn(Lib.GetOutlinePage(OutlineID));
// And the document is still the same length
WriteLn(Lib.PageCount);

Τι δεν κάνει ακόμα για εσάς η αντικατάσταση επιτόπου;

Οι annotations πηγής, τα πεδία φόρμας πηγής και τα outlines πηγής σκόπιμα δεν εισάγονται. Η μεταφορά ενός widget χωρίς την εγγραφή πεδίου /AcroForm του, ή μιας annotation που φέρει marked content χωρίς την ιδιοκτησία της στο structure tree, παράγει ένα μισο-εισαγμένο διαδραστικό αντικείμενο που κανένας viewer δεν μπορεί να συλλογιστεί, οπότε η πράξη μεταφέρει μόνο την εμφάνιση. Η πρακτική συνέπεια είναι ότι αν η σελίδα αντικατάστασης πρέπει να φέρει νέα πεδία φόρμας ή νέους συνδέσμους, αυτά προστίθενται στη σελίδα-στόχο εκ των υστέρων, πάνω στο αντικείμενο σελίδας-στόχου που εξακολουθεί να βρίσκεται εκεί περιμένοντάς τα

Δύο ακόμα όρια αξίζει να ελέγχονται στα δικά σας αρχεία. Πρώτον, το /Annots διατηρείται αλλά η γεωμετρία σελίδας όχι, οπότε η αντικατάσταση μιας σελίδας 220 mm με μια σελίδα 320 mm κρατά τα ορθογώνια annotation στις παλιές τους συντεταγμένες μέσα σε ένα /MediaBox διαφορετικού μεγέθους· αν η γεωμετρία αλλάζει, οι annotations που κρατήθηκαν πρέπει να επανατοποθετηθούν. Δεύτερον, οι εγγραφές εκτός των έντεκα οπτικών κλειδιών παραμένουν με τη σελίδα-στόχο εκ σχεδιασμού, κάτι που είναι σωστό για το /Trans ή το /AA και μπαγιάτικο για το /Thumb, οπότε τα thumbnails πρέπει να αναγεννηθούν μετά από μια αντικατάσταση. Τα tagged έγγραφα χρειάζονται μία επιπλέον σκέψη: τα στοιχεία δομής εξακολουθούν να δείχνουν στο σωστό αντικείμενο σελίδας μέσω του /Pg, αλλά οι αναγνωριστικοί τους δείκτες marked-content περιγράφουν περιεχόμενο που δεν βρίσκεται πια εκεί, οπότε μια εναλλαγή σελίδας μέσα σε μια ροή εργασίας PDF/UA είναι επεξεργασία structure tree εξίσου με επεξεργασία περιεχομένου. Αν η δουλειά είναι πράγματι σύνθεση αντί για εναλλαγή, στρώσεις εικαστικού πάνω σε σελίδες που κρατιούνται, η προσέγγιση page stitching και template είναι το φθηνότερο εργαλείο

Όλα όσα περιγράφονται εδώ, συμπεριλαμβανομένης της σύνταξης έκφρασης εύρους, των τιμών επιλογών και του γύρω API χειρισμού σελίδων, διατίθενται στην τυπική PDFlibPas Delphi PDF Library για Delphi και C++Builder, της οποίας η τεκμηρίωση αναφοράς φέρει την πλήρη καταχώριση για την κλήση αντικατάστασης σελίδας και τους κωδικούς σφάλματός της