مقال تقني

استيراد تعليقات FDF في Delphi: علاج الصفر الصامت

قبل v3.539.30 كانت TPDFlib.ImportAnnotationsFromFDFString في losLab PDF Library تعيد عدد مدخلات التعليقات التوضيحية في FDF التي حللتها دون أن تضيف واحداً منها إلى المستند: كل مدخل حُسب، وكل مدخل أُسقط. ومنذ v3.539.30 يقرأ مستورد FDF المفاتيح بأي ترتيب، ويحلل /Rect بشكل صحيح ومستقل عن الإعدادات المحلية، والمصدّر الموافق يكتب /Rect الحقيقية للتعليق، فتصدير ثم استيراد ثم تصدير ثانٍ ينتج FDF متطابقة بايتاً ببايت. وبقية هذه الملاحظة تشرح كيف أنتج إزاحة بداية خاطئة واحدة فشلاً صامتاً تاماً، وأي ثلاثة عيوب أخرى كانت تختبئ خلفه، وكيف تفحص الاستيراد بنفسك بدل الوثوق بالقيمة المرجعة

السيناريو عادي تماماً. مراجع يعلّم على عقد، وتنتقل التعليقات في ملف FDF (ويسميها Acrobat ‏Export Comments)، وخدمة Delphi لديك تدمجها في نسخة نظيفة عبر ImportAnnotationsFromFDF. الاستدعاء يعيد 7، والسجل يقول «7 comments imported»، والمهمة تنجاح، و PDF الناتج بلا تعليق واحد. لا استثناء أُثير ولا تحذير صدر، والعدد بدا معقولاً لأنه كان العدد الحقيقي للمدخلات في الملف. هذه أسوأ صورة يمكن أن يتخذها عيب: دالة إشارتها الوحيدة على النجاح عدّاد يُحسب باستقلال تام عن العمل الذي يدّعي أنه يبلّغ عنه

لماذا بلغت ImportAnnotationsFromFDFString نجاحاً ولم تضف شيئاً؟

كان المستورد يقرأ كل /Subtype كسلسلة فارغة، والمساعدة التي تنشئ التعليق تنسحب مبكراً على subtype فارغ بينما يزيد المستدعي النتيجة على أي حال. كان باحث المفاتيح يعيد الموضع الذي يلي /Subtype مباشرة، وهو الفراغ قبل القيمة. و ReadName تبدأ من تلك المسافة وتتوقف عند أول محرف فراغ، فتتوقف قبل أن تقرأ شيئاً. و AddAnnotationToPage ترفض بناء تعليق بلا subtype، وهو خيار دفاعي صائب بمعزل عن السياق، لكنها كانت إجراء بلا قيمة مرجعة، و Inc(Result) كان جالساً خارجها. كل حرس كان معقولاً بمفرده؛ واجتماعها حوّل «لا شيء يعمل» إلى «كل شيء يعمل». يجعل الإصلاح ReadName تتخطى الفراغ، وتشترط / الافتتاحية لكائن اسم PDF، وتتوقف عند أي محدد، ومنه [ و ( و )، فتعطي /Subtype/Text و /Subtype /Text كلتيهما Text

وجدت PDFlibPas ‏ImportAnnotationsFromFDFString‏ /Subtype وبدأت ReadName من الفراغ بعد المفتاح فأعادت اسماً فارغاً، ونسحبت AddAnnotationToPage على subtype المفقود، وزاد المستدعي النتيجة على أي حال، فبلغ سبع تعليقات مستوردة دون أن يضيف واحداً إلى المستند
كل حرس كان معقولاً بمعزل عن الآخرين؛ واجتماعها حوّل «لا شيء يعمل» إلى «كل شيء يعمل»، ولهذا يجب ألا تكون القيمة المرجعة الشيء الوحيد الذي يفحصه اختبار استيراد

استحقت القيمة المرجعة عناية حتى بعد ذلك الإصلاح. وحتى v3.539.39 كانت ImportAnnotationsFromFDFString تزيد نتيجتها عن كل قاموس مكتوب التكوين في مصفوفة /Annots، بما في ذلك المدخلات التي /Page فيها بالترقيم من الصفر خارج النطاق أو التي /Subtype فيها مفقودة، وكلاهما يُتخطى. ومنذ PDFlibPas v3.539.40 تعيد ImportAnnotationsFromFDFString و ImportAnnotationsFromFDF عدد التعليقات المضافة فعلاً، مثل استيراد XFDF: فمساعدة FDF ‏AddAnnotationToPage تعيد الآن قيمة منطقية والعدّاد لا يتحرك إلا على النجاح. وقياس المستند يبقى الفحص الأقوى، لأنه يصح على الإصدارات الأقدم أيضاً، فالمخطط أدناه يقارن AnnotationCount على كل صفحة قبل الاستيراد وبعده

function TotalAnnotations(Lib: TPDFlib): Integer;
var
  Page, Saved: Integer;
begin
  Result := 0;
  Saved := Lib.SelectedPage;
  for Page := 1 to Lib.PageCount do
    if Lib.SelectPage(Page) = 1 then
      Inc(Result, Lib.AnnotationCount);   // لكل صفحة مختارة، الودجات مشمولة
  Lib.SelectPage(Saved);
end;

var
  Lib: TPDFlib;
  Before, Reported, Added: Integer;
begin
  Lib := TPDFlib.Create;
  try
    Lib.LoadFromFile('contract.pdf', '');
    Before := TotalAnnotations(Lib);
    Reported := Lib.ImportAnnotationsFromFDF('review-comments.fdf');
    Added := TotalAnnotations(Lib) - Before;
    if Added <> Reported then   // متساويان منذ v3.539.40
      Writeln(Format('Importer reported %d, %d landed on a page', [Reported, Added]));
    Lib.SaveToFile('contract-reviewed.pdf');
  finally
    Lib.Free;
  end;
end;

ثلاثة عيوب أخرى خلف العيب الأول

إصلاح subtype وحده كان سيكشف ثلاثة أعطال أخرى في الدالة نفسها، كل منها كان غير مرئي فقط لأن أي تعليق لم يصل يوماً إلى صفحة. أولاً، كانت ReadNumber تأخذ موضعها معاملاً بالقيمة، فقراءة أرقام /Rect الأربعة بالتتابع كانت تقرأ الموضع نفسه أربع مرات، ولم تكن تتخطى [ الافتتاحية، فعلى أرض الواقع لم تقرأ شيئاً على الإطلاق. ثانياً، كان FindKey يتشارك مؤشراً واحداً متقدماً بين كل عمليات البحث. المصدّر يكتب /Subtype و /Rect و /Page و /Contents و /T و /Subj، لكن المستورد كان يبحث بترتيب /Subtype و /Contents و /T و /Subj و /Page و /Rect؛ وبمجرد أن يجتاز المؤشر /Contents كان البحث عن /Page و /Rect يجتاز المدخل الحالي فلا يجد شيئاً أو يطابق مفاتيح التعليق التالي. المكتبة لم تستطع قراءة مخرجاتها هي. ثالثاً، كانت الأرقام تمر عبر PLStrToFloat التي تتبع الفاصلة العشرية للنظام. و ISO 32000-1 §12.7.7 تعرف FDF بوصفها صيغة كائنات PDF، ومفاتيح القواميس في PDF غير مرتبة (§7.3.7)، فأي محلل FDF يفترض ترتيب مفاتيح فهو خاطئ بالبناء، أيّة أداة أنتجت الملف

المستورد المصلح يحدد كل مدخل أولاً. يتجول FindDictEnd من << الافتتاحية إلى >> الموافقة لها، متتبعاً القواميس المتداخلة ومتخطياً متن السلاسل الحرفية مع محارف الشرطة المائلة المعاكسة فيها، فلا يستطيع >> داخل تعليق مثل (see section >> 4) أن ينهي المدخل مبكراً. وكل بحث عن مفتاح يبدأ عند بداية المدخل نفسه ويُحد بنهايته، فيصبح ترتيب المفاتيح بلا معنى ويُمنع تعليق من استعارة /Page لغيره. ومطابقة المفتاح تقبل أيضاً محدداً يلي الاسم مباشرة، لأن /Contents(Hi) صالحة كـ /Contents (Hi)، بينما تمنع قاعدة حدود الكلمة /Subj من مطابقة بداية /Subtype و /T من مطابقة /Type. وتأخذ ReadNumber الآن موضعها معاملاً var، وتتخطى الفراغ و [، وتحلل بـ PLTryStrToFloatInvariant التي تفشل بهدوء على رمز مشوّه بدل أن ترفع استثناء. وإذا فشل أي رقم من أرقام المستطيل الأربعة سقطت الأربعة كلها إلى الصفر بدل أن تنتج مستطيلاً نصف مقروء

يحدد PDFlibPas ‏FindDictEnd الآن كل تعليق FDF من << الافتتاحية إلى >> الموافقة، فيبدأ كل بحث عن مفتاح من بداية المدخل ويتوقف عند نهايته، وتأخذ ReadNumber موضعاً var وتتخطى القوس وتحلل بـ PLTryStrToFloatInvariant
المؤشر المشترك لم يستطع قراءة تصدير المكتبة ذاته: بمجرد أن يجتاز /Contents كانت عمليات البحث عن /Page و /Rect تصطدم بمفاتيح التعليق التالي، ولم يعد مسموحاً لترتيب المفاتيح أن يهم

لماذا أزحت دورات FDF كل تعليقاً بعلوّه هو؟

كان المصدّر القديم يكتب مستطيلاً بنموذج إحداثيات خاطئ. ‏/Rect للتعليق هي [llx lly urx ury] في فضاء المستخدم الافتراضي (ISO 32000-1 §12.5.2، والمستطيلات معرفة في §7.9.5)، و FDF تحمل المصفوفة نفسها. لكن ExportAnnotationsToFDFString كانت تستدعي GetAnnotRectEx التي تبلّغ Left و Top و Width و Height في إحداثيات الرسم لدى المكتبة، أي الفضاء الذي يتحكم فيه SetOrigin، وتسلسلها على صورة [L T L+W T+H]. والمستورد، متى صار يعمل، كان يكتب تلك القيم الأربع حرفياً مستطيل PDF، فتهبط الحافة العليا حيث تعود الزاوية السفلية اليسرى وتحرك كل دورة ذهاب وإياب التعليقاً إلى أعلى بعلوّه هو. ينسخ المصدّر الآن أرقام /Rect الخاصة بالتعليق نفسه، بثلاث عشريات وفاصل نقطة ودون أُس، ولا يرجع إلى المستطيل المحسوب إلا حين تكون المصفوفة المخزنة مفقودة أو ليست أربعة أرقام

كانت PDFlibPas تسلسل FDF ‏/Rect بوصفها يساراً وأعلى وعرضاً وعلواً في إحداثيات الرسم، فاستيراد تلك الأرقام الأربع من جديد بوصفها llx lly urx ury أهبط الحافة العليا حيث تعود الزاوية السفلية اليسرى وأزح كل تعليق إلى أعلى بعلوّه هو في كل دورة ذهاب وإياب
ينسخ المصدّر الآن أرقام /Rect الخاصة بالتعليق — ثلاث عشريات، فاصل نقطة، دون أُس — واختبار الانحدار يقارن تصديراً ثانياً بالأول بايتاً ببايت

اختبار الانحدار الذي يثبّت هذا يستحق النسخ، لأنه يجزم على المستند وعلى تصدير ثانٍ، لا على القيمة المرجعة للمستورد. وانتبه للعدد المتوقع 2: تنشئ AddNoteAnnotation تعليق Text مع المنبثقة Popup الخاصة به، وكلاهما ينتقل. ويشغل الاختبار التصدير والاستيراد أيضاً تحت فاصلة عشرية فاصلة، وهنا يسكن النصف الآخر من هذه القصة

var
  Source, Target: TPDFlib;
  FDF: AnsiString;
  OldSep: Char;
begin
  Source := TPDFlib.Create;
  Target := TPDFlib.Create;
  try
    Source.NewPages(1);                     // الآن صفحتان
    Source.SelectPage(2);
    Source.AddNoteAnnotation(50.5, 60.25, 0, 80, 80, 120, 60,
      'Reviewer', 'Check this', 0.25, 0.5, 0.75, 0);
    Target.NewPages(1);

    OldSep := FormatSettings.DecimalSeparator;
    FormatSettings.DecimalSeparator := ',';   // محاكاة سطح مكتب ألماني أو فرنسي
    try
      FDF := Source.ExportAnnotationsToFDFString;   // ما زال يكتب /Rect [50.5 ...
      Target.ImportAnnotationsFromFDFString(FDF);
    finally
      FormatSettings.DecimalSeparator := OldSep;
    end;

    Target.SelectPage(2);
    Assert(Target.AnnotationCount = 2);           // الملاحظة ومنبثقتها
    Assert(Target.GetAnnotType(1) = 'Text');
    Assert(Target.ExportAnnotationsToFDFString = Source.ExportAnnotationsToFDFString);
  finally
    Target.Free;
    Source.Free;
  end;
end;

كن واضحاً حول ما يحمله مسار FDF. يعيد المستورد بناء كل مدخل قاموساً يضم /Type و /Subtype و /Rect و /Contents و /T و /Subj؛ واللون والأعلام ونمط الحدود وروابط المنبثقة وتدفقات المظهر ليست جزءاً من هذا الطريق، والمصدّر يتخطى تعليقات Widget لأن حقول النماذج من شأن طرق بيانات النماذج. والخريطة الأوسع لأي بيانات تنتقل عبر أي طريقة في نظرة تبادل بيانات النماذج FDF و XFDF و XFA، وإذا احتجت إلى تفتيش ما وصل فعلاً فإن القارئات لكل فهرس مثل GetAnnotType و GetAnnotTitle و GetAnnotContentsEx مغطاة في التأمل في المخططات والتعليقات والأفعال

كيف تقرأ ملفات FDF و XFDF بعشريات الفاصلة من تصديرات أقدم؟

في FDF الجواب قاطع: الفاصلة ليست محدداً في صيغة PDF، فرمز عدد يحوي فاصلة واحدة بالضبط ودون نقطة لا يمكن إلا أن يكون عشرية كُتبت على جهاز لغته تستخدم الفاصلة. الإصدارات الأقدم كانت تكتب فعلاً ملفات كهذه، مثل /Rect [10,500 20,250 40,750 60,125]، و ReadNumber الجديدة تحول تلك الفاصلة الوحيدة إلى نقطة قبل التحليل. والرمز الذي يحمل فاصلتين، أو فاصلة ونقطة، يُرفض بدل أن يُخمَّن. والقارئة لا تستهلك ترميز الأُس أيضاً، وهو ما يوافق ISO 32000-1 §7.3.3: أعداد PDF لا تستخدمه أبداً

XFDF أصعب، لأن الفاصلة في خصائص XML هي الفاصل. XFDF القياسي (ISO 19444-1) يكتب rect="50.5,80.25,70.75,100.125" و dashes="4,2"، بينما v3.539.28 وما قبله، على نظام لغته تستخدم الفاصلة، كتب rect="50,500 80,250 70,750 100,125" و opacity="0,600"، وفشل أيضاً بخطأ EConvertError عند قراءة opacity="0.6" قياسية. ومنذ v3.539.29 كلا الاتجاهين مستقل عن اللغة، والصورة القديمة يعرفها XFDFNormalizeLegacyDecimals فقط حين تنقسم الخاصية على الفراغ إلى العدد المتوقع من الرموز بالضبط (أربعة لـ rect، وواحد لـ opacity و width) ويكون لكل رمز صورة أرقام-فاصلة-أرقام. و rect قياسي لا يطابق أبداً: فهو إما رمز واحد بثلاث فواصل أو رموز تنتهي بفاصلة. أما dashes فتُترك عمداً وشأنها، لأن 4,2 قد تكون طولاً للشرطتين أو 4.2 قديمة، ولا قاعدة تستطيع التفريق بينهما

const
  // مفاتيح خارج ترتيب المصدّر، مع عشريات بفاصلة من تصدير أقدم على نظام فاصلة
  LegacyFDF: AnsiString = '%FDF-1.2'#10'1 0 obj'#10'<< /FDF << /Annots ['#10 +
    '<< /Rect [10,500 20,250 40,750 60,125] /Page 0 /Contents (First) ' +
    '/Subtype /Text /T (Alpha) /Type /Annot >>'#10 +
    '] >> >>'#10'endobj'#10'trailer'#10'<< /Root 1 0 R >>'#10'%%EOF'#10;
var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;               // المستند الجديد يملك صفحة واحدة
  try
    Lib.ImportAnnotationsFromFDFString(LegacyFDF);
    Assert(Lib.AnnotationCount = 1);
    Assert(Lib.GetAnnotTitle(1) = 'Alpha');
    // أعيد تصديره XFDF بعشريات نقطة: rect="10.500 20.250 40.750 60.125"
    Writeln(Lib.ExportAnnotationsToXFDFString);
  finally
    Lib.Free;
  end;
end;

ماذا يجب أن يجزم عليه اختبار استيراد التعليقات فعلاً؟

اختبار الاستيراد المفيد يجزم على حالة المستند الهدف، ولا يجزم أبداً على ما يقوله المستورد عن نفسه فقط. لم يفحص شيء في حزمة الاختبارات AnnotationCount بعد استيراد FDF، والقيمة المرجعة، العدد الوحيد الذي نظر إليه أحد، كانت العدد الوحيد الذي تركه العيب سليماً. ثلاث مجزمات كانت ستلتقط كل عيب وُصف هنا: عدد التعليقات على الصفحة المتوقعة، وحقل واحد مقروء من جديد عبر GetAnnotType أو GetAnnotContentsEx، وتصدير ثانٍ يقارن بالأول بايتاً ببايت. والانضباط نفسه يسري على أي API يعيد كتابة بنية المستند بالجملة، بما في ذلك توحيد الحقول الموصوف في دمج حقول النماذج المكررة: افحص الشجرة الناتجة، لا مجموعاً مرجعاً. وطرق تعليقات FDF و XFDF، بنسختيها للملف والسلسلة، تأتي في losLab PDF Library for Delphi and C++Builder، و v3.539.30 أو أحدث هي الإصدار الذي تشغله إذا كان يجب أن تنجو التعليقات من الرحلة، و v3.539.40 أو أحدث إذا كان يجب أن يطابق العدد المرجع ما أُضيف