مقال تقني

انغلاق تجزئة الخطوط: فقدان الحروف المشكَّلة في PDF بـDelphi

تُعرَض الحروف المشكَّلة كمربعات .notdef حين يُبقي مجزّئ خط subsetter فقط الحروف القابلة للوصول من نقاط الشيفرة code points المُصدَرة. HotPDF، مكوّن VCL الأصلي لملفات PDF في Delphi وC++Builder، حمل بالضبط هذا العيب حتى الإصدار 2.435.0: كانت مخرجات GSUB الخاصة بـOpenType تُسجَّل في خريطة بتات استخدام داخلية أعلن المجزّئ أنه سيحترمها ثم لم يقرأها فعليًا قط

هذا فشل مختلف عن ذلك الموصوف في عِلّة EndDoc التي عطّلت تجزئة الخطوط بصمت. تلك العِلّة كانت عن متى تعمل التجزئة نسبةً إلى التسلسل، وكانت تعطّل التجزئة كليًا. هذه عن ماذا تحتوي التجزئة حين تعمل بجدولة مثالية تمامًا. يُطلَق خط الأنابيب في اللحظة الصحيحة، وتظهر بادئة التجزئة المكوّنة من ستة أحرف على /BaseFont تمامًا كما يفرض ISO 32000-1 §9.6.4، ويصغر الملف، وتخرج كل صفحة لاتينية نظيفة في المراجعة، وتخرج صفحة عربية كصف من المستطيلات الفارغة. علل الترتيب صاخبة بمجرد أن تنظر. أما علل الانغلاق فتبقى صامتة إلى الأبد، لأن التجزئة صحيحة بنيويًا وخاطئة فقط بشأن قائمة عضويتها هي نفسها

لماذا تُعرَض الحروف المشكَّلة كـ.notdef؟

لأن مجموعة نقاط الشيفرة التي يُصدرها مستند ما ليست مجموعة الحروف التي يرسمها المستند، والمجزّئ الذي يخلط بين الاثنين يُسقط كل حرف أنتجه التشكيل shaping. تحويل التشكيل sequence شخصي منطقي من المحارف إلى تسلسل حروف موضَّع، والغرض الكامل منه إنتاج حروف لا يخطط لها أي محرف مدخل واحد: هاء عربية وسطية، رباط fi، حرف مركَّب devanagari conjunct، بديل سياقي contextual alternate تختاره سمة rclt. كل واحد من هؤلاء معرّف حرف glyph ID صنعه بحث lookup في GSUB، لا معرّف يسلّمك إياه جدول cmap لأي محرف في سلسلتك. فالمجزّئ المدفوع بـcmap وحده إذن يجتاز الفهرس الخاطئ. فهو يحتفظ بأمانة بكل حرف كان النص يمكن أن يستخدمه قبل التشكيل، ويسقط بالضبط الحروف التي يستخدمها النص فعلًا بعد التشكيل. عندئذ يطلب العارض من الخط المضمَّن معرّف الحرف GID 1847، وقد صفّرت التجزئة ذلك المدخل في loca، فيعود فهرس الحرف 0 بدلًا منه. فهرس الحرف 0 هو .notdef حسب تعريف OpenType، ولهذا فإن بصمة الفشل مربع فارغ لا حرف خاطئ ولا انهيار. لا شيء في PDF مشوَّه؛ الخط ببساطة لا يحتوي الحرف الذي طلبه تدفق المحتوى

نقاط الشيفرة ليست حروفًا: المصادر الثلاثة للتجزئة

انغلاق التجزئة الصحيح يجب أن يوحّد ثلاثة مصادر مستقلة، لكل منها مُجمِّعها الخاص. الأول هو المجموعة المشتقة من نقاط الشيفرة: تجمّع HotPDF FUnicodeUsedCps بينما تُصدَر محارف BMP وFUnicodeSmpUsed للمحارف من المستوى التكميلي المُوصَل إليها عبر أزواج بديلة surrogate pairs، ثم تخطّط كل واحد عبر FUnicodeCpToGid إلى معرّف حرف. الثاني هو المجموعة المشتقة من التشكيل، معرّفات الحروف التي أنتجها استبدال GSUB، مُسجَّلة عبر MarkUnicodeGlyphUsed وEnableShapingFeatureForSubset داخل FUnicodeExtraUsedGlyphs. الثالث هو الانغلاق المركَّب: الحرف الذي تكون قيمة numberOfContours فيه -1 في glyf يُجمَّع من معرّفات حروف مكوِّنة، والإبقاء على المركَّب مع إسقاط مكوّناته ينتج مخططًا فارغًا لا .notdef، وهو أسوأ إلى حد بعيد لأنه يُقرأ كعِلّة تباعد

عالجت HotPDF المصدر الأول والثالث دومًا. BuildAndApplyUnicodeFontSubset، نقطة دخول التجزئة التي يستدعيها EndDoc قبل التسلسل، تبذر مصفوفة الحروف المستخدَمة بـGID 0، وتجتاز نقاط شيفرة BMP، وتجتاز قائمة استخدام SMP، وتسلّم المصفوفة إلى باني تجزئة يحلّ مكوّنات المركَّبات داخليًا. أما المصدر الثاني فقد كُتب لكن لم يُستهلَك قط، ولأن المصادر الثلاثة تفشل على محتوى مختلف، فإن الفجوة يمكن أن تختبئ لسنوات في قاعدة كود مجموعتها الانحدارية regression corpus لاتينية في معظمها

المصفوفة التي كُتبت ولم تُقرأ قط

وُثِّق العقد في ثلاثة مواضع ولم يُحترَم في أي منها. أعلن تصريح FUnicodeExtraUsedGlyphs أن مجزّئ EndDoc يوحّده مع الاستخدام المشتق من نقاط الشيفرة؛ ووعد تعليق الترويسة على ApplyArabicGSUBRefinement أن كل GID بديل مُصدَر يمرّ عبر MarkUnicodeGlyphUsed بحيث يسحب المجزّئ الحرف إلى الخط المضمَّن؛ ويظهر الوعد نفسه حرفيًا على ApplyArabicGSUBContextualRefinement لمسار rclt. كلا المستدعيين أوفى بنصفه. عملية grep عبر كل إشارة إلى الحقل حسمت النصف الآخر في نحو تسعين ثانية: تصريح واحد، وتخصيص SetLength واحد داخل RegisterUnicodeTTF، وكتابات في روتيني التعليم الاثنين. ولا قراءة واحدة. هذا هو التشخيص الذي يستحق الاستيعاب، لأنه يعمّم إلى ما وراء الخطوط بكثير. حين يُكتَب حقل من عدة مواضع استدعاء ولا يُقرَأ من أي منها، فإن السمة التي يمثّلها غير موجودة، مهما كانت معلَّقة بدقة. الخطوة 1 من المجزّئ صغيرة بما يكفي لقراءتها في شاشة واحدة، والفجوة واضحة بمجرد أن تعرف أن تبحث عنها

// Step 1: derive the used-glyph set (as it stood before 2.435.0)
SetLength(UsedGlyphs, FUnicodeNumGlyphs);
for I := 0 to FUnicodeNumGlyphs - 1 do
  UsedGlyphs[I] := False;
UsedGlyphs[0] := True;                       // .notdef is always present

for Cp := 0 to $FFFF do                      // source 1a: BMP code points
  if (Cp < Length(FUnicodeUsedCps)) and FUnicodeUsedCps[Cp]
     and (Cp < Length(FUnicodeCpToGid)) then
  begin
    GID := FUnicodeCpToGid[Cp];
    if (GID > 0) and (GID < FUnicodeNumGlyphs) then
      UsedGlyphs[GID] := True;
  end;

for I := 0 to High(FUnicodeSmpUsed) do       // source 1b: SMP code points
begin
  GID := FUnicodeSmpUsed[I].GID;
  if (GID > 0) and (GID < FUnicodeNumGlyphs) then
    UsedGlyphs[GID] := True;
end;

// source 2 was missing here: nothing ever consulted FUnicodeExtraUsedGlyphs

إصلاح الحلقة الواحد، وتعليم الحروف بنفسك

الإصلاح توحيد union، وحجّة أمانه تأتي من اتجاه العملية: فهو يضبط بتات فقط، ولا يمسحها أبدًا، فلا حرف كان ينجو من التجزئة سابقًا يمكن أن يبدأ بالسقوط

// v2.435.0: pull GSUB-derived extra glyphs into the subset.
// MarkUnicodeGlyphUsed / EnableShapingFeatureForSubset record GIDs that
// shaping produced but that no emitted code point maps to directly.
for I := 0 to FUnicodeNumGlyphs - 1 do
  if (I < Length(FUnicodeExtraUsedGlyphs)) and FUnicodeExtraUsedGlyphs[I] then
    UsedGlyphs[I] := True;

ثلاث خصائص تجعل هذا تغييرًا منخفض المخاطر لا إعادة كتابة لمحرك خط. فهو رتيب monotone، كما أعلاه. وهو بلا أثر no-op على الخطوط التي لم تشكِّل أي شيء قط، إذ يبقى FUnicodeExtraUsedGlyphs كله False ومخرج البايتات لمستند لاتيني فقط دون تغيير. ويقع قبل الخطوة 2، فيرثه كلا بانيي التجزئة: البناء المتناثر الذي يحافظ على ترقيم GID الأصلي، والبناء المدمج _BuildCompactSubsetTTF الذي تختاره HotPDF تحت PDF/A لإعادة ترقيم الحروف المُبقاة إلى نطاق كثيف، وتصغير maxp.numGlyphs، وإصدار التخطيط من القديم إلى الجديد كتدفق /CIDToGIDMap الذي يفرضه ISO 32000-1 §9.7.4.2. يستدعي كلاهما _TTFWalkCompositeClosure داخليًا، فالحرف المشكَّل الذي يصادف أن يكون مركَّبًا يسحب الآن مكوّناته أيضًا. لم يكن الانغلاق المركَّب معطَّلًا قط؛ ببساطة لم يُصَل إليه قط لمعرّفات الحروف هذه، لأن معرّفات الحروف لم تكن في المجموعة التي يجتازها. إن كنت تُشغّل محرك GSUB مباشرة بدل الاعتماد على تمريرات التنقيح المدمجة، يصبح الانغلاق مسؤوليتك، وكل معرّف حرف بديل تُصدره يجب تعليمه قبل أن يُجمّد EndDoc مجموعة الحروف المستخدَمة

var
  Pdf: THotPDF;
  GIDs: array[0..1] of Word;
  LigGID: Word;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'shaped.pdf';
    Pdf.BeginDoc;
    Pdf.RegisterUnicodeTTF('C:\Fonts\NotoNaskhArabic-Regular.ttf');
    Pdf.ShapingFeatures := [sfArabicGSUB, sfStandardLigatures,
                            sfContextualAlternates];

    GIDs[0] := Pdf.GetUnicodeGlyphForCodepoint($0644);   // lam
    GIDs[1] := Pdf.GetUnicodeGlyphForCodepoint($0627);   // alef
    if Pdf.ApplyLigatureSubstitution(GIDs, 0, 'liga', LigGID) then
      Pdf.MarkUnicodeGlyphUsed(LigGID);   // omit this and you get .notdef

    Pdf.EnableShapingFeatureForSubset('rclt');
    Pdf.CurrentPage.RtLTextOut(50, 700, 0, WideString(ArabicText));
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

EnableShapingFeatureForSubset هو نظير الاستدعاء الفردي لمعرّف الحرف الواحد على مستوى الدفعة batch، وهو محافظ عمدًا. فهو يجتاز قائمة بحث GSUB عن عمليات البحث المربوطة بوسم سمة واحد من أربعة بايتات تحت مسار السكربت واللغة المحدَّد حاليًا، ويعلّم معرّفات الحروف البديلة التي يمكن أن تنتجها عمليات البحث تلك. وهو بلا أثر دفاعيًا حين لا يحمل الخط جدول GSUB أو حين تغيب السمة عن ذلك المسار، فاستدعاؤه دون شرط آمن. وهو أيضًا تقريب زائد بالتصميم: قد يحتفظ بحروف لا يرسمها مستند معيّن قط. بالنسبة للتجزئة، الشمول الزائد يكلّف بايتات والنقص يكلّف الصحة، ما يجعل تلك المقايضة سهلة. بنية عمليات البحث هذه، وجداول التغطية coverage tables التي تقرر أي الحروف تشارك، مشروحة في شرح بدائل GSUB الأسلوبية بـDelphi خالص

كيف تثبت أن الحرف موجود فعلًا في التجزئة؟

بقراءة الخط المُصدَر، لا بمعاينة الصفحة بالعين في عارض قد يستبدل خطًا من النظام خلف ظهرك. الفحص الذي يمسك بهذا الصنف الكامل من العلل ميكانيكي: استخرج تدفق /FontFile2 من ملف PDF الناتج، وحلّل loca، وتأكد أن معرّف الحرف الذي تتوقعه يحمل مدخلًا غير فارغ، أي أن إزاحتي بدايته ونهايته مختلفتان. المدخل الفارغ هو المجزّئ وقد قرر أن الحرف غير مستخدَم. عادتان تجعلان بعدها إعادة شحن الفشل أصعب كثيرًا. احتفظ بصفحة سكربت مشكَّل داخل مجموعة الفحص الآلي smoke corpus لا في مجموعة المراجعة اليدوية فقط، لأن العربية والديفاناغاري والخميرية تُشغّل مسارات انغلاق لن يلمسها أي قدر من التغطية اللاتينية. وكلما وجد مُجمِّع accumulator، أكّد أن شيئًا يستهلكه، لأن حقلًا للكتابة فقط سمة تُصرَّف وتنجح اختباراتها على المجموعة الخاطئة ولا تفعل شيئًا

أين يتوقف الإصلاح

انغلاق التجزئة ضروري ليُعرَض حرف مشكَّل، وهو غير كافٍ. الحرف يجب أيضًا أن يكون قابلًا للعنونة من تدفق المحتوى، وهذه مشكلة منفصلة لها حدها الخاص. تمريرات التنقيح العربية المدمجة في HotPDF لا تلتزم باستبدال إلا حين يكون كل معرّف حرف بديل قابلًا للوصول عبر نقطة شيفرة صيغة عرض يونيكود presentation-form عبر مسح cmap عكسي فوق نحو 690 نقطة شيفرة من U+FB50 إلى U+FDFF ومن U+FE70 إلى U+FEFF. حين يهبط بديل على معرّف حرف خارج ذلك النطاق، تمر نافذة الدخل دون تغيير بدل إصدار شيء يتعذر على القارئ عنونته؛ والبدائل الخاصة بالخط عند معرّفات حروف عشوائية تحتاج نقطة شيفرة استخدام خاص synthetic private-use مخصَّصة في U+E000 إلى U+F8FF لتحملها عبر مسار الإصدار. فالخلاصة الصادقة أن إصلاح 2.435.0 أزال عائقًا صلبًا لا أنه أكمل القصة. قبله، كان يمكن أن يُشكَّل حرف بشكل صحيح، ويُصدَر بشكل صحيح، ثم يختفي مع ذلك عند وقت التجزئة، ما كان يعني أن محرك التشكيل لا يمكن الوثوق به من طرف إلى طرف مهما كانت عمليات البحث فيه جيدة. ما تبقى هو قابلية العنونة، وذلك القيد على الأقل يفشل بشكل مرئي عند نقطة الإصدار لا بصمت في خطوة بناء تعمل بعد كل شيء كنت تراقبه. لجانب الإصدار من خط الأنابيب نفسه، انظر دليل تشكيل النص العربي واتجاه RTL في ملفات PDF بـDelphi

تجزئة الخطوط ومحرك GSUB وتشكيل السكربتات المعقّدة الموصوفة هنا تُشحن ضمن HotPDF Component القياسي لـ Delphi وC++Builder؛ وتحمل صفحة المنتج المرجع الكامل لواجهة برمجة الخطوط اليونيكودية والتشكيل المذكورة أعلاه