גרסאות PDF Library for Delphi (PDFlibPas) לפני v3.539.47 יכלו לפענח טקסט עם escape פעמיים כשמציירים HTML או Markdown לתוך PDF. DrawHTMLText ו-DrawHTMLTextBox מפרסרות את ה-HTML, מנרמלות אותו חזרה ל-HTML, ואז מפרסרות שוב, כך שטקסט שנכתב כ-<unsafe> הגיע לפרסור השני בתור תג אמיתי. מאז v3.539.47 כל entity מפוענח בדיוק פעם אחת וטקסט עובר re-escape בכל נקודה שבה הוא חוזר להיות HTML
התרחיש שחושף את זה הוא יומיומי. help desk מייצא פניות ל-PDF, והערת הלקוח נכנסת לתוך תבנית HTML. המפתח עשה את הדבר הנכון וביצע escape להערה, כך ש-<b> הפך ל-<b>. בתוך ה-renderer ה-escape הזה בוטל בשקט: ההערה יצאה מודגשת, שם תג לא מוכר פשוט נעלם מהעמוד, ועוגן עם escape הפך להערת קישור לחיצה. בלי חריגה, בלי אזהרה, PDF תקין לחלוטין שאומר משהו שונה מהנתונים
למה טקסט עם escape הופך לתג אמיתי בתוך ה-PDF?
טקסט עם escape הפך ל-markup כי ה-renderer מריץ שני מעברי פרסור, ושלב הנרמול ביניהם כתב טקסט שכבר פוענח חזרה ל-HTML בלי לבצע עליו escape שוב. כל פענוח שהפרסור הראשון ביצע היה זמין אז לפרסור השני בתור תחביר חי
שני המעברים קיימים מסיבה טובה. הפרסור הראשון בונה רשימה של אלמנטי תג ומילה. NormalizeParsedHTML אז מפענח את ה-cascade של גיליון הסגנון: הוא מתאים את הכללים מבלוקי <style> מול כל תג, ממזג אותם עם תכונות style inline, מאחסן את התוצאה על התג, וממספר את כל רשימת האלמנטים חזרה למחרוזת HTML. מעבר הפריסה מפרסר את המחרוזת המנורמלת הזאת. זו אותה מכונה שמניעה את פריסת ה-flexbox, ה-CSS grid וההערות השוליים בעיבוד ה-HTML של PDFlibPas
הפגם היה באופן שבו המילים מוספרו. תגים נכתבו חזרה מהצורה המקורית שלהם בקוד המקור, בזמן שמילים נכתבו חזרה בצורה המפוענחת שלהן. מילה שהפרסור הראשון פענח מ-<unsafe> ל-<unsafe> נחתה ב-HTML המנורמל כסוגריים משולשים גולמיים, והפרסור השני קרא אותה בתור אלמנט. סביב הבאג המרכזי הזה ישבו שלושה דליפות קטנות יותר שהצביעו לאותו כיוון:
&לא היה בקבוצת ה-entities הנתמכים, ולכןR&Dהודפס ליטרלית ולא הייתה דרך לכתוב איות entity ליטרלי כמו<בתור טקסט- שלב הציור החליף את
בפעם השנייה, אחרי שהפרסור כבר הסתיים, כך שאיות entity ליטרלי עדיין יכול היה להיעלם ברגע האחרון - ה-escape של קוד Markdown דילג על ה-ampersand, ומייצא ה-dataset ביצע escape רק לסוגריים המשולשים, כך שאיותי entities בתוך קוד או ערכי תאים פוענחו כ-markup
| קלט שמגיע ל-renderer | לפני v3.539.47 | מאז v3.539.47 |
|---|---|---|
<unsafe> | מפוענח בתור תג, הטקסט לעולם לא מגיע לעמוד | <unsafe> מצויר בתור טקסט |
<b>x</b> | x מצויר מודגש | <b>x</b> מצויר בתור טקסט |
R&D | R&D הודפס ליטרלית | R&D |
&lt; | &lt; הודפס ליטרלית | < |
מקטע קוד Markdown שמכיל | הפך לרווח שאינו שובר שורה | מצויר בתור טקסט |
ערך תא ב-dataset: < | < | < |
איך v3.539.47 הופכת את פענוח ה-entities של HTML למעבר אחד
PDFlibPas v3.539.47 הופכת את פענוח ה-entities למעבר אחד עם שלושה שינויים מתואמים: הפרסר מפענח את & אחרון, שלב הציור כבר לא מפענח כלום, וכל מקום שהופך מילים מפוענחות חזרה ל-HTML מבצע עליהן escape מחדש קודם
קבוצת ה-entities הנתמכת לתוכן טקסט היא עכשיו <, >, & ו- . כל דבר אחר, כולל הפניות מספריות כמו A ו-entities בעלי שם כמו ", נשאר טקסט ליטרלי. הגבול הזה משנה לגבי איך מבצעים escape לקלט משלכם, כפי שיוצג להלן
הסדר בתוך המפענח הוא התיקון הראשון. אילו & היה מפוענח ראשון, הקלט &lt; היה הופך ל-< וההחלפה הבאה הייתה הופכת אותו ל-<, פענוח כפול שקורה בתוך מעבר אחד. לכן נתיב המילים של ANSI מחליף את <, > ו- ראשון ואת & אחרון, כך שה-ampersand שהוא מייצר לעולם לא נבדק שוב. נתיב המילים של UTF-16 הוא סריקה אחת משמאל לימין בצעדים של שני בייטים שכותבת כל התאמה מחדש במקום וממשיכה מעבר לה, מה שנותן את אותה ערובה מבנית
התיקון השני מסיר את ההחלפה המאוחרת של משלב הציור. פענוח שייך לפרסר ולשום מקום אחר, ולכן מילה שמגיעה לשובר השורות היא טקסט סופי
התיקון השלישי הוא כלל הגבול. NormalizeParsedHTML מבצע עכשיו escape ל-&, < ו-> בכל מילה מפוענחת לפני שהיא מצורפת ל-HTML המנורמל. הפרסור השני מפענח אותה חזרה לאותו טקסט בדיוק, כך שהאפקט הנטו על פני כל הצינור הוא פענוח אחד. מחרוזת ההמשך נאמנה לאותו כלל: מילים שלא נכנסו לתיבה עוברות escape לפני שהן מצורפות ל-LeftOverText, והשאר של השארית מועתק מה-HTML המנורמל, שכבר נמצא בצורה עם escape. הלולאה שאוספת את מילי השארית האלה חסומה עכשיו גם במניין המילים, בזמן שלולאת ה-repeat הישנה יכלה לדרוך מעבר למילה האחרונה
למה escape של UTF-16BE לא יכול להשתמש בהחלפה ברמת בייטים?
escape של UTF-16BE לא יכול להשתמש בהחלפה ברמת בייטים כי תבנית שני הבייטים של ampersand יכולה לרכב על שני תווים בלתי קשורים. יחידת העבודה הנכונה היחידה היא יחידת הקוד בת ה-16 ביט כולה
ה-renderer מאחסן מילים Unicode בתור UTF-16 big-endian ארוז למחרוזות בייטים, בייט גבוה קודם. ampersand הוא 00 26. עכשיו קחו את U+0100 (A גדולה לטינית עם מקרון, בייטים 01 00) ואחריה U+2603 (איש השלג, בייטים 26 03). רצף הבייטים הוא 01 00 26 03, והבייטים השני והשלישי קוראים 00 26. חיפוש בייטים אחרי #0'&' מוצא ampersand שלא קיים, משתיל את הבייטים של & לאמצע של שני תווים, וגוזר בייט אחד מכל תו שאחריו
זו לא פינה אקזוטית. כל תו שהבייט הנמוך שלו אפס יכול לספק את החצי הראשון; U+4E00, אחת האידאוגרמות ה-CJK הנפוצות ביותר, עומדת בתנאי. לסוגריים המשולשים אותה חשיפה: 00 3C ו-00 3E מופיעים בכל פעם שתו כזה בא אחרי תו מ-U+3C00 עד U+3EFF ב-CJK Extension A. התיקון ב-EscapeHTMLWord פורק את הבייטים ל-WideString, מבצע escape תו-תו ואורז את התוצאה שוב. צד המפענח כבר היה בטוח כי הוא בודק תבניות רק בגבולות זוגיים של יחידות קוד
אותו כלל חל על הקוד שלכם. אם אי פעם אתם מחזיקים טקסט UTF-16 בתור TBytes, למשל אחרי TEncoding.BigEndianUnicode.GetBytes, אל תחפשו בו תבניות בייטים. המירו חזרה למחרוזת ועבדו על תווים
בלוקי קוד Markdown וייצוא dataset: escape ל-ampersand קודם
מאז v3.539.47 שני מפיקי ה-HTML בתוך PDFlibPas, ממיר ה-Markdown ומייצא ה-dataset, מבצעים escape ל-ampersand לפני הסוגריים המשולשים, כך שהפענוח הבודד ב-renderer משחזר בדיוק את הטקסט המקורי
בתוך MarkdownToHTML, מקטעי קוד inline ובלוקי קוד מגודרים או מוזחים ממפים עכשיו את & ל-&, את < ל-< ואת > ל->, בזמן שרווחים הופכים ל- וטאב הופך לארבעה כאלה כדי לשמר הזחה. פרוזה רגילה של Markdown מבצעת escape רק לסוגריים המשולשים, כך ש-HTML גולמי בפרוזה לא יכול להזריק תגים בזמן שכותב עדיין יכול לכתוב & בכוונה, כמעט כפי שכותבי 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); // נקודות
// העמוד מציג את הקוד בדיוק כפי שהוקלד, כולל איות ה-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 לתוכן טקסט לא מהימן על ידי החלפת &, אחר כך <, ואז >, בדיוק פעם אחת, ומרחיקים נתונים לא מהימנים מערכי תכונות כליל
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 אותו קלט עם escape יכול היה לייצר הערת קישור חיה, וזה החלק שהופך פגם תצוגה לבעיית אבטחה: הערת פנייה לעולם לא צריכה להיות מסוגלת לשתול URL לחיץ במסמך שהצוות שלכם סומך עליו
שימו לב למה שהפונקציה לא מבצעת עליו escape. escapers כלליים של HTML ממירים גם את " ל-" ואת ' ל-', וזה נכון לדפדפן. פענוח הטקסט של PDFlibPas מזהה רק את ארבעת ה-entities שהוזכרו קודם, ולכן שניים אלה היו מודפסים ליטרלית בתור " ו-'. מרכאות לא מזיקות בתוכן טקסט; הן חשובות רק בתוך ערכי תכונות, וה-renderer בכלל לא מפענח entities בתכונות. העיצוב הבטוח הוא אפוא לא escaper טוב יותר אלא כלל: נתונים לא מהימנים לעולם לא נכנסים ל-href, src או style. אם יעד קישור באמת חייב להגיע מנתוני משתמש, אמתו אותו בעצמכם מול רשימת סכמות ותווים מותרים ודחו כל דבר שמכיל מרכאות או סוגריים משולשים
שתי הערות שדרוג נובעות ישירות מהתיקון:
- אם הקוד שלכם הפסיק לבצע escape ל-
&כי גרסאות ישנות הדפיסו&ליטרלית, החזירו אותו. בלעדיו, טקסט משתמש שמכיל<מוצג עכשיו בתור<, עדיין טקסט לא מזיק אבל כבר לא מה שהמשתמש הקליד - אל תבצעו escape פעמיים. טקסט שעובר דרך שני escapers מציג את
<בתור האיות הגלוי<, ולכן מצאו את הגבול האחד שבו הנתונים שלכם נכנסים ל-HTML ובצעו escape רק שם
עימוד עם LeftOverText בלי לשבור את ה-escape
DrawHTMLTextBox מחזירה את ה-HTML שלא נכנס, שנקרא בדרך כלל LeftOverText, ומאז v3.539.47 השארית הזאת שומרת על איותי entities ליטרליים ועל סוגריים משולשים עם escape כשמעבירים אותה לתיבה הבאה. הכלל לקוראים פשוט: מעבירים אותה חזרה ללא שינוי
const
BoxLeft = 50;
BoxTop = 50;
BoxWidth = 495; // מתוכנן לעמוד A4 בנקודות
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 הוא כבר HTML של המנוע עם escape: לעולם לא מבצעים עליו 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 המנורמל של המנוע, עם סגנונות שכבר הופקו, ולכן לא מריצים אותה דרך ה-escaper שלכם, לא מפענחים אותה, ולא משתילים לתוכה טקסט משתמש. תקרת העמודים היא ביטוח זול: אם אלמנט מסוים לעולם לא יכול להיכנס לתיבה, ללולאה ללא תקרה אין יציאה טבעית
ל-Markdown יש המשכה משלה. DrawMarkdownTextBox מחזירה token שמתחיל במחוון פנימי כדי שהקריאה הבאה תוכל לדלג על ההמרה; מחזירים אותו ל-DrawMarkdownTextBox או ל-DrawMarkdownText, לא לנקודות הכניסה של ה-HTML, שיציירו את המחוון בתור טקסט
הלקח הכללי: מפענחים פעם אחת, מקודדים מחדש בכל גבול
כל צינור שמפרסר טקסט, ממספר את התוצאה חזרה לאותו תחביר ומפרסר שוב חייב להתייחס לפענוח בתור פעולה שקורה במקום אחד בדיוק, וחייב לקודד מחדש בכל גבול שבו טקסט מפוענח הופך שוב לתחביר. מנועי תבניות, sanitizers של HTML ושרשראות Markdown-ל-HTML-ל-PDF חולקות את הצורה הזאת ונכשלות באותו אופן כשסריאליזר שוכח שהוא מייצר markup
הסימפטומים צפויים ברגע שמכירים את הצורה. מעט מדי קידוד מחדש הופך נתונים לתחביר, וזהו כיוון ההזרקה. יותר מדי קידוד, או מפענח שרץ פעמיים, מציג לקורא איותי entities או בולע אותם, וזהו כיוון התצוגה. תיקון של כיוון אחד בלבד בדרך כלל שובר את השני, ולכן התיקון של PDFlibPas נאלץ להוסיף פענוח של &, לסדר אותו מחדש, להסיר את הפענוח המאוחר ולהוסיף re-escape באותה מהדורה. אותו עיקרון רץ לכיוון השני כשתוכן PDF מיוצא בתור טקסט מובנה, כמו ב-ייצוא סמנטי של PDF ל-Markdown ו-DOCX מ-Delphi, שבו כל תו ליטרלי חייב לעבור escape עבור תחביר היעד בדיוק פעם אחת
רשימת בדיקה מהירה
- שדרגו ל-PDFlibPas v3.539.47 ומעלה אם אתם מרנדרים HTML או Markdown שמכילים נתוני משתמש
- מבצעים escape לתוכן טקסט עם
&ראשון, אחר כך<ו->; לא ממירים מרכאות עבור טקסט PDFlibPas - escape פעם אחת, בנקודה היחידה שבה הנתונים נכנסים למחרוזת ה-HTML
- מרחיקים ערכים לא מהימנים מ-
href,srcו-style, או מאמתים אותם מול רשימת היתר - מצפים שרק
<,>,&ו- יפוענחו בטקסט; entities אחרים נשארים ליטרליים - מחזירים את
LeftOverTextל-DrawHTMLTextBoxללא שינוי וחוסמים את לולאת העמודים - מעבירים token המשכה של Markdown רק ל-
DrawMarkdownTextBoxאו ל-DrawMarkdownText - לעולם לא מחפשים תבניות בייטים בחוצצי בייטים של UTF-16; עובדים על יחידות קוד שלמות
עיבוד HTML ו-Markdown, ייצוא דוחות dataset ושאר מנוע הפריסה מגיעים בקוד המקור הפסקלי הטבעי של PDF Library for Delphi, עבור Delphi ו-Free Pascal. ראו את עמוד המוצר של PDFlibPas למהדורות, תמיכת פלטפורמות והורדת ניסיון