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

Γιατί ορισμένες ροές αντικειμένων PDF αποκωδικοποιούνται σε σκουπίδια σε Delphi

Μια ροή αντικειμένων PDF που φουσκώνει χωρίς σφάλμα αλλά εξακολουθεί να διαβάζεται ως θόρυβος συνήθως της λείπει ένα βήμα: η αναστροφή του Predictor κατά ISO 32000-1. Όταν το λεξικό /DecodeParms μιας ροής φέρει /Predictor 2 ή υψηλότερο, τα byte που δίνει πίσω το FlateDecode δεν είναι τα αρχικά δεδομένα — είναι τιμές με διαφορά γραμμής στυλ PNG ή οριζόντια διαφοροποιημένες στυλ TIFF που χρειάζονται ένα δεύτερο πέρασμα ανακατασκευής πριν έχει νόημα οποιαδήποτε αναζήτηση λεξικού. Το PDFiumPas, η γηγενής βιβλιοθήκη εξαρτήματος PDF VCL για Delphi και C++Builder, πρόσθεσε εκείνο το πέρασμα ανακατασκευής στην v2.16.0, συγκεκριμένα επειδή ροές αντικειμένων PDF 1.5+ επεκτείνονταν σε byte με διαφορά που κανένας αναλυτής λεξικού δεν μπορούσε να διαβάσει

Γιατί το FlateDecode μόνο του δεν αρκεί

Το ίδιο το FlateDecode είναι μόνο αποσυμπίεση DEFLATE (ISO 32000-1 §7.4.4.1): αναπαράγει όποια byte έδωσε ο κωδικοποιητής στον συμπιεστή, τίποτα περισσότερο. Ο Predictor ζει ένα επίπεδο πιο πάνω, στο λεξικό /DecodeParms της ροής, και περιγράφει έναν μετασχηματισμό που εφάρμοσε ο κωδικοποιητής πριν τη συμπίεση — η διαφοροποίηση μετατρέπει μακρές σειρές παρόμοιων δομημένων τιμών, όπως οι σφιχτά πακεταρισμένοι ακέραιοι μέσα σε μια ροή παραπομπής παύλας ή μια ροή αντικειμένων, σε μακρές σειρές μικρών αριθμών που το DEFLATE συμπιέζει πολύ καλύτερα. Το ISO 32000-1 §7.4.4.3 (Πίνακας 8) είναι σαφές ότι η αναίρεση αυτού του μετασχηματισμού είναι μέρος της αποκωδικοποίησης μιας φιλτραρισμένης ροής, όχι ένα προαιρετικό πέρασμα καθαρισμού, ωστόσο είναι εύκολο να γράψετε έναν βοηθό FlateDecode που απλώς καλεί το inflate και σταματά εκεί

Το σύμπτωμα είναι διακριτό μόλις ξέρετε να το αναζητήσετε. Τα byte με διαφορά Predictor δεν είναι τυχαίος θόρυβος — εξακολουθούν να φέρουν το σχήμα μιας συμπιεσμένης ροής, οπότε ένας αφελής αναλυτής συχνά περπατά πέρα από μερικά έγκυρα-φαινόμενα tokens πριν χτυπήσει μια ακολουθία byte που δεν μπορεί με κανέναν τρόπο να είναι όνομα, αριθμός, ή οριοθέτης PDF, και διαφορετικές γραμμές αποτυγχάνουν σε διαφορετικές μετατοπίσεις ανάλογα με το πόσο έτυχε να διαφέρουν οι υποκείμενες τιμές από τους γείτονές τους. Εκείνη η ασυνέπεια είναι αυτό που κάνει το σφάλμα δύσκολο να εντοπιστεί από ένα μοναδικό αποτυχημένο αρχείο: δύο PDF από τον ίδιο παραγωγό μπορούν να διαφέρουν μόνο στο ποιες τιμές τυχαίνει να επαναλαμβάνονται, οπότε το ένα αναλύεται σχεδόν κατά τύχη ενώ το άλλο αποτυγχάνει εντελώς

Τι κάνει πράγματι η παράμετρος Predictor του PDF;

Η καταχώριση /Predictor στο /DecodeParms λέει σε έναν συμμορφούμενο αναγνώστη ποια αναστροφή να εκτελέσει, και ο Πίνακας 8 του ISO 32000-1 ορίζει τις τιμές που έχουν σημασία στην πράξη: το 1 σημαίνει ότι δεν εφαρμόστηκε καμία πρόβλεψη, το 2 επιλέγει TIFF Predictor 2 (οριζόντια διαφοροποίηση), και οποιαδήποτε τιμή από 10 έως 15 επιλέγει πρόβλεψη στυλ PNG. Τρία ακόμη κλειδιά ταξιδεύουν μαζί του — /Colors, /BitsPerComponent, και /Columns — και μαζί περιγράφουν τη γεωμετρία γραμμής έναντι της οποίας υπολογίστηκε η διαφοροποίηση, ακόμη και όταν η ροή δεν κρατά καθόλου δεδομένα εικόνας: μια ροή αντικειμένων δεν είναι εικόνα, αλλά οι γραφείς PDF επαναχρησιμοποιούν τον ίδιο μηχανισμό predictor βασισμένο σε γραμμές για αυτήν επειδή το delta-έπειτα-deflate συμπιέζει πιο σφιχτά πακεταρισμένους ακεραίους και μετατοπίσεις αντικειμένων καλύτερα από το να τα κάνει deflate ακατέργαστα

Το TIFF Predictor 2 είναι το απλούστερο από τα δύο σχήματα: κάθε συστατικό αποθηκεύεται ως η διαφορά από το ίδιο συστατικό στο προηγούμενο pixel στην ίδια γραμμή, και κάθε γραμμή επαναφέρεται στην αριστερή της άκρη αντί να μεταφέρει μια διαφορά από τη γραμμή από πάνω. Η πρόβλεψη PNG είναι πιο ιδιαίτερη, επειδή το πραγματικό φίλτρο μπορεί να αλλάζει από γραμμή σε γραμμή: κάθε γραμμή ξεκινά με ένα μοναδικό byte ετικέτας — 0 για None, 1 για Sub, 2 για Up, 3 για Average, 4 για Paeth — και εκείνη η ετικέτα, όχι η δηλωμένη τιμή /Predictor, αποφασίζει πώς ανακατασκευάζεται εκείνη η συγκεκριμένη γραμμή. Ένα /Predictor 12 είναι πραγματικά απλώς η υπόδειξη του κωδικοποιητή ότι ευνόησε το φίλτρο Up, όπου κάθε byte αποκαθίσταται προσθέτοντας το byte ακριβώς από πάνω του στην προηγούμενη γραμμή, αλλά ένας σωστός αποκωδικοποιητής πρέπει ακόμη να διαβάζει την ετικέτα σε κάθε γραμμή αντί να υποθέτει Up σε όλη τη διάρκεια

Γιατί οι ροές αντικειμένων κάνουν έναν χαμένο Predictor αόρατο;

Οι ροές αντικειμένων επιδεινώνουν το πρόβλημα αντί απλώς να το επαναλαμβάνουν. Το ISO 32000-1 §7.5.7 επιτρέπει σε έναν γραφέα PDF 1.5+ να πακετάρει πολλαπλά έμμεσα αντικείμενα σε ένα μοναδικό συμπιεσμένο δοχείο, ένα /ObjStm, και είναι συνηθισμένο ακριβώς τα αντικείμενα που χρειάζεται περισσότερο ένας επικυρωτής — ο κατάλογος, τα /OutputIntents, ή μια ροή XMP /Metadata — να ταξιδεύουν μέσα από εκείνο το δοχείο με προσαρτημένο /Predictor 12, επειδή εκείνα τα αντικείμενα είναι αρκετά σύντομα και επαναλαμβανόμενα ώστε να επωφελούνται από τη διαφοροποίηση γραμμών. Όταν λείπει το βήμα predictor, η επέκταση της ροής αντικειμένων δεν εγείρει σφάλμα: παράγει μια ακολουθία byte που φαίνεται επιφανειακά εύλογη αλλά δεν αναλύεται σε tokens στα αναμενόμενα αντικείμενα, οπότε ό,τι πακεταρίστηκε μέσα απλώς δεν εμφανίζεται. Η απόδοση σπάνια το παρατηρεί, επειδή μια συμμορφούμενη μηχανή απόδοσης ήδη ανακατασκευάζει τα δεδομένα με διαφορά predictor προτού φτάσουν ποτέ στη διάταξη· ο κώδικας που το παρατηρεί είναι ακριβώς το είδος που έκρυβε αυτό το σφάλμα — ένας επικυρωτής, υπογράφων, ή ελεγκτής έκδοσης που διατρέχει τα ίδια τα ακατέργαστα byte PDF για να απαντήσει σε ένα δομικό ερώτημα, χωρίς εναλλακτική λύση μόλις η δική του όψη της ροής αντικειμένων επιστρέψει λάθος

Το PDFiumPas χτύπησε ακριβώς αυτή την αποτυχία πριν την v2.16.0. Ροές αντικειμένων χτισμένες με /Predictor 12, η συνηθισμένη περίπτωση για γραφείς PDF 1.5+, επεκτείνονταν μέσω PdfExpandObjectStreams σε byte με διαφορά που ο δομικός σαρωτής δεν μπορούσε να αναλύσει, οπότε ο κατάλογος, τα /OutputIntents, και τα αντικείμενα /Metadata πακεταρισμένα μέσα ήταν ουσιαστικά αόρατα στις σαρώσεις συμμόρφωσης — καμία εξαίρεση, καμία προειδοποίηση, απλώς μια σάρωση που σιωπηλά συμπεριφερόταν σαν εκείνα τα αντικείμενα να ήταν απόντα. Οι βαθύτεροι μηχανισμοί για το πώς το PDFiumPas επιλύει μια ροή αντικειμένων έναντι του ενεργού πίνακα παραπομπής παύλας, συμπεριλαμβανομένων των υβριδικών και καθαρών περιπτώσεων ροής xref, καλύπτονται ξεχωριστά στο το άρθρο για την επικύρωση ροών αντικειμένων και xref με το PDFiumPas· το βήμα predictor που περιγράφεται εδώ τρέχει μετά από εκείνη την επίλυση, στα byte που πράγματι περιέχει κάθε συμπιεσμένο αντικείμενο

Αναστρέφοντας γραμμές Predictor PNG και TIFF σε Pascal

Το PDFiumPas αναστρέφει τη διαφοροποίηση σε μία μοναδική ρουτίνα, το PdfApplyPredictor, και τα μαθηματικά γεωμετρίας του αξίζει να τα γνωρίζετε είτε το καλείτε είτε επαναϋλοποιείτε την ιδέα στον δικό σας κώδικα Delphi. Το πλάτος γραμμής σε byte είναι ceil(Columns × Colors × BitsPerComponent ÷ 8) και το πλάτος byte ανά pixel που χρησιμοποιούν και οι δύο αλγόριθμοι είναι ceil(Colors × BitsPerComponent ÷ 8) — κάντε λάθος σε οποιαδήποτε στρογγυλοποίηση και η ανακατασκευή διαβάζει πέρα από ένα όριο γραμμής αντί μέσα σε ένα. Ένα /Predictor κάτω από 2 μένει ανέγγιχτο, αφού 1 σημαίνει ότι ο κωδικοποιητής δεν εφάρμοσε καθόλου μετασχηματισμό· το 2 επιλέγει τον κλάδο TIFF που φαίνεται παρακάτω, και οτιδήποτε από 10 και πάνω πέφτει στην ανακατασκευή φίλτρου γραμμής PNG, όπου το byte ετικέτας στην αρχή κάθε γραμμής — όχι η δηλωμένη τιμή /Predictor — αποφασίζει πώς αναιρείται εκείνη η συγκεκριμένη γραμμή

function PdfApplyPredictor(const Src: TBytes;
  Predictor, Colors, Bpc, Columns: Integer): TBytes;
var
  RowLen, Bpp, R, I: Integer;
begin
  Result:= Src;
  if Predictor< 2 then
    Exit;                                   // 1 = no prediction, nothing to undo
  if Colors<= 0 then Colors:= 1;
  if Bpc<= 0 then Bpc:= 8;
  if Columns<= 0 then Columns:= 1;
  if (Colors> 64)or (Bpc> 32)or (Columns> 1 shl 24) then
    Exit;                                   // reject hostile row geometries
  RowLen:= (Columns* Colors* Bpc+ 7) div 8;  // ceil(), per ISO 32000-1 Table 8
  Bpp:= (Colors* Bpc+ 7) div 8;
  if Predictor= 2 then
  begin
    if Bpc<> 8 then
      Exit;                                 // only the 8-bit layout is reconstructed
    Result:= Copy(Src, 0, Length(Src));
    R:= 0;
    while R+ RowLen<= Length(Result) do
    begin
      for I:= R+ Bpp to R+ RowLen- 1 do
        Result[I]:= Byte(Result[I]+ Result[I- Bpp]);
      Inc(R, RowLen);
    end;
    Exit;
  end;
  // Predictor >= 10 falls through to PNG row-filter reconstruction below
end;
// Continues inside PdfApplyPredictor once Predictor>= 10 (PNG row filters).
// Rows:= Length(Src) div (RowLen+ 1); each row is a 1-byte filter tag
// followed by RowLen data bytes, decoded left to right.
for R:= 0 to Rows- 1 do
begin
  SrcOfs:= R* (RowLen+ 1);
  DstOfs:= R* RowLen;
  Tag:= Src[SrcOfs];
  Inc(SrcOfs);
  for I:= 0 to RowLen- 1 do
  begin
    if I>= Bpp then A:= Result[DstOfs+ I- Bpp] else A:= 0;   // byte to the left
    if R> 0 then B:= Result[DstOfs+ I- RowLen] else B:= 0;   // byte above
    case Tag of
    1: Result[DstOfs+ I]:= Byte(Src[SrcOfs+ I]+ A);            // Sub
    2: Result[DstOfs+ I]:= Byte(Src[SrcOfs+ I]+ B);            // Up
    3: Result[DstOfs+ I]:= Byte(Src[SrcOfs+ I]+ (A+ B) div 2); // Average
    // Paeth (tag 4) adds whichever of A, B or the byte above-left sits
    // closest to the linear predictor A+ B- C; tag 0 (None) copies the
    // filtered byte through unchanged
    else Result[DstOfs+ I]:= Src[SrcOfs+ I];
    end;
  end;
end;

Τι άλλαξε το PDFiumPas στην v2.16.0

Η διόρθωση που κυκλοφόρησε στην PDFiumPas v2.16.0 κάθεται μέσα στο PdfReadAndDecodeStream, τη ρουτίνα που διαβάζει τα ακατέργαστα byte μιας ροής και τα αποκωδικοποιεί για κάθε καλούντα που χρειάζεται να επιθεωρήσει τη δομή PDF σε επίπεδο byte, συμπεριλαμβανομένης της επέκτασης ροής αντικειμένων· επιχειρεί ανακατασκευή μόνο αφού επιβεβαιώσει ότι το /Filter είναι γυμνό FlateDecode, ποτέ καταρράκτης, επειδή ένα αλυσιδωτό φίλτρο δεν μπορεί να διορθωθεί με ασφάλεια από predictor σε αυτό το επίπεδο. Η ανάγνωση των /Predictor, /Colors, /BitsPerComponent, και /Columns πίσω από το λεξικό ροής επίσης δεν χρειάζεται γενικό αναλυτή λεξικού: το PdfDictRefNum βρίσκει κάθε κλειδί με άμεση αναζήτηση token ονόματος μέσα σε εκείνο το εύρος byte ενός λεξικού, κάτι που είναι ασφαλές εδώ ακριβώς επειδή εκείνα τα τέσσερα κλειδιά δεν μπορούν να επαναληφθούν ή να ενσωματωθούν μέσα σε ένα μοναδικό λεξικό ροής. Η ίδια αναζήτηση token ονόματος είναι πολύ πιο επικίνδυνη μόλις κατευθυνθεί σε μια μεγαλύτερη ή λιγότερο οριοθετημένη περιοχή ενός αρχείου PDF, κάτι που είναι το θέμα του συνοδευτικού άρθρου για την ασφαλή ανάλυση λεξικών PDF

// Inside PdfReadAndDecodeStream, right after PdfInflate() has already run:
if PdfFilterIsPureFlate(DictTxt) then
begin
  Inflated:= PdfInflate(Raw);
  Predictor:= PdfDictRefNum(Data, DS, DE, 'Predictor');
  if Predictor>= 2 then
  begin
    PColors:= PdfDictRefNum(Data, DS, DE, 'Colors');
    PBpc:= PdfDictRefNum(Data, DS, DE, 'BitsPerComponent');
    PColumns:= PdfDictRefNum(Data, DS, DE, 'Columns');
    Result:= PdfApplyPredictor(Inflated, Predictor, PColors, PBpc, PColumns);
  end
  else
    Result:= Inflated;
end;

Πριν την v2.16.0, μια ροή αντικειμένων χτισμένη με /Predictor 12 επεκτεινόταν σε byte με διαφορά χωρίς να εγερθεί κανένα σφάλμα, οπότε οποιοδήποτε αντικείμενο καταλόγου, /OutputIntents, ή /Metadata πακεταρισμένο μέσα της εξαφανιζόταν από τις δομικές σαρώσεις του PDFiumPas χωρίς καμία προειδοποίηση. Μετά τη διόρθωση, η ίδια ροή αντικειμένων φουσκώνει και έπειτα ανακατασκευάζεται σωστά, και τα αντικείμενα πακεταρισμένα μέσα της γίνονται ξανά ορατά σε εκείνες τις σαρώσεις. Αμυντικά όρια ταξίδεψαν μαζί με τη διόρθωση: το PdfApplyPredictor τώρα απορρίπτει /Colors πάνω από 64, /BitsPerComponent πάνω από 32, και /Columns πάνω από 2^24 εντελώς, επειδή εκείνοι οι συνδυασμοί περιγράφουν γεωμετρίες γραμμής που κανένας πραγματικός παραγωγός PDF δεν χρειάζεται και υπάρχουν κυρίως για να κάνουν έναν αποκωδικοποιητή να εκχωρήσει πολύ περισσότερη μνήμη από όση δικαιολογούν τα byte εισόδου

var
  Pdf: TPdf;
  Report: TPdfAValidationResult;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'incoming.pdf';
    Pdf.Active := True;
    Report := Pdf.ValidatePdfA;
    if not Report.IsCompliant then
      LogNonCompliance(Report); // caller-supplied handler
  finally
    Pdf.Free;
  end;
end;

Όρια που αξίζει να γνωρίζετε

Η ανακατασκευή predictor του PDFiumPas έχει δύο όρια που αξίζει να γνωρίζετε πριν βασιστείτε σε αυτήν. Η ανακατασκευή TIFF Predictor 2 καλύπτει μόνο την περίπτωση 8-bit-ανά-συστατικό· το PDF επιτρέπει στενότερα πακεταρίσματα, αλλά υπο-byte δεδομένα διαφοροποιημένα κατά TIFF περνούν χωρίς ανακατασκευή αντί να μαντεύονται, οπότε μια ροή που δηλώνει /Predictor 2 με /BitsPerComponent 1, 2, ή 4 δεν θα αποκωδικοποιηθεί σωστά μέσω αυτής της διαδρομής σήμερα. Η πρόβλεψη PNG δεν έχει τέτοιον περιορισμό — κάθε γραμμή παρέχει το δικό της byte φίλτρου, και όλοι οι πέντε ορισμένοι τύποι ανακατασκευάζονται ανεξάρτητα από το ποια τυχαίνει να είναι η δηλωμένη τιμή /Predictor μεταξύ 10 και 15, κάτι που ταιριάζει με το πώς πράγματι λειτουργεί η φιλτραρίσματος στυλ PNG: η δηλωμένη τιμή είναι πιο κοντά σε μια υπόδειξη για το τι χρησιμοποίησε κυρίως ο κωδικοποιητής παρά μια υπόσχεση για κάθε γραμμή

Η γηγενής μηχανή απόδοσης του PDFium ήδη ανακατασκευάζει σωστά δεδομένα εικόνας και ροής περιεχομένου με διαφορά predictor, κάτι που εξηγεί ακριβώς γιατί ένα αρχείο μπορεί να αποδοθεί τέλεια σε οποιονδήποτε συνηθισμένο viewer ενώ ένας επικυρωτής, υπογράφων, ή ελεγκτής έκδοσης σε επίπεδο byte χτισμένος πάνω του διαβάζει λάθος τα ίδια byte. Η αποκωδικοποίηση ενήμερη για predictor που περιγράφεται εδώ υποστηρίζει τις δυνατότητες επικύρωσης PDF/A, δομικής σάρωσης, και υπογραφής του PDFiumPas, του γηγενούς εξαρτήματος PDFium VCL για Delphi και C++Builder