مقال تقني

اقرأ إجراءات الإشارات المرجعية والتعليقات التوضيحية في PDF في Delphi

ترث مجلدًا من ملفات PDF من جهة سابقة، ويبدو المطلوب بسيطًا: أخبرني أي الإشارات المرجعية تقفز إلى عنوان URL خارجي، وأيها يشغّل JavaScript، وأين تنتهي الإشارات الداخلية فعليًا. ثم تفتح مرجع API فتكتشف أن المكتبة تستطيع إنشاء كل واحد من تلك الإجراءات، لكنها لا تقدم أي وسيلة لقراءتها مرة أخرى. هذا التفاوت موجود في كل أدوات PDF تقريبًا. كتابة إشارة مرجعية تفتح https://example.com هو أمر من سطر واحد؛ أما سؤال إشارة مرجعية موجودة "ماذا تفعل، وإلى أي هدف تتجه؟" فعادةً يعني تتبّع شجرة الكائنات الخام يدويًا عبر /A, /S, /Dest وتفرعات كثيرة لأنواع الملاءمة التي يكاد لا يصيبها أحد من المحاولة الأولى

PDFlibPas هي مكتبة PDF أصلية بلغة Object Pascal لـ Delphi وC++Builder، وظلت لفترة طويلة تملك الفجوة نفسها: أدوات كتابة غنية، ومقابلات وصول تعيد إليك مجرد TPDFObject وتتركك تغوص في البنية. إصدار v3.77.0 سد جزءًا من تلك الفجوة بمجموعة صغيرة من نداءات الاستقصاء المعرّفة التي تُرجع نوع الإجراء، وحمولة الإجراء، وهندسة الوجهة كسجلات عادية. هذا المقال يشرح كيف تنطبق تلك النداءات على نموذج الإجراءات والوجهات في ISO 32000-1، وما هي الفخاخ العملية الثلاثة التي تجعل النسخ اليدوية من هذا الكود تنحرف بهدوء

لماذا قراءة الإجراءات أصعب من كتابتها

إجراء في PDF هو معجم يحتوي على /S يحدد نوعه الفرعي: GoTo, GoToR, URI, Launch, Named, JavaScript ثم يأتي ذيل أطول نادرًا ما تصادفه (ISO 32000-1 §12.6.4). المشكلة أن الحمولة تعيش في مفتاح مختلف لكل نوع فرعي، ولا توجد خانة موحّدة من نوع "أعطني الهدف". إجراء URI يحتفظ بعنوانه في /URI. إجراء GoToR أو Launch يحتفظ بمواصفات الملف في /F. إجراء JavaScript يحتفظ ببرنامجه النصي في /JS، وقد يكون إما سلسلة أو تدفقًا. إجراء GoTo لا يحمل أي حمولة خاصة به على الإطلاق؛ فالهدف يكون وجهة، معلّقًا على /D، ثم عليك حلّه بشكل منفصل

عندما تكتب إجراءً فأنت تعرف نوعه مسبقًا، لذا لا يهم شيء من هذا. عندما تقرأه، عليك أن تتفرع أولًا على /S، ثم تدخل إلى المفتاح الصحيح، ثم تتعامل مع حقيقة أن المفهوم المنطقي نفسه، أي "الشيء الذي يشير إليه هذا الإجراء"، مشفّر بثلاثة أشكال غير متوافقة. هذا التفرع هو بالضبط ما تستوعبه أدوات الجلب المعرّفة. GetOutlineActionInfo وGetAnnotActionInfo كلاهما يعيد TPDFlibActionInfo سجلًا:

type
  TPDFlibActionKind = (akNone, akGoTo, akGoToR, akURI,
                       akLaunch, akNamed, akJavaScript);

  TPDFlibActionInfo = record
    Kind: TPDFlibActionKind;
    URI: AnsiString;          // populated for akURI
    JavaScript: WideString;   // populated for akJavaScript
    FileName: AnsiString;     // populated for akGoToR / akLaunch
    OpenInNewWindow: Boolean; // akGoToR / akLaunch
  end;

يبين لك السجل أي الحقول ذات معنى عبر Kind. إذا Kind يعود بـ akURI، فاقرأ URI وتجاهل الباقي. وإذا عاد بـ akGoTo، فلا تنطبق أي من حقول الحمولة، وتنتقل إلى الوجهة، وهي نداء منفصل سنغطيه لاحقًا. akNone هو الجواب الصادق عندما لا تملك الإشارة المرجعية أو التعليق التوضيحي أي إجراء أصلًا، بدلًا من صفر عليك تخمين معناه

التنقّل في شجرة الإشارات المرجعية للعثور على إشارة مرجعية

قبل أن تتمكن من استقصاء إشارة مرجعية، تحتاج إلى المعرّف الخاص بها. PDFlibPas يعرّف عُقد الإشارات المرجعية بواسطة رقم صحيح، وFindOutlineByTitle يعثر على واحدةٍ منها بحسب نصها الظاهر مع تحكم صريح في مدى وصول البحث:

type
  TPDFlibOutlineSearchDepth =
    (osdSiblingsOnly, osdChildrenOnly, osdFullSubTree);

function FindOutlineByTitle(const Title: WideString;
  StartOutlineID: Integer;
  Depth: TPDFlibOutlineSearchDepth): Integer;

المعامل Depth هو الجزء الذي يستحق التوقف عنده. osdSiblingsOnly يفحص سلسلة الأشقاء عند مستوى العقدة الابتدائية ويتوقف؛ سيعثر على إشارة مرجعية شقيقة لكنه لن ينزل أبدًا إلى أبناء تلك الشقيقة. osdChildrenOnly ينظر مستوى واحدًا إلى الأسفل، إلى الأبناء المباشرين لعقدة البداية. osdFullSubTree يستدعي البحث عبر الفرع بأكمله. اختيار الخاطئ هنا يعني فشلًا صامتًا لا خطأً: البحث على مستوى الأشقاء فقط عن عنوان يقع على عمق مستويين يعيد صفرًا ببساطة، فتستنتج أن الإشارة المرجعية غير موجودة بينما هي موجودة طوال الوقت. مرّر GetFirstOutline بوصفه معرّف البداية للبحث من جذر المستند

var
  Lib: TPDFlib;
  FoundID: Integer;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('report.pdf', '') = 1 then
    begin
      // Search the whole tree from the root for a nested bookmark.
      FoundID := Lib.FindOutlineByTitle('Appendix B',
        Lib.GetFirstOutline, osdFullSubTree);
      if FoundID <> 0 then
        // FoundID is now a handle you can pass to the action and
        // destination getters below.
        ;
    end;
  finally
    Lib.Free;
  end;
end;

المطابقة تتم مع سلسلة العنوان الدقيقة، وتُقارن بوصفها WideString، لذا فهي حساسة لحالة الأحرف وتحترم نص Unicode كما هو مخزّن بالضبط. إذا كانت ملفات PDF المصدرية تأتي من منتجين غير متسقين، فطبّع العنوان الذي تبحث عنه بالطريقة نفسها التي خزّنه بها المستند، وإلا ستطارد حالات فشل وهمية

حلّ إجراء الإشارة المرجعية وهدفها

ومع وجود المعرّف في اليد، GetOutlineActionInfo يمنحك العرض المعرّف. النمط هو: استدعِه، ثم بدّل على Kind، ثم اقرأ الحقل الذي يملؤه ذلك النوع

var
  Info: TPDFlibActionInfo;
begin
  Info := Lib.GetOutlineActionInfo(FoundID);
  case Info.Kind of
    akURI:
      Writeln('Opens URL: ', Info.URI);
    akGoToR, akLaunch:
      Writeln('Opens file: ', Info.FileName,
        ' (new window: ', Info.OpenInNewWindow, ')');
    akJavaScript:
      Writeln('Runs script: ', string(Info.JavaScript));
    akGoTo:
      Writeln('Jumps within this document');  // see destination below
    akNamed:
      Writeln('Named action (NextPage, Print, etc.)');
    akNone:
      Writeln('Bookmark has no action');
  end;
end;

هنا تكمن الفخّة الحقيقية الأولى، وهي نفسها التي كشف عنها اختبار الملاحظات أثناء التنفيذ. هناك getter أقدم، GetActionURL، واللجوء إليه لقراءة إجراء URI هو الخطأ الذي يبدو بديهيًا. GetActionURL يحل مواصفة الملف عبر المفتاح /F . هذا صحيح بالنسبة إلى GoToR وLaunch، لأن أهدافهما ملفات فعلًا، لكنه المفتاح الخاطئ تمامًا لإجراء URI . إجراء URI عنوانه مجرد سلسلة عادية على المفتاح الخاص به /URI، لا مواصفة ملف. إذا أرسلت إجراء URI إلى مسار مواصفات الملف فستحصل على نتيجة فارغة أو عديمة المعنى. يتعامل getter المعرّف مع هذا داخليًا بقراءة /URI مباشرةً لـ akURI، ولا يستدعي محلّل مواصفات الملف إلا من أجل akGoToR وakLaunch، وهي بالضبط التفرقة التي يميل التنفيذ اليدوي إلى طمسها

أنواع ملاءمة الوجهة والهندسة الكامنة وراءها

إجراء akGoTo يعني "التنقل داخل هذا المستند"، لكنه لا يخبرك بشيء عن أين أو كيف. هذه مهمة الوجهة، والوجهات تحمل قدرًا من الدقة أكثر مما يتوقعه الناس. وجهة PDF ليست مجرد رقم صفحة؛ إنها صفحة مع مواصفة "fit" تحدد كيف يجب أن يعرض القارئ تلك الصفحة (ISO 32000-1 §12.3.2.2). GetOutlineDestinationInfo يعيدها كسجل:

type
  TPDFlibDestinationKind = (dkNone, dkXYZ, dkFit, dkFitH,
    dkFitV, dkFitR, dkFitB, dkFitBH, dkFitBV);

  TPDFlibDestinationInfo = record
    Kind: TPDFlibDestinationKind;
    Page: Integer;   // 1-based; 0 when unresolved
    Left, Top, Right, Bottom, Zoom: Double;
  end;

الأنواع الثمانية لـ fit تجيب عن أسئلة مختلفة حول طريقة الإطار.dkXYZ يضع نقطة محددة عند الزاوية العلوية اليسرى مع تكبير صريح، لذلك يستخدم Left, Top وZoom. dkFit يملأ الصفحة كلها داخل النافذة ويتجاهل الإحداثيات. dkFitH وdkFitV يطابقان عرض الصفحة أو ارتفاعها بإحداثي واحد ذي صلة فقط (حافة علوية أو حافة يسرى). dkFitR هو النوع الأشد إثارة للاهتمام: فهو يطابق مستطيلًا محددًا، لذا تهم الحواف الأربع كلها. عائلة dkFitB* تفعل الشيء نفسه بالنسبة إلى صندوق إحاطة المحتوى المرئي بدلًا من الصفحة الكاملة. معرفة الحقول الحية لكل نوع هي الفارق بين قراءة الوجهة بشكل صحيح وطباعة إحداثيات فارغة تكون صفرًا بالصدفة

PDF reader bookmark navigation panel showing a nested outline tree
كل إشارة مرجعية في لوحة التنقل هذه تُحل إلى إجراء، وبالنسبة إلى القفزات الداخلية، إلى وجهة لها نوع fit وإحداثياتها الخاصة.

تحت الغطاء، يستند التنفيذ إلى نوع متعمد من الاصطفاف يستحق أن تعرفه لأنه يفسر لماذا تكون المطابقة موثوقة. الدالة الداخلية GetDestType تُرجع عددًا صحيحًا من 1 إلى 8 للأنواع الثمانية لـ fit بالترتيب XYZ/Fit/FitH/FitV/FitR/FitB/FitBH/FitBV تمامًا. TPDFlibDestinationKind مُعلنة بحيث تصطف القيم الترتيبية واحدًا لواحد: dkXYZ هو الترتيب 1، dkFitBV هو الترتيب 8، مع dkNone في الصفر. لذلك فإن التحويل هو إسناد ترتيبي مباشر مع حارس نطاق، وليس جدول بحث يمكن أن يخرج عن التزامن مع نمو التعداد. هذه تفصيلة صغيرة، لكنها من النوع الذي، إذا نُفذ بطريقة ساذجة، يتحول إلى خطأ واحد-لأحد عند أول إعادة ترتيب للتعداد

var
  Dest: TPDFlibDestinationInfo;
begin
  Dest := Lib.GetOutlineDestinationInfo(FoundID);
  if Dest.Page = 0 then
    Exit;  // destination did not resolve
  case Dest.Kind of
    dkXYZ:
      Writeln(Format('Page %d at (%.0f, %.0f), zoom %.2f',
        [Dest.Page, Dest.Left, Dest.Top, Dest.Zoom]));
    dkFitR:
      Writeln(Format('Page %d, rect L%.0f T%.0f R%.0f B%.0f',
        [Dest.Page, Dest.Left, Dest.Top, Dest.Right, Dest.Bottom]));
    dkFit, dkFitB:
      Writeln(Format('Page %d, fit whole page', [Dest.Page]));
  else
    Writeln(Format('Page %d, fit kind %d',
      [Dest.Page, Ord(Dest.Kind)]));
  end;
end;

قيمة Page تساوي صفرًا هي الإشارة إلى أن الوجهة لم تُحل، عادةً لأن الإجراء لا يحمل وجهة أو لأن الوجهة المسماة لم يمكن العثور عليها. افحصها قبل أن تثق بأي إحداثي. ولاحظ أيضًا أن GetOutlineDestinationInfo يبحث في المكانين اللذين قد تعيش فيهما الوجهة: مباشرةً على /Dest، وداخل GoTo مضمن داخل /D. لا تحتاج إلى معرفة أي صيغة استخدمها المنتج

إجراءات التعليقات التوضيحية وورطة SelectPage

التعليقات التوضيحية للروابط تحمل إجراءات تمامًا كما تفعل الإشارات المرجعية، وGetAnnotActionInfo يعيد السجل نفسه TPDFlibActionInfo مع النمط نفسه من النوع ثم الحمولة. لكن هناك هنا مأزقًا يعتمد على الحالة ولا ينطبق على المخططات، وهو الفخ الثالث

تنتمي التعليقات التوضيحية إلى الصفحات، ويعرض PDFlibPas تعليقات الصفحة الحالية عبر حالة لا تصبح صالحة إلا بعد أن تحدد تلك الصفحة. استدعِ GetAnnotActionInfo من دون أن تستدعي أولًا SelectPage(N)، ويكون معرّف التعليق التوضيحي صفرًا؛ عندها يعيد الاستدعاء akNone وتستنتج خطأً أن الصفحة لا تحتوي على أي تعليقات توضيحية قابلة للتنفيذ. الإصلاح سطر واحد، لكنه سهل النسيان عندما تدور داخل حلقة على الصفحات:

var
  P: Integer;
  Info: TPDFlibActionInfo;
begin
  for P := 1 to Lib.PageCount do
  begin
    Lib.SelectPage(P);   // mandatory before touching annotations
    // GetAnnotActionID(1) <> 0 is the reliable "has an action"
    // test. CheckPageAnnots returns a boolean-style flag, not a
    // count, so it is the weaker signal here.
    if Lib.GetAnnotActionID(1) <> 0 then
    begin
      Info := Lib.GetAnnotActionInfo(1);
      if Info.Kind = akURI then
        Writeln(Format('Page %d link -> %s', [P, Info.URI]));
    end;
  end;
end;

شيئان في تلك الحلقة متعمدان. أولًا، SelectPage(P) يسبق أي وصول إلى التعليقات التوضيحية في كل تكرار؛ حالة التعليقات لكل صفحة لا تنتقل إلى الصفحة التالية. ثانيًا، يستخدم اختبار الوجود GetAnnotActionID(1) <> 0 بدلًا من CheckPageAnnots. فالأخيرة تبلغ عن الوجود على شكل علم شبيه بالقيمة المنطقية لا على شكل عدّاد، لذا فإن معرّف إجراء غير صفري هو الطريقة الأدق لسؤال: هل هناك تعليق توضيحي أول، وهل يحمل إجراءً يمكنني قراءته؟ وتفصيلة أخرى تستحق الإشارة: بالنسبة إلى التعليقات التوضيحية، يُقرأ برنامج إجراء JavaScript من /JS مباشرةً، مع فك ترميز التدفق عندما يُخزَّن البرنامج بهذه الطريقة وقراءة سلسلة نصية في غير ذلك، بحيث يدعم كلا الترميزين الشائعين

أين يقع الاستقصاء من جهة القراءة

These getters are intentionally narrow. They are pure reads built on top of the library's existing integer-handle action and destination layers, so they touch no write path and add no risk to documents you are also editing. They report what is in the file; they do not validate it against a policy or rewrite anything. If your goal is the inverse, building bookmarks and link annotations that carry these actions in the first place, that lives on the write side, and the companion piece on interactive form actions and JavaScript in Delphi تستعرض كيفية إنشائها. ولإخراج المحتوى المرئي والبنيوي من PDF بدلًا من رسمه الملاحي، راجع استخراج النصوص والصور والخطوط باستخدام PDFlibPas

الحد الصادق الذي ينبغي تذكره: الاستقصاء لا يرى إلا ما كتبه المنتج فعلًا. إشارة مرجعية ترك منشئها إجراءها معيبًا، أو وجهة تشير إلى هدف مسمى لم يُعرّف قط، ستظهر على أنها akNone أو صفحة صفر بدلًا من استثناء. هذا هو السلوك الصحيح لواجهة قراءة تراجع ملفات غير موثوقة، لكنه يعني أن يعامل كودك تلك النتائج الصفرية على أنها "غائب أو غير محلول"، لا كضمان أن الإدخال سليم البنية. الاستقصاء المعرّف للإجراء والوجهة المعروض هنا هو جزء من PDFlibPas، مكتبة PDF الأصلية لـ Delphi وC++Builder