مقال تقني

PDFlibPas من HTML إلى PDF: إصلاح فك الكيانات المزدوج

إصدارات PDF Library for Delphi ‏(PDFlibPas) الأقدم من v3.539.47 كانت قد تفك نصاً مهرباً مرتين عند رسم HTML أو Markdown داخل PDF. ‏DrawHTMLText و DrawHTMLTextBox يحللان الـ HTML، ثم يعيدان تطبيعه إلى HTML، ثم يحللانه مرة ثانية، فنص مكتوب كـ <unsafe> يصل إلى التحليل الثاني وسمة حقيقية. ومنذ v3.539.47 يُفك كل كيان مرة واحدة بالضبط، ويُهرَّب النص من جديد أينما عاد ليصير HTML

السيناريو الذي يكشف هذا عادي تماماً. مكتب دعم تصدّر التذاكر إلى PDF، وتعليق العميل يدخل في قالب HTML. والمطوّر فعل الصواب وهرب التعليق، فصار <b> قيمة &lt;b&gt;. وداخل المحرك فُكّ ذلك التهريب بصمت: خرج التعليق عريضاً، واسم وسم مجهول اختفى ببساطة من الصفحة، ومرساة مهربة صارت annotation رابط قابلاً للنقر. لا استثناء ولا تحذير، و PDF صالح تماماً يقول شيئاً غير ما تقوله البيانات

لماذا يصير النص المهرب وسمَاً حقيقياً في الـ PDF؟

صار النص المهرب ترميزاً لأن المحرك يجري تمريرتي تحليل، وخطوة التطبيع بينهما كتبت نصاً مفكوكاً مسبقاً عائداً إلى HTML دون تهريبه ثانية. فكل فك أنجزه التحليل الأول صار متاحاً للتحليل الثاني بوصفه ترميزاً حياً

والتمريرتان موجودتان لسبب وجيه. التحليل الأول يبني قائمة عناصر وسوم وكلمات. ثم يحل NormalizeParsedHTML تعاقب الـ stylesheet: يطابق القواعد القادمة من كتل <style> مع كل وسم، ويدمجها مع خصائص style المضمّنة، ويخزن النتيجة على الوسم، ثم يسلسل قائمة العناصر كلها عائداً إلى سلسلة HTML. وتمريرة التخطيط تحلل تلك السلسلة المطعَّمة. وهي الآلية نفسها التي تقود تخطيط flexbox و CSS grid والحواشي في عرض HTML لدى PDFlibPas

كان الخلل في طريقة تسلسل الكلمات. فالوسوم كُتبت عائدةً من صيغتها المصدرية الأصلية، بينما كُتبت الكلمات بصيغتها المفكوكة. فكلمة فكها التحليل الأول من &lt;unsafe&gt; إلى <unsafe> هبطت في الـ HTML المطعَّم بأقواس زاوية خام، وقرأها التحليل الثاني عنصراً. وحول هذا الخلل الجذري تسربت ثلاثة تسريبات أصغر تشير جميعها إلى الاتجاه نفسه:

  • &amp; لم تكن في مجموعة الكيانات المدعومة، فطُبعت R&amp;D حرفياً ولم تكن ثمة طريقة لكتابة تهجين كيان حرفي مثل &lt; بوصفه نصاً
  • مرحلة الرسم كانت تستبدل &nbsp; مرة ثانية بعد انتهاء التحليل، فتهجين كيان حرفي كان يمكن أن يختفي في اللحظة الأخيرة
  • تهريب كود Markdown تخطى علامة &، ومصدّر dataset هرب الأقواس الزاوية فقط، ففُكت تهجينات الكيانات داخل الكود أو قيم الخلايا بوصفها ترميزاً
مسار PDFlibPas لـ HTML في DrawHTMLText حيث يبني التحليل الأول العناصر ويسلسلها NormalizeParsedHTML عائداً إلى HTML ويضع التحليل الثاني النتيجة في التخطيط؛ قبل v3.539.47 كانت الكلمات المفكوكة تكتب عائدة دون تهريب فتصير وسوم حية، ومنذ v3.539.47 تُهرَّب كل كلمة عند الحدود
الكلمات المفكوكة تعود إلى المحلل بوصفها ترميزاً حين ينسى المُطبِّع أنه ينتج markup، وهكذا صار تعليق مهرب عريضاً أو أنبت رابطاً
المدخل الذي يصل إلى المحركقبل v3.539.47منذ v3.539.47
&lt;unsafe&gt;يُحلَّل وسمَاً ولا يصل النص إلى الصفحة<unsafe> يُرسم نصاً
&lt;b&gt;x&lt;/b&gt;x يُرسم عريضاً<b>x</b> يُرسم نصاً
R&amp;DR&amp;D تُطبع حرفياًR&D
&amp;lt;&amp;lt; تُطبع حرفياً&lt;
مدى كود Markdown يحوي &nbsp;صار مسافة غير فاصلة&nbsp; يُرسم نصاً
قيمة خلية dataset هي &lt;<&lt;

كيف تجعل v3.539.47 فك كيانات HTML بتمريرة واحدة

تجعل PDFlibPas v3.539.47 فك الكيانات بتمريرة واحدة بثلاثة تغييرات متناغمة: المحلل يفك &amp; أخيراً، ومرحلة الرسم لم تعد تفك شيئاً، وكل موضع يعيد الكلمات المفكوكة إلى HTML يهرّبها أولاً

مجموعة الكيانات المدعومة لمحتوى النص الآن هي &lt; و &gt; و &amp; و &nbsp;. وأي شيء آخر، بما فيه المراجع الرقمية مثل &#65; والكيانات المسماة مثل &quot;، يبقى نصاً حرفياً. وهذه الحدود مهمة لطريقة تهريبك دخلك الخاص كما سيتبين أدناه

الترتيب داخل المفكوك هو الإصلاح الأول. لو فُكت &amp; أولاً لصار المدخل &amp;lt; قيمة &lt; وتحوّله الاستبدال التالي إلى <، وهو فك مزدوج يحدث داخل تمريرة واحدة. لذا يستبدل مسار كلمات ANSI القيم &lt; و &gt; و &nbsp; أولاً و &amp; أخيراً، فلا يُفحص الـ ampersand الذي ينتجه أبداً من جديد. ومسار كلمات UTF-16 مسح واحد من اليسار إلى اليمين بخطوات بايتَين يعيد كتابة كل تطابق في مكانه ويتجاوزه، وهو ما يعطي الضمانة نفسها بنيوياً

ترتيب مفكوك PDFlibPas لكيان متسلسل مثل &amp;lt;: فك الـ ampersand أولاً يطويه إلى قوس زاوية حقيقي داخل تمريرة واحدة، بينما فك lt و gt و nbsp قبل الـ ampersand يبقي التهجين الحرفي سليماً فيصل النص إلى الصفحة مفكوكاً مرة واحدة بالضبط
الـ ampersand هو محرف التهريب، فيجب فكه أخيراً وتهريبه أولاً، وإلا أمكن لتمريرة واحدة أن تفك مرتين

الإصلاح الثاني يزيل استبدال &nbsp; المتأخر من مرحلة الرسم. فالفك من شأن المحلل ولا مكان له في غيره، فالكلمة التي تصل إلى فاصل الأسطر نص نهائي

والإصلاح الثالث هو قاعدة الحدود. يهرّب NormalizeParsedHTML الآن & و < و > في كل كلمة مفكوكة قبل إلحاقها بالـ HTML المطعَّم. فيفكها التحليل الثاني عائدةً إلى النص نفسه تماماً، فيكون الأثر الصافي على المسار كله فكَاً واحداً. وسلسلة الاستكمال تتبع القاعدة نفسها: الكلمات التي لم تتسع في الصندوق تُهرَّب قبل إلحاقها بـ LeftOverText، وبقية الفائض يُنسخ من الـ HTML المطعَّم وهو أصلاً بصيغة مهربة. والحلقة التي تجمع كلمات الفائض تلك أصبحت محدودة بعدد الكلمات أيضاً، حيث كانت حلقة التكرار القديمة قد تتجاوز الكلمة الأخيرة

لماذا لا يستطيع تهريب UTF-16BE أن يستخدم استبدالاً على مستوى البايت؟

لا يستطيع تهريب UTF-16BE أن يستخدم استبدالاً على مستوى البايت لأن نمط البايتَين لعلامة ampersand يمكن أن يمتد على محرفين لا صلة بينهما. فالوحدة الصحيحة الوحيدة للعمل هي وحدة الكود ذات ‏16-بت كاملة

يخزن المحرك كلمات Unicode كـ UTF-16 بايت مكتبي (big-endian) معبأة في سلاسل بايتات، البايت العالي أولاً. وعلامة ampersand هي 00 26. والآن خذ U+0100 ‏(حرف A اللاتيني الكبير مع macron، بايتات 01 00) يليه U+2603 ‏(رجل الثلج، بايتات 26 03). فالتسلسل البايتي هو 01 00 26 03، والبايتان الثاني والثالث يُقرآن 00 26. وبحث بايتي عن #0'&' يجد ampersand غير موجود، ويزرع بايتات &amp; في وسط محرفين، وتمزق كل محرف لاحق بإزاحة بايت واحد

خطر تهريب UTF-16BE في PDFlibPas حيث البايتات 01 00 26 03 للرمزين U+0100 و U+2603 تحوي النمط 00 26 عبر محرفين، فبحث بايتي عن الـ ampersand يزرع كياناً في وسط code point؛ ومسح وحدات الكود يفحص الإزاحات الزوجية فقط
بحث بايتي يجد ampersand لم يحوِه أي محرف قط؛ اعمل على وحدات كود كاملة لا على مخازن بايتات UTF-16 خام

تلك ليست حالة زاوية غريبة. أي محرف بايته المنخفض صفر يمكنه توفير النصف الأول؛ و U+4E00، أحد أكثر ideographs الـ CJK شيوعاً، مؤهل. والأقواس الزاوية معرّضة بالطريقة نفسها: تظهر 00 3C و 00 3E كلما تبعه محرف كهذا آخر من U+3C00 إلى U+3EFF في CJK Extension A. والإصلاح في EscapeHTMLWord يفك البايتات إلى WideString، ويهرّب محرفاً بمحرف، ثم يعبئ النتيجة من جديد. وجانب المفكوك كان آمناً أصلاً لأنه يفحص الأنماط عند حدود وحدات كود زوجية فقط

والقاعدة نفسها تسري على كودك أنت. إن حملت يوماً نص UTF-16 كـ TBytes، مثل بعد TEncoding.BigEndianUnicode.GetBytes، فلا تبحث فيه عن أنماط بايتية. حوّله عائداً إلى سلسلة واعمل على المحارف

كتل كود Markdown وتصديرات dataset: هُرِّب الـ ampersand أولاً

منذ v3.539.47 يهرّب كلا منتِجَي HTML داخل PDFlibPas، محوّل Markdown ومصدّر dataset، علامة & قبل الأقواس الزاوية، فيعيد الفك الوحيد في المحرك النص الأصلي تماماً

في MarkdownToHTML تُطابق الآن مدى الكود المضمّنة وكتل الكود المسوّرة أو المزاحة قيمة & إلى &amp;، و < إلى &lt; و > إلى &gt;، بينما تصير المسافات &nbsp; والجدولة أربع مسافات منها حفاظاً على الإزاحة. ونثر Markdown العادي يهرّب الأقواس الزاوية فقط، فـ HTML الخام في النثر لا يستطيع حقن وسوم بينما لا يزال الكاتب قادراً على كتابة &amp; عمداً كما يتوقع كتّاب 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(''&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);  // نقاط
    // الصفحة تعرض الكود كما كُتب تماماً بتهجينات الكيانات
    Lib.DrawMarkdownText(50, 50, 495, Md);
    Lib.SaveToFile('code-sample.pdf');
  finally
    Lib.Free;
  end;
end;

ومصدّر dataset هو الحالة المفيدة للتعلم. قبل v3.539.47 كان يهرّب الأقواس الزاوية فقط، وعمداً: فالمحرك لم يكن يفك &amp;، لذا كان تهريب الـ ampersand سيطبع &amp; في كل خلية تحوي واحدة. وكان الحل الالتفافي صائباً للمحرك القديم وخاطئاً عموماً، لأن قيمة خلية صدفة تحوي &lt; كانت تُفك إلى <. ومع إصلاح المحرك يهرّب المصدّر & أولاً، وقيمة مثل R&D &lt; &amp; &nbsp; تهبط في الـ PDF حرفياً. وإن بنت تقاريرك على تلك الطريقة فالجولة في تصدير TDataSet إلى تقرير PDF في Delphi تغطي بقية المصدّر

ولماذا يجب أن يسبق الـ ampersand الجميع يستحق أن يُشرح مرة واحدة. هُرِّب < أولاً فتحصل على &lt;؛ ثم هُرِّب & فيصير ذلك &amp;lt;، وهو ما يعرضه فك وحيد صحيح كقيمة &lt; بدل <. فسلسلة الاستبدال المتتالية لا تكون صحيحة إلا حين يُعالَج محرف التهريب نفسه قبل أي شيء يُدخله

كيف تهرّب نصاً غير موثوق لـ DrawHTMLTextBox؟

في عرض HTML لدى PDFlibPas، هُرِّب محتوى النص غير الموثوق باستبدال & ثم < ثم >، مرة واحدة بالضبط، وأبقِ البيانات غير الموثوقة خارج قيم الخصائص كلياً

uses
  System.SysUtils, PDFlibrary;

// تهريب نص غير موثوق لمحتوى نص HTML لدى PDFlibPas.
// يجب استبدال '&' أولاً وإلا هُرِّبت علامة & داخل
// قيمة '&lt;' المنتجة مسبقاً تهريباً ثانياً
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 كان المدخل المهرب نفسه قد ينتج annotation رابط حي، وهو الجزء الذي يحوّل خلل عرض إلى مشكلة أمنية: فتعليق تريقة لا ينبغي أبداً أن يستطيع زرع URL قابل للنقر في مستند يثق به طاقمك

لاحظ ما لا يهرّبه هذا التابع. فمهرِّبات HTML العامة تحوّل أيضاً " إلى &quot; و ' إلى &#39;، وهذا صائب للمتصفح. فك نص PDFlibPas لا يعرف إلا الكيانات الأربعة المذكورة سابقاً، فتلك الاثنتان ستطبعان حرفياً كقيمتَي &quot; و &#39;. وعلامات الاقتباس غير مؤذية في محتوى النص؛ إنها مهمة فقط داخل قيم الخصائص، والمحرك لا يفك الكيانات في الخصائص أصلاً. فالتصميم الآمن إذن ليس مهرِّباً أفضل بل قاعدة: البيانات غير الموثوقة لا تدخل href ولا src ولا style أبداً. وإن اضطر هدف رابط فعلاً أن يأتي من بيانات المستخدم فتحقق منه بنفسك ضد قائمة سماح بالمخططات والمحارف وارفض كل ما يحوي علامات اقتباس أو أقواس زاوية

ملاحظتا ترقية تتبعان الإصلاح مباشرة:

  • إن كان كودك قد توقف عن تهريب & لأن الإصدارات الأقدم تطبع &amp; حرفياً فأعده. فبدونه نص المستخدم المحوي على &lt; يعرض الآن كقيمة <، نص غير مؤذٍ مع ذلك لكنه لم يعد ما كتبه المستخدم
  • لا تهرب مرتين. فالنص الذي يمر عبر مهرِّبَين يعرض < بالتهجين المرئي &lt;، فابحث عن الحد الوحيد الذي يدخل فيه بياناتك إلى HTML وهُرِّب هناك فقط

ترقيم الصفحات بـ LeftOverText دون كسر التهريب

يعيد DrawHTMLTextBox الـ HTML الذي لم يتسع، ويسمى عادة LeftOverText، ومنذ v3.539.47 يحفظ ذلك الفائض تهجينات الكيانات الحرفية والأقواس الزاوية المهربة حين تمرره إلى الصندوق التالي. وقاعدة المستدعي بسيطة: مرّره عائداً دون تغيير

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 محرك مهربة مسبقاً: لا تهربها ولا تفك تهريبها أبداً
    Rest := Lib.DrawHTMLTextBox(BoxLeft, BoxTop, BoxWidth, BoxHeight, Rest);
  end;
  if Rest <> '' then
    raise Exception.CreateFmt('Content still left after %d pages', [MaxPages]);
end;

عامل الفائض كصندوق أسود. فهو الـ HTML المطعَّم للمحرك وقد حُلّت أنماطه مسبقاً، فلا تمرره عبر مهرِّبك أنت، ولا تفكه، ولا تزرع فيه نص مستخدم. وسقف الصفحات تأمين رخيص: فإن كان عنصر ما لا يتسع في الصندوق أبداً فحلقة بلا سقف لا مخرج طبيعي لها

ولـ Markdown استكمالها الخاص. يعيد DrawMarkdownTextBox رمزاً يبدأ بعلامة داخلية ليستطيع النداء التالي تخطي التحويل؛ سلّمه إلى DrawMarkdownTextBox أو DrawMarkdownText، لا إلى مداخل HTML التي سترسم العلامة نصاً

الدرس العام: فك مرة، وأعد الترميز عند كل حد

أي مسار يحلل نصاً ثم يسلسل النتيجة عائدةً إلى الترميز نفسه ثم يحللها مجدداً يجب أن يعامل الفك عمليةً تجري في موضع واحد بالضبط، وأن يعيد الترميز عند كل حد يعود فيه النص المفكوك ليصير ترميزاً. ومحركات القوالب ومُنقّيات HTML وسلاسل Markdown إلى HTML إلى PDF تتقاسم هذا الشكل وتفشل بالطريقة نفسها حين ينسى مُسلسِل أنه ينتج markup

والأعراض متوقعة متى عرفت الشكل. نقص الترميز من جهة يحوّل البيانات إلى ترميز، وهو اتجاه الحقن. والإفراط في الترميز، أو مفكوك يجري مرتين، يعرض تهجينات الكيانات للقارئ أو يلتهمها، وهو اتجاه العرض. وإصلاح اتجاه واحد وحده يكسر الآخر عادة، ولهذا اضطر إصلاح PDFlibPas إلى إضافة فك &amp; وإعادة ترتيبه وإزالة الفك المتأخر وإضافة إعادة التهريب في الإصدار نفسه. والمبدأ نفسه يسري بالعكس حين يُصدَّر محتوى PDF نصاً مهيكلاً، كما في التصدير الدلالي من PDF إلى Markdown و DOCX من Delphi، حيث يجب تهريب كل محرف حرفي للترميز الهدف مرة واحدة بالضبط

قائمة مرجعية سريعة

  • رقِّ إلى PDFlibPas v3.539.47 أو أحدث إن كنت تعرض HTML أو Markdown يحوي بيانات مستخدم
  • هُرِّب محتوى النص بـ & أولاً ثم < و >؛ ولا تحوّل علامات الاقتباس لنصوص PDFlibPas
  • هُرِّب مرة واحدة، عند النقطة الوحيدة التي تدخل فيها البيانات إلى سلسلة HTML
  • أبقِ القيم غير الموثوقة خارج href و src و style، أو تحقق منها ضد قائمة سماح
  • توقع فك &lt; و &gt; و &amp; و &nbsp; فقط في النص؛ وبقية الكيانات تبقى حرفية
  • مرّر LeftOverText عائدةً إلى DrawHTMLTextBox دون تغيير وضع سقفاً لحلقة الصفحات
  • مرّر رموز استكمال Markdown إلى DrawMarkdownTextBox أو DrawMarkdownText فقط
  • لا تبحث أبداً في مخازن بايتات UTF-16 عن أنماط بايتية؛ اعمل على وحدات كود كاملة

عرض HTML و Markdown وتصدير تقارير dataset وبقية محرك التخطيط تصل في الكود المصدري Pascal الأصلي لـ PDF Library for Delphi، لـ Delphi و Free Pascal. وانظر صفحة منتج PDFlibPas للإصدارات ودعم المنصات وتنزيل التجربة