Ένας PDF renderer που δεν σχεδιάζει τίποτα συνήθως δεν έχει κανένα bug στον κώδικα σχεδίασής του. Στο HotPDF Component για Delphi και C++Builder, τέσσερα ξεχωριστά ελαττώματα έκαναν τις σελίδες να αποδίδονται κενές ενώ κάθε γραμμή log παρέμενε καθαρή: ορίσματα ονόματος με πρόθεμα κάθετου, μια αντεστραμμένη σύνθεση cm, και ένας δείκτης token που διάβαζε μηδέν. Κανένα από αυτά δεν σήκωνε εξαίρεση. Κανένα δεν καταγραφόταν. Το content stream γινόταν tokenized σωστά, ο dispatcher τελεστών αναγνώριζε κάθε τελεστή, το image XObject αποκωδικοποιούνταν σε έγκυρο bitmap, και μετά η σελίδα έβγαινε άδεια. Αυτός ο συνδυασμός — ένα pipeline που αναφέρει επιτυχία σε κάθε στάδιο και δεν παράγει τίποτα ορατό — είναι η υπογραφή μιας αναζήτησης ή ενός δείκτη που αστοχεί σιωπηλά αντί να αποτυγχάνει. Αυτό είναι ένα post-mortem μιας τέτοιας οικογένειας, και της πειθαρχίας δοκιμών που της επέτρεψε να επιβιώσει επί 38 εκδόσεις
Γιατί ένας PDF renderer δεν σχεδιάζει τίποτα καθόλου;
Επειδή μια αποτυχημένη αναζήτηση πόρου σε έναν PDF renderer είναι αδιάκριτη από μια κενή σελίδα. Τα ορίσματα ονόματος στο content stream και τα κλειδιά του resource dictionary είναι δύο διαφορετικοί χώροι strings, και το HotPDF τα συνέκρινε μεταξύ τους χωρίς κανονικοποίηση. Ο tokenizer διαβάζει /Im0 και κρατά τον solidus, γιατί αυτό είναι το token· το φορτωμένο dictionary /Resources /XObject αποθηκεύει το κλειδί ως Im0, γιατί ο parser αφαιρεί τον οριοθέτη όταν χτίζει κλειδιά dictionary. Κάθε FindValue έναντι ενός ονόματος ορίσματος επομένως επέστρεφε -1. Η ακτίνα έκρηξης ήταν ευρύτερη από τις εικόνες. Το ISO 32000-1 §8.9 καλύπτει το Do, το §8.4 καλύπτει το gs και την αναζήτηση /ExtGState του, το §8.6 καλύπτει τα cs και CS, και το §8.7.4.3 καλύπτει το sh. Και οι πέντε τελεστές είχαν κλειδί το υπο-dictionary πόρων τους με το ακατέργαστο όρισμα, οπότε και οι πέντε αστοχούσαν. Τα ονομαστικά χρωματικά μοντέλα υποχωρούσαν σε DeviceGray, κάτι που μετατρέπει το 1 scn σε λευκό μελάνι πάνω σε λευκή σελίδα. Τα image XObjects δεν ζωγραφίζονταν καθόλου — το μονοπάτι εικόνας bitmap, στην πράξη, δεν είχε δουλέψει ποτέ από την ημέρα που κυκλοφόρησε. Η διόρθωση είναι ένας βοηθός σε επίπεδο unit που εφαρμόζεται σε κάθε αναζήτηση με κλειδί όρισμα, που είναι ο μόνος τρόπος να μην ξαναπαρεκκλίνει η σύμβαση
// Page content stream, the ordinary image-placement idiom:
// q
// /GS0 gs
// 200 0 0 120 60 400 cm
// /Im0 Do
// Q
// The operand token is '/Im0'. The resource dictionary key is 'Im0'.
function HPDFStripNameSlash(const N: AnsiString): AnsiString;
begin
Result := N;
if (Result <> '') and (Result[1] = '/') then
Delete(Result, 1, 1);
end;
// Every resource lookup keyed by an operand name goes through the helper.
Name := HPDFStripNameSlash(Name);
XObjIdx := FPageResources.FindValue('XObject');
if XObjIdx < 0 then
Exit;
// The /XObject sub-dictionary may itself be an indirect reference.
XObjDict := FAccess.ResolveDictionary(FAccess.Context,
FPageResources.GetIndexedItem(XObjIdx));
if XObjDict = nil then
Exit;
Μια δεύτερη, συναφής αστοχία καθόταν ένα επίπεδο πιο κάτω. Ο renderer είχε typed resolvers μόνο για streams και dictionaries, οπότε μια έμμεση αναφορά που έδειχνε σε αντικείμενο array ανώτατου επιπέδου — το συνηθισμένο /CS0 5 0 R με [/Separation ...] στην άλλη άκρη — επιλυόταν σε nil και στα δύο και υποχωρούσε στον μη-επιλυμένο σύνδεσμο. Η προσθήκη ενός γενικού resolver αντικειμένων διόρθωσε τα ονομαστικά χρωματικά μοντέλα και τα arrays συναρτήσεων με μία κίνηση. Αν συνδέεις dictionaries σκίασης, η ίδια πειθαρχία επίλυσης ισχύει και για το μονοπάτι αξονικής και ακτινικής σκίασης, όπου η καταχώρηση /Function είναι πολύ συχνά έμμεση
Ο τελεστής cm και μια σύνθεση γραμμένη ανάποδα
Το δεύτερο ελάττωμα τοποθετούσε εικόνες περίπου εκατό χιλιάδες pixel έξω από τη σελίδα, κάτι που μοιάζει ακριβώς με το να μην τις σχεδιάζει. Το ISO 32000-1 §8.3.4 ορίζει τους μετασχηματισμούς PDF με row vectors, και ο τελεστής cm συνθέτει τον πίνακα ορίσματός του M πάνω στον τρέχοντα πίνακα μετασχηματισμού ως M × CTM — το M ενεργεί πρώτο, ο υπάρχων CTM μετά. Το HotPDF συνθέτει πίνακες μέσω του HPDFMatMul(A, B), που εφαρμόζει το B πριν το A. Η σωστή κλήση επομένως περνά τον παλιό CTM ως A. Ο κώδικας που κυκλοφόρησε περνούσε τον πίνακα ορίσματος ως A, παράγοντας CTM × M
Η αντεστραμμένη σειρά είναι ακίνδυνη για ένα μεμονωμένο cm και καταστροφική για το τυπικό ιδίωμα δύο βημάτων. Τοποθέτησε μια εικόνα με 1 0 0 1 x y cm ακολουθούμενο από w 0 0 h 0 0 cm και η σωστή αλυσίδα κλιμακώνει το μοναδιαίο τετράγωνο κατά (w, h) και μετά το μεταθέτει κατά (x, y). Στην αντεστραμμένη αλυσίδα η μετάθεση μπαίνει πρώτη και η κλίμακα την πολλαπλασιάζει, οπότε μια εικόνα ονομαστικά στο (60, 400) κλιμακωμένη σε 200 επί 120 προσγειώνεται στο (12000, 48000). Ο έλεγχος αποκοπής στην κορυφή του blit την απορρίπτει, το blit παραλείπεται, και τίποτα πουθενά δεν αναφέρει πρόβλημα
// HPDFMatMul(A, B) applies B first, then A.
// ISO 32000-1 cm semantics: new CTM = M x CTM, so M must be B.
// Wrong, and shipped for 38 versions:
GS.CTM := HPDFMatMul(HPDFMatFromOps(NumAt(6), NumAt(5), NumAt(4),
NumAt(3), NumAt(2), NumAt(1)), GS.CTM);
// Correct:
GS.CTM := HPDFMatMul(GS.CTM, HPDFMatFromOps(NumAt(6), NumAt(5), NumAt(4),
NumAt(3), NumAt(2), NumAt(1)));
Αυτό που κάνει το συγκεκριμένο διδακτικό είναι ότι το ίδιο αρχείο πηγαίου κώδικα περιείχε ήδη τη σωστή σειρά. Η καταχώρηση /Matrix ενός Form XObject είχε την ίδια αντεστραμμένη σύνθεση, αλλά το μονοπάτι γλυφών Type 3 και το μονοπάτι περιγράμματος ενσωματωμένης γλυφής το είχαν σωστά από την αρχή, γιατί η τοποθέτηση γλυφών καταρρέει ορατά στην αρχή των αξόνων όταν το αντιστρέφεις, και κάποιος είχε ήδη αναγκαστεί να το διορθώσει. Δύο συμβάσεις συνυπήρχαν σε ένα unit επί τρεις δωδεκάδες εκδόσεις, η καθεμία σωστή στη δική της συνάρτηση, και κανένας reviewer δεν το πρόσεξε γιατί κανένα σημείο κλήσης δεν φαινόταν λάθος μεμονωμένα
Τι συμβαίνει όταν ένας δείκτης token είναι λάθος κατά ένα;
Παίρνεις δώδεκα τελεστές που είναι συντακτικά χειρισμένοι και σημασιολογικά νεκροί. Ο προσπελαστής ορισμάτων στον renderer είναι το NumAt(Back), που διαβάζει το Tokens[OpIndex - Back], και το OpIndex είναι ο δείκτης του ίδιου του token τελεστή. Ένας τελεστής ενός ορίσματος επομένως βρίσκει τον αριθμό του στο back 1. Δώδεκα από αυτούς είχαν γραφτεί ως NumAt(0), που διαβάζει το token τελεστή, αποτυγχάνει τον έλεγχο τύπου ctOperandNumber, και επιστρέφει την προεπιλογή μηδέν. Η λίστα είναι τα Tc, Tw, Tz, TL, Ts και Tr από τους τελεστές κατάστασης κειμένου του ISO 32000-1 §9.3, συν τα w, J, j, M, ri και i από τους τελεστές κατάστασης γραφικών του §8.4.3. Το διάστημα χαρακτήρα και λέξης έγιναν no-op, η οριζόντια κλιμάκωση δεν εφαρμοζόταν ποτέ, το leading έμενε στο μηδέν οπότε το T* δεν προχωρούσε ποτέ γραμμή, η ανύψωση κειμένου δεν έκανε τίποτα, ο τρόπος απόδοσης ήταν πάντα fill, και κάθε πινελιά σε κάθε έγγραφο έβγαινε ως τρίχα 1 pixel ανεξάρτητα από το δηλωμένο πλάτος γραμμής. Τελεστές πολλαπλών ορισμάτων όπως τα m, rg και Tm χρησιμοποιούσαν NumAt(1..6) και ήταν όλοι σωστοί, οπότε ένας reviewer που σάρωνε τη συνάρτηση έβλεπε έναν τοίχο ευλογοφανούς αριθμητικής δεικτών με δώδεκα λάθος καταχωρήσεις ενσωματωμένες μέσα του
function NumAt(Back: Integer): Double;
begin
Result := 0;
if (OpIndex - Back >= 0)
and (Tokens[OpIndex - Back].Kind = ctOperandNumber) then
Result := Tokens[OpIndex - Back].NumValue;
end;
// OpIndex addresses the operator token, so a lone operand sits at back 1.
else if Op = 'Tc' then GS.Text.CharSpace := NumAt(1) // previously NumAt(0)
else if Op = 'TL' then GS.Text.Leading := NumAt(1) // previously NumAt(0)
else if Op = 'Tr' then GS.Text.RenderMode := Round(NumAt(1))
else if Op = 'w' then GS.LineWidth := NumAt(1) // previously NumAt(0)
Γιατί η σουίτα δοκιμών παρέμεινε πράσινη επί 38 εκδόσεις;
Επειδή οι διαβεβαιώσεις ήταν πολύ αδύναμες για να διακρίνουν μια αποδομένη σελίδα από μια μερικώς αποδομένη. Τα rendering smokes διαβεβαίωναν πράγματα όπως ότι το bitmap εξόδου δεν είναι εξ ολοκλήρου μαύρο, ή ότι η σελίδα δεν είναι κενή, ή ότι το digest της εικόνας είναι μη μηδενικό. Καθένα από αυτά ισχύει όταν το κείμενο αποδίδεται και οι εικόνες όχι. Το κείμενο σχεδιαζόταν μια χαρά, οπότε ο frame buffer δεν ήταν ποτέ ομοιόμορφος, το digest δεν ήταν ποτέ μηδέν, και η σουίτα ανέφερε επιτυχία ενώ ολόκληρο το pipeline εικόνων ήταν στην πράξη νεκρός κώδικας. Οι αδύναμες διαβεβαιώσεις είναι δελεαστικές για γραφικά ακριβώς επειδή οι ισχυρές φαίνονται εύθραυστες. Κανείς δεν θέλει ένα test που σπάει όταν μια ακμή anti-aliasing μετατοπίζεται κατά ένα pixel, οπότε η φυσική υποχώρηση είναι να διαβεβαιώσεις κάτι που καμία λογική αλλαγή δεν θα παραβίαζε — και αυτή η υποχώρηση σε προσγειώνει σε κατηγορήματα που ούτε μια παράλογη αλλαγή δεν μπορεί να παραβιάσει. Ένα test χρωματικού μοντέλου separation διαβεβαίωνε ότι η έξοδος ήταν διακριτή από το μαύρο· γκρι πάνω σε λευκό το περνούσε, όπως και λευκό πάνω σε λευκό. Το test δεν μετρούσε αν το σωστό χρώμα ζωγραφίστηκε. Μετρούσε αν συνέβη κάτι καθόλου στον καμβά
Πώς γράφεις μια διαβεβαίωση απόδοσης που πραγματικά αποτυγχάνει;
Μέτρησε pixels του αναμενόμενου χρώματος, στην αναμενόμενη ποσότητα, και άφησε τη θέση και το μέγεθος να προκύψουν από την καταμέτρηση. Η πειθαρχία αντικατάστασης είναι ένα χειροποίητο ελάχιστο PDF, ένα οπτικό γεγονός ανά αρχείο, και μια διαβεβαίωση για το πόσα pixels προσγειώνονται εντός ανοχής από ένα συγκεκριμένο RGB τριπλέτο. Μια εικόνα 200 επί 120 καθαρού κόκκινου τοποθετημένη σε γνωστή μετατόπιση πρέπει να παράγει περίπου 24000 κόκκινα pixels. Αν η αναζήτηση πόρου αστοχεί, η καταμέτρηση είναι 0. Αν η αλυσίδα cm είναι αντεστραμμένη, η καταμέτρηση είναι 0. Αν η εικόνα αποδίδεται στο λάθος χρωματικό μοντέλο, η καταμέτρηση είναι 0. Ένας αριθμός πιάνει και τα τρία, και η ζώνη ανοχής απορροφά τον θόρυβο anti-aliasing που έκανε τον κόσμο να διστάζει από την ακριβή σύγκριση εξαρχής
function CountPixelsNear(Bmp: TBitmap; R, G, B, Tol: Integer): Integer;
var
X, Y: Integer;
C: TColor;
begin
Result := 0;
for Y := 0 to Bmp.Height - 1 do
for X := 0 to Bmp.Width - 1 do
begin
C := Bmp.Canvas.Pixels[X, Y];
if (Abs(GetRValue(C) - R) <= Tol)
and (Abs(GetGValue(C) - G) <= Tol)
and (Abs(GetBValue(C) - B) <= Tol) then
Inc(Result);
end;
end;
// A 200x120 red image placed at 60,400 must paint about 24000 red pixels.
Check(CountPixelsNear(Bmp, 255, 0, 0, 12) > 20000,
'image XObject was never drawn');
Τέσσερα smokes ξαναγράφτηκαν με αυτόν τον τρόπο — ένας μετασχηματισμός απόχρωσης Type 4, μια τοποθέτηση εικόνας Do, μια περίπτωση προαιρετικής ορατότητας περιεχομένου και μια λειτουργία πινελιάς Tr — και μεταξύ τους αποκάλυψαν ολόκληρη την οικογένεια. Αυτό είναι το πραγματικό μάθημα, και γενικεύεται πέρα από αυτή τη βάση κώδικα: σε ένα pipeline απόδοσης, η διαβεβαίωση πρέπει να ονομάζει το χρώμα. Οτιδήποτε πιο ήπιο είναι έλεγχος ότι ο renderer έτρεξε, όχι έλεγχος ότι σχεδίασε. Αν χτίζεις τη δική σου δοκιμαστική διάταξη σελίδας-σε-bitmap, η περιήγηση rasterisation σελίδας είναι το φυσικό σημείο για να προσαρτήσεις έναν βοηθό καταμέτρησης pixel στο πρώτο σου regression test
Ειλικρινή όρια
Αξίζει να δηλωθούν καθαρά δύο περιορισμοί. Οι τρόποι απόδοσης αποκοπής κειμένου 4 έως 7 σχεδιάζονται στη βασική τους λειτουργία fill ή stroke, γιατί ο renderer δεν μοντελοποιεί συσσωρευμένα clip paths από περιγράμματα γλυφών· έγγραφα που βασίζονται σε αποκοπή με σχήμα κειμένου θα αποδώσουν το κείμενο αντί για το αποκομμένο σχέδιο από κάτω. Και η πειθαρχία καταμέτρησης pixel που περιγράφεται εδώ είναι τεχνική smoke-test, όχι σουίτα συμμόρφωσης — αποδεικνύει ότι ένα συγκεκριμένο οπτικό γεγονός έφτασε στον frame buffer, κάτι που είναι πολύ χαμηλότερος πήχης από το να αποδείξεις ότι η έξοδος ταιριάζει με έναν rasteriser αναφοράς. Είναι, ωστόσο, ακριβώς ο πήχης που αυτά τα τέσσερα bugs απέτυχαν να ξεπεράσουν επί τρία χρόνια εκδόσεων
Ο renderer που συζητείται εδώ διατίθεται ως μέρος του τυπικού HotPDF Component για Delphi και C++Builder· η σελίδα του προϊόντος φέρει την πλήρη αναφορά API απόδοσης σελίδων, συμπεριλαμβανομένων της cache bitmap και των σημείων εισόδου background prefetch