مقاله فنی

HTML به PDF در PDFlibPas: رفع decode دوگانهٔ entityها

نسخه‌های 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> شده &lt;b&gt;. داخل 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 اول از &lt;unsafe&gt; به <unsafe> decode کرده بود در HTML نرمال‌شده به‌شکل گیومه‌های زاویه‌ای خام فرود می‌آمد و parse دوم آن را به‌شکل یک المان می‌خواند. دور این باگ اصلی سه نشت کوچک‌تر هم بود که همه به یک سمت اشاره می‌کردند:

  • &amp; در مجموعهٔ entityهای پشتیبانی‌شده نبود، پس R&amp;D عیناً چاپ می‌شد و راهی برای نوشتن عین املای entity مثل &lt; به‌عنوان متن وجود نداشت
  • مرحلهٔ کشیدن &nbsp; را بار دوم جایگزین می‌کرد، بعد از اینکه parse کلاً تمام شده بود، پس یک املای literal از entity هنوز می‌توانست در همان آخر محو شود
  • escape کد در مارک‌داون از ampersand می‌گذشت و exporter دیتاست فقط گیومه‌های زاویه‌ای را escape می‌کرد، پس املای entity داخل کد یا مقدار سلول‌ها به‌عنوان markup decode می‌شد
خط لولهٔ HTML در PDFlibPas برای DrawHTMLText که parse یک المان‌ها را می‌سازد، NormalizeParsedHTML آن‌ها را به HTML برمی‌گرداند و parse دو نتیجه را صفحه‌بندی می‌کند؛ قبل از v3.539.47 کلمات decode‌شده بدون escape نوشته می‌شدند و به تگ‌های زنده تبدیل می‌شدند، از v3.539.47 هر کلمه در مرز دوباره escape می‌شود
کلمات decode‌شده وقتی به parser برمی‌گردند سینتکس‌اند، اگر نرمال‌ساز فراموش کند خروجی‌اش markup است؛ به همین دلیل یک کامنت escape‌شده bold می‌شد یا لینک جوانه می‌زد
ورودی‌ای که به renderer می‌رسدقبل از v3.539.47از v3.539.47
&lt;unsafe&gt;به‌عنوان تگ parse می‌شد، متن هرگز به صفحه نمی‌رسید‏<unsafe> به‌عنوان متن کشیده می‌شود
&lt;b&gt;x&lt;/b&gt;‏x به‌شکل bold کشیده می‌شد‏<b>x</b> به‌عنوان متن کشیده می‌شود
R&amp;D‏R&amp;D عیناً چاپ می‌شدR&D
&amp;lt;‏&amp;lt; عیناً چاپ می‌شد&lt;
کد اسپن مارک‌داون حاوی &nbsp;به یک فاصلهٔ نشکن تبدیل می‌شد‏&nbsp; به‌عنوان متن کشیده می‌شود
مقدار سلول دیتاست &lt;<&lt;

چگونه v3.539.47 decode کردن entityهای HTML را تک‌گذری می‌کند

‏PDFlibPas v3.539.47 با سه تغییر هماهنگ decode کردن entity را تک‌گذری می‌کند: parser ‏&amp; را آخر decode می‌کند، مرحلهٔ کشیدن دیگر هیچ چیزی decode نمی‌کند، و هر جایی که کلمات decode‌شده را به HTML برمی‌گرداند اول دوباره escape‌شان می‌کند

مجموعهٔ entity پشتیبانی‌شده برای محتوای متنی حالا &lt; و &gt; و &amp; و &nbsp; است. هر چیز دیگری، از جمله ارجاع‌های عددی مثل &#65; و entityهای نام‌دار مثل &quot;، به‌شکل متن literal می‌ماند. این مرز برای نحوهٔ escape کردن ورودی خودت مهم است، همان‌طور که پایین نشان داده می‌شود

ترتیب داخل decoder اولین فیکس است. اگر &amp; اول decode می‌شد، ورودی &amp;lt; به &lt; تبدیل می‌شد و جایگزینی بعدی آن را به < می‌رساند؛ یک decode دوگانه که داخل یک گذر اتفاق می‌افتد. پس مسیر کلمهٔ ANSI اول &lt; و &gt; و &nbsp; را جایگزین می‌کند و &amp; را آخر، تا ampersand ای که تولید می‌کند دیگر هیچ‌وقت بررسی نشود. مسیر کلمهٔ UTF-16 یک اسکن تکی از چپ به راست با گام‌های دوبایتی است که هر تطبیق را درجا بازنویسی می‌کند و از رویش رد می‌شود، که همان تضمین را ساختاری می‌دهد

ترتیب decoder در PDFlibPas برای یک entity زنجیره‌ای مثل &amp;lt;: اگر ampersand اول decode شود داخل یک گذر به یک گیومهٔ زاویه‌ای واقعی فرومی‌ریزد، در حالی که decode کردن lt و gt و nbsp قبل از ampersand املای literal را سالم نگه می‌دارد تا متن دقیقاً یک بار decode‌شده به صفحه برسد
ampersand نویسهٔ escape است، پس باید آخر decode و اول escape شود، وگرنه یک گذر می‌تواند دو بار decode کند

فیکس دوم جایگزینی دیرهنگام &nbsp; را از مرحلهٔ کشیدن حذف می‌کند. 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 پیدا می‌کند که وجود ندارد، بایت‌های &amp; را وسط دو نویسه دوخت می‌زند و هر نویسهٔ بعدی را یک بایت قیچی می‌کند

خطر escape در UTF-16BE در PDFlibPas که در آن بایت‌های 01 00 26 03 برای U+0100 و U+2603 الگوی 00 26 را روی دو نویسه دارند، پس جست‌وجوی بایتی ampersand یک entity وسط یک code point دوخت می‌زند؛ اسکن code unit فقط آفست‌های زوج را می‌آزماید
جست‌وجوی بایتی یک ampersand پیدا می‌کند که هیچ نویسه‌ای هرگز نداشته است؛ روی code unitهای کامل کار کن، هرگز روی بافرهای بایتی خام UTF-16

این یک حالت گوشه‌ای عجیب نیست. هر نویسه‌ای که بایت پایینش صفر است می‌تواند نیمهٔ اول را تأمین کند؛ 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های درون‌خطی و بلوک‌های کد محصور یا تورفته حالا & را به &amp; و < را به &lt; و > را به &gt; نگاشت می‌کنند، فاصله‌ها به &nbsp; تبدیل می‌شوند و یک tab چهار تا از آن می‌شود تا تورفتگی حفظ شود. نثر عادی مارک‌داون فقط گیومه‌های زاویه‌ای را escape می‌کند، پس HTML خام در نثر نمی‌تواند تگ تزریق کند در حالی که نویسنده هنوز می‌تواند عمداً &amp; بنویسد، تقریباً همان‌طور که نویسندگان مارک‌داون انتظار دارند. ‏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(''&lt;tag&gt; &amp; R&amp;D'');' + sLineBreak +
        '```';
  Lib := TPDFlib.Create;
  try
    // HTML را بررسی کن: داخل کد، '&' به '&amp;' و '<' به '&lt;' تبدیل می‌شود
    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 ‏&amp; را decode نمی‌کرد، پس escape کردن ampersand در هر سلولی که داشت &amp; چاپ می‌کرد. آن دور زدن برای renderer قدیمی درست و در کلیت اشتباه بود، چون مقدار سلولی که اتفاقاً &lt; داشت به < decode می‌شد. با فیکس شدن renderer، ‏exporter اول & را escape می‌کند و مقداری مثل R&D &lt; &amp; &nbsp; عیناً در PDF فرود می‌آید. اگر گزارش‌هایت را همین‌طور می‌سازی، ‏خروجی گرفتن یک TDataSet به گزارش PDF در Delphi بقیهٔ exporter را پوشش می‌دهد

چرا ampersand باید اول برود ارزش یک بار توضیح دادن دارد. اول < را escape کن و &lt; می‌گیری؛ بعد & را escape کن و آن می‌شود &amp;lt;، که یک decode تک‌باریِ درست آن را به‌شکل &lt; نمایش می‌دهد به‌جای <. یک زنجیرهٔ جایگزینی ترتیبی فقط وقتی درست است که خود نویسهٔ escape قبل از هر چیزی که آن را معرفی می‌کند مدیریت شود

متن غیرقابل‌اعتماد را برای DrawHTMLTextBox چطور escape کنی؟

برای رندر HTML در PDFlibPas محتوای متنیِ غیرقابل‌اعتماد را با جایگزین کردن &، بعد <، بعد >، دقیقاً یک بار escape کن، و دادهٔ غیرقابل‌اعتماد را کلاً به مقدار attributeها نبر

uses
  System.SysUtils, PDFlibrary;

// متن غیرقابل‌اعتماد را برای محتوای متنی HTML در PDFlibPas escape می‌کند.
// '&' باید اول جایگزین شود، وگرنه ampersand داخل یک '&lt;'
// از قبل تولیدشده بار دوم escape می‌شد
function EscapeHTMLText(const S: string): string;
begin
  Result := StringReplace(S, '&', '&amp;', [rfReplaceAll]);
  Result := StringReplace(Result, '<', '&lt;', [rfReplaceAll]);
  Result := StringReplace(Result, '>', '&gt;', [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> & &lt;b&gt; نویسه‌به‌نویسه روی صفحه ظاهر می‌شود. قبل از v3.539.47 همان ورودی escape‌شده می‌توانست یک حاشیه‌نویسی لینک زنده تولید کند، و همین بخش است که یک ایراد نمایشی را به یک مشکل امنیتی تبدیل می‌کند: کامنت یک تیکت هرگز نباید بتواند یک URL قابل‌کلیک در سندی که همکارانت به آن اعتماد دارند بکار بگذارد

دقت کن این تابع چه چیزی را escape نمی‌کند. escaperهای HTML همه‌منظوره ‏" را هم به &quot; و ' را هم به &#39; تبدیل می‌کنند که برای یک مرورگر درست است. decode کردن متن در PDFlibPas فقط همان چهار entity قبلی را می‌شناسد، پس آن دو به‌شکل عین &quot; و &#39; چاپ می‌شدند. گیومه‌ها در محتوای متنی بی‌ضررند؛ فقط داخل مقدار attributeها مهم‌اند، و renderer اصلاً entity داخل attributeها را decode نمی‌کند. پس طراحی امن نه یک escaper بهتر بلکه یک قاعده است: دادهٔ غیرقابل‌اعتماد هرگز وارد href و src و style نمی‌شود. اگر هدف لینک واقعاً باید از دادهٔ کاربر بیاید، خودت آن را با یک لیست مجاز از schemeها و نویسه‌ها اعتبارسنجی کن و هر چیزی که گیومه یا گیومهٔ زاویه‌ای دارد رد کن

دو نکتهٔ ارتقا مستقیم از این فیکس دنبال می‌شوند:

  • اگر کدت escape کردن & را کنار گذاشته بود چون نسخه‌های قدیمی &amp; را عیناً چاپ می‌کردند، دوباره اضافه‌اش کن. بدون آن، متن کاربر حاوی &lt; حالا به‌شکل < نمایش داده می‌شود؛ همچنان متن بی‌ضرر اما دیگر همان چیزی که کاربر تایپ کرده نیست
  • دو بار escape نکن. متنی که از دو escaper رد می‌شود ‏< را به‌شکل املای مرئی &lt; رندر می‌کند، پس آن یک مرز را پیدا کن که داده‌ات وارد 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 ‏&amp; را به 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 شدن &lt; و &gt; و &amp; و &nbsp; را داشته باش؛ entityهای دیگر literal می‌مانند
  • LeftOverText را بدون تغییر به DrawHTMLTextBox برگردان و حلقهٔ صفحه را سقف‌دار کن
  • توکن‌های ادامهٔ مارک‌داون را فقط به DrawMarkdownTextBox یا DrawMarkdownText بده
  • هرگز دنبال الگوهای بایتی در بافرهای بایتی UTF-16 نگرد؛ روی code unitهای کامل کار کن

رندر HTML و مارک‌داون، ‏export گزارش دیتاست و بقیهٔ موتور layout در سورس پاسکالِ بومی PDF Library for Delphi برای Delphi و Free Pascal عرضه می‌شوند. ‏صفحهٔ محصول PDFlibPas را برای ویرایش‌ها و پشتیبانی پلتفرم و دانلود نسخهٔ آزمایشی ببین