Στο PDFium Component πριν το v3.121.1, η ανάγνωση μιας σχολίας μέσω TPdf.Annotation[] και η ανάθεση της εγγραφής πίσω μπορούσε να προσθέσει κενές εγγραφές /R και /D στο appearance dictionary /AP της, ακόμα κι όταν το πρωτότυπο κουβαλούσε μόνο /N. Οι validators PDF/A απορρίπτουν εκείνο το dictionary. Από το v3.121.1 ο getter αναφέρει μόνο appearance που όντως διάβασε, οπότε ένα αμετάβλητο round trip δεν γράφει τίποτα νέο. Η αποτυχία αξίζει κατανόηση σε βάθος, επειδή η συνηθισμένη αφορμή είναι ένα fix που σκόπευε να κάνει ένα αρχείο πιο συμμορφούμενο, όχι λιγότερο
Τι πάει στραβά όταν γράφετε μια σχολία πίσω αμετάβλητη;
Η σύντομη απάντηση: η σχολία αποκτά streams appearance που δεν είχε ποτέ, και ένα αρχείο που περνούσε επικύρωση PDF/A πριν την επέμβασή σας την αποτυγχάνει μετά. Το τυπικό σενάριο τρέχει έτσι. Ένα αρχείο πελάτη φτάνει με square και text σχολίες που λείπει η σημαία Print, το PDF/A απαιτεί κάθε σχολία να τυπώνεται, οπότε φτιάχνετε βρόχο πάνω στις σελίδες, προσθέτετε afPrint, και αναθέτετε κάθε εγγραφή πίσω. Τίποτα σε εκείνον τον κώδικα δεν αγγίζει appearances. Η εγγραφή από το TPdf.Annotation[] είναι ένα TPdfAnnotation, και το SetAnnotationData γράφει κάθε πεδίο του οποίου ο sentinel Has* είναι θετειμένος, που είναι ακριβώς ο τρόπος που προορίζονται να δουλεύουν τα ζεύγη HasContents / ContentsText. Το πρόβλημα ήταν ότι ο getter έθετε HasAppearanceRollover και HasAppearanceDown σε True με κενά strings για modes που δεν υπήρχαν, και ο setter υπακούσε γράφοντας δύο κενά streams:
procedure MarkAnnotationsPrintable(const FileName: string);
var
Pdf: TPdf;
PageNo, I: Integer;
A: TPdfAnnotation;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := FileName;
Pdf.Active := True;
for PageNo := 1 to Pdf.PageCount do
begin
Pdf.PageNumber := PageNo;
for I := 0 to Pdf.AnnotationCount - 1 do
begin
A := Pdf.Annotation[I];
if not (afPrint in A.Flags) then
begin
A.Flags := A.Flags + [afPrint] - [afHidden, afInvisible, afNoView];
// Πριν το v3.121.1 αυτή η ανάθεση έγραφε επίσης κενά /AP/R και
// /AP/D όταν η σχολία πηγής κουβαλούσε μόνο /AP/N
Pdf.Annotation[I] := A;
end;
end;
end;
Pdf.SaveAs(ChangeFileExt(FileName, '.printable.pdf'));
finally
Pdf.Free;
end;
end;
Το ISO 32000-1 §12.5.5 ορίζει το appearance dictionary με τρεις εγγραφές: /N για την κανονική εμφάνιση, /R για rollover, και /D για down. Τα /R και /D είναι προαιρετικά, και όταν λείπουν ένας viewer πέφτει πίσω στο /N. Ένα κενό stream /R δεν είναι λείπαν, όμως. Είναι έγκυρο stream που δεν ζωγραφίζει τίποτα, οπότε ένας viewer που σέβεται rollover appearances δείχνει ένα άδειο ορθογώνιο τη στιγμή που ο δείκτης περνά πάνω από τη σχολία. Το PDF/A είναι ακόμα αυστηρότερο: το ISO 19005-1 (με Corrigendum 2) και τα ISO 19005-2 / 19005-3 επιτρέπουν μόνο /N σε appearance dictionary σχολίας. Το veraPDF αναφέρει το round-tripped αρχείο στον κανόνα 6.5.3-4 για PDF/A-1 και στον 6.3.3-2 για PDF/A-2 και PDF/A-3, και το ενσωματωμένο TPdf.ValidatePdfA το απαριθμεί ως pvaiAnnotationApDictViolation. Η επέμβαση που πρόσθεσε τη σημαία Print για να ικανοποιήσει έναν όρο του προτύπου έσπασε έναν άλλον
Γιατί το FPDFAnnot_GetAP επιστρέφει 2 για λείπαν appearance;
Το PDFium δεν επιστρέφει ποτέ μηδέν από το FPDFAnnot_GetAP, ούτε όταν το ζητούμενο stream appearance δεν υπάρχει. Η συνάρτηση ακολουθεί το συνηθισμένο μοτίβο δύο κλήσεων του PDFium: περάστε buffer nil για να πάρετε το απαιτούμενο μέγεθος σε bytes, δεσμεύστε, μετά καλέστε ξανά για να αντιγραφεί το κείμενο UTF-16LE. Το μέγεθος περιλαμβάνει πάντα τον τερματιστή UTF-16, οπότε ένα λείπαν stream αναφέρει 2 bytes, ένα κενό string συν τον τερματιστή του. Ο getter πριν το v3.121.1 τεστάριζε ByteLength >= SizeOf(FPDF_WCHAR), έναν έλεγχο που περνά κάθε κλήση, οπότε και οι τρεις σημαίες HasAppearance* επέστρεφαν True για οποιαδήποτε σχολία με οποιοδήποτε appearance καθόλου. Ένα round trip μέσω της εγγραφής μετά ζητούσε από το FPDFAnnot_SetAP να αποθηκεύσει κενό string για κάθε mode, και το PDFium δημιουργούσε το stream για να το χωρέσει. Καμία εξαίρεση, καμία προειδοποίηση, και η ορατή σελίδα έμοιαζε πανομοιότυπη, γι' αυτό το ελάττωμα αναδύθηκε σε fixture veraPDF και όχι σε viewer
Πώς αποφασίζει το v3.121.1 ότι υπάρχει appearance
Το ReadAppearance, ο βοηθός μέσα στο GetPageAnnotation που γεμίζει τα AppearanceNormal, AppearanceRollover και AppearanceDown, μεταχειρίζεται πλέον ένα αποτέλεσμα ως περιεχόμενο μόνο όταν κουβαλά τουλάχιστον έναν χαρακτήρα πέρα από τον τερματιστή. Η πρώτη κλήση πρέπει να επιστρέψει περισσότερα από SizeOf(FPDF_WCHAR) bytes και άρτιο πλήθος bytes, αφού μια περιττή μήκος δεν μπορεί να είναι UTF-16. Η δεύτερη κλήση, που όντως αντιγράφει το κείμενο, επικυρώνεται ξανά: επιστρεφόμενο μήκος 2 ή λιγότερο, ή μεγαλύτερο από τον buffer που δεσμεύτηκε, επαναφέρει το HasValue σε False και αφήνει το string κενό. Στην πλευρά εγγραφής τίποτα δεν άλλαξε. Το SetAnnotationData εξακολουθεί να καλεί το FPDFAnnot_SetAP μόνο για modes των οποίων η σημαία HasAppearance* είναι True, οπότε μια εγγραφή διαβασμένη από σχολία που έχει μόνο /N ξαναγράφει πλέον μόνο /N. Το regression fixture καλύπτει και τις δύο κατευθύνσεις: μια square σχολία με κανονικό appearance, διαβασμένη και ξαναγραμμένη αμετάβλητη, περνά PDF/A-1b, PDF/A-2b και PDF/A-3b, ενώ η ίδια σχολία με αφαιρεμένη τη σημαία Print αποτυγχάνει στον αναμενόμενο κανόνα σημαίας και σε τίποτα άλλο
Λείπαν και κενά streams μοιάζουν πανομοιότυπα, οπότε ο getter μένει συντηρητικός
Το native API δεν μπορεί να ξεχωρίσει ένα λείπαν stream appearance από ένα που υπάρχει αλλά είναι κενό, και το PDFium Component δεν προσποιείται το αντίθετο. Και οι δύο περιπτώσεις επιστρέφουν τα ίδια 2 bytes από το FPDFAnnot_GetAP, οπότε και οι δύο διαβάζονται πίσω ως HasAppearanceRollover = False με κενό AppearanceRollover. Αυτό έχει δύο συνέπειες γύρω από τις οποίες πρέπει να σχεδιάσετε. Πρώτον, ένας sentinel False σημαίνει «δεν διαβάστηκε περιεχόμενο, οπότε μια εγγραφή πίσω θα αφήσει αυτό το mode ήσυχο», όχι «το κλειδί /R λείπει από το dictionary». Δεύτερον, η εγγραφή δεν μπορεί να ανιχνεύσει ένα κενό stream που είναι ήδη στο αρχείο: ένα έγγραφο χαλασμένο από παλιότερο build ή από άλλο εργαλείο διαβάζεται πίσω καθαρό, και η ανάθεση της εγγραφής πίσω ούτε το διορθώνει ούτε το χειροτερεύει. Για να βρείτε εκείνα τα αρχεία χρειάζεστε έλεγχο σε επίπεδο bytes, γι' αυτό υπάρχουν το TPdf.ValidatePdfA και το workflow προπτήρωσης και επικύρωσης PDF/A με PDFium Component
Πώς καθαρίζετε ένα appearance σκόπιμα;
Θέτετε τον sentinel ρητά και περνάτε κενό string· ο setter το γράφει. Το να μπλοκάρονται τα κενά strings στο SetAnnotationData θα ήταν το πρόχειρο fix για αυτό το bug, αλλά θα έσπαγε και καλούντες που καθαρίζουν ένα appearance σκόπιμα, το ίδιο συμβόλαιο που ακολουθούν τα HasContents και HasAuthor για κείμενο. Οπότε το fix ζει ολόκληρο στον getter, και ο setter εξακολουθεί να υπακούει σε ό,τι ζητά ο καλών:
// Αντικαταστήστε το rollover appearance, μετά καθαρίστε το ξανά
A := Pdf.Annotation[0];
A.HasAppearanceRollover := True;
A.AppearanceRollover := 'q Q';
Pdf.Annotation[0] := A;
A := Pdf.Annotation[0];
// Το A.HasAppearanceRollover είναι True και το κείμενο κάνει round trip ως 'q Q'
A.HasAppearanceRollover := True; // επαναβεβαιώστε ρητά την πρόθεση
A.AppearanceRollover := ''; // γράψτε σκόπιμα ένα κενό stream
Pdf.Annotation[0] := A;
A := Pdf.Annotation[0];
// Διαβάζεται πίσω ως HasAppearanceRollover = False με κενό string:
// ένα κενό stream και ένα λείπαν είναι αδιαδιάκριτα εδώ
Έχετε υπόψη ότι ένα ρητά αδειασμένο /R ή /D μετρά ακόμα ως επιπλέον κλειδί υπό τους κανόνες PDF/A που παρατέθηκαν πιο πάνω. Αν ο στόχος είναι ένα archive profile, το να γράψετε μη κενό /N και να αφήσετε τα άλλα δύο modes ανέγγιχτα είναι το μόνο σχήμα που επικυρώνει. Κάθε workflow που μετακινεί σχολίες ανάμεσα σε έγγραφα, όπως το XFDF export και import με PDFium Component, πρέπει να ακολουθεί τον ίδιο κανόνα: αντιγράψτε τα modes που η πηγή όντως είχε και αφήστε τους υπόλοιπους sentinels False
Ένα μοτίβο read-modify-write που μένει ασφαλές για PDF/A
Αναβαθμίστε σε v3.121.1 ή νεότερο, αφήστε τους sentinels appearance ακριβώς όπως τους επέστρεψε ο getter, και επικυρώστε το αποθηκευμένο αρχείο πριν το στείλετε. Επειδή ένα παρωχημένο κενό stream διαβάζεται πίσω ως λείπαν, το βήμα επαλήθευσης πρέπει να κοιτάζει το σειριοποιημένο έγγραφο και όχι την εγγραφή, και είναι φθηνό αρκετά για να τρέχει μετά από κάθε παρτίδα:
uses
PDFium, FPdfPdfa; // το FPdfPdfa δηλώνει το TPdfAValidationIssue
function AnnotationAppearancesAreClean(Pdf: TPdf): Boolean;
var
Report: TPdfAValidationResult;
begin
// Επικυρώνει το έγγραφο που είναι φορτωμένο στο Pdf, συμπεριλαμβανομένων
// edits που έγιναν μέσω Pdf.Annotation[] από τότε που άνοιξε
Report := Pdf.ValidatePdfA;
Result := not (pvaiAnnotationApDictViolation in Report.Issues);
end;
Η ίδια πειθαρχία ισχύει για κάθε panel που ξαναχρωματίζει ή σχολιάζει σελίδες για έλεγχο, ένα workflow που καλύπτει το χτίσιμο workflow ελέγχου σχολίων στο Delphi με PDFium Component: η εγγραφή είναι snapshot του τι μπορούσε να διαβάσει η μηχανή, και ένας sentinel που δεν ορίσατε μόνοι σας πρέπει να ταξιδέψει πίσω αμετάβλητος. Το πλήρες API σχολίων, η προπτήρωση PDF/A και η native μηχανή PDFium έρχονται μαζί στο PDFium Component for Delphi, C++Builder and Lazarus