Technical Article

خطوط PDF والنص: لماذا تتحول glyphs إلى مربعات

إن ملف PDF الذي يبدو مثالياً على جهازك ثم يظهر كسطر من المربعات الفارغة على جهاز شخص آخر هو أكثر عيوب font شيوعاً في برمجيات المستندات، وهو نادراً ما يعني أن النص نفسه خاطئ. الحروف سليمة، والترميز صحيح، والـ glyphs ببساطة غير موجودة. ما تغيّر بين الجهازين هو الخطوط المثبتة في نظام التشغيل، والفاصل بين ملف محمول وملف هش هو قرار واحد اتُّخذ عند كتابة الصفحة: هل سافر font داخل PDF أم افترضنا أنه موجود في الطرف الآخر.

إن فهم سبب حدوث ذلك، ولماذا ينتج فشل آخر نصاً يبدو قابلاً للبحث لكنه يُنسخ على هيئة هراء، يعني النظر إلى كيفية تخزين PDF للنص. فهو لا يخزن الجمل، بل يخزن glyph codes مع font program وجداول تربط الواحد بالآخر، وكل خطأ في العرض أو الاستخراج يعيش في فجوة بين هذه العناصر الثلاثة. ما يلي جولة في هذه الآلية، مستندة إلى ISO 32000، مع استدعاءات Delphi التي تتحكم فيها حيثما كان ذلك مهماً.

الحروف والرموز وglyphs ثلاثة أشياء مختلفة

المصطلحات تربك الناس لأن الكلام اليومي يختزل ثلاث أفكار متميزة في كلمة واحدة هي "letter". الحرف وحدة كتابية مجردة، فكرة الحرف A الكبير، ويُعرَّف في Unicode على أنه U+0041. أما glyph فهو الشكل المرسوم، أي المخطط المنحني والخطي الذي يستخدمه font معين لتصوير ذلك الحرف. وبينهما توجد code: البايت أو البايتات في content stream التي تخبر العارض أي glyph في font الحالي يجب رسمه.

يعمل PDF بالرموز. عندما يعرض content stream سلسلة نصية، فإن تلك البايتات تكون فهارس داخل font النشط، وليست Unicode. يقرر encoding الخاص بـ font أن code 65 يعني رسم glyph المخزن تحت 65، ولا يعرف شيء في تلك العملية أن النتيجة تبدو كحرف A للبشر. وهذا ما يجعل PDF يعرض بالطريقة نفسها أينما وجد glyphs، وهو أيضاً السبب في أن extraction مشكلة منفصلة عن العرض: الرسم يحتاج فقط code-to-glyph، والقراءة تحتاج code-to-Unicode، وهما جدولان مختلفان قد يتعارضان أو يختفي كلٌّ منهما بصورة مستقلة.

أنواع font التي ستقابلها فعلاً

يعرّف ISO 32000 عدة أنواع من font dictionary، وفي الممارسة العملية يستخدم المستند الذي تتلقاه أو تنشئه واحداً من ثلاثة. معرفة النوع الذي تنظر إليه تفسر معظم ما قد يخطئ.

Type 1 هو صيغة Adobe الأصلية للمخططات الخطية في PostScript، مبنية من منحنيات Bezier التكعيبية. الخطوط الأربعة عشر القياسية التي يجب أن يوفرها كل قارئ متوافق، أي عائلات Helvetica وTimes وCourier وSymbol وZapfDingbats، هي من Type 1، ويمكن لـ font dictionary الذي يذكر واحداً منها أن يحذف font program قانونياً. هذه هي الحالة الوحيدة التي يكون فيها ترك font غير مضمن آمناً بحكم المواصفة لا بحكم الحظ. أما أي وجه Type 1 آخر فيجب أن يكون font program مضمناً، أو أن يستبدله العارض بشيء آخر، غالباً font قريباً في القياسات لكنه مختلف بصرياً.

TrueType يستخدم منحنيات تربيعية وجاء من عالم Apple وMicrosoft. وهو ما تكون عليه معظم خطوط النظام، وما ستقوم بتضمينه في الغالب. font بسيط من TrueType في PDF يقتصر على codes أحادية البايت، لذا يمكن لمثل هذا font أن يخاطب 256 glyphs كحد أقصى في كل مرة. هذا السقف هو السبب البنيوي في أن CJK وغيرها من اللغات الكبيرة لا يمكن أن تعتمد على font بسيط.

Type 0، وهو font المركب أو CID-keyed، هو الجواب على ذلك القيد. فهو يستخدم codes متعددة البايت و CMap لتوجيهها عبر descendant CIDFont، وتكون مخططاته نفسها إما TrueType أو CFF/Type 1. هذا هو نوع font الوحيد القادر على حمل آلاف glyphs، لذا فإن أي PDF يحتوي على الصينية أو اليابانية أو الكورية أو مزيجاً واسعاً من اللغات يستخدم Type 0 سواء فكر المؤلف في ذلك أم لا. المقابل هو التعقيد: أجزاء متحركة أكثر، ويجب أن يكون عدد أكبر منها صحيحاً كي ينجح كل من العرض والاستخراج.

One TrueType font rendered at 12, 18, 24, and 36 points in a PDF, showing that a single embedded outline scales to any size

تفصيل واحد خلف هذه الصورة هو الذي يحدد حجم الملف. font هو مكتبة من المخططات، لا خرائط نقطية ثابتة الحجم، لذا فإن البرنامج المضمن نفسه يخدم كل حجم نقاط في الصفحة. التحجيم هو تحويل يُطبَّق عند الرسم، ولهذا يشترك العنوان ونص المتن في وجه مضمن واحد، ولهذا تكون كلفة التضمين لكل font لا لكل حجم.

Embedding هو الفارق بين القابلية للنقل والهشاشة

Embedding يعني أن font program، أي بيانات المخطط الفعلية، تُكتب داخل PDF على هيئة stream. القارئ على جهاز لم يسمع بهذا font من قبل يقرأ تلك المخططات مباشرة من الملف ويرسم glyphs دقيقة. إذا تجاهلت التضمين فأنت تراهن على أن الوجهة تملك fontاً بالاسم نفسه، وإذا لم تملكه فإن العارض يلجأ إلى بديل. بالنسبة إلى الخطوط الأربعة عشر القياسية يكون هذا البديل معرّفاً وغير مؤذٍ. أما في كل الحالات الأخرى فيتراوح الأمر بين بديل قريب في font مختلف، وبين نتيجة المربعات الفارغة عندما لا يغطي أي بديل ذلك النص إطلاقاً.

مع HotPDF يكون التحكم في خاصية واحدة، تُضبط قبل فتح المستند. تُخبر FontEmbedding المكتبة بأن تضع fonts التي ترسم بها داخل الملف:

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'report.pdf';
    Pdf.Compression := cmFlateDecode;
    Pdf.FontEmbedding := True;          // outlines travel inside the file
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Calibri', [], 11);
    Pdf.CurrentPage.TextOut(72, 760, 0, 'This renders the same on a machine without Calibri.');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

الترتيب هنا ليس شكلياً. BeginDoc هو الموضع الذي يثبت فيه HotPDF بنية المستند، لذا يجب أن يكون FontEmbedding true قبل ذلك الاستدعاء. إذا عيّنته بعده فلن يظهر خطأ ولا تحذير، بل يخرج ملف بصمت من دون font. هذا أسوأ نوع من العلل: ينجح في كل اختبار على جهاز المطوّر، حيث يكون font مثبتاً صدفة، ولا يظهر إلا عند العميل، حيث لا يكون كذلك.

Embedding هو أيضاً الموضع الذي يلتقي فيه الترخيص مع الهندسة. font program يحمل أعلاماً تصف ما إذا كان يمكن تضمينه بحرية أو للمعاينة فقط أو لا يمكن تضمينه إطلاقاً. احترام تلك الأعلام مسؤوليتك أنت لا مسؤولية العارض، وعبارة "عمل" ليست هي نفسها عبارة "كان مسموحاً".

Subsetting: تضمين glyphs التي استخدمتها فقط

التضمين الكامل يكتب font program بأكمله داخل الملف. يمكن أن يصل TrueType كبير خاص بـ CJK إلى عدة ميغابايت، وتضمينه كاملاً لإظهار بضع عشرات من الأحرف هو هدر يتضاعف عبر مستند متعدد الصفحات. يحل subsetting هذه المشكلة بكتابة glyphs التي يشير إليها المستند فقط، ثم إعادة تسمية font بوسم من ستة أحرف وعلامة زائد، وهي صيغة ABCDEF+Calibri في قائمة font الخاصة بأي PDF مجزأ، بحيث لا يخلط القارئ أبداً بين الوجه الجزئي وfont النظام الكامل الذي يحمل الاسم نفسه.

بالنسبة إلى معظم المستندات المولدة، يكون subsetting هو الإعداد الافتراضي الصحيح. فهو يبقي حجم الملف متناسباً مع المحتوى لا مع font المصدر، وهذا مهم خصوصاً بالنسبة إلى خطوط اللغات المتعددة الكبيرة التي كانت ستسيطر على الملف لولا ذلك. والتحفظ الوحيد هو أن subset لا يحتوي إلا على ما استُخدم عند الإنشاء. إذا حاولت عملية لاحقة إضافة نص إلى font مجزأ، فقد لا تكون glyphs المطلوبة موجودة في الملف، وهو قيد حقيقي على التحرير التدريجي لـ PDF شخص آخر.

خطوط Unicode ومشكلة مربعات CJK

عندما لا يكون النص لاتينياً بسيطاً، ينتهي مسار font البسيط، والحل هو تسجيل font قادر على Unicode صراحةً والسماح لـ HotPDF ببناء font من Type 0 انطلاقاً منه. RegisterUnicodeTTF يحمّل ملف TrueType بالمسار؛ وبعد ذلك يصبح الاسم المسجل قابلاً للاستخدام في SetFont مثل أي اسم آخر:

Pdf.FontEmbedding := True;
Pdf.RegisterUnicodeTTF('C:\Fonts\NotoSansCJKsc-Regular.ttf');
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('NotoSansCJKsc-Regular', [], 14);
Pdf.CurrentPage.TextOut(72, 720, 0, '你好,世界 こんにちは 안녕하세요');
Pdf.EndDoc;

هناك شيئان يحددان نجاح هذا المسار أو فشله. يجب أن يغطي font النصوص الموجودة في السلسلة: TrueType لاتيني فقط لن ينبت glyphs صينية لمجرد أنك طلبت ذلك، والنتيجة تكون مربعات فارغة مرة أخرى، وهذه المرة لأن glyph نفسها غير موجودة فعلاً في ذلك الوجه. كما يجب أن يبقى التضمين مفعلاً، لأن font من Type 0 جرى تجميعه من TTF مسجل لا معنى له بالنسبة إلى قارئ لا يستطيع العثور على المخططات. بالنسبة إلى المحتوى المختلط، يكون الاختيار المتين هو وجه واسع التغطية، وعائلتا Noto وArial Unicode MS هما الجواب المعتاد، مع التضمين والتجزئة.

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

جدول ToUnicode: حيث تعيش النسخ واللصق

كل ما سبق يتعلق بالرسم. أما الاستخراج فهو الصورة المعكوسة ويفشل لأسبابه الخاصة. يعرض العارض الصفحة باستخدام mapping من code إلى glyph الخاصة بالfont، لكن عندما يحدد المستخدم نصاً وينسخه، يحتاج العارض إلى تحويل تلك الرموز نفسها إلى Unicode. هذا الـ mapping العكسي هو ToUnicode CMap، وهو stream اختياري مرتبط بالfont.

عندما يكون هذا الجدول موجوداً وصحيحاً، يخرج النص المنسوخ بالحروف الصحيحة. وعندما يكون غائباً أو خاطئاً، أو عندما يكون font قد جُزِّئ باستخدام codes glyph مخصصة ولم يُكتب ToUnicode، تبدو الصفحة مثالية لكن الحافظة تمتلئ بترميز غير مفهوم: code glyph تُقرأ كما لو كانت Unicode، وهي ليست كذلك في subset ذي الترميز المخصص. لهذا السبب يمكن لمستند ممسوح ضوئياً مع طبقة OCR نصية أن يكون قابلاً للبحث، بينما لا يكون PDF مولداً رقمياً من منشئ مهمل كذلك. العرض والاستخراج يعتمدان على جداول مختلفة، لذا يمكن للملف أن ينجح في أحدهما ويفشل في الآخر. إذا كان الاستخراج مهماً لمخرجاتك، فاعتبر mapping صحيحاً لـ ToUnicode متطلباً، وتحقق منه بنسخ النص من عينة بدلاً من افتراض أنه موجود.

كيفية تشخيص عطل font بسرعة

نمط الفشل يخبرك أين تنظر. ظهور مربعات فارغة على جهاز آخر يعني في الغالب أن font لم يُضمَّن، لذا افحص التضمين أولاً وتغطية glyphs ثانياً. المربعات التي تظهر حتى على جهازك أنت تشير إلى التغطية: font لا يحتوي ذلك النص، بصرف النظر عن التضمين. النص الذي يعرض بشكل صحيح لكنه يُنسخ على هيئة هراء هو مشكلة ToUnicode، لا مشكلة عرض، والعبث بالخطوط أو التضمين لن يصلحها لأن الرسم لم يكن معطلاً أصلاً. لقراءة ملف منتهٍ، افتحه في Acrobat وانظر إلى Document Properties، ثم Fonts: الإدخال السليم يعرض النوع، ويقول Embedded أو Embedded Subset، ويذكر encoding. أما font الذي كان يجب أن يكون مضمناً ولم يكن، فيعلن عن نفسه هناك قبل أن يفعل ذلك العميل.

لا شيء من هذا غريب عندما يصبح الفصل بين character وcode وglyph واضحاً. ضمِّن fonts التي ترسم بها، واجتزئ الكبيرة منها، وعند أول خروج للنص من اللاتينية اتجه إلى font يدعم Unicode واستخدم RegisterUnicodeTTF، واحتفظ بـ mapping صحيح لـ ToUnicode إذا كان أحد سيستخرج النص. إذا أُنجزت هذه الأمور على نحو صحيح، تتوقف المربعات عن الظهور. أما بالنسبة إلى الآلية المحيطة، فإن تشريح PDF بسيط يوضح مكان تموضع font dictionary داخل شجرة الكائنات، وجولة في بنية المستند تشرح كيفية مشاركة الموارد عبر الصفحات.

إن الاستدعاءات SetFont وFontEmbedding وRegisterUnicodeTTF المعروضة هنا هي جزء من مكوّن HotPDF لـ Delphi و C++Builder.