نسخههای PDF Library for Delphi (PDFlibPas) قبل از v3.539.47 میتوانستند متن escapeشده را موقع کشیدن HTML یا Markdown به PDF دو بار decode کنند. DrawHTMLText و DrawHTMLTextBox اول HTML را parse میکنند، بعد آن را به HTML برمیگردانند و دوباره parse میکنند، پس متنی که بهشکل <unsafe> نوشته شده بود در parse دوم بهشکل یک تگ واقعی میرسید. از v3.539.47 هر entity دقیقاً یک بار decode میشود و متن هر جا که به HTML برمیگردد دوباره escape میشود
سناریویی که این باگ را لو میدهد کاملاً عادی است. یک help desk تیکتها را به PDF export میکند و کامنت مشتری داخل یک قالب HTML میرود. توسعهدهنده کار درست را کرده و کامنت را escape کرده، پس <b> شده <b>. داخل renderer آن escape بیسروصدا خنثی میشد: کامنت bold بیرون میآمد، یک نام تگ ناشناس کلاً از صفحه محو میشد، و یک anchor فراردهشده به یک حاشیهنویسی لینک قابلکلیک تبدیل میشد. بدون exception، بدون هشدار، یک PDF کاملاً معتبر که چیز دیگری از داده میگوید
چرا متن escapeشده در PDF به یک تگ واقعی تبدیل میشود؟
متن escapeشده به markup تبدیل میشد چون renderer دو گذر parse اجرا میکند و مرحلهٔ نرمالسازی بین آن دو متن decodeشده را بدون escape دوباره به HTML برمیگرداند. هر decode ای که parse اول انجام داده بود بعد در parse دوم بهشکل سینتکس زنده در دسترس بود
این دو گذر دلیل خوبی دارند. parse اول فهرستی از المانهای تگ و کلمه میسازد. NormalizeParsedHTML بعد آبشار stylesheet را حل میکند: قواعد داخل بلوکهای <style> را با هر تگ تطبیق میدهد، آنها را با attributeهای style درونخطی ادغام میکند، نتیجه را روی تگ نگه میدارد و کل فهرست المانها را به یک رشتهٔ HTML سریالایز میکند. گذر layout همان رشتهٔ نرمالشده را parse میکند. همین موتور است که layout flexbox و CSS grid و پانوشت در رندر HTML در PDFlibPas را میراند
اشکال در نحوهٔ سریالایز شدن کلمات بود. تگها از فرم اصلی منبعشان نوشته میشدند، در حالی که کلمات بهشکل decodeشده نوشته میشدند. کلمهای که parse اول از <unsafe> به <unsafe> decode کرده بود در HTML نرمالشده بهشکل گیومههای زاویهای خام فرود میآمد و parse دوم آن را بهشکل یک المان میخواند. دور این باگ اصلی سه نشت کوچکتر هم بود که همه به یک سمت اشاره میکردند:
&در مجموعهٔ entityهای پشتیبانیشده نبود، پسR&Dعیناً چاپ میشد و راهی برای نوشتن عین املای entity مثل<بهعنوان متن وجود نداشت- مرحلهٔ کشیدن
را بار دوم جایگزین میکرد، بعد از اینکه parse کلاً تمام شده بود، پس یک املای literal از entity هنوز میتوانست در همان آخر محو شود - escape کد در مارکداون از ampersand میگذشت و exporter دیتاست فقط گیومههای زاویهای را escape میکرد، پس املای entity داخل کد یا مقدار سلولها بهعنوان markup decode میشد
| ورودیای که به renderer میرسد | قبل از v3.539.47 | از v3.539.47 |
|---|---|---|
<unsafe> | بهعنوان تگ parse میشد، متن هرگز به صفحه نمیرسید | <unsafe> بهعنوان متن کشیده میشود |
<b>x</b> | x بهشکل bold کشیده میشد | <b>x</b> بهعنوان متن کشیده میشود |
R&D | R&D عیناً چاپ میشد | R&D |
&lt; | &lt; عیناً چاپ میشد | < |
کد اسپن مارکداون حاوی | به یک فاصلهٔ نشکن تبدیل میشد | بهعنوان متن کشیده میشود |
مقدار سلول دیتاست < | < | < |
چگونه v3.539.47 decode کردن entityهای HTML را تکگذری میکند
PDFlibPas v3.539.47 با سه تغییر هماهنگ decode کردن entity را تکگذری میکند: parser & را آخر decode میکند، مرحلهٔ کشیدن دیگر هیچ چیزی decode نمیکند، و هر جایی که کلمات decodeشده را به HTML برمیگرداند اول دوباره escapeشان میکند
مجموعهٔ entity پشتیبانیشده برای محتوای متنی حالا < و > و & و است. هر چیز دیگری، از جمله ارجاعهای عددی مثل A و entityهای نامدار مثل "، بهشکل متن literal میماند. این مرز برای نحوهٔ escape کردن ورودی خودت مهم است، همانطور که پایین نشان داده میشود
ترتیب داخل decoder اولین فیکس است. اگر & اول decode میشد، ورودی &lt; به < تبدیل میشد و جایگزینی بعدی آن را به < میرساند؛ یک decode دوگانه که داخل یک گذر اتفاق میافتد. پس مسیر کلمهٔ ANSI اول < و > و را جایگزین میکند و & را آخر، تا ampersand ای که تولید میکند دیگر هیچوقت بررسی نشود. مسیر کلمهٔ UTF-16 یک اسکن تکی از چپ به راست با گامهای دوبایتی است که هر تطبیق را درجا بازنویسی میکند و از رویش رد میشود، که همان تضمین را ساختاری میدهد
فیکس دوم جایگزینی دیرهنگام را از مرحلهٔ کشیدن حذف میکند. decode کردن به parser تعلق دارد و به هیچجای دیگر، پس کلمهای که به شکستدهندهٔ خط میرسد متن نهایی است
فیکس سوم قاعدهٔ مرز است. NormalizeParsedHTML حالا قبل از اضافه کردن هر کلمهٔ decodeشده به HTML نرمالشده، & و < و > آن را escape میکند. parse دوم آن را دقیقاً به همان متن decode میکند، پس اثر خالص روی کل خط لوله یک decode است. رشتهٔ ادامه هم همان قاعده را دنبال میکند: کلماتی که در جعبه جا نشدند قبل از اضافه شدن به LeftOverText escape میشوند و بقیهٔ باقیمانده از HTML نرمالشده کپی میشود که از قبل بهشکل escapeشده است. حلقهای که آن کلمات باقیمانده را جمع میکند هم حالا با شمارندهٔ کلمات محدود شده است، در حالی که حلقهٔ repeat قدیمی میتوانست از آخرین کلمه رد شود
چرا escape در UTF-16BE نمیتواند از جایگزینی در سطح بایت استفاده کند؟
escape در UTF-16BE نمیتواند از جایگزینی در سطح بایت استفاده کند چون الگوی دوبایتیِ ampersand میتواند روی دو نویسهٔ بیربط سر بخورد. تنها واحد کار درست، کل code unit شانزدهبیتی است
renderer کلمات Unicode را بهشکل UTF-16 بایتبزرگاولِ بستهبندیشده در رشتههای بایتی نگه میدارد، بایت بالا اول. ampersand میشود 00 26. حالا U+0100 را در نظر بگیر (A بزرگ لاتین با خط بالایی، بایتهای 01 00) بعد از آن U+2603 (آدمبرفی، بایتهای 26 03). توالی بایتها 01 00 26 03 است و بایتهای دوم و سوم خوانده میشوند 00 26. یک جستوجوی بایتی برای #0'&' یک ampersand پیدا میکند که وجود ندارد، بایتهای & را وسط دو نویسه دوخت میزند و هر نویسهٔ بعدی را یک بایت قیچی میکند
این یک حالت گوشهای عجیب نیست. هر نویسهای که بایت پایینش صفر است میتواند نیمهٔ اول را تأمین کند؛ U+4E00، یکی از پرتکرارترین ideographهای CJK، واجد شرایط است. گیومههای زاویهای هم همین مواجهه را دارند: 00 3C و 00 3E هر وقت چنین نویسهای بعد از یک نویسه از U+3C00 تا U+3EFF در CJK Extension A بیاید ظاهر میشوند. فیکس در EscapeHTMLWord بایتها را به یک WideString باز میکند، نویسهبهنویسه escape میکند و نتیجه را دوباره بستهبندی میکند. سمت decoder از قبل امن بود چون الگوها را فقط در مرزهای code unit زوج میآزماید
همین قاعده برای کد خودت هم صدق میکند. اگر روزی متن UTF-16 را بهشکل TBytes نگه داشتی، مثلاً بعد از TEncoding.BigEndianUnicode.GetBytes، دنبال الگوهای بایتی در آن نگرد. به رشته برگردان و روی نویسهها کار کن
بلوکهای کد مارکداون و exportهای دیتاست: اول ampersand را escape کن
از v3.539.47 هر دو تولیدکنندهٔ HTML داخل PDFlibPas، یعنی converter مارکداون و exporter دیتاست، ampersand را قبل از گیومههای زاویهای escape میکنند، تا decode تکباری renderer دقیقاً متن اصلی را بازگرداند
در MarkdownToHTML، code spanهای درونخطی و بلوکهای کد محصور یا تورفته حالا & را به & و < را به < و > را به > نگاشت میکنند، فاصلهها به تبدیل میشوند و یک tab چهار تا از آن میشود تا تورفتگی حفظ شود. نثر عادی مارکداون فقط گیومههای زاویهای را escape میکند، پس HTML خام در نثر نمیتواند تگ تزریق کند در حالی که نویسنده هنوز میتواند عمداً & بنویسد، تقریباً همانطور که نویسندگان مارکداون انتظار دارند. 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); // بر حسب point
// صفحه کد را عیناً همانطور که تایپ شده نشان میدهد، شامل املای entityها
Lib.DrawMarkdownText(50, 50, 495, Md);
Lib.SaveToFile('code-sample.pdf');
finally
Lib.Free;
end;
end;
exporter دیتاست حالت آموزنده است. قبل از v3.539.47 فقط گیومههای زاویهای را escape میکرد، و عمداً: renderer & را decode نمیکرد، پس escape کردن ampersand در هر سلولی که داشت & چاپ میکرد. آن دور زدن برای renderer قدیمی درست و در کلیت اشتباه بود، چون مقدار سلولی که اتفاقاً < داشت به < decode میشد. با فیکس شدن renderer، exporter اول & را escape میکند و مقداری مثل R&D < & عیناً در PDF فرود میآید. اگر گزارشهایت را همینطور میسازی، خروجی گرفتن یک TDataSet به گزارش PDF در Delphi بقیهٔ exporter را پوشش میدهد
چرا ampersand باید اول برود ارزش یک بار توضیح دادن دارد. اول < را escape کن و < میگیری؛ بعد & را escape کن و آن میشود &lt;، که یک decode تکباریِ درست آن را بهشکل < نمایش میدهد بهجای <. یک زنجیرهٔ جایگزینی ترتیبی فقط وقتی درست است که خود نویسهٔ escape قبل از هر چیزی که آن را معرفی میکند مدیریت شود
متن غیرقابلاعتماد را برای DrawHTMLTextBox چطور escape کنی؟
برای رندر HTML در PDFlibPas محتوای متنیِ غیرقابلاعتماد را با جایگزین کردن &، بعد <، بعد >، دقیقاً یک بار escape کن، و دادهٔ غیرقابلاعتماد را کلاً به مقدار attributeها نبر
uses
System.SysUtils, PDFlibrary;
// متن غیرقابلاعتماد را برای محتوای متنی HTML در PDFlibPas escape میکند.
// '&' باید اول جایگزین شود، وگرنه 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 نمیکند. escaperهای HTML همهمنظوره " را هم به " و ' را هم به ' تبدیل میکنند که برای یک مرورگر درست است. decode کردن متن در PDFlibPas فقط همان چهار entity قبلی را میشناسد، پس آن دو بهشکل عین " و ' چاپ میشدند. گیومهها در محتوای متنی بیضررند؛ فقط داخل مقدار attributeها مهماند، و renderer اصلاً entity داخل attributeها را decode نمیکند. پس طراحی امن نه یک escaper بهتر بلکه یک قاعده است: دادهٔ غیرقابلاعتماد هرگز وارد href و src و style نمیشود. اگر هدف لینک واقعاً باید از دادهٔ کاربر بیاید، خودت آن را با یک لیست مجاز از schemeها و نویسهها اعتبارسنجی کن و هر چیزی که گیومه یا گیومهٔ زاویهای دارد رد کن
دو نکتهٔ ارتقا مستقیم از این فیکس دنبال میشوند:
- اگر کدت escape کردن
&را کنار گذاشته بود چون نسخههای قدیمی&را عیناً چاپ میکردند، دوباره اضافهاش کن. بدون آن، متن کاربر حاوی<حالا بهشکل<نمایش داده میشود؛ همچنان متن بیضرر اما دیگر همان چیزی که کاربر تایپ کرده نیست - دو بار escape نکن. متنی که از دو escaper رد میشود
<را بهشکل املای مرئی<رندر میکند، پس آن یک مرز را پیدا کن که دادهات وارد HTML میشود و فقط همانجا escape کن
صفحهبندی با LeftOverText بدون شکستن escapeها
DrawHTMLTextBox HTML ای را که در جعبه جا نشد برمیگرداند، چیزی که معمولاً به آن LeftOverText گفته میشود — و از v3.539.47 آن باقیمانده املای entityها و گیومههای زاویهای escapeشده را وقتی به جعبهٔ بعدی میدهی حفظ میکند. قاعدهٔ فراخوانندهها ساده است: آن را بدون تغییر برگردان
const
BoxLeft = 50;
BoxTop = 50;
BoxWidth = 495; // اندازه برای یک صفحهٔ A4 بر حسب point
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 خودت ردش نکن، decode اش نکن و متن کاربر را داخلش جایگذاری نکن. سقف صفحه بیمهٔ ارزان است: اگر المانی هیچوقت در جعبه جا نشود، حلقهٔ بدون سقف خروج طبیعی ندارد
مارکداون ادامهٔ خودش را دارد. DrawMarkdownTextBox یک توکن برمیگرداند که با یک نشانگر داخلی شروع میشود تا فراخوانی بعدی بتواند از تبدیل بگذرد؛ آن را به DrawMarkdownTextBox یا DrawMarkdownText بده، نه به ورودیهای HTML که نشانگر را بهعنوان متن میکشند
درس کلی: یک بار decode، در هر مرز دوباره encode
هر خط لولهای که متن را parse میکند، نتیجه را به همان سینتکس برمیگرداند و دوباره parse میکند، باید decode کردن را عملیاتی بداند که دقیقاً در یک جا اتفاق میافتد و باید در هر مرزی که متن decodeشده دوباره سینتکس میشود دوباره encode کند. موتورهای قالب، sanitizerهای HTML و زنجیرههای مارکداون-به-HTML-به-PDF همین شکل را دارند و وقتی سریالایزری فراموش کند خروجیاش markup است به همان شکل میشکنند
علامتها وقتی شکل را بشناسی قابل پیشبینیاند. encode کردن کم، داده را به سینتکس تبدیل میکند؛ همان جهت تزریق. encode کردن زیاد، یا decoder ای که دو بار اجرا میشود، املای entity را به خواننده نشان میدهد یا میبلعد؛ همان جهت نمایش. فیکس فقط یک سمت معمولاً سمت دیگر را میشکند، و برای همین فیکس PDFlibPas باید در همان یک release & را به decode اضافه میکرد، ترتیبش را عوض میکرد، decode دیرهنگام را حذف میکرد و re-escaping را اضافه میکرد. همین اصل وقتی محتوای PDF بهعنوان متن ساختیافته export میشود به سمت دیگر هم میرود، مثل export معنایی PDF به Markdown و DOCX از Delphi، جایی که هر نویسهٔ literal باید دقیقاً یک بار برای سینتکس مقصد escape شود
چکلیست مرجع سریع
- اگر HTML یا مارکداون حاوی دادهٔ کاربر رندر میکنی به PDFlibPas v3.539.47 یا بعدتر ارتقا بده
- محتوای متنی را اول با
&escape کن بعد<و>؛ برای متن PDFlibPas گیومهها را تبدیل نکن - یک بار escape کن، در همان تکنقطهای که داده وارد رشتهٔ HTML میشود
- مقادیر غیرقابلاعتماد را از
hrefوsrcوstyleدور نگه دار، یا با یک لیست مجاز اعتبارسنجیشان کن - در متن فقط انتظار decode شدن
<و>و&و را داشته باش؛ entityهای دیگر literal میمانند LeftOverTextرا بدون تغییر بهDrawHTMLTextBoxبرگردان و حلقهٔ صفحه را سقفدار کن- توکنهای ادامهٔ مارکداون را فقط به
DrawMarkdownTextBoxیاDrawMarkdownTextبده - هرگز دنبال الگوهای بایتی در بافرهای بایتی UTF-16 نگرد؛ روی code unitهای کامل کار کن
رندر HTML و مارکداون، export گزارش دیتاست و بقیهٔ موتور layout در سورس پاسکالِ بومی PDF Library for Delphi برای Delphi و Free Pascal عرضه میشوند. صفحهٔ محصول PDFlibPas را برای ویرایشها و پشتیبانی پلتفرم و دانلود نسخهٔ آزمایشی ببین