Οι εκδόσεις του PDF Library for Delphi (PDFlibPas) πριν το v3.539.47 μπορούσαν να αποκωδικοποιήσουν escaped κείμενο δύο φορές όταν σχεδίαζαν HTML ή Markdown σε PDF. Τα DrawHTMLText και DrawHTMLTextBox κάνουν parse το HTML, το κανονικοποιούν πίσω σε HTML, και μετά κάνουν ξανά parse, οπότε κείμενο γραμμένο ως <unsafe> έφτανε στο δεύτερο parse ως πραγματικό tag. Από το v3.539.47 κάθε entity αποκωδικοποιείται ακριβώς μία φορά και το κείμενο ξανακάνει escape όπου κι αν γυρίζει σε HTML
Το σενάριο που το αποκαλύπτει είναι συνηθισμένο. Ένα help desk εξάγει tickets σε PDF, και το σχόλιο του πελάτη μπαίνει σε ένα HTML template. Ο προγραμματιστής έκανε το σωστό και έκανε escape το σχόλιο, οπότε το <b> έγινε <b>. Μέσα στον renderer εκείνο το escape αναιρούνταν αθόρυβα: το σχόλιο βγαινε έντονο, ένα άγνωστο όνομα tag εξαφανιζόταν απλώς από τη σελίδα, και ένα escaped anchor γινόταν clickable link annotation. Καμία exception, καμία προειδοποίηση, ένα απολύτως έγκυρο PDF που λέει κάτι διαφορετικό από τα δεδομένα
Γιατί το escaped κείμενο γίνεται πραγματικό tag στο PDF;
Το escaped κείμενο γινόταν markup επειδή ο renderer τρέχει δύο πέρασματα parse, και το βήμα κανονικοποίησης ανάμεσά τους έγραφε ήδη αποκωδικοποιημένο κείμενο πίσω σε HTML χωρίς να το ξανακάνει escape. Κάθε αποκωδικοποίηση που έκανε το πρώτο parse ήταν μετά διαθέσιμη στο δεύτερο parse ως ενεργή σύνταξη
Τα δύο περάσματα υπάρχουν για καλό λόγο. Το πρώτο parse χτίζει μια λίστα από στοιχεία tags και λέξεων. Το NormalizeParsedHTML μετά λύνει το cascade του stylesheet: ταιριάζει τους κανόνες από blocks <style> σε κάθε tag, τους συγχωνεύει με inline ιδιότητες style, αποθηκεύει το αποτέλεσμα πάνω στο tag, και σειριοποιεί όλη τη λίστα στοιχείων πίσω σε ένα HTML string. Το πέρασμα layout κάνει parse εκείνη την κανονικοποιημένη συμβολοσειρά. Είναι ο ίδιος μηχανισμός που κινεί το flexbox, CSS grid και διάταξη υποσημειώσεων στην απόδοση HTML του PDFlibPas
Το ελάττωμα ήταν στο πώς σειριοποιούνταν οι λέξεις. Τα tags γράφονταν πίσω από την αρχική τους μορφή πηγής, ενώ οι λέξεις γράφονταν πίσω στην αποκωδικοποιημένη τους μορφή. Μια λέξη που το πρώτο parse είχε αποκωδικοποιήσει από <unsafe> σε <unsafe> κατέληγε στο κανονικοποιημένο HTML ως γυμνές γωνιακές αγκύλες, και το δεύτερο parse τη διάβαζε ως στοιχείο. Γύρω από αυτό το βασικό bug κάθονταν τρεις μικρότερες διαρροές που δείχνανε την ίδια κατεύθυνση:
- Το
&δεν ήταν στο υποστηριζόμενο σύνολο entities, οπότε τοR&Dτυπωνόταν κυριολεκτικά και δεν υπήρχε τρόπος να γραφτεί κυριολεκτική ορθογραφία entity όπως<ως κείμενο - Το στάδιο σχεδίασης αντικαθιστούσε το
δεύτερη φορά, αφού το parsing είχε ήδη τελειώσει, οπότε μια κυριολεκτική ορθογραφία entity μπορούσε ακόμα να εξαφανιστεί στην τελευταία στιγμή - Το escape κώδικα Markdown προσπερνούσε τον ampersand, και ο εξαγωγέας dataset έκανε escape μόνο τις γωνιακές αγκύλες, οπότε ορθογραφίες entities μέσα σε κώδικα ή τιμές κελιών αποκωδικοποιούνταν ως markup
| Είσοδος που φτάνει στον renderer | Πριν το v3.539.47 | Από το v3.539.47 |
|---|---|---|
<unsafe> | Κάνεται parse ως tag, το κείμενο δεν φτάνει ποτέ στη σελίδα | <unsafe> σχεδιάζεται ως κείμενο |
<b>x</b> | Το x σχεδιάζεται έντονο | <b>x</b> σχεδιάζεται ως κείμενο |
R&D | Το R&D τυπωνόταν κυριολεκτικά | R&D |
&lt; | Το &lt; τυπωνόταν κυριολεκτικά | < |
Code span Markdown που περιέχει | Γινόταν non-breaking space | σχεδιάζεται ως κείμενο |
Τιμή κελιού dataset < | < | < |
Πώς το v3.539.47 κάνει την αποκωδικοποίηση HTML entities single-pass
Το PDFlibPas v3.539.47 κάνει την αποκωδικοποίηση entities single-pass με τρεις συντονισμένες αλλαγές: ο parser αποκωδικοποιεί το & τελευταίο, το στάδιο σχεδίασης δεν αποκωδικοποιεί πια τίποτα, και κάθε σημείο που γυρνά αποκωδικοποιημένες λέξεις σε HTML τις ξανακάνει escape πρώτα
Το υποστηριζόμενο σύνολο entities για περιεχόμενο κειμένου είναι πλέον <, >, & και . Οτιδήποτε άλλο, συμπεριλαμβανομένων αριθμητικών αναφορών όπως A και ονομαστικών entities όπως ", μένει κυριολεκτικό κείμενο. Αυτό το όριο μετράει για το πώς κάνετε escape τα δικά σας δεδομένα, όπως φαίνεται παρακάτω
Η σειρά μέσα στον decoder είναι η πρώτη διόρθωση. Αν το & αποκωδικοποιούνταν πρώτο, η είσοδος &lt; θα γινόταν < και η επόμενη αντικατάσταση θα την έκανε <, μια διπλή αποκωδικοποίηση που συμβαίνει μέσα σε ένα πέρασμα. Η διαδρομή ANSI λέξεων αντικαθιστά λοιπόν τα <, > και πρώτα και το & τελευταίο, οπότε ο ampersand που παράγει δεν εξετάζεται ξανά ποτέ. Η διαδρομή UTF-16 λέξεων είναι ένα μοναδικό σκανάρισμα από αριστερά προς τα δεξιά σε βήματα των δύο bytes που ξαναγράφει κάθε ταίριασμα στη θέση του και προχωρά μετά από αυτό, που δίνει την ίδια εγγύηση δομικά
Η δεύτερη διόρθωση αφαιρεί την όψιμη αντικατάσταση του από το στάδιο σχεδίασης. Η αποκωδικοποίηση ανήκει στον parser και πουθενά αλλού, οπότε μια λέξη που φτάνει στον line breaker είναι τελικό κείμενο
Η τρίτη διόρθωση είναι ο κανόνας του ορίου. Το NormalizeParsedHTML κάνει τώρα escape τα &, < και > σε κάθε αποκωδικοποιημένη λέξη πριν την προσαρτήσει στο κανονικοποιημένο HTML. Το δεύτερο parse την αποκωδικοποιεί πίσω σε ακριβώς το ίδιο κείμενο, οπότε η καθαρή επίδραση σε όλο τον αγωγό είναι μία αποκωδικοποίηση. Η συμβολοσειρά συνέχειας ακολουθεί τον ίδιο κανόνα: λέξεις που δεν χώρεσαν στο box κάνουν escape πριν προσαρτηθούν στο LeftOverText, και το υπόλοιπο του υπολοίπου αντιγράφεται από το κανονικοποιημένο HTML, που είναι ήδη σε escaped μορφή. Ο βρόχος που μαζεύει εκείνες τις υπόλοιπες λέξεις οριοθετείται επίσης πλέον από το πλήθος λέξεων, ενώ ο παλιός repeat βρόχος μπορούσε να πατήσει μετά την τελευταία λέξη
Γιατί το escape UTF-16BE δεν μπορεί να χρησιμοποιήσει αντικατάσταση σε επίπεδο byte;
Το escape UTF-16BE δεν μπορεί να χρησιμοποιήσει αντικατάσταση σε επίπεδο byte επειδή το μοτίβο των δύο bytes για έναν ampersand μπορεί να απλώνεται πάνω σε δύο άσχετους χαρακτήρες. Η μοναδική σωστή μονάδα εργασίας είναι ολόκληρη η 16-bit code unit
Ο renderer αποθηκεύει Unicode λέξεις ως big-endian UTF-16 πακεταρισμένες σε byte strings, με το υψηλό byte πρώτο. Ένας ampersand είναι 00 26. Πάρτε τώρα το U+0100 (κεφαλαίο λατινικό A με μακρόν, bytes 01 00) ακολουθούμενο από το U+2603 (ο χιονάνθρωπος, bytes 26 03). Η ακολουθία bytes είναι 01 00 26 03, και τα bytes δύο και τρία διαβάζονται 00 26. Μια αναζήτηση byte για #0'&' βρίσκει έναν ampersand που δεν υπάρχει, μπήτεινε τα bytes για & στη μέση δύο χαρακτήρων, και κόβει κάθε επόμενο χαρακτήρα κατά ένα byte
Δεν είναι εξωτική γωνιακή περίπτωση. Κάθε χαρακτήρας του οποίου το χαμηλό byte είναι μηδέν μπορεί να δώσει το πρώτο μισό· το U+4E00, ένα από τα συχνότερα CJK ideographs, πληροί το κριτήριο. Οι γωνιακές αγκύλες έχουν την ίδια έκθεση: τα 00 3C και 00 3E εμφανίζονται όποτε τέτοιος χαρακτήρας ακολουθείται από έναν από το U+3C00 έως U+3EFF στο CJK Extension A. Η διόρθωση στο EscapeHTMLWord αποπακετάρει τα bytes σε WideString, κάνει escape χαρακτήρα χαρακτήρα και πακετάρει ξανά το αποτέλεσμα. Η πλευρά του decoder ήταν ήδη ασφαλής γιατί ελέγχει μοτίβα μόνο σε ζυγά όρια code units
Ο ίδιος κανόνας ισχύει για τον δικό σας κώδικα. Αν ποτέ κρατάτε κείμενο UTF-16 ως TBytes, για παράδειγμα μετά από TEncoding.BigEndianUnicode.GetBytes, μην το ψάχνετε για μοτίβα bytes. Μετατρέψτε πίσω σε string και δουλέψτε πάνω σε χαρακτήρες
Code blocks Markdown και εξαγωγές dataset: escape του ampersand πρώτα
Από το v3.539.47 και οι δύο παραγωγοί HTML μέσα στο PDFlibPas, ο μετατροπέας Markdown και ο εξαγωγέας dataset, κάνουν escape τον ampersand πριν τις γωνιακές αγκύλες, οπότε η μοναδική αποκωδικοποίηση στον renderer επαναφέρει ακριβώς το αρχικό κείμενο
Στο MarkdownToHTML, τα inline code spans και τα fenced ή indented code blocks αντιστοιχούν τώρα το & σε &, το < σε < και το > σε >, ενώ τα κενά γίνονται και ένα tab γίνεται τέσσερα από αυτά για να κρατηθεί η εσοχή. Το συνηθισμένο κείμενο Markdown κάνει escape μόνο τις γωνιακές αγκύλες, οπότε raw HTML σε πεζό δεν μπορεί να εγχύσει tags ενώ ένας συγγραφέας μπορεί ακόμα να γράψει & σκόπιμα, όπως περιμένουν οι συγγραφείς Markdown. Τα DrawMarkdownText και DrawMarkdownTextBox χρησιμοποιούν την ίδια μετατροπή, οπότε ο κώδικας εμφανίζεται στο PDF ακριβώς όπως πληκτρολογήθηκε:
uses
System.SysUtils, PDFlibrary;
procedure RenderCodeSample;
var
Lib: TPDFlib;
Md, Html: WideString;
begin
Md := 'Comparison helper:' + sLineBreak + sLineBreak +
'```' + sLineBreak +
'if (A < B) and (Flags <> 0) then' + sLineBreak +
' WriteLn(''<tag> & R&D'');' + sLineBreak +
'```';
Lib := TPDFlib.Create;
try
// Ελέγξτε το HTML: σε κώδικα, το '&' γίνεται '&' και το '<' γίνεται '<'
Html := Lib.MarkdownToHTML(Md);
Lib.SetOrigin(1); // αρχή πάνω αριστερά, το Y μεγαλώνει προς τα κάτω
Lib.SetMeasurementUnits(0); // μονάδες points
// Η σελίδα δείχνει τον κώδικα ακριβώς όπως πληκτρολογήθηκε, ορθογραφίες entities συμπεριλαμβανομένων
Lib.DrawMarkdownText(50, 50, 495, Md);
Lib.SaveToFile('code-sample.pdf');
finally
Lib.Free;
end;
end;
Ο εξαγωγέας dataset είναι η διδακτική περίπτωση. Πριν το v3.539.47 έκανε escape μόνο τις γωνιακές αγκύλες, και σκόπιμα: ο renderer δεν αποκωδικοποιούσε το &, οπότε το escape του ampersand θα τύπωνε & σε κάθε κελί που τον περιείχε. Το workaround ήταν σωστό για τον παλιό renderer και λάθος γενικά, επειδή μια τιμή κελιού που τύχαινε να περιέχει < αποκωδικοποιούνταν σε <. Με τον renderer διορθωμένο, ο εξαγωγέας κάνει escape το & πρώτα, και μια τιμή όπως R&D < & καταλήγει στο PDF κατά λέξη. Αν χτίζετε αναφορές έτσι, ο οδηγός για την εξαγωγή ενός TDataSet σε PDF αναφορά σε Delphi καλύπτει το υπόλοιπο του εξαγωγέα
Αξίζει να εξηγηθεί μία φορά γιατί ο ampersand πρέπει να πάει πρώτος. Κάντε escape το < πρώτα και παίρνετε <· κάντε escape το & δεύτερο και εκείνο γίνεται &lt;, που μια σωστή μοναδική αποκωδικοποίηση εμφανίζει ως < αντί για <. Μια αλληλουχία διαδοχικών αντικαταστάσεων είναι σωστή μόνο όταν ο χαρακτήρας escape μεταχειρίζεται πριν από οτιδήποτε τον εισάγει
Πώς κάνετε escape μη αξιόπιστο κείμενο για το DrawHTMLTextBox;
Για απόδοση HTML στο PDFlibPas, κάντε escape μη αξιόπιστο περιεχόμενο κειμένου αντικαθιστώντας το &, μετά το <, μετά το >, ακριβώς μία φορά, και κρατήστε τα μη αξιόπιστα δεδομένα εντελώς έξω από τιμές attributes
uses
System.SysUtils, PDFlibrary;
// Κάνει escape μη αξιόπιστο κείμενο για περιεχόμενο HTML του PDFlibPas.
// Το '&' πρέπει να αντικατασταθεί πρώτα, αλλιώς ο ampersand μέσα
// σε ένα ήδη παραχθέν '<' θα κανόταν escape δεύτερη φορά
function EscapeHTMLText(const S: string): string;
begin
Result := StringReplace(S, '&', '&', [rfReplaceAll]);
Result := StringReplace(Result, '<', '<', [rfReplaceAll]);
Result := StringReplace(Result, '>', '>', [rfReplaceAll]);
end;
procedure RenderTicket(const CustomerComment: string);
var
Lib: TPDFlib;
Html: WideString;
begin
Lib := TPDFlib.Create;
try
Lib.SetOrigin(1);
Lib.SetMeasurementUnits(0);
Html := '<p><b>Customer comment</b></p>' +
'<p>' + EscapeHTMLText(CustomerComment) + '</p>';
Lib.DrawHTMLText(50, 50, 495, Html);
Lib.SaveToFile('ticket.pdf');
finally
Lib.Free;
end;
end;
Στο v3.539.47 ένα σχόλιο όπως Try <a href="https://example.com">this</a> & <b> εμφανίζεται στη σελίδα χαρακτήρα προς χαρακτήρα. Πριν το v3.539.47 η ίδια escaped είσοδος μπορούσε να παράγει ενεργή link annotation, που είναι το κομμάτι που γυρνά ένα glitch απεικόνισης σε πρόβλημα ασφάλειας: ένα σχόλιο ticket δεν πρέπει ποτέ να μπορεί να φυτέψει clickable URL σε ένα έγγραφο που εμπιστεύεται το προσωπικό σας
Προσέξτε τι δεν κάνει escape η συνάρτηση. Οι escapers HTML γενικής χρήσης μετατρέπουν επίσης το " σε " και το ' σε ', που είναι σωστό για browser. Η αποκωδικοποίηση κειμένου του PDFlibPas αναγνωρίζει μόνο τα τέσσερα entities που αναφέρθηκαν νωρίτερα, οπότε εκείνα τα δύο θα τυπώνονταν κυριολεκτικά ως " και '. Τα εισαγωγικά είναι αβλαδή σε περιεχόμενο κειμένου· μετράνε μόνο μέσα σε τιμές attributes, και ο renderer δεν αποκωδικοποιεί καθόλου entities σε attributes. Η ασφαλής σχεδίαση είναι λοιπόν όχι ένα καλύτερο escaper αλλά ένας κανόνας: μη αξιόπιστα δεδομένα δεν μπαίνουν ποτέ σε href, src ή style. Αν ένας στόχος συνδέσμου πρέπει πραγματικά να προέρχεται από δεδομένα χρήστη, επικυρώστε τον εσείς σε σχέση με μια allow-list schemes και χαρακτήρων και απορρίψτε οτιδήποτε περιέχει εισαγωγικά ή γωνιακές αγκύλες
Δύο σημειώσεις αναβάθμισης ακολουθούν απευθείας από τη διόρθωση:
- Αν ο κώδικάς σας σταμάτησε να κάνει escape το
&επειδή παλαιότερες εκδόσεις τύπωναν&κυριολεκτικά, βάλτε το πίσω. Χωρίς αυτό, κείμενο χρήστη που περιέχει<εμφανίζεται πλέον ως<, ακόμα αβλαδές κείμενο αλλά όχι πια αυτό που πληκτρολόγησε ο χρήστης - Μην κάνετε escape δύο φορές. Κείμενο που περνά από δύο escapers απεικονίζει το
<ως την ορατή ορθογραφία<, οπότε βρείτε το ένα όριο όπου τα δεδομένα σας μπαίνουν στο HTML και κάντε escape μόνο εκεί
Σελιδοποίηση με LeftOverText χωρίς να σπάτε τα escapes
Το DrawHTMLTextBox επιστρέφει το HTML που δεν χώρεσε, που συνήθως λέγεται LeftOverText, και από το v3.539.47 εκείνο το υπόλοιπο διατηρεί κυριολεκτικές ορθογραφίες entities και escaped γωνιακές αγκύλες όταν το περνάτε στο επόμενο box. Ο κανόνας για τους καλούντες είναι απλός: περάστε το πίσω αμετάβλητο
const
BoxLeft = 50;
BoxTop = 50;
BoxWidth = 495; // μέγεθος για σελίδα A4 σε points
BoxHeight = 740;
MaxPages = 500;
procedure RenderLongHTML(Lib: TPDFlib; const Html: WideString);
var
Rest: WideString;
Pages: Integer;
begin
Lib.SetOrigin(1);
Lib.SetMeasurementUnits(0);
Rest := Lib.DrawHTMLTextBox(BoxLeft, BoxTop, BoxWidth, BoxHeight, Html);
Pages := 1;
while (Rest <> '') and (Pages < MaxPages) do
begin
Lib.NewPage;
Inc(Pages);
// Το LeftOverText είναι ήδη escaped HTML της μηχανής: ποτέ escape ή unescape
Rest := Lib.DrawHTMLTextBox(BoxLeft, BoxTop, BoxWidth, BoxHeight, Rest);
end;
if Rest <> '' then
raise Exception.CreateFmt('Content still left after %d pages', [MaxPages]);
end;
Μεταχειριστείτε το υπόλοιπο ως αδιαφανές. Είναι το κανονικοποιημένο HTML της μηχανής, με styles ήδη λυμένα, οπότε μην το περάσετε από δικό σας escaper, μην το αποκωδικοποιήσετε, και μην μπείτε user text μέσα του. Το όριο σελίδων είναι φθηνή ασφάλεια: αν κάποιο στοιχείο δεν χωράει ποτέ στο box, ένας βρόχος χωρίς όριο δεν έχει φυσική έξοδο
Το Markdown έχει τη δική του συνέχεια. Το DrawMarkdownTextBox επιστρέφει ένα token που αρχίζει με εσωτερικό marker ώστε η επόμενη κλήση να παρακάμψει τη μετατροπή· δώστε το πίσω στο DrawMarkdownTextBox ή στο DrawMarkdownText, όχι στα σημεία εισόδου HTML, που θα σχεδίαζαν το marker ως κείμενο
Το γενικό μάθημα: αποκωδικοποίηση μία φορά, επανακωδικοποίηση σε κάθε όριο
Κάθε αγωγός που κάνει parse κείμενο, σειριοποιεί το αποτέλεσμα πίσω στην ίδια σύνταξη και το ξανακάνει parse πρέπει να μεταχειρίζεται την αποκωδικοποίηση ως πράξη που συμβαίνει σε ακριβώς ένα σημείο, και να επανακωδικοποιεί σε κάθε όριο όπου αποκωδικοποιημένο κείμενο ξανά γίνεται σύνταξη. Template engines, HTML sanitizers και αλυσίδες Markdown-σε-HTML-σε-PDF μοιράζονται αυτό το σχήμα και αποτυγχάνουν με τον ίδιο τρόπο όταν ένας serializer ξεχνά ότι παράγει markup
Τα συμπτώματα είναι προβλέψιμα μόλις ξέρετε το σχήμα. Πολύ λίγη επανακωδικοποίηση γυρνά δεδομένα σε σύνταξη, που είναι η κατεύθυνση της έγχυσης. Πολύ μεγάλη κωδικοποίηση, ή ένας decoder που τρέχει δύο φορές, δείχνει ορθογραφίες entities στον αναγνώστη ή τις τρώει, που είναι η κατεύθυνση της απεικόνισης. Η διόρθωση μόνο μιας κατεύθυνσης σπάει συνήθως την άλλη· η διόρθωση του PDFlibPas χρειαζόταν λοιπόν να προσθέσει αποκωδικοποίηση του &, να την ταξινομήσει ξανά, να αφαιρέσει την όψιμη αποκωδικοποίηση και να προσθέσει επανα-escape στην ίδια έκδοση. Η ίδια αρχή τρέχει ανάποδα όταν περιεχόμενο PDF εξάγεται ως δομημένο κείμενο, όπως στο σημασιολογικό export PDF σε Markdown και DOCX από Delphi, όπου κάθε κυριολεκτικός χαρακτήρας πρέπει να κάνει escape για τη σύνταξη στόχο ακριβώς μία φορά
Λίστα ελέγχου για γρήγορη αναφορά
- Αναβαθμίστε σε PDFlibPas v3.539.47 ή νεότερο αν απεικονίζετε HTML ή Markdown που περιέχει δεδομένα χρήστη
- Κάντε escape περιεχόμενο κειμένου με
&πρώτα, μετά<και>· μην μετατρέπετε εισαγωγικά για κείμενο PDFlibPas - Escape μία φορά, στο μοναδικό σημείο όπου τα δεδομένα μπαίνουν στο HTML string
- Κρατήστε μη αξιόπιστες τιμές έξω από
href,srcκαιstyle, ή επικυρώστε τις σε σχέση με μια allow-list - Περιμένετε μόνο τα
<,>,&και να αποκωδικοποιούνται σε κείμενο· τα άλλα entities μένουν κυριολεκτικά - Περάστε το
LeftOverTextπίσω στοDrawHTMLTextBoxαμετάβλητο και βάλτε όριο στον βρόχο σελίδων - Δώστε tokens συνέχειας Markdown μόνο στο
DrawMarkdownTextBoxή στοDrawMarkdownText - Μην ψάχνετε ποτέ buffers UTF-16 bytes για μοτίβα bytes· δουλέψτε πάνω σε ολόκληρα code units
Η απόδοση HTML και Markdown, το export αναφορών dataset και το υπόλοιπο της μηχανής διάταξης έρχονται στον εγγενή πηγαίο κώδικα Pascal του PDF Library for Delphi, για Delphi και Free Pascal. Δείτε τη σελίδα προϊόντος PDFlibPas για εκδόσεις, υποστήριξη πλατφορμών και δοκιμαστική λήψη