مقال تقني

إصلاح ToUnicode: استخراج NBSP والواصلة اللينة في PDF لدلفي

يدمج مكوّن PDFium لدلفي خطوط النظام التي يستخدمها TPdf.AddText بوصفها خطوط CID مفهرسة برمز لكل نقطة ترميز Unicode، فيحمل كل CID مطابقة ToUnicode واحدة بالضبط. ذلك ما يمنع المسافات المستخرجة من العودة U+00A0‏ (مسافة غير فاصلة) والواصلات U+00AD‏ (واصلة لينة)، من المستند الحي ومن الملف المحفوظ معًا

العرَض قبيح لأنه غير مرئي. فهرس بحث يفوت «two-x» لأن السلسلة المخزنة تحوي واصلة لينة، وتصدير CSV يقسم على نحو مختلف، وأداة diff تعلّم أسطرًا تبدو متطابقة في كل عارض. لا شيء في الصفحة المرسومة خاطئ؛ الخاطئ هو Unicode خلف الحروف الرسمية

لماذا تعود المسافات المستخرجة U+00A0؟

تتحول المسافات المستخرجة إلى U+00A0 لأن خريطة ToUnicode CMap التي يولدها PDFium في FPDFText_LoadFont مفهرسة بالحرف الرسمي، والحرف الرسمي الواحد يبلغ إليه من نقطتي ترميز. في Arial يخدم الحرف الرسمي 3 كلا U+0020 وU+00A0، ويخدم حرف الواصلة كلا U+002D وU+00AD. لذا يربط CMap المولد CID نفسه مرتين، مرة عبر مدخل bfchar ومرة عبر bfrange بصيغة مصفوفة، ويربح المدخل الذي يفضله قانون أسبقية القارئ ويصير النص المستخرج

لماذا كسر حرف Arial رسمي واحد استخراج نص PDF في دلفي: يبلغ U+0020 وU+00A0 إلى الحرف الرسمي 3 ويلغ U+002D وU+00AD إلى حرف الواصلة، فيربط ToUnicode CMap المولد CID 0003 مرتين عبر مدخل bfchar وbfrange مصفوفي، ويقرر قانون أسبقية القارئ أي نقطة ترميز تستخرج
أبقت أسبقية الأدنى يفوز المسافات عادية سنوات، حتى قلب تحويل في المستوى الأعلى القاعدة إلى الأعلى يفوز فاستخرج كل مسافة AddText بوصفها NBSP وكل واصلة واصلة لينة
1 beginbfchar
<0003> <0020>
endbfchar
1 beginbfrange
<0003> <0010> [<00A0> ...]
endbfrange

ظل هذا التناقض بلا ضرر طويلًا، لأن قارئ PDFium كان يدع الأدنى من المطابقات يفوز. وقد قلب تغيير في المستوى الأعلى القارئ إلى الأعلى يفوز، ومن تلك البنية فصاعدًا استخرجت كل مسافة تكتب بـ AddText بوصفها NBSP وكل واصلة بوصفها واصلة لينة. ولاحظ النمط في الأزواج: تختلف 0x20/0xA0 و0x2D/0xAD في البت العالي فقط، وهو بالضبط ما تتوقعه من خط يرسل له الـ cmap شبيهات Latin-1 إلى المخطط التفصيلي نفسه. وإن كان كود الاستخراج لديك سليمًا أمس وفاشل اليوم على محارف غير مرئية، فأفرغ نقاط الترميز بدل الثقة بعرض المصحح؛ وأساسيات سحب النص مغطاة في استخراج النص من مستندات PDF عبر PDFium في دلفي

uses
  SysUtils, PDFium;

const
  // تشترك المسافة/U+00A0 والواصلة/U+00AD في حرف Arial رسمي واحد، وكذلك
  // حرف Omega اليوناني (U+03A9) وإشارة Ohm (U+2126)
  Sample: WString = 'two-x'#$00A0'y'#$00AD'z '#$03A9#$2126;

function CodePoints(const S: WString): string;
var
  I: Integer;
begin
  Result := '';
  for I := 1 to Length(S) do
    Result := Result + 'U+' + IntToHex(Ord(S[I]), 4) + ' ';
end;

var
  Pdf: TPdf;
  Live, Reloaded: WString;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.CreateDocument;
    Pdf.AddPage(1, 595, 842);
    Pdf.AddText(Sample, 'Arial', 12, 72, 770);
    Live := Pdf.Text;                  // مستند حي غير محفوظ
    Pdf.SaveAs('codepoints.pdf');
  finally
    Pdf.Free;
  end;

  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'codepoints.pdf';
    Pdf.Active := True;
    Pdf.PageNumber := 1;
    Reloaded := Pdf.Text;              // بعد حفظ كامل وإعادة تحميل
  finally
    Pdf.Free;
  end;

  if (Live <> Sample) or (Reloaded <> Sample) then
    Writeln('Mismatch: ', CodePoints(Live), '/ ', CodePoints(Reloaded));
end;

لماذا لم يكف ترقيع CMap بعد الحفظ

ترقيع الملف المحفوظ يصلح الملف المحفوظ فقط، وفقط إن أبقى الترقيع بنية CMap سليمة بايتًا ببايت. الإصلاح الأول، RepairSubsetToUnicodeCMaps في وحدة FPdfCompress، يجري بعد كل TPdf.SaveAs غير تزايدي ويحسم كل CID متضارب: مدخل bfchar يفوز، والزوج الذي يختلف في البت العالي فقط يحسم إلى أصغر نقطة ترميز Latin أساسية، وكل ما سوى ذلك يبقي مطابقته الأولى

الجزء المثير هو النتيجة السلبية. بني CMap المتضارب من جديد بنظافة، بصيغة start-code أو المصفوفة، بدا الخيار البديهي، ورفض PDFium كل CMap أعيد بناؤه رفضًا قاطعًا عائدًا إلى Identity. والناتج الوحيد الذي قبله القارئ الأصلي هو استبدال في الموضع بطول متساو للقيم الست عشرية المتضاربة، مع ترك تخطيط الكتلة وتغطية CID بلا مساس. والدرس الثاني كان أشد تواضعًا: لامحت ملاحظتنا حينها حالة الذاكرة على أن المستند الحي لا يملك تدفق ToUnicode أصلًا. وقد نبذ الاستدعاء المباشر للـ DLL ذلك، إذ يحمل المستند الحي التدفق الغامض نفسه، أي أن الإصلاح الحقيقي كان لا بد أن يقع قبل أن يولد PDFium الـ CMap أصلًا. وتبقى روتينية الإصلاح في المكتبة دفاعًا عن ملفات PDF أنتجتها أدوات أخرى مبنية على PDFium

uses
  Classes, FPdfCompress;

var
  Source, Dest: TFileStream;
begin
  Source := TFileStream.Create('from-other-tool.pdf',
    fmOpenRead or fmShareDenyWrite);
  try
    Dest := TFileStream.Create('repaired.pdf', fmCreate);
    try
      // تعديلات بطول متساو فقط؛ والملفات بلا تضارب قابل للإصلاح،
      // وملفات تدفقات الإشارات المرجعية أو تدفقات الكائنات، تنسخ كما هي
      RepairSubsetToUnicodeCMaps(Source, Dest);
    finally
      Dest.Free;
    end;
  finally
    Source.Free;
  end;
end;

فهرسة الخط بنقطة الترميز بدل الحرف الرسمي

الإصلاح الجذري هو التوقف عن طلب توليد CMap من PDFium كليًا. يسلم TPdf.LoadCachedFont الآن بايتات خط النظام إلى TPdf.LoadUnicodeKeyedCidFont، التي تقرأ جدول sfnt‏ cmap الخاص بالخط، مفضلة جدولًا فرعيًا بالصيغة 12 وتعود إلى الصيغة 4 عند غيابه. تعود نقاط الترميز مرتبة ومزالة التكرار، ويسند CID‏ k+1 إلى نقطة الترميز k، ويبقى CID 0 بوصفه .notdef. وترسل CIDToGIDMap صريحة كل CID إلى حرفه الرسمي، فتنال U+0020 وU+00A0 رسميَّي CID مختلفين يرسمان المخطط التفصيلي نفسه، وتربط خريطة ToUnicode CMap كل CID بنقطة ترميز واحدة فقط. ثم يحمَّل الخط عبر FPDFText_LoadCidType2Font، نقطة الدخول نفسها خلف الكتابة على مستوى الحروف الرسمية في تضمين خطوط CID Type 2 بخرائط CID إلى GID صريحة

الإصلاح المفهرس بنقطة الترميز في مكوّن PDFium: تقرأ LoadUnicodeKeyedCidFont جدول sfnt cmap للخط، وتسند CID k+1 إلى كل نقطة ترميز مرتبة مع CID 0 كـ notdef، وتوصل CIDToGIDMap صريحة فتبقي U+0020 وU+00A0 رسميَّي CID مختلفين، ويعطي BuildUnicodeKeyedCidCMap كل CID نقطة ترميز واحدة بالضبط
عندها تنجو NBSP والواصلة اللينة وإشارة Ohm كما هما تحت أي قانون أسبقية، في المستند الحي وبعد أي حفظ، فلا يجد إصلاح CMap ما يصلحه
// مختصرة من TPdf.LoadUnicodeKeyedCidFont
SetLength(CidToGidMap, (Length(Entries) + 1) * 2);   // CID 0 = .notdef
for I := 0 to High(Entries) do
begin
  CidToGidMap[(I + 1) * 2]     := Byte(Entries[I].GlyphID shr 8);
  CidToGidMap[(I + 1) * 2 + 1] := Byte(Entries[I].GlyphID and $FF);
end;
ToUnicode := BuildUnicodeKeyedCidCMap(Entries);       // CID واحد، نقطة ترميز واحدة
Result := FPDFText_LoadCidType2Font(Document, @Data[0], Length(Data),
  PAnsiChar(ToUnicode), @CidToGidMap[0], Length(CidToGidMap));

حين يكتب FPDFText_SetText سلسلة لاحقًا، يهبط البحث العكسي على CID واحد لكل محرف، فتنجو NBSP والواصلة اللينة وإشارة Ohm كل كما هي تحت أي قانون أسبقية، في الذاكرة وبعد أي حفظ. ولأن الملف المحفوظ يحمل تدفق ToUnicode الخاص بالمكوّن لا المولد من المحرك، لا يجد RepairSubsetToUnicodeCMaps ما يصلحه فيه

لماذا يستطيع مدخل bfrange واحد محو كتلة كاملة؟

‏bfrange واحد تمتد سلسلة CID فيه عبر حد xxFF يجعل PDFium يرمي الكتلة كلها التي يجلس فيها. لا تجيز ISO 32000-1 §9.10.3 إلا تغاير البايت الأخير من الوجهة داخل مدى، لكن جانب CID له فخه الخاص: يشتق HandleBeginBFRange في PDFium الـ CID الأعلى بوصفه (low and $FFFFFF00) or (high and $FF). فتقرأ سلسلة من CID 00FE إلى 0101 بوصفها 00FE إلى 0001، والأدنى أكبر من الأعلى، وتوسم الكتلة كلها غير صحيحة. والفشل صامت: ينجح SetText، وترسم الصفحة بشكل تام، ويعيد الاستخراج U+0000 لكل محرف في تلك الكتلة

فخ bfrange الصامت في تحليل CMap في PDF: سلسلة CID من 00FE إلى 0101 تعبر حد xxFF، ويشتق HandleBeginBFRange الـ CID الأعلى بوصفه 0001، فيوسم الأدنى الأكبر من الأعلى الكتلة كلها غير صحيحة، ويظل SetText والرسم ناجحين، ويعيد الاستخراج U+0000 لكل محرف في الكتلة
يتجنب BuildUnicodeKeyedCidCMap الفخ بإنهاء كل سلسلة قبل بايت منخفض يساوي FF، مبقيًا الكتل داخل حد المداخيل البالغ 100 وكاتبًا نقاط الترميز في المستوى التكميلي مداخيل bfchar فردية

تنهي BuildUnicodeKeyedCidCMap كل سلسلة قبل أن يبلغ نقطة الترميز أو الـ CID بايتًا منخفضًا يساوي FF، وتبقي كل كتلة داخل حد المداخيل البالغ 100 في قواعد CMap، وتكتب نقاط الترميز في المستوى التكميلي مداخيل bfchar فردية وجهاتها أزواج بديلة UTF-16، لأن زيادة زوج بديل داخل مدى لا معنى معرفًا له؛ وجانب الأزواج البديلة من تلك القصة في معالجة emoji وCJK وأزواج الاستبدال في دلفي. وCMap من bfchar وحده كانت ستتجاوز مشكلة الحدود كليًا، على حجم يفوق بعدة مرات

ماذا لا يغطيه الخط المفهرس بنقطة الترميز؟

يغطي المسار المفهرس بنقطة الترميز كل خط يعرض جدول cmap فرعيًا بترميز Unicode، ويعود إلى السلوك القديم المفهرس بالحروف الرسمية للبقية. والحدود التي تستحق المعرفة قبل الاعتماد عليه:

  • خطوط Symbol ذات cmap من‏ (3,0) فقط، وأي خط يعجز مسار CID عن تحميله، تمر عبر FPDFText_LoadFont كما قبل، فقد يستخرج هناك حرف رسمي مشترك بين نقطتي ترميز بغموض بعد
  • بلا جدول فرعي بالصيغة 12 تقتصر الخريطة على BMP، وسقف عدد المداخيل 65535 كي يتسع كل CID في بايتين فوق الصفر
  • الحفظ التزايدي‏ (saIncremental) يتخطى RepairSubsetToUnicodeCMaps عمدًا، لأن المراجعة التزايدية يجب أن تبقى إلحاقًا فقط؛ وخطوط الفهرسة بنقطة الترميز تجعل ذلك بلا موضوع للنص الذي يكتبه المكوّن بنفسه
  • مجموعات TrueType تحتاج عناية إضافية: يعيد GDI ‏GetFontData ملف .ttc كاملًا، وFPDFText_LoadCidType2Font لا يملك معامل مؤشر وجه، فطلب NSimSun من simsun.ttc كان يضم ويرسم SimSun، الوجه 0. ويطابق المكوّن الآن اسم العائلة ضد جدول الاسم‏ (nameID 1 و16) ويستخرج الوجه المطلوب sfnt مستقلًا قبل تحليل cmap؛ وإن فشل التحليل، عبرت بايتات المجموعة كما هي وعاد السلوك إلى الوجه 0

تتقاسم كتابة النص وتضمين الخطوط والاستخراج نموذج صفحة واحد عبر Delphi وC++Builder وLazarus، وAPI كامل موصوف في صفحة منتج مكوّن PDFium لدلفي