Technical Article

خلل EndDoc الذي عطل تجزئة الخطوط بصمت

قم بإنشاء تقرير، وتضمين خط TrueType، وسيفتح المخرجات بشكل صحيح في كل برنامج عرض تجربه. الصور الرمزية صحيحة، والنص قابل للتحديد، والملف صالح. الشيء الوحيد الخاطئ هو الحجم. يحمل المستند الذي استخدم بضع عشرات من الأحرف اللاتينية الخط بأكمله بحجم 350 كيلوبايت. والمستند الذي طبع فقرة من اللغة الصينية يحمل خط CJK بحجم 14 ميجابايت بدلاً من الشريحة التي تبلغ نصف ميجابايت والتي يجب أن يحتاجها. لم يتم إرجاع أي استثناء، ولم يتم تسجيل أي تحذير، واجتاز الملف عملية التحقق. هذا هو الشكل الذي تبدو عليه خطوة الإنهاء غير المرتبة من الخارج: لا شيء يفشل، والدليل الوحيد هو رقم كبير جداً.

كان الخلل الذي أنتج ذلك موجوداً في HotPDF لخط إصدار واحد وتم إصلاحه منذ ذلك الحين. الأمر يستحق الكتابة عنه ليس كإشعار عيب ولكن كدرس، لأن شكل الخطأ عام. يحتوي أي محرك مستندات على مرحلة إنهاء تقوم بتعديل الكائنات قبل كتابتها مباشرة، وتعتمد صحة تلك المرحلة تماماً على ترتيب خطواتها بالنسبة للتسلسل. ضع خطوة واحدة في الجانب الخاطئ من الكتابة ولن تفعل شيئاً، بصمت.

ما يفترض أن تفعله تجزئة الخطوط

الخط المجزأ (subset font) هو جزء من ملف TrueType الذي يستخدمه المستند بالفعل. تصف المواصفة ISO 32000-1 §9.9 كيف يتم تشغيل برنامج خط مدمج في دفق مشار إليه بواسطة واصف الخط، وبالنسبة لبرنامج TrueType فإن هذا الدفق هو /FontFile2 مع /Length1 الذي يعطي عدد البايت غير المضغوط. تعمل التجزئة على إعادة كتابة جدولي glyf و loca بحيث يحتويان فقط على الصور الرمزية التي يشير إليها المستند، ويعيد ترقيم معرفات الصور الرمزية، ويبدأ اسم /BaseFont ببادئة مكونة من ستة أحرف مثل ABCDEF+ لتمييز الخط كمجموعة فرعية، تماماً كما تتطلب المواصفات. إن الخط اللاتيني الذي يتجزأ إلى عشرة أو خمسة عشر كيلوبايت هو الفرق بين ملف PDF خفيف وملف يشحن خطاً كاملاً من أجل عنوان واحد.

النقطة التي يحدث فيها هذا مهمة. التجزئة ليست تحويلاً تطبقه على البايتات الموجودة بالفعل على القرص. بل تقوم بتعديل مخطط الكائنات في الذاكرة: حيث تقلص محتوى دفق /FontFile2، وتصلح /Length1، وتعيد كتابة سلسلة /BaseFont. يجب أن يكون كل ذلك في مكانه عندما يمر برنامج التسلسل (serializer) عبر المخطط ويصدر البايتات. إذا تمت التعديلات بعد كتابة البايتات، فإنها ستقوم بتحديث كائنات لن يقرأها أحد أبداً.

الأعراض، ولماذا لم تظهر أي شكوى

كان السلوك المبلغ عنه هو وجود خطوط كاملة في المخرجات دون أي تشخيص. وجد المستخدم الذي سجل خط Unicode TrueType وأنتج مستنداً عادياً أن كائن الخط المدمج كان بنفس طول ملف .ttf المصدر، وأن اسم /BaseFont لم يحمل بادئة التجزئة المكونة من ستة أحرف. لم يتقلص الإخراج أبداً بين عمليات التشغيل التي استخدمت عشر صور رمزية وعمليات التشغيل التي استخدمت عشرة آلاف.

إن غياب أي خطأ هو الجزء الذي يجعل هذا النوع من الخلل مكلفاً. روتين التجزئة الذي يعمل في الوقت الخطأ لا يزال يعمل. فهو يتصفح استخدام نقاط الترميز المتراكمة، ويبني مجموعة فرعية صحيحة تماماً، ويطبقها على مخطط الكائنات في الذاكرة. داخلياً يتم العمل ويعود الاستدعاء بشكل نظيف. الشيء الوحيد الخاطئ هو أن مخطط الكائنات الذي قام بتعديله لم يعد هو الشيء الذي تتم كتابته، لأن الكاتب انتهى بالفعل. من وجهة نظر المستدعي، تم إنتاج المستند وحفظه دون أي مشكلة، وهو بالضبط الانطباع الذي يعطيه الفشل الصامت.

السبب الجذري كان ترتيب الإنهاء

في HotPDF، يحدث عمل الإغلاق داخل EndDoc. خطوة التجزئة هي روتين داخلي يسمى BuildAndApplyUnicodeFontSubset. يقوم بقراءة مجموعة نقاط الترميز المستخدمة لكل مستند، والمحفوظة في خريطة بتات يملأها مسار إرسال النص أثناء عرض الصور الرمزية، ويرسم كل نقطة ترميز مستخدمة من خلال جدول نقاط الترميز إلى الصور الرمزية المخزن مؤقتاً إلى معرف صورة رمزية حقيقي، ويعيد كتابة برنامج الخط حول هذا الإغلاق. عند تسجيل خط Unicode TrueType، يحدد مسار الإرسال بت في مجموعة نقاط الترميز المستخدمة لكل حرف يرسمه، وبحلول وقت إغلاق المستند، يعرف المحرك تماماً الصور الرمزية التي يجب أن تحتفظ بها المجموعة الفرعية.

كان الخلل هو أنه تم استدعاء BuildAndApplyUnicodeFontSubset بعد أن قام SaveToStream أو SaveToFile بالفعل بتسلسل المستند. تم حساب تعديلات برنامج التجزئة على /FontFile2، وقيمته المصححة لـ /Length1، والبادئة المكونة من ستة أحرف لـ /BaseFont بالكامل مقابل مخطط كائنات تم تحويله بالفعل إلى بايتات. كان الإصلاح عبارة عن إعادة ترتيب من سطر واحد: نقل استدعاء التجزئة قبل التسلسل، بحيث يصدر الكاتب الخط المجزأ بدلاً من الخط الأصلي. يقوم التسلسل المصحح بتشغيل برنامج التجزئة أولاً ثم يقوم بالتسلسل بعد ذلك.

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.RegisterUnicodeTTF('C:\Fonts\NotoSansSC-Regular.ttf');
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Noto Sans SC', [], 12);
    Pdf.CurrentPage.TextOut(72, 760, 0, '报表标题 Report Heading');
    Pdf.EndDoc;                 // subsetting runs here, before the write
    Pdf.SaveToFile('Report.pdf');
  finally
    Pdf.Free;
  end;
end;

مع تصحيح الترتيب، لا يتغير أي شيء في كود الاستدعاء. تكون التجزئة قيد التشغيل افتراضياً بمجرد تسجيل خط Unicode TrueType. تقوم بتسجيل الخط، وبدء المستند، والرسم، وإنهاء العمل، ويتم بناء المجموعة الفرعية من الصور الرمزية التي استخدمتها قبل أن تغادر البايتات الذاكرة.

لماذا تعد خطوة واحدة في غير مكانها فئة كاملة

السبب في أن هذا يستحق درساً بدلاً من ملاحظة هامشية هو أن EndDoc يصدر قائمة بخطوات الإغلاق، وكل واحدة منها حساسة لموضعها بالنسبة للكتابة. تجزئة الخطوط هي إحداها. يتطلب إخراج PDF/A دفق /CIDSet يسرد بدقة معرفات الصور الرمزية الموجودة في المجموعة الفرعية، وهو قيود تفرضها المواصفة ISO 19005 بحيث يمكن لأداة التحقق تأكيد مطابقة البرنامج المدمج لما يدعيه واصف الخط؛ ويتم إرسال هذا الدفق في نفس نافذة الإنهاء ويعتمد على بناء المجموعة الفرعية أولاً. يتطلب PDF/UA-1، بموجب ISO 14289-1 §7.18.3، أن تعلن كل صفحة تحمل تعليقاً توضيحياً عن /Tabs بالقيمة /S، ويقوم روتين داخلي باسم EnsurePDFUATabsOnAnnotatedPages بختم هذا المفتاح خلال نفس المرحلة. كما يتم تشغيل فحوصات نية الإخراج هناك أيضاً.

إن نفس خطأ الترتيب الذي عطل التجزئة أدى أيضاً إلى إسقاط مفتاح ترتيب علامات التبويب لـ PDF/UA في الصفحات التي تحمل تعليقات توضيحية، لأن هذه الخطوة كانت في نفس الجانب الخاطئ من الكتابة. يبلغ كل من veraPDF و PAC عن فقدان /Tabs /S باعتباره انتهاكاً لنقطة فحص بروتوكول Matterhorn رقم 21-001. وبالتالي، لم يؤد استدعاء واحد في غير موضعه إلى تضخيم حجم الملف فحسب؛ بل أدى في نفس الوقت وبصمت إلى كسر متطلبات مطابقة إمكانية الوصول، مع نفس غياب أي خطأ. هذا هو خطر مرحلة الإنهاء: تشترك خطواتها في شرط مسبق، ويمكن لخطأ ترتيب واحد أن يعطل العديد منها في نفس الوقت بينما يستمر كل استدعاء في إرجاع النجاح.

كيف يتم كشف فشل الإرسال الصامت بالفعل

الخلل الذي لا يثير أي استثناء لا يتم اكتشافه عن طريق تشغيل البرنامج. يتم اكتشافه عن طريق فحص المخرجات ومقارنتها بما كان يجب أن ينتجه المدخل. بالنسبة لتجزئة الخطوط، تكون الفحوصات ملموسة. قارن حجم ملف الإخراج بالتوقع التقريبي: يجب ألا يكون المستند الذي استخدم عدداً قليلاً من الصور الرمزية بحجم محرف كامل. افتح كائن الخط المدمج واقرأ طول البايت الخاص به؛ حيث يمثل /FontFile2 المجزأ للوجه اللاتيني جزءاً صغيراً من ملف المصدر. اقرأ اسم /BaseFont وتأكد من وجود البادئة المكونة من ستة أحرف، لأن غيابها إشارة مباشرة إلى عدم تطبيق التجزئة.

var
  Pdf: THotPDF;
  Output: TMemoryStream;
begin
  Output := TMemoryStream.Create;
  try
    Pdf := THotPDF.Create(nil);
    try
      Pdf.RegisterUnicodeTTF('C:\Fonts\DejaVuSans.ttf');
      Pdf.BeginDoc;
      Pdf.CurrentPage.SetFont('DejaVu Sans', [], 11);
      Pdf.CurrentPage.TextOut(72, 760, 0, 'Subset me');
      Pdf.EndDoc;
      Pdf.SaveToStream(Output);
    finally
      Pdf.Free;
    end;
    // A few glyphs from a ~700 KB face must not yield a multi-hundred-KB stream.
    if Output.Size > 100 * 1024 then
      raise Exception.Create('Font subset did not shrink the output');
  finally
    Output.Free;
  end;
end;

بالنسبة لمخرجات PDF/A، يكون التحقق أكثر دقة، لأن أداة التحقق تقوم بالعمل نيابة عنك. حدد مستوى المطابقة وقم بتشغيل النتيجة عبر veraPDF: يتم الإبلاغ عن فقدان /CIDSet، أو مجموعة فرعية لا تطابق الواصف، كبند فاشل بدلاً من تركه لتلاحظه بالعين المجردة. مفاتيح المطابقة التي تقود عمل الإنهاء هذا هي خصائص في المستند. يأخذ PDFACompliance سلسلة مثل '2B' لـ PDF/A-2 Level B، و PDFUACompliance هو قيمة منطقية تقوم بتفعيل متطلبات PDF ذي العلامات (tagged-PDF) وترتيب علامات التبويب.

Pdf := THotPDF.Create(nil);
try
  Pdf.PDFACompliance := '2B';     // PDF/A-2 Level B, drives /CIDSet emission
  Pdf.PDFUACompliance := True;    // stamps /Tabs /S on annotated pages
  Pdf.RegisterUnicodeTTF('C:\Fonts\NotoSansSC-Regular.ttf');
  Pdf.BeginDoc;
  Pdf.CurrentPage.SetFont('Noto Sans SC', [], 12);
  Pdf.CurrentPage.TextOut(72, 760, 0, '合规报告');
  Pdf.EndDoc;
  Pdf.SaveToFile('Report_PDFA.pdf');
finally
  Pdf.Free;
end;

الدرس الهندسي

يخلص هذا إلى قاعدتين. الأولى هي أن أي خطوة إنهاء تقوم بتعديل الكائنات يجب أن يتم تشغيلها قبل تسلسل تلك الكائنات، ويجب قراءة المرحلة الختامية لمحرك المستندات كخط أنابيب مرتب حيث يكون التسلسل هو الإجراء الأخير، وليس إجراءً واحداً من بين عدة إجراءات. الثانية هي تلك التي كلفت معظم الوقت هنا: بالنسبة لخطوة الإرسال، فإن غياب الخطأ ليس دليلاً على النجاح. الروتين الذي يبني المجموعة الفرعية الصحيحة ويطبقها على المخطط الخاطئ المكتوب بالفعل لا يبلغ عن أي خطأ، لأنه من منظوره الخاص لم يكن هناك أي خطأ. يجب أن ينظر التحقق إلى النتيجة المصنعة، وليس رمز الإرجاع. تحقق من حجم الإخراج، واقرأ طول بايت الخط المدمج وبادئته /BaseFont، ودع veraPDF يحكم على مخرجات PDF/A حيث يحول /CIDSet المفقود النقص الصامت إلى فشل محدد.

تتم تغطية جانب المنتج للتعامل مع الخطوط، وكيفية تسجيل الخطوط ودمجها لإخراج التقارير، في مقالنا حول الخطوط والصور في إخراج التقارير. ويتم تغطية جانب التحقق، حيث يتم فحص خطوات الإنهاء هذه مقابل المعايير، في الدليل الإرشادي حول التحقق من PDF/A و PDF/UA. يقترن كلاهما بعمل التجزئة والمطابقة الموصوف هنا، والذي يتم شحنه كجزء من مكون HotPDF لـ Delphi و C++Builder جنباً إلى جنب مع واجهات برمجة التطبيقات للتحميل والتحرير والتشفير والتوقيع المغطاة في مكان آخر في هذه المدونة.