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

Επεξεργασία μεταδεδομένων φορτωμένου PDF στο Delphi χωρίς επανεγγραφή

Έχετε δέκα χιλιάδες PDF συμβολαίων από δώδεκα διαφορετικούς παραγωγούς και το νομικό τμήμα θέλει σε καθένα να υπάρχει το σωστό Author, μια διορθωμένη συμβολοσειρά Producer και μια λειτουργία ανάγνωσης που ανοίγει τον πίνακα σελιδοδεικτών με την εκκίνηση. Η αφελής λύση είναι να φορτώσετε κάθε αρχείο, να ξανατοποθετήσετε τις σελίδες και να γράψετε ένα νέο έγγραφο. Αν το κάνετε αυτό, πετάτε όλα τα υπάρχοντα object numbers, το ιστορικό σταδιακών ενημερώσεων, τυχόν ψηφιακή υπογραφή και το προσεκτικά ρυθμισμένο xref που παρήγαγε το αρχικό εργαλείο. Οι σελίδες μοιάζουν ίδιες και το αρχείο είναι, δομικά, ένα διαφορετικό σώμα. Για μια επεξεργασία μεταδεδομένων, αυτό είναι το λάθος αντάλλαγμα.

Η σωστή κίνηση είναι να αντιμετωπίσετε το φορτωμένο έγγραφο ως γράφημα αντικειμένων που μεταβάλλετε επιτόπου: να μπείτε στο Info dictionary, στο ρεύμα /Metadata και στο Catalog, να αλλάξετε τις λίγες εγγραφές που σας ενδιαφέρουν και να γράψετε το αποτέλεσμα πίσω. Το HotPDF, το εγγενές στοιχείο PDF VCL για Delphi και C++Builder, εκθέτει ακριβώς αυτό το επίπεδο μέσω του API εγγραφής φορτωμένου εγγράφου. Αυτό το άρθρο δείχνει πώς να το χρησιμοποιήσετε σωστά και ποιο είναι το ένα λάθος που κάνουν σχεδόν όλοι, να επεξεργάζονται το Info dictionary και να ξεχνούν ότι ένα δεύτερο αντίγραφο των ίδιων μεταδεδομένων ζει στο XMP.

Δύο σημεία αποθηκεύουν τα ίδια μεταδεδομένα, και διαφωνούν μεταξύ τους

Το PDF κρατά τις πληροφορίες του εγγράφου σε δύο παράλληλες τοποθεσίες, και αυτό είναι η ρίζα των περισσότερων περιστατικών του τύπου "άλλαξα τον τίτλο αλλά το Acrobat εξακολουθεί να δείχνει τον παλιό". Η πρώτη είναι το document information dictionary, το κλασικό /Info αντικείμενο με τα κλειδιά /Title, /Author, /Subject, /Keywords, /Creator και /Producer, όπως ορίζεται στο ISO 32000-1 §14.3.3. Η δεύτερη είναι ένα XMP packet, ένα έγγραφο XML που αποθηκεύεται ως ρεύμα κρεμασμένο από το Catalog κάτω από το /Metadata, όπως ορίζεται στο §14.3.2 και βασίζεται στο μοντέλο δεδομένων Adobe XMP.

Και τα δύο μπορούν να κρατήσουν τίτλο. Τίποτα στο πρότυπο δεν τα αναγκάζει να συμφωνούν. Οι σύγχρονοι προβολείς και οι περισσότεροι validators PDF/A προτιμούν το XMP packet όταν υπάρχει και επιστρέφουν στο Info dictionary όταν δεν υπάρχει. Άρα, αν ενημερώσετε μόνο το /Info, κάτι που κάνει η συντριπτική πλειονότητα του κώδικα "set PDF metadata", ένας αναγνώστης που εμπιστεύεται το XMP θα συνεχίσει να δείχνει την παλιά τιμή και ένας ελεγκτής PDF/A θα επισημάνει τη διαφορά. Η σωστή ενέργεια σε κάθε αρχείο που έχει ήδη XMP packet είναι διπλή εγγραφή, αλλάξτε την εγγραφή στο Info και αναδημιουργήστε το XMP, ώστε τα δύο να μείνουν συνεπή. Το HotPDF σας δίνει και τα δύο μισά, η πειθαρχία να τα χρησιμοποιείτε μαζί είναι δική σας.

Επεξεργασία του Info dictionary

Οι βοηθητικές συναρτήσεις του Info είναι λεπτές και προβλέψιμες. Οι SetLoadedTitle, SetLoadedAuthor, SetLoadedSubject, SetLoadedKeywords, SetLoadedCreator και SetLoadedProducer δέχονται καθεμία ένα μόνο AnsiString και γράφουν το αντίστοιχο κλειδί στο φορτωμένο Info dictionary, αντικαθιστώντας την τιμή αν το κλειδί υπάρχει και προσθέτοντάς την αν δεν υπάρχει. Για να αφαιρέσετε εντελώς ένα κλειδί, για παράδειγμα ένα διαρρέον /Creator που αναφέρει τα εσωτερικά σας εργαλεία, καλέστε τη RemoveLoadedInfoKey με το γυμνό όνομα του κλειδιού. Τίποτα από αυτά δεν αγγίζει το XMP, λειτουργούν αποκλειστικά πάνω στο αντικείμενο /Info που εντόπισε το LoadFromFile όταν ανέλυσε το αρχείο.

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('contract-in.pdf', '') > 0 then
    begin
      Pdf.SetLoadedTitle('Master Services Agreement 2026');
      Pdf.SetLoadedAuthor('Legal Department');
      Pdf.SetLoadedSubject('Executed contract, retention 7 years');
      Pdf.SetLoadedKeywords('contract; MSA; 2026; executed');
      Pdf.SetLoadedProducer('Acme Document Pipeline');
      Pdf.RemoveLoadedInfoKey('Creator');  // drop the originating tool name
      Pdf.SaveLoadedDocument('contract-out.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

Μία λεπτομέρεια που πρέπει να παραμείνει ακριβής: όλα αυτά δέχονται AnsiString. Για τίτλους ASCII αυτό δεν δημιουργεί θέμα, όμως οι συμβολοσειρές κειμένου PDF που χρειάζονται μη λατινικούς χαρακτήρες πρέπει να κωδικοποιούνται όπως απαιτεί το πρότυπο, UTF-16BE με byte-order mark ή PDFDocEncoding, πριν τις παραδώσετε. Η βιβλιοθήκη γράφει τα bytes που της δίνετε σε ένα αντικείμενο συμβολοσειράς, δεν μαντεύει κωδικοποίηση για εσάς. Αν οι τίτλοι σας είναι απλά αγγλικοί, αγνοήστε το. Αν περιέχουν τονισμένους χαρακτήρες ή CJK χαρακτήρες, κωδικοποιήστε συνειδητά και δοκιμάστε σε πραγματικό προβολέα.

Επανεγγραφή του XMP packet

Η SetLoadedXMPMetadata είναι το άλλο μισό της διπλής εγγραφής. Δώστε της ολόκληρο το XMP packet ως AnsiString και κάνει ένα από δύο πράγματα: αν το Catalog ήδη παραπέμπει σε ρεύμα /Metadata, αντικαθιστά επιτόπου το περιεχόμενο αυτού του ρεύματος, κρατώντας τον ίδιο αριθμό αντικειμένου, ενώ αν δεν υπάρχει ρεύμα μεταδεδομένων, δημιουργεί ένα, το σημειώνει ως /Type /Metadata και /Subtype /XML, του εκχωρεί αριθμό αντικειμένου και το συνδέει από το Catalog. Σε κάθε περίπτωση καταλήγετε με έγκυρο αντικείμενο μεταδεδομένων που θα διαβάσουν οι προβολείς.

Εσείς παρέχετε το XML, άρα ελέγχετε το σχήμα, dc:title, dc:creator, xmp:CreatorTool και ούτω καθεξής. Αυτό είναι ταυτόχρονα δύναμη και ευθύνη: η βιβλιοθήκη δεν αναλύει ούτε επικυρώνει το packet σας και γράφει τα bytes ασυμπίεστα, χωρίς να εφαρμόσει φίλτρο ρεύματος. Ένα κακοσχηματισμένο packet θα περάσει κανονικά από την κλήση και θα εμφανιστεί αργότερα ως παράπονο για κατεστραμμένα μεταδεδομένα. Φτιάξτε το XML προσεκτικά και αντιγράψτε ακριβώς τις τιμές που γράψατε στο Info dictionary, ώστε οι δύο όψεις να μη διαφωνούν ποτέ.

const
  XMP_TEMPLATE =
    '<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>' +
    '<x:xmpmeta xmlns:x="adobe:ns:meta/">' +
    '<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">' +
    '<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/">' +
    '<dc:title><rdf:Alt><rdf:li xml:lang="x-default">%s</rdf:li></rdf:Alt></dc:title>' +
    '<dc:creator><rdf:Seq><rdf:li>%s</rdf:li></rdf:Seq></dc:creator>' +
    '</rdf:Description></rdf:RDF></x:xmpmeta><?xpacket end="w"?>';
begin
  // After setting the Info dictionary, mirror the same values into XMP:
  Pdf.SetLoadedTitle('Master Services Agreement 2026');
  Pdf.SetLoadedAuthor('Legal Department');
  Pdf.SetLoadedXMPMetadata(
    AnsiString(Format(XMP_TEMPLATE,
      ['Master Services Agreement 2026', 'Legal Department'])));
  Pdf.SaveLoadedDocument('contract-out.pdf');
end;

Αυτή η σειρά, πρώτα Info, δεύτερο XMP, μετά αποθήκευση, είναι το μοτίβο που πρέπει να απομνημονεύσετε. Οι δύο κλήσεις είναι ανεξάρτητες, η συνέπεια υπάρχει μόνο επειδή τους δώσατε τις ίδιες συμβολοσειρές. Αν παραλείψετε την κλήση XMP σε αρχείο που έχει XMP packet, επιστρέφετε στο σφάλμα της σιωπηλής παλαιότητας που όλη αυτή η ενότητα υπάρχει για να αποτρέψει.

Diagram showing a PDF Info dictionary and an XMP metadata stream both holding title and author, edited in place alongside the bookmark outline tree
Τα μεταδεδομένα ζουν σε δύο σημεία, στο Info dictionary και στο XMP stream, μαζί με τις ενδείξεις ανάγνωσης σε επίπεδο Catalog και το δέντρο περιγραμμάτων. Μια επιτόπια επεξεργασία αγγίζει το καθένα χωρίς να αναδομήσει το έγγραφο.

Καθορισμός του τρόπου με τον οποίο ανοίγει το αρχείο

Τρεις εγγραφές του Catalog αποφασίζουν τι θα δει ο αναγνώστης τη στιγμή που ανοίγει το έγγραφο, και οι τρεις είναι επεξεργασίες μιας γραμμής στο φορτωμένο γράφημα. Η SetLoadedPageMode γράφει το /PageMode ως name object: περάστε 'UseOutlines' για να εμφανιστεί ο πίνακας σελιδοδεικτών, 'UseThumbs' για τη λωρίδα μικρογραφιών, 'FullScreen' για λειτουργία παρουσίασης ή 'UseAttachments' για να εμφανιστεί το πλαίσιο συνημμένων (ISO 32000-1 §7.7.3.1, Table 28). Η SetLoadedPageLayout γράφει το /PageLayout με τον ίδιο τρόπο, 'SinglePage', 'OneColumn', 'TwoColumnLeft' και τα υπόλοιπα. Και οι δύο δέχονται το όνομα χωρίς αρχικό slash, το οποίο η βιβλιοθήκη προσθέτει στην έξοδο.

Η SetLoadedLanguage γράφει την εγγραφή /Lang του Catalog, την ετικέτα φυσικής γλώσσας για ολόκληρο το έγγραφο, 'en-US', 'de-DE', μια ετικέτα BCP 47. Προσέξτε τη διαφορά τύπου που μπερδεύει πολλούς, το /PageMode και το /PageLayout είναι PDF αντικείμενα name, ενώ το /Lang είναι string. Το HotPDF το χειρίζεται σωστά εσωτερικά, αλλά αν ποτέ επιθεωρήσετε την έξοδο θα δείτε /PageMode /UseOutlines δίπλα σε /Lang (en-US), και τώρα ξέρετε γιατί. Η εγγραφή /Lang έχει μεγαλύτερη σημασία από όσο φαίνεται, γιατί αυτό διαβάζει η υποστηρικτική τεχνολογία για να επιλέξει προφορά και αποτελεί αυστηρή απαίτηση για συμμόρφωση με το PDF/UA.

if Pdf.LoadFromFile('handbook.pdf', '') > 0 then
begin
  Pdf.SetLoadedPageMode('UseOutlines');     // /PageMode, a name
  Pdf.SetLoadedPageLayout('TwoColumnLeft'); // /PageLayout, a name
  Pdf.SetLoadedLanguage('en-US');           // /Lang, a string
  Pdf.SaveLoadedDocument('handbook-tagged.pdf');
end;

Μετονομασία σελιδοδεικτών χωρίς να διαταράσσεται το δέντρο

Οι τίτλοι των σελιδοδεικτών είναι συνηθισμένος καθαρισμός, ένα τυπογραφικό λάθος σε επικεφαλίδα, μια ενότητα που ξαναριθμήθηκε αφού δημιουργήθηκε το περίγραμμα. Η SetLoadedOutlineTitle παίρνει ένα μηδενικής βάσης ευρετήριο στις εγγραφές περιγράμματος ανώτατου επιπέδου και έναν νέο τίτλο, ακολουθεί την αλυσίδα Catalog → /Outlines/First/Next μέχρι εκείνη τη θέση και αντικαθιστά τη συμβολοσειρά /Title της εγγραφής. Αλλάζει μόνο τον τίτλο, ο προορισμός, η κατάσταση ανοιχτό ή κλειστό και η δομή των παιδιών μένουν ανέπαφα.

if Pdf.LoadFromFile('report.pdf', '') > 0 then
begin
  Pdf.SetLoadedOutlineTitle(0, 'Executive Summary');
  Pdf.SetLoadedOutlineTitle(1, 'Financial Results');
  Pdf.SaveLoadedDocument('report-renamed.pdf');
end;

Η μετονομασία είναι ασφαλής ακριβώς επειδή δεν αγγίζει ποτέ τους δομικούς μετρητές. Η διαγραφή μιας εγγραφής περιγράμματος είναι η περίπτωση που δημιουργεί πρόβλημα και αξίζει να την κατανοήσετε ακόμη κι όταν απλώς μετονομάζετε, γιατί σας δείχνει τι δεν πρέπει να επεξεργάζεστε χειροκίνητα. Κάθε κόμβος περιγράμματος φέρει ένα /Count και, σύμφωνα με το ISO 32000-1 §12.3.3, αυτός ο αριθμός δεν είναι ο αριθμός των άμεσων παιδιών. Είναι ο συνολικός αριθμός των ορατών απογόνων: ένα θετικό /Count ίσο με N σημαίνει ότι N απόγονοι είναι αυτή τη στιγμή εμφανείς, ενώ μια αρνητική τιμή σημαίνει ότι ο κόμβος έχει απογόνους αλλά είναι συμπτυγμένος. Όταν αφαιρείται μια εγγραφή ανώτατου επιπέδου, το root count του /Outlines δεν μπορεί απλώς να μειωθεί κατά ένα, πρέπει να υπολογιστεί ξανά αθροίζοντας, για κάθε επιζώντα κόμβο ανώτατου επιπέδου, "ένα για τον ίδιο τον κόμβο συν το θετικό του /Count", παραλείποντας τους απογόνους κάθε συμπτυγμένου κόμβου με αρνητικό count. Αν αυτό γίνει λάθος, το σύνολο των σελιδοδεικτών που εμφανίζει ο αναγνώστης αποκλίνει και αυξομειώνεται κατά περισσότερο από ένα για κάθε διαγραφή. Η μετονομασία παρακάμπτει όλα αυτά, κάτι που είναι ένας ακόμη λόγος να προτιμάτε τον στοχευμένο βοηθό αντί να πειράζετε μόνοι σας το dictionary.

Πώς η αποθήκευση μένει επιτόπου

Κάθε επεξεργασία παραπάνω μεταβάλλει αντικείμενα στη μνήμη, τίποτα δεν φτάνει στον δίσκο μέχρι να εκτελεστεί η SaveLoadedDocument. Ο λόγος που αυτή η προσέγγιση είναι φθηνή είναι ότι η αποθήκευση δεν αναγεννά το έγγραφο, διατηρεί τους υπάρχοντες αριθμούς αντικειμένων και τη δομή που ανέλυσε το HotPDF κατά τη φόρτωση και γράφει πίσω το ίδιο γράφημα με τα λίγα αντικείμενα που άλλαξαν ή δημιουργήθηκαν πρόσφατα. Αυτό είναι που εμποδίζει μια διέλευση μεταδεδομένων να ξαναγράφει ολόκληρο το αρχείο, και είναι ο ίδιος μηχανισμός επιτόπιας ενημέρωσης που κάνει να λειτουργούν τα object streams και οι incremental updates. Αν τα πηγαία αρχεία σας προέρχονται από το Word ή από άλλη office suite, η διάταξη των αντικειμένων τους έχει τις δικές της ιδιομορφίες που αξίζει να γνωρίζετε πριν τα επεξεργαστείτε, το άρθρο για τα υβριδικές ροές διασταυρούμενων αναφορών σε Office PDFs εξηγεί πώς είναι δομημένα αυτά τα αρχεία και τι επιβιώνει μετά από round trip.

Δύο όρια χρειάζονται σεβασμό. Πρώτον, αυτό είναι μοντέλο επιτόπιας επεξεργασίας, όχι εργαλείο redaction ή sanitization, η αφαίρεση ενός Info key αφαιρεί αυτό το κλειδί, αλλά δεν καθαρίζει παλαιότερες τιμές που μπορεί να επιμένουν σε προηγούμενη incremental-update γενιά του ίδιου αρχείου. Αν η απαίτησή σας είναι η πραγματική αφαίρεση ευαίσθητων μεταδεδομένων, αυτό είναι διαφορετική και βαρύτερη εργασία. Δεύτερον, η εγγραφή XMP είναι κυριολεκτική, η βιβλιοθήκη εμπιστεύεται το XML σας και δεν το επικυρώνει, οπότε για οτιδήποτε προορίζεται για PDF/A ή για αυστηρό validator, δημιουργήστε το packet από δοκιμασμένο πρότυπο και επαληθεύστε την έξοδο. Όταν χρησιμοποιείται μέσα σε αυτά τα όρια, η επιτόπια επεξεργασία μεταδεδομένων είναι το σωστά διαστασιολογημένο εργαλείο, διορθώνει τα λίγα bytes που είναι λάθος και αφήνει το ενενήντα εννέα τοις εκατό του αρχείου που ήταν ήδη σωστό ακριβώς όπως το έγραψε ο αρχικός παραγωγός.

Το API εγγραφής φορτωμένου εγγράφου που φαίνεται εδώ περιλαμβάνεται στο τυπικό HotPDF Component για Delphi και C++Builder, μαζί με το πλήρες σύνολο μεθόδων επεξεργασίας μεταδεδομένων, περιγράμματος και Catalog.