مقال تقني

البحث في نص PDF في Delphi مع إحداثيات النتائج: PDFlibPas

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

PDFlibPas هي مكتبة PDF أصلية بلغة Object Pascal لـ Delphi وC++Builder، ومنذ الإصدار v3.78.0 تجيب بالضبط عن هذا السؤال. ثلاث واجهات استعلام تجلس فوق مستخرج كتل النصوص الموجود أصلًا: SearchText تتنقل عبر نطاق صفحات وتعيد كل نتيجة مع صفحتها ومستطيلها المحاذي للمحاور، EnumPageElements تسرد كل ما على صفحة واحدة، سواء كتل النصوص أو الصور المضمنة، وGetTextInAreaEx تبلغ عن مستطيل كل كتلة داخل منطقة بدلًا من تسويتها إلى قائمة سلاسل. ولا تلمس أيًا منها مسار الكتابة؛ فهي إضافات خالصة من جهة القراءة فوق الآلية التي كانت المكتبة تملكها أصلًا

لماذا تعيش الهندسة في قائمة كتل النصوص لا في القمع

الحدس الطبيعي هو إعادة استخدام أي شيء ينفذه GetPageText داخليًا. ذلك المسار يمر عبر "قمع" استخراج مؤقت ينتج سلسلة الصفحة ثم يحرر نفسه قبل عودة الاستدعاء. وبحلول الوقت الذي تمسك فيه بالنتيجة، تكون إحداثيات الكتل قد اختفت. لم تكن يومًا ملكك لتحتفظ بها

تنجو الإحداثيات في بنية مختلفة.ExtractPageTextBlocks(3) يعيد مقبض قائمة كتل نصية تكون عناصرها، كل واحدة، رباعي احتواء من ثماني قيم Double، واسم خط، وحجم خط، ونص الكتلة. ذلك المقبض هو المكان الوحيد الذي تحتفظ فيه الهندسة بعد الاستخراج، ولهذا بُنيت عليه كل واجهات الاستعلام الجديدة بدلًا من القمع. إعادة استخدام قائمة الكتل تعني أن البحث، والتعداد، والاستعلامات النطاقية تشترك جميعًا في مرور استخراج واحد وتعريف واحد لموضع الكتلة

لذلك فإن شكل SearchText ينشأ من هذا القيد. لكل صفحة ضمن النطاق يستخرج قائمة الكتل، ويقرأ نص كل كتلة عبر GetTextBlockText، يختبره مقابل الاستعلام، وبالنسبة إلى الكتل المطابقة يختزل الرباعي إلى مستطيل. والنتيجة التي يعيدها سجل صغير:

type
  TPDFlibSearchHit = record
    Page: Integer;                       // 1-based page of the match
    Left, Top, Right, Bottom: Double;    // axis-aligned hit rectangle
    MatchText: WideString;               // the block text that contained the query
  end;

الفهرس المحيط X/Y متداخل، لا أربع زوايا

هذه هي التفصيلة التي تعض أولًا. GetTextBlockBound(ListID, Index, BoundIndex) يأخذ BoundIndex من 1 إلى 8، وهذه القيم الثمانية ليست "الزاوية 1، الزاوية 2، الزاوية 3، الزاوية 4" مع حقلين لكل منها كما قد تتوقع. إنها X, Y, X, Y, X, Y, X, Y: فالفهارس الفردية إحداثيات X، والفهارس الزوجية إحداثيات Y، أربع نقاط في المجموع. اقرأها مع pairing خاطئ وستصبح المستطيل هراءً

السبب في وجود رباعي أصلًا، بدلًا من مستطيل بسيط، هو الدوران. كتلة نص موضوعة بزاوية تملك مضلع احتواء حقيقيًا من أربع نقاط، والقيم الثماني تصفه بأمانة. أما لحالة التظليل والقفز فعادةً ما تريد صندوقًا مستقيمًا بدلًا من ذلك، لذا تقلّص المكتبة الرباعي إلى مستطيل محاذٍ للمحاور بتمشيط النقاط الأربع لاستخراج أصغر وأكبر X وY. النص المدوّر ينهار إلى الصندوق القائم الذي يحيطه، وهو بالضبط ما يحتاجه تراكب التظليل:

var
  Pdf: TPDFlib;
  Hits: array[0..255] of TPDFlibSearchHit;
  Found, I: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.LoadFromFile('contract.pdf', '');
    // Search pages 1 to 10, case-insensitive, substring match.
    Found := Pdf.SearchText('indemnity', [], '1-10', Hits);
    for I := 0 to Found - 1 do
      if I <= High(Hits) then
        WriteLn(Format('p%d: [%.1f %.1f %.1f %.1f] %s',
          [Hits[I].Page, Hits[I].Left, Hits[I].Top,
           Hits[I].Right, Hits[I].Bottom, Hits[I].MatchText]));
  finally
    Pdf.Free;
  end;
end;

لاحظ أن المستطيل في نقاط فضاء المستخدم الخاصة بـ PDF، مع الأصل في أسفل يسار الصفحة، وهو نفس نظام الإحداثيات الذي تمرره إلى استدعاءات الرسم والتعليقات التوضيحية. ذلك مقصود: فالمستطيل الذي تعيده نتيجة بحث هو المستطيل الذي يمكنك تمريره مباشرةً إلى تعليق تظليل توضيحي أو أمر "التنقل إلى هنا" من دون تحويل أي شيء

حساسية حالة الأحرف، والكلمات الكاملة، وأين يختلف CJK

المعامل الثاني هو مجموعة TPDFlibSearchOptions المستمدة من soCaseSensitive وsoWholeWord. المجموعة الفارغة [] هي الحالة الشائعة: بحث مقطع غير حساس لحالة الأحرف. أضف soCaseSensitive لجعل Indemnity وindemnity متميزين، وأضف soWholeWord لمنع sign من المطابقة داخل signature، أو اجمع الاثنين

تحتاج المطابقة على كلمة كاملة إلى تعريف لحد الكلمة، وهنا يجدر ذكر القاعدة بوضوح لأنها متمحورة حول ASCII تصميمًا. يُحسب الحرف جزءًا من كلمة عندما يكون حرف ASCII أو رقم ASCII أو شرطة سفلية: الفئة [A-Za-z0-9_] المألوفة من قواعد المعرّفات. وتُعد المطابقة كلمةً كاملة فقط عندما تكون المحارف مباشرة قبلها وبعدها ليست كلمة (أو تقع المطابقة عند حافة الكتلة)

العاقبة بالنسبة إلى النصوص غير اللاتينية شيء ينبغي معرفته قبل أن تشحن صندوق بحث متعدد اللغات. لأن محارف Han وkana والحروف الأخرى غير ASCII تقع خارج تلك الفئة، فإن كل حد بجانبها يُقرأ باعتباره حدًا غير كلمي. عمليًا يعني ذلك أن البحث على كلمة كاملة في نص CJK يتصرف كما لو أن كل موضع حد كلمة صالح، لذلك يتدهور هذا العلم فعليًا إلى بحث مقطع هناك. هذا قيد موثق، لا خطأ، وهو يطابق السلوك الذي صُممت الميزة على أساسه. إذا كان corpus لديك في الأساس CJK، فلن يمنحك نمط الكلمة الكاملة التقسيم الذي يوفره tokenizer مخصص؛ خطط لذلك بدلًا من الاعتماد عليه

ملاحظة تنفيذية هامشية تفسر فئة من الإخفاقات الدقيقة في مواضع أخرى: المقارنة غير الحساسة لحالة الأحرف تستخدم UpperCase على WideString، لا AnsiUpperCase. يعيد متغير Ansi AnsiString، وهو ما لن ينسجم مع WideString الذي يستخدمه بقية المسار، وخلط الاثنين ينتج تعارضات في الأنواع، والأسوأ من ذلك طيًا خاسرًا للمحارف خارج صفحة الرموز النشطة. Unicode in, Unicode out، في كل الطريق

محلل نطاق صفحات واحد للمكتبة كلها

المعامل الثالث هو سلسلة نطاق صفحات مثل "1,3,5-9". لا شيء مخصص في طريقة تحليلها: نفس PLParsePageRangeList الذي يدعم PrintPages وروتينات نسخ الصفحات يتولى الأمر هنا أيضًا، لذا فإن نطاقًا يطبع بشكل صحيح يبحث بشكل صحيح. سلسلة النطاق الفارغة هي الرمز لكل صفحة، وفي تلك الحالة SearchText يبني القائمة الكاملة بنفسه

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

داخليًا، يغير كل من البحث والتعداد الصفحة المحددة أثناء التكرار، لذلك يحفظ كل واحد الصفحة المحددة للمتصل عند الدخول ويعيدها في كتلة finally. استدعِ SearchText في منتصف بناء صفحة وستجد أن التحديد عاد بالضبط إلى المكان الذي تركته فيه عندما يعود الاستدعاء. ذلك العقد الخاص بالحفظ والاستعادة هو من الأمور التي لا تلاحظها إلا عندما تكون مفقودة، ولهذا السبب تحديدًا هي موجودة

تعداد صفحة كاملة: النصوص والصور في قائمة واحدة

يجيب البحث عن سؤال "أين تقع هذه الكلمة". أما النصف الآخر من الاستقصاء فهو "ما الموجود على هذه الصفحة أصلًا"، وذلك هو EnumPageElements. وهي تعيد قائمة موحدة يكون كل عنصر فيها إما كتلة نصية أو صورة مضمنة، ويُميز بينها حقل Kind:

type
  TPDFlibPageElementKind = (ekText, ekImage);

  TPDFlibPageElement = record
    Kind: TPDFlibPageElementKind;
    Page: Integer;
    Left, Top, Right, Bottom: Double;
    Text: WideString;        // ekText
    FontName: WideString;    // ekText
    FontSize: Double;        // ekText
    ImageID: Integer;        // ekImage; usable with SelectImage / GetImageID
  end;

تأتي العناصر النصية من المرور نفسه الخاص بـ ExtractPageTextBlocks، لذا يصل كل عنصر مع مستطيله واسم خطه وحجمه مكتملة مسبقًا. وتأتي عناصر الصور من قائمة الصور المضمنة في الصفحة عبر FindImages وGetImageID؛ وImageID الذي تحملُه هو المعرّف الذي تمرره إلى SelectImage لفحص الصورة لاحقًا. يهبط النوعان في مصفوفة واحدة بحيث ترى جولة واحدة على الصفحة كل ما فيها

var
  Pdf: TPDFlib;
  Elems: array[0..511] of TPDFlibPageElement;
  Total, I: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.LoadFromFile('report.pdf', '');
    Total := Pdf.EnumPageElements(1, Elems);
    for I := 0 to Total - 1 do
      if I <= High(Elems) then
        if Elems[I].Kind = ekText then
          WriteLn(Format('text  %s/%.1f  "%s"',
            [Elems[I].FontName, Elems[I].FontSize, Elems[I].Text]))
        else
          WriteLn(Format('image id=%d', [Elems[I].ImageID]));
  finally
    Pdf.Free;
  end;
end;

هنا اصطلاح عدٍّ يتبع بقية المكتبة وعليك احترامه وإلا قرأت ذاكرة غير مهيأة. قيمة الإرجاع هي الإجمالي لعدد العناصر، وقد تكون أكبر من المصفوفة التي مررتها. لا يملأ الإجراء إلا عدد الخانات الذي يتسع له ويواصل عد الباقي، تمامًا كما يعمل تعداد التواقيع. لذا فالحارس دائمًا هو نفسه: اقصر الحلقة على الأصغر بين العدد المعاد وHigh(array)، ولا تتخطَّ العدد بشكل أعمى. الأمثلة أعلاه تُظهر فحص I <= High(...) لهذا السبب. إذا تجاوزت القيمة المعادة مخزنَك، فخصص مصفوفة أكبر واستدعِه مرة أخرى

إذا كنت قد استخدمت استدعاءات كتل النص منخفضة المستوى في المكتبة، فهذه هي الطبقة المعرّفة الواعية بالهندسة فوقها؛ والاستخراج الأساسي هو نفسه الموصوف في استخراج نصوص وصور وخطوط PDF في Delphi باستخدام PDFlibPas. وعندما لا يكون الهدف "أين يقع هذا النص" بل "كيف تُبنى هذه الوثيقة لتكنولوجيا المساعدة"، فالقصة المواكبة من جهة القراءة هي شجرة بنية tagged-PDF، التي تكشف ترتيب القراءة المنطقي بدلًا من تخطيط الكتل المادي

استعلامات المنطقة عندما تعرف مسبقًا أين تنظر

أحيانًا لا يكون لديك أي مصطلح بحث أصلًا؛ بل يكون لديك مستطيل. قالب نموذج يضع رقم الفاتورة دائمًا في الزاوية العلوية اليمنى، أو تخطيط ممسوح ضوئيًا يحجز شريطًا ثابتًا لجدول. GetTextInAreaEx يخدم هذه الحالة. وهو نظير حمل الحدود لـ GetTextInArea: حيث يعيد الاستدعاء الأقدم قائمة مسطحة من السلاسل لمنطقة، يعيد الجديد مستطيل كل كتلة محفوظة مع نصها، فتتعلم ليس فقط ما الموجود في الصندوق بل أين تجلس كل سطر داخله

var
  Pdf: TPDFlib;
  Hits: array[0..63] of TPDFlibSearchHit;
  Found, I: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.LoadFromFile('invoice.pdf', '');
    Pdf.SelectPage(1);
    // Left, Top, Width, Height in PDF points on the selected page.
    Found := Pdf.GetTextInAreaEx(360, 720, 180, 60, Hits);
    for I := 0 to Found - 1 do
      if I <= High(Hits) then
        WriteLn(Hits[I].MatchText);
  finally
    Pdf.Free;
  end;
end;

شيئان يجب تثبيتهما في الذهن. GetTextInAreaEx يعمل على الصفحة المحددة حاليًا، لذا استدعِ SelectPage أولًا؛ وعلى عكس SearchText، فهو لا يأخذ نطاقًا. وتبقى الكتلة عندما يتقاطع مع المستطيل المطلوب، لا فقط عندما يكون محتواة بالكامل، لذلك فإن سطرًا يعبر الحدود يمر أيضًا. وهذا عادةً ما تريده لصندوق تحديد مرسوم يدويًا، لكن إذا كنت تحتاج إلى احتواء صارم فيمكنك ترشيح المستطيلات المعادة بنفسك، لأنك أصبحت تملكها الآن

تطبيق ذلك عمليًا

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

تشحن واجهات الاستعلام هذه بوصفها جزءًا من PDFlibPas Delphi PDF Library، إلى جانب طبقة استخراج كتل النصوص الكاملة التي بُنيت عليها وبقية سطح الاستقصاء من جهة القراءة لـ Delphi وC++Builder