مقال تقني

فخاخ codepage في FPC تكسر بيانات XMP لـPDF/A في Delphi

يبني PDFium Delphi Component حزمة XMP لإخراج PDF/A بضم أجزاء UTF-8 داخل AnsiString، وعلى Free Pascal 3.2.2 توقفت تلك الحزمة بهدوء عن كونها UTF-8 صالحة في اللحظة التي حمل فيها عنوان المستند محرفًا غير ASCII. وتتطلب ISO 19005-1 6.7.2 أن يكون تدفق البيانات الوصفية UTF-8 صالحًا، ولذلك فشل الملف في التحقق. ويصلح الإصدار 3.103.1 المرمّز نفسه داخل StringToUtf8. والجزء المثير ليس الرقعة، بل أن سطر المصدر الذي لم يتغير أنتج بايتات صحيحة تحت Delphi، وبايتات صحيحة في تطبيق Lazarus LCL، وبايتات تالفة في برنامج Free Pascal console عادي مترجم من الوحدة نفسها. يجب أن تتراصف ثلاثة سلوكيات منفصلة لسلاسل Free Pascal قبل أن يصبح ذلك منطقيًا، وكل واحد منها قابل للدفاع عنه منفردًا

لماذا تصدر شيفرة البيانات الوصفية نفسها بايتات مختلفة في Delphi وFPC؟

لأن string ليس النوع نفسه في المصرّفين. يترجم FPC 3.2.2 في {$MODE Delphi} النوع string إلى AnsiString موسوم بـDefaultSystemCodePage، بينما يترجمه Delphi إلى UnicodeString. ولذلك يعلن كل حقل بيانات وصفية في TPdfASaveOptions على أنه string، فتحمل Title وAuthor وSubject وKeywords وCreator وProducer وحدات UTF-16 في مصرّف، ومحارف ذات بايت واحد مع label خاص بـcodepage في الآخر. السجل نفسه، والحمولة مختلفة. وتصل القيم نفسها من المستند بصيغة UTF-16. تعيد TPdf.GetTitle وشقيقاتها WString، وهي WideString على FPC وstring على Delphi، كما تملأ SaveAsPdfAToStream كل حقل خيار فارغ من قاموس Info قبل حقن العلامات. وهذا الإسناد تحويل تضييق في Free Pascal، وينفذه RTL عبر codepage السلسلة الهدف. ففي برنامج LCL يكون LazUTF8 قد ضبط DefaultSystemCodePage على CP_UTF8، ولذلك ينتج التضييق UTF-8 ويصبح كل ما بعده صحيحًا مصادفة. أما في برنامج console عادي فيهبط التضييق إلى ANSI codepage، ثم تنسخ StringToUtf8 تلك octets من دون تغيير لأنها افترضت أنها UTF-8 أصلًا. وتشترك جسور الحفظ الستة في هذا الشكل: SaveAsPdfAToStream وSaveAsPdfUaToStream وSaveAsPdfEToStream وSaveAsPdfXToStream وSaveAsPdfRToStream وSaveAsPdfVTToStream، ولكل منها سجل خياراته

// PDFium.pas: تكون موصلات وصول المستند دائمًا UTF-16
//   WString = WideString على FPC، و= string (UnicodeString) على Delphi
function TPdf.GetTitle: WString;

// FPdfPdfa.pas: يحمل سجل خيارات الحفظ البيانات الوصفية كـ`string`
TPdfASaveOptions = record
  Conformance: TPdfAConformance;
  IccProfileData: TBytes;
  Title: string;      // UnicodeString على Delphi
                      // AnsiString + DefaultSystemCodePage على FPC
  Author: string;
  Subject: string;
  Keywords: string;
  Creator: string;
  Producer: string;
  CreationDate: string;
  ModDate: string;
  DocumentId: TBytes;
  InstanceId: TBytes;
  class function Default: TPdfASaveOptions; static;
end;

// يملأ SaveAsPdfAToStream الحقول الفارغة من قاموس Info
// وقد صيغ التضييق الآن صراحة بدل تركه ضمنيًا
if Eff.Title = '' then
  Eff.Title := WStringToStr(GetTitle);

ولا يغير توجيه الجسور الستة كلها عبر مساعد واحد هو WStringToStr ما يفعله RTL، لكنه يضع التحويل حيث يستطيع القارئ رؤيته، وقد أزال 92 تحذير تحويل ضمنيًا كانت تحجب هذه الفئة من المشكلات بالضبط. وهذا هو الوجه المقابل للفساد من جانب Delphi الموصوف في ملاحظاتنا حول فخاخ المصرّفات المتعددة في Delphi وFPC داخل بناء PDFium، حيث يهدم ضم في Delphi بايتًا عاليًا يحافظ عليه Free Pascal

ثلاثة سلوكيات في Free Pascal تهزم الإصلاح الواضح

الإصلاح الواضح هو استدعاء UTF8Encode والانتهاء. لكنه يفشل ثلاث مرات متتالية على FPC 3.2.2 في وضع Delphi، وكل فشل صامت

var
  W: UnicodeString;
  S: string;
  U: UTF8String;
  R: RawByteString;
  Xmp: AnsiString;
begin
  // الفخ 1: تكون متغيرة UTF8String في وضع Delphi مجرد AnsiString،
  // ولذلك يعيد الإسناد ترميز octets مباشرة إلى codepage المضيف
  U := UTF8Encode(W);

  // الفخ 2: تكون S أصلًا AnsiString، ولذلك لا يفعل UTF8Encode شيئًا
  R := UTF8Encode(S);                 // لا فك، ولا ترميز، ولا خطأ
  R := UTF8Encode(UnicodeString(S));  // هذا هو الذي يرمّز فعلًا

  // الفخ 3: يوحّد الضم كل معامل إلى codepage الوجهة،
  // كما أن وجهة RawByteString ليست استثناءً
  Xmp := Xmp + R;
end;

يعني الفخ الأول أن النتيجة المرمّزة يجب أن تبقى في AnsiString أو RawByteString التي أنتجت فيها. مررها عبر متغير مؤقت من نوع UTF8String في طريق الخروج، فتكون قد أبطلت العمل. أما الفخ الثاني فهو الأطول اختفاءً، لأن UTF8Encode(S) يترجم ويعمل ويعيد قيمة بالطول الصحيح، ولا ينفذ أي تحويل عندما يكون معامله أصلًا AnsiString؛ وفقط التوسيع إلى UnicodeString أولًا يجعل الاستدعاء يفك أي شيء. والفخ الثالث هو سبب إمكانية أن ينتج مرمّز صحيح مستندًا مكسورًا: إذ يجمع BuildXmpBytes الحزمة في Xmp: AnsiString محلي، ويحوّل Free Pascal كل معامل في عملية الضم إلى codepage متغير الوجهة، فيعيد طي التسلسلات متعددة البايت إلى بايتات ANSI مفردة في طريق الدخول

ما الذي يضمنه SetCodePage مع False فعلًا؟

تعيد SetCodePage(RawByteString(Result), DefaultSystemCodePage, False) وسم السلسلة من دون لمس أي بايت. والمعامل الثالث هو Convert؛ وتمرير False يعني «افترض أن الحمولة موجودة أصلًا في codepage الهدف وغير الوسم فقط». وهذه كذبة عن المحتوى، قيلت عن قصد: فالمحارف octets هي UTF-8 فعلًا، لكن وسمها كـcodepage المضيف هو ما يمنع عملية الضم في الفخ الثالث من تحويلها. فتنضم إلى مخزن XMP كـraw bytes وتخرج من الجهة الأخرى من دون تغيير

function StringToUtf8(const S: string): AnsiString;
begin
{$IFDEF UNICODE}
  // Delphi: يعيد UTF8Encode octets موسومة بـCP_UTF8، ويحافظ
  // الضم إلى AnsiString عليها
  Result := AnsiString(UTF8Encode(S));
{$ELSE}
  // FPC: وسّع أولًا، وإلا يكون UTF8Encode بلا تأثير على معامل AnsiString
  Result := UTF8Encode(UnicodeString(S));
  // أعد الوسم من دون تحويل، حتى تبقى octets عند ضمها إلى المخازن
  // الموسومة بـANSI التي تجمع حزم XMP وكائنات سلاسل PDF
  if Length(Result) > 0 then
    SetCodePage(RawByteString(Result), DefaultSystemCodePage, False);
{$ENDIF}
end;

كن واضحًا بشأن الحد. إعادة الوسم خاصة بـFPC، وليست تفويضًا عامًا لخلط السلاسل الموسومة وغير الموسومة. وهي تعمل هنا لأن نمط مستهلك واحدًا بالضبط موجود في الأسفل: الإضافة إلى AnsiString ثم كتابة المخزن كبايتات. وأي شيء يحاول تفسير القيمة المعاد وسمها كنص في codepage المضيف سيقرأ mojibake، وهذا صحيح. أما الاتجاه العكسي فيُعالج بالطريقة الأخرى وهو متطابق في المصرّفين: وسم المخزن الوارد بـCP_UTF8 باستخدام SetCodePage(..., False)، ثم استدعاء UTF8ToString

لماذا حملت اختبارات التراجع الفخ نفسه؟

لأن الاختبار الذي يبني البايتات المتوقعة من literal مصدر يختبر المصرّف لا المكتبة. فثابت مثل #$C3#$A9 المكتوب في ملف مصدر Pascal يحمل codepage وقت ترجمة ذلك الملف، وعندما يمرر إلى معامل AnsiString يعيد RTL ترميزه، وهو التحويل موضوع الاختبار بالضبط. لذلك يجب تجميع التوقع وقت التشغيل، بايتًا بايتًا، ومقارنته بايتًا ببايت، لأن = على قيمتي AnsiString بوسمين مختلفين يصالح codepages قبل المقارنة ويعيد false سريعة ومطمئنة

function BytesPattern(const Values: array of Byte): AnsiString;
var
  I: Integer;
begin
  SetLength(Result, Length(Values));
  for I := 0 to High(Values) do
    Result[I + 1] := AnsiChar(Values[I]);
end;

procedure TPdfATests.StringToUtf8_AnsiCodePageString_EncodesUtf8;
var
  Saved: Word;
  Wide: WideString;
  Narrowed: string;
  Encoded, ExpectedUtf8: AnsiString;
begin
  Saved := DefaultSystemCodePage;
  try
    SetMultiByteConversionCodePage(1252);
    Wide := WideChar($0043) + WideChar($0061) + WideChar($0066) + WideChar($00E9);
    Narrowed := Wide;                    // التضييق موضوع الاختبار
    Encoded := StringToUtf8(Narrowed);
  finally
    SetMultiByteConversionCodePage(Saved);
  end;
  // 'Caf' + U+00E9 بترميز UTF-8، مبني وقت التشغيل حتى لا يعاد ترميز literal
  ExpectedUtf8 := BytesPattern([$43, $61, $66, $C3, $A9]);
  AssertTrue('StringToUtf8 must emit UTF-8 for a string carrying a non-UTF-8 codepage',
    SameOctets(Encoded, ExpectedUtf8));
end;

إن harness نفسه برنامج LCL، ولذلك تكون قيمة DefaultSystemCodePage هي CP_UTF8 ويظل العيب غير مرئي حتى يبدلها الاختبار. ويعيد SetMultiByteConversionCodePage(1252) داخل try..finally إنتاج بيئة console العادية طوال اختبار واحد. ويمضي الفحص من البداية إلى النهاية أبعد من ذلك ويؤكد الاتجاهين: يجب أن تحتوي حزمة XMP التي ينتجها حقن العلامة على $43 $61 $66 $C3 $A9 وألا تحتوي $43 $61 $66 $E9، ولذلك يفشل مستقبلًا التراجع الذي يعود إلى إخراج أحادي البايت خام بصوت عالٍ بدل إنتاج ملف يبدو معقولًا في تفريغ سداسي عشري فقط. وإذا كنت تعمل مع بيانات وصفية غير لاتينية، فإن انضباط التوسيع نفسه يحكم الحالات في نصوص emoji وCJK التي تكسر معالجة WideChar في Delphi

أين يهبط التضييق أيضًا؟

تظهر XMP بوصفها الضحية المرئية، لكن أي جسر من TBytes إلى string في قاعدة الكود نفسها كان معرضًا للشيء ذاته. وصُحح جسران آخران في v3.103.1: Utf8BytesToString وStringToUtf8Bytes في FPdfProduction، اللذان يعيدان حزمة مجموعات بيانات XFA ذهابًا وإيابًا عبر string حتى تستطيع MergePdfXfaDatasets استبدال القيم المرتبطة، وBytesToUtf8 في FPdfTrustedList الذي يفك XML لقائمة ثقة أوروبية بعد إزالة علامة ترتيب البايتات. ويضع كلاهما الآن المخزن مؤقتًا في RawByteString، ويضعان عليه وسم CP_UTF8 من دون تحويل، ثم يفكانه عبر UTF8ToString. وكانت وحدة واحدة محصنة أصلًا، وسبب ذلك جدير بالنسخ. إذ يعلن كاتب XFDF نوع نصه الخاص كـXFDFString، الذي يحل إلى WideString تحت FPC وUnicodeString تحت Delphi، ولذلك لا يرى مرمّزه AnsiString موسومًا بـcodepage أصلًا. وهذا هو الإصلاح البنيوي: احتفظ بالنص في نوع UTF-16 حتى نقطة التسلسل الدقيقة، ودع دالة ضيقة واحدة تملك التحويل إلى بايتات. وكل خطأ في هذه العائلة جاء من حقل string جالس في منتصف مسار كان UTF-16 من طرف وoctets من الطرف الآخر

ما الذي تفحصه في كود PDF ثنائي المصرّف الخاص بك؟

إذا كنت تشحن Object Pascal يعمل على المصرّفين ويكتب بيانات وصفية إلى PDF مطابق للمواصفة، فستعثر أربعة فحوص على معظم هذه الفئة قبل أن يعثر عليها validator

  • ابحث عن UTF8Encode مع معامل من نوع string. فهذا الاستدعاء بلا تأثير على FPC، وهو السطر الأعلى عائدًا للتدقيق
  • اعتبر كل متغير UTF8String مشبوهًا في وضع Delphi. فهو AnsiString عادي هناك، وإسناد البايتات المرمّزة إليه يعيد ترميزها
  • شغّل تراجعًا واحدًا على الأقل تحت SetMultiByteConversionCodePage مع codepage أحادي البايت. فـLCL test harness يعمل على CP_UTF8 ولن يعيد إنتاج برنامج console عادي أبدًا
  • ابنِ متجهات البايت المتوقعة وقت التشغيل وقارنها octetًا octet. فـliterals المصدر و= كلاهما يمران عبر مصالحة codepage وسيخفيان العيب الذي تطارده

لا شيء من هذا trivia غريب عن Free Pascal. إنها الكلفة العادية للغة أبقت نوع سلسلة موجهًا للبايتات حيًا إلى جانب نوع UTF-16، واختار المصرّفان بصورة معقولة لكن مختلفة ما ينبغي أن يعنيه string. والنتيجة العملية لعمل PDF ضيقة وحادة: قد تصل البيانات الوصفية التي تقرأ جيدًا في IDE إلى حزمة XMP كـUTF-8 غير صالح، ولا تهتم ISO 19005-1 6.7.2 بأي مصرّف وضعها هناك. وإذا كنت تبني مسار أرشفة، فطبقة الترميز تستحق انتباهًا بقدر بقية مسار الامتثال للأرشفة PDF/A المحيط بها. وتأتي هذه التحويلات في PDFium Delphi Component كجزء من المكتبة، ولذلك يصدر SaveAsPdfA وشقيقاته الخمس للمعايير بيانات وصفية UTF-8 مطابقة على Delphi وLazarus وبنى Free Pascal العادية من دون أي إعداد codepage من المستدعي. وتوجد وثائق API الكاملة والإصدار الحالي في صفحة منتج PDFium Delphi Component