Για να επισυνάψετε ένα αρχείο πηγής σε έγγραφο PDF/A-3 από Delphi, το PDFium Component γράφει μια αλυσίδα associated-files του PDF 2.0: ένα ενσωματωμένο file stream με MIME /Subtype, μια προδιαγραφή αρχείου που κουβαλά /AFRelationship, και έναν πίνακα /AF κρεμασμένο στον κατάλογο ή σε σελίδα. Τα InjectAssociateFiles και TPdf.SaveAsWithAssociateFiles χτίζουν εκείνη την αλυσίδα σε ένα incremental update, και από το v3.121.2 ο MIME τύπος σειριοποιείται ως ένα ενιαίο, σωστά escaped PDF name. Το υπόλοιπο αυτού του άρθρου καλύπτει τι ελέγχει ένας validator, το bug ενός χαρακτήρα που έσπασε το text/plain, και τα σημεία όπου παλαιότερες εκδόσεις έκαναν ήσυχα κάτι άλλο από αυτό που ζητήσατε
Τι χρειάζεται στην πραγματικότητα ένα associated file PDF/A-3;
Μια επισύναψη PDF/A-3 περνά επικύρωση μόνο όταν τρία objects συμφωνούν μεταξύ τους: το ενσωματωμένο file stream δηλώνει /Type /EmbeddedFile συν ένα MIME /Subtype, το dictionary προδιαγραφής αρχείου (ISO 32000-2 §7.11.3) κουβαλά /F, /UF, /EF και /AFRelationship, και κάτι στο έγγραφο αναφέρει εκείνη την προδιαγραφή μέσω πίνακα /AF (ISO 32000-2 §14.13). Η σκέτη ενσωμάτωση μέσω του δέντρου /Names /EmbeddedFiles, που είναι αυτό που κάνει το TPdf.CreateAttachment, δεν θέτει καθόλου τα πεδία συσχέτισης. Το δικό του fixture επικύρωσης PDF/A-3b του PDFium Component κάνει την εξάρτηση χειροπιαστή: μετονομάστε μόνο το κλειδί /AFRelationship και το αρχείο αποτυγχάνει σε ακριβώς έναν κανόνα της ενότητας 6.8 του ISO 19005-3· πετάξτε μόνο το MIME /Subtype και αποτυγχάνει ένας άλλος κανόνας 6.8· βάλτε την ίδια επισύναψη σε υποψήφιο PDF/A-1b και απορρίπτεται ολότελα, επειδή το PDF/A-1 απαγορεύει ενσωματωμένα αρχεία όσο τακτοποιημένα κι αν είναι τα metadata
Η τιμή της σχέσης είναι το κομμάτι που οι άνθρωποι τείνουν να μαντεύουν. Το TPdfAFRelationship στο FPdfAssocFiles αντιστοιχίζει ένα μέλος enum σε κάθε name token που ο injector μπορεί να εκπέμψει, και μόνο τα πρώτα πέντε ανήκουν στο υποσύνολο που αναγνωρίζει το ISO 19005-3:
afSource→/Source: το πρωτότυπο από το οποίο παρήχθη το PDF, όπως ένα αρχείο επεξεργασίας κειμένου ή ένα spreadsheetafData→/Data: αναγνώσιμα από μηχανή δεδομένα από τα οποία παράχθηκε ή τα οποία αναπαριστά το ορατό περιεχόμενοafAlternative→/Alternative,afSupplement→/Supplement,afUnspecified→/UnspecifiedafEncryptedPayload,afFormData,afTemplate: προσθήκες του PDF 2.0 που πέφτουν έξω από το υποσύνολο PDF/A-3, οπότε κρατήστε τα μακριά από αρχεία αρχειοθέτησης
Γιατί το /Subtype /text/plain έσπασε την επικύρωση;
Το bug MIME ήταν λάθος tokenization, όχι κενό συμμόρφωσης: πριν το v3.121.2 ο injector συνένωνε το string του καλούντα κατευθείαν μετά από κάθετο, παράγοντας /Subtype /text/plain. Στη σύνταξη PDF ο δεύτερος κάθετος ξεκινά νέο name object (ISO 32000-1 §7.3.5), οπότε το dictionary του stream ξαφνικά κρατούσε το κλειδί /Subtype, το name /text, και ένα κρεμάμενο extra name /plain που αποϊσορροπούσε τα ζεύγη κλειδιού-τιμής. Ένας ανεξάρτητος validator PDF/A απέρριπτε το αρχείο όσο κάνει parse το dictionary EmbeddedFile, πριν φτάσει ποτέ σε κανόνα PDF/A, γι' αυτό η αποτυχία έμοιαζε με κατεστραμμένο αρχείο και όχι με λείπουσα ιδιότητα επισύναψης
Το fix δρομολογεί τη τιμή MIME μέσω EscapePdfName, που εκπέμπει /text#2Fplain: ένα name του οποίου η αποκωδικοποιημένη τιμή είναι text/plain. Το escaping είναι σκόπιμα φαρδύτερο από τον κάθετο. Κάθε byte στο ή κάτω από 32 (κενό, tab, CR, LF), κάθε byte στο ή πάνω από 127, οι οριοθέτες ()<>[]{}/% και ο ίδιος ο χαρακτήρας escape # γίνονται #XX. Το escaping μόνο του καθέτου θα άφηνε άλλη τρύπα: ένα MIME string που περιέχει >> ή λευκό χώρο μπορούσε να κλείσει νωρίς το dictionary ή να εγχύσει extra κλειδιά, οπότε το regression τεστ ταΐζει εχθρική τιμή με κάθε οριοθέτη συν tab, LF και CR και ελέγχει την ακριβή κωδικοποιημένη έξοδο
// Τι γράφει ο injector για MIMEType = 'text/plain'
// πριν το v3.121.2: /Type /EmbeddedFile /Subtype /text/plain (δύο names)
// v3.121.2: /Type /EmbeddedFile /Subtype /text#2Fplain (ένα name)
//
// Οι καλούντες περνούν πάντα τη συνηθισμένη τιμή MIME. Αν την κάνετε pre-escape
// μόνοι σας, διπλο-κωδικοποιείτε το '#', που μετατρέπει το 'text#2Fplain' σε 'text#232Fplain'
Options.Files[0].MIMEType := 'text/plain';
Χτίζοντας ένα αρχείο PDF/A-3 με το InjectAssociateFiles
Για έξοδο PDF/A-3, παράγετε το σύμφωνο βασικό έγγραφο με TPdf.SaveAsPdfAToStream και μετά καλείτε InjectAssociateFiles πάνω σε εκείνο το stream· εκείνο το pipeline δύο βημάτων είναι ακριβώς αυτό που τρέχει το fixture επικύρωσης πριν περάσει PDF/A-3b. Το TPdf.SaveAsWithAssociateFiles είναι ο wrapper της ευκολίας, αλλά αποθηκεύει μέσω της συνηθισμένης διαδρομής SaveAs με saRemoveSecurity αντί μέσω του writer PDF/A, οπότε δεν προσθέτει την ταυτοποίηση XMP και το output intent που απαιτεί το PDF/A. Σημειώστε ότι οι τύποι εγγραφών ζουν στα FPdfAssocFiles και FPdfPdfa, οπότε και οι δύο units ανήκουν στη ρήτρα uses σας. Από το v3.121.3, τα FileName και Description δεν χρειάζεται πλέον να είναι σκέτα ASCII: τα /UF και /Desc γράφονται ως PDF text strings, τα printable ASCII κυριολεκτικά και οτιδήποτε άλλο ως UTF-16BE με byte order mark, ενώ το παρωχημένο name /F είναι πάντα φορητό printable ASCII με κάθε άλλο χαρακτήρα αντικατεστημένο από _, ώστε readers που αποκωδικοποιούν το /F με τη δική τους code page να δείχνουν underscore αντί για mojibake. Παλαιότερα builds μετέτρεπαν και τα τρία μέσω της ANSI code page του συστήματος σε Delphi ή έγραφαν raw bytes UTF-8 σε Free Pascal, οπότε κρατήστε τα ονόματα ASCII μόνο αν παλαιότερα builds πρέπει να παράγουν την ίδια έξοδο
uses
System.SysUtils, System.Classes, System.IOUtils,
PDFium, FPdfPdfa, FPdfAssocFiles;
procedure SaveWithSourceData(Pdf: TPdf; const XmlPath, OutPath: string);
var
PdfAOptions: TPdfASaveOptions;
Options: TAssocFilesOptions;
Base: TMemoryStream;
Output: TFileStream;
begin
PdfAOptions := TPdfASaveOptions.Default;
PdfAOptions.Conformance := pac3b;
Options := TAssocFilesOptions.Default; // TargetPage = 0: /AF επιπέδου καταλόγου
SetLength(Options.Files, 1);
Options.Files[0].FileName := 'invoice-data.xml';
Options.Files[0].Description := 'Structured invoice data';
Options.Files[0].Content := TFile.ReadAllBytes(XmlPath);
Options.Files[0].Relationship := afData;
Options.Files[0].MIMEType := 'application/xml'; // γράφεται ως /application#2Fxml
Base := TMemoryStream.Create;
try
if not Pdf.SaveAsPdfAToStream(Base, PdfAOptions) then
raise Exception.Create('PDF/A-3 base save failed');
Output := TFileStream.Create(OutPath, fmCreate);
try
InjectAssociateFiles(Base, Output, Options); // επαναφέρει το Base· σηκώνει EPdfAssocFilesError σε αποτυχία
finally
Output.Free;
end;
finally
Base.Free;
end;
end;
Κατάλογος ή σελίδα: πού προσγειώνεται ο πίνακας /AF;
Το TAssocFilesOptions.TargetPage αποφασίζει τον ιδιοκτήτη του πίνακα /AF: το 0 το προσδένει στον κατάλογο ως συσχέτιση επιπέδου εγγράφου, και το 1..N στο dictionary εκείνης της σελίδας, 1-based. Ο injector προσθέτει τα πάντα ως ένα ενιαίο incremental update σε σταθερή διάταξη (τα ενσωματωμένα streams, μετά οι προδιαγραφές αρχείων, μετά ο πίνακας /AF, μετά ένα ξαναγραμμένο object καταλόγου ή σελίδας), οπότε τα υπάρχοντα objects κρατούν τα offsets τους και τίποτα δεν ξανασυμπιέζεται. Κάθε προγενέστερη εγγραφή /AF στο dictionary στόχο αντικαθίσταται, δεν συγχωνεύεται, που κάνει μια επαναλαμβανόμενη αποθήκευση idempotent αλλά σημαίνει επίσης ότι μια δεύτερη κλήση με διαφορετική λίστα αρχείων κερδίζει. Δύο συμπεριφορές παλιά άξιζαν δικό σας guard, και αμφότερες έχουν αλλάξει. Πριν το v3.122.0 ένα TargetPage εκτός εύρους δεν απέτυχε· έπεφτε πίσω στον κατάλογο, οπότε ένα typo μετέτρεπε συσχέτιση επιπέδου σελίδας σε επιπέδου εγγράφου χωρίς κανένα σήμα. Από το v3.122.0, τα SaveAsWithAssociateFiles και SaveAsWithAssociateFilesToStream σηκώνουν EPdfError όταν το TargetPage είναι έξω από 0..PageCount, και το InjectAssociateFiles σηκώνει το νέο EPdfAssocFilesError για αρνητικό TargetPage ή ένα που δεν ονομάζει υπάρχουσα σελίδα, αφήνοντας το stream προορισμού αμετάβλητο. Πριν το v3.121.4 η αναζήτηση σελίδας σάρωνε τα αποθηκευμένα bytes για dictionaries /Type /Page με σειρά αρχείου, που μπορούσε να προσδέσει το αρχείο σε διαφορετική σελίδα μόλις τα page objects αποθηκεύονταν σε άλλη σειρά από όση εμφανίζονται, για παράδειγμα μετά από αναδιάταξη ή εισαγωγή σελίδων· από το v3.121.4 το TargetPage ονομάζει τη σελίδα σε εκείνη τη θέση της σειράς σελίδων του εγγράφου
procedure AttachChartSource(Pdf: TPdf; PageNumber: Integer;
const CsvBytes: TBytes; const OutPath: string);
var
Options: TAssocFilesOptions;
begin
// Από το v3.122.0 ένα out-of-range TargetPage σηκώνει EPdfError (παλαιότερα
// builds έπεφταν σιωπηλά σε /AF επιπέδου καταλόγου)· ο έλεγχος πρώτα ονομάζει τη σελίδα
if (PageNumber < 1) or (PageNumber > Pdf.PageCount) then
raise EArgumentOutOfRangeException.CreateFmt('No page %d', [PageNumber]);
Options := TAssocFilesOptions.Default;
Options.TargetPage := PageNumber;
SetLength(Options.Files, 1);
Options.Files[0].FileName := 'chart-data.csv';
Options.Files[0].Content := CsvBytes;
Options.Files[0].Relationship := afSource;
Options.Files[0].MIMEType := 'text/csv';
if not Pdf.SaveAsWithAssociateFiles(OutPath, Options) then
raise Exception.Create('Associated-file save failed');
end;
Πώς διαβάζετε αξιόπιστα το AFRelationship πίσω;
Το TPdf.AttachmentRelationship[Index] επιστρέφει το name /AFRelationship μιας επισύναψης μέσω της native εξαγωγής FPDFAttachment_GetAFRelationship, αλλά ένα κενό string έχει δύο πιθανές σημασίες, οπότε καλέστε πρώτα το AttachmentRelationshipFeaturesAvailable. Το binding φορτώνεται επιεικώς: όταν η DLL του PDFium στερείται εκείνης της εξαγωγής, κάθε σχέση διαβάζεται ως κενή, που είναι αδιαδιάκριτο από μια προδιαγραφή αρχείου που απλώς δεν έχει /AFRelationship. Η ιδιότητα μοιράζεται επίσης τον δείκτη της με το AttachmentCount, που μετρά εγγραφές στο δέντρο /Names /EmbeddedFiles. Ο injector γράφει μόνο την αλυσίδα /AF και δεν προσθέτει εγγραφή name-tree, οπότε ένα αρχείο προσδεμένο μέσω InjectAssociateFiles είναι εκτός εκείνου του δείκτη· για να επιβεβαιώσετε την εγχυμένη αλυσίδα, εξετάστε τα αποθηκευμένα bytes ή τρέξτε έναν validator PDF/A. Τα internals εκείνου του name tree καλύπτονται στο εργασία με PDF attachments στο Delphi με PDFium Component
procedure ReportRelationships(const FileName: string);
var
Pdf: TPdf;
I: Integer;
Rel: string;
begin
if not AttachmentRelationshipFeaturesAvailable then
begin
Writeln('This PDFium build cannot report /AFRelationship');
Exit; // μια κενή απάντηση θα ήταν διφορούμενη, οπότε μην ρωτάτε
end;
Pdf := TPdf.Create(nil);
try
Pdf.FileName := FileName;
Pdf.Active := True;
for I := 0 to Pdf.AttachmentCount - 1 do
begin
Rel := Pdf.AttachmentRelationship[I];
if Rel = '' then
Rel := '(no /AFRelationship)';
Writeln(Pdf.AttachmentName[I], ': ', Rel);
end;
finally
Pdf.Free;
end;
end;
Τι δεν εγγυάται το SaveAsWithAssociateFiles;
Το TPdf.SaveAsWithAssociateFiles εγγυάται τον φάκελο μορφής αρχείου και ότι τα ζητούμενα αρχεία εγχύθηκαν, όχι συμμόρφωση. Το κομμάτι της έγχυσης είναι νέο: πριν το v3.122.0, όταν τα αποθηκευμένα bytes δεν είχαν αναγνώσιμο trailer ή το dictionary καταλόγου δεν μπορούσε να εντοπιστεί, το InjectAssociateFiles αντέγραφε την είσοδο αμετάβλητη και η μέθοδος εξακολουθούσε να επιστρέφει True. Από το v3.122.0 το InjectAssociateFiles σηκώνει EPdfAssocFilesError σε εκείνες τις περιπτώσεις πριν γράψει οτιδήποτε, το SaveAsWithAssociateFiles επιστρέφει False, και επειδή πλέον χτίζει την πλήρη έξοδο σε save store πριν ανοίξει τον στόχο, μια απορριφθείσα ή αποτυχημένη αποθήκευση δεν κόβει πλέον ένα υπάρχον αρχείο. Ένας κενός πίνακας Files εξακολουθεί να αντιγράφει το έγγραφο αμετάβλητο εκ σχεδίου. Το περιεχόμενο του payload είναι επίσης δική σας ευθύνη: ο injector δεν ελέγχει ότι ένα XML αρχείο είναι καλοσχηματισμένο, ότι ο MIME τύπος ταιριάζει τα bytes, ή ότι το βασικό έγγραφο είναι καθόλου PDF/A. Μεταχειριστείτε το τελικό αρχείο ως ανεπαλήθευτο μέχρι να το δει validator, την ίδια πειθαρχία που περιγράφει το PDFium Component και συμμόρφωση αρχειοθέτησης PDF/A. Αν κάνετε και μόνοι σας parse εισερχόμενα dictionaries, οι ίδιοι κανόνες names #XX ισχύουν αντιστρόφως, θέμα που καλύπτει το παγίδες name tokens στο parsing PDF dictionaries
Associated files, έξοδος PDF/A, metadata επισυνάψεων και επικύρωση έρχονται όλα στο ίδιο component, οπότε το pipeline πιο πάνω τρέχει χωρίς δεύτερη βιβλιοθήκη PDF στο build. Η αναφορά API, η λήψη trial και οι επιλογές αδειοδότησης είναι στη σελίδα προϊόντος του PDFium Component