مقاله فنی

جست‌وجوی متن PDF در Delphi با مختصات Hit: PDFlibPas

بیرون کشیدن متن یک page نیمهٔ آسان مسئله است. لحظه‌ای که کاربر یک واژه را در کادر جست‌وجو تایپ می‌کند و انتظار دارد viewer به آن بپرد و یک کادر زرد دورش بکشد، به چیزی نیاز دارید که string تخت نمی‌تواند بدهد: pageای که هر match روی آن نشسته، و rectangleای که در مختصات PDF اشغال کرده است. یک string که روی چند page concatenate شده باشد آن geometry را از دست داده است. substring را پیدا می‌کنید، اما نمی‌توانید با انگشت به آن اشاره کنید

PDFlibPas یک کتابخانهٔ بومی Object Pascal PDF برای Delphi و C++Builder است، و از v3.78.0 دقیقاً همین سؤال را پاسخ می‌دهد. سه API پرس‌وجو روی extractor موجودِ text-block سوار می‌شوند: SearchText یک page range را walk می‌کند و هر hit را با page و rectangle هم‌راستا با محورهایش برمی‌گرداند، EnumPageElements همه‌چیز را روی یک page فهرست می‌کند (هم text blockها و هم imageهای embedded)، و GetTextInAreaEx rectangle هر block را درون یک region گزارش می‌کند به‌جای این‌که آن‌ها را به یک string list تخت کند. هیچ‌کدام مسیر write را لمس نمی‌کنند؛ آن‌ها افزونه‌های خالصِ سمت read روی machineryای هستند که library از قبل داشت

چرا geometry در فهرست text-block زندگی می‌کند، نه در funnel

غریزهٔ طبیعی این است که هر چه را GetPageText درونی انجام می‌دهد دوباره‌استفاده کنیم. آن path از یک funnel موقتِ extraction می‌گذرد که string page را تولید می‌کند و سپس قبل از بازگشت call خود را آزاد می‌کند. وقتی نتیجه را در دست دارید، coordinateهای هر block از بین رفته‌اند. آن‌ها هیچ‌وقت برای نگه‌داشتن شما نبودند

مختصات در یک structure دیگر زنده می‌مانند. ExtractPageTextBlocks(3) یک text-block list handle برمی‌گرداند که هر item آن یک bounding quad هشت‌دابل، یک font name، یک font size، و متن block را حمل می‌کند. آن handle تنها جایی است که geometry بعد از extraction حفظ می‌شود، و به همین دلیل تمام APIهای پرس‌وجوی جدید روی آن ساخته شده‌اند نه روی funnel. استفادهٔ دوباره از block list یعنی search، enumeration، و region queryها همگی یک pass extraction و یک تعریف از این‌که block کجاست را share می‌کنند

پس شکلِ SearchText از این قید بیرون می‌آید. برای هر page در range، block list را استخراج می‌کند، متن هر block را با GetTextBlockText می‌خواند، آن را در برابر query می‌سنجد، و برای blockهایی که match می‌شوند quad را به rectangle کاهش می‌دهد. hitی که برمی‌گرداند یک record کوچک است:

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 می‌گیرد، و آن هشت value «گوشهٔ 1، گوشهٔ 2، گوشهٔ 3، گوشهٔ 4» نیستند با دو field که مثل چیزی که شاید حدس بزنید کنار هم گروه شده باشند. آن‌ها X, Y, X, Y, X, Y, X, Y هستند: indexهای فرد X coordinate هستند، indexهای زوج Y coordinate، در مجموع چهار point. آن‌ها را با pairing اشتباه بخوانید و rectangle شما بی‌معنی می‌شود

دلیل این‌که اصلاً quad داریم، نه یک rectangle ساده، rotation است. یک text block که با زاویه تنظیم شده باشد یک polygon مرزیِ چهارpointی واقعی دارد، و آن هشت double آن را صادقانه توصیف می‌کنند. برای use caseِ highlight-and-jump تقریباً همیشه یک box قائم می‌خواهید، پس library quad را با sweeping چهار point برای minimum و maximum X و Y به یک rectangle هم‌راستا با محورهای صفحه کاهش می‌دهد. متن چرخیده به box قائمِ دربرگیرندهٔ خودش فرو می‌ریزد، که همان چیزی است که یک highlight overlay نیاز دارد:

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;

توجه کنید که rectangle در PDF user-space pointهاست با مبدأ در پایین-چپ page، همان coordinate systemی که برای drawing و annotation callها می‌دهید. این عمدی است: rectangleی که از یک hit جست‌وجو پس می‌گیرید همان rectangleی است که می‌توانید مستقیم به یک highlight annotation یا یک دستور «اینجا اسکرول کن» بدهید، بدون این‌که چیزی را تبدیل کنید

حساسیت به حروف، whole wordها، و جایی که CJK فرق می‌کند

پارامتر دوم یک TPDFlibSearchOptions set است که از soCaseSensitive و soWholeWord ساخته شده است. set خالی [] حالت رایج است: یک substring search بدون حساسیت به حروف. soCaseSensitive را اضافه کنید تا Indemnity و indemnity متمایز شوند، soWholeWord را اضافه کنید تا sign از match شدن درون signature بازایستد، یا هر دو را ترکیب کنید

تطبیق whole-word به یک تعریف از مرز word نیاز دارد، و این‌جا rule شایسته است که صریح گفته شود چون عمداً ASCII-centric است. یک character وقتی بخشی از یک word حساب می‌شود که یک ASCII letter، یک ASCII digit، یا یک underscore باشد: همان [A-Za-z0-9_] آشنایی که از ruleهای identifier می‌شناسید. یک match فقط وقتی whole-word محسوب می‌شود که characterهای بلافاصله قبل و بعد از آن نباشندword character نباشند (یا match روی لبهٔ block قرار گرفته باشد)

پیامد این موضوع برای scriptهای غیرلاتین چیزی است که باید قبل از ship کردن یک کادر جست‌وجوی چندزبانه بدانید. چون characterهای Han، kana، و دیگر letterهای غیرASCII بیرون از آن class قرار می‌گیرند، هر boundary کنار آن‌ها به‌صورت یک edgeِ غیرword خوانده می‌شود. در عمل این یعنی whole-word search روی متن CJK طوری رفتار می‌کند که انگار هر موقعیتی یک مرز word معتبر است، پس flag آن‌جا عملاً به substring matching تنزل می‌کند. این یک limitation مستندشده است، نه یک bug، و با رفتاری که این feature بر اساس آن model شده است هم‌خوانی دارد. اگر corpus شما عمدتاً CJK است، whole-word mode segmentationی را که یک tokenizer اختصاصی می‌دهد فراهم نمی‌کند؛ به‌جای تکیه بر آن، از قبل برایش برنامه‌ریزی کنید

یک footnote پیاده‌سازی که یک class از failureهای ظریف در جاهای دیگر را توضیح می‌دهد: comparison بدون حساسیت به حروف از UpperCase روی WideString استفاده می‌کند، نه AnsiUpperCase. variantِ Ansi یک AnsiString برمی‌گرداند که با WideStringای که بقیهٔ path استفاده می‌کند هم‌راستا نمی‌شود، و mix کردن این دو type mismatch ایجاد می‌کند و بدتر از آن، foldingِ lossy برای characterهای بیرون از code page فعال را به‌همراه دارد. Unicode وارد، Unicode خارج، تا انتها

یک parser واحد برای page range در کل library

پارامتر سوم یک رشتهٔ page range مثل "1,3,5-9" است. هیچ چیز customی در نحوهٔ parse شدن آن وجود ندارد: همان PLParsePageRangeList که پشت PrintPages و routineهای page-copy است اینجا هم آن را handle می‌کند، پس هر rangeای که درست print می‌شود درست هم search می‌شود. یک رشتهٔ page range خالی sentinelِ «هر page» است، که در آن حالت SearchText خود full list را می‌سازد

Scope برای cost مهم است. search در یک slice ده‌صفحه‌ای از یک document هزارصفحه‌ای blockها را فقط برای ده page استخراج می‌کند، نه هزار page، چون loop فقط pageهایی را انتخاب و استخراج می‌کند که range نام برده است. وقتی از قبل می‌دانید یک clause در appendix زندگی می‌کند، همان را در range بگویید و بقیهٔ file را رد کنید

درون‌ساز، search و enumeration هر دو هنگام iterate کردن page انتخاب‌شده را عوض می‌کنند، پس هر کدام page انتخاب‌شدهٔ caller را در ورود ذخیره می‌کنند و آن را در یک finally block برمی‌گردانند. SearchText را وسط ساختن یک page صدا بزنید و انتخاب شما دقیقاً همان‌جاست که قبل از call بوده است وقتی call برمی‌گردد. این قرارداد save-and-restore از آن چیزهایی است که فقط وقتی نبودنش را می‌فهمید، متوجهش می‌شوید، و دقیقاً به همین دلیل وجود دارد

فهرست کردن کل یک page: text و image در یک list

search به سؤال «این واژه کجاست» پاسخ می‌دهد. نیمهٔ دیگر introspection این است که «اصلاً روی این page چه چیزهایی هست» و آن EnumPageElements است. این call یک list واحد برمی‌گرداند که هر element آن یا یک text block است یا یک image embedded، و با یک Kind field از هم تشخیص داده می‌شوند:

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;

elementهای text از همان passِ ExtractPageTextBlocks می‌آیند، پس هر کدام از آن‌ها rectangle، font name و size خود را از قبل پرشده همراه دارند. elementهای image از فهرست imageهای embedded page از طریق FindImages و GetImageID می‌آیند؛ ImageIDای که حمل می‌کنند همان handleی است که به SelectImage می‌دهید تا image را بیشتر inspect کنید. این دو kind در یک array می‌نشینند تا یک walk واحد روی page همه‌چیز را ببیند

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;

در این‌جا یک convention شمارشی وجود دارد که از بقیهٔ library پیروی می‌کند و باید رعایتش کنید وگرنه memory مقداردهی‌نشده را می‌خوانید. return value total count element است، که می‌تواند از arrayی که پاس داده‌اید بزرگ‌تر باشد. function فقط به‌اندازه‌ای که slotها جا می‌دهند آن‌ها را پر می‌کند و باقی را همچنان می‌شمارد، دقیقاً همان‌طور که signature enumeration کار می‌کند. پس guard همیشه همان است: loop را به کوچک‌ترِ count برگردانده‌شده و High(array) محدود کنید، و هرگز به‌صورت blind تا count iterate نکنید. مثال‌های بالا از همین رو I <= High(...) check استفاده می‌کنند. اگر return value از buffer شما بیشتر شد، یک array بزرگ‌تر اندازه بزنید و دوباره call کنید

اگر از callهای text-block سطح پایین‌تر library استفاده کرده‌اید، این لایهٔ typed و geometry-aware روی آن‌هاست؛ extraction زیربنایی همان چیزی است که در جست‌وجوی text، image و font در PDF با PDFlibPas توضیح داده شده است. و وقتی هدف «این text کجاست» نیست بلکه «این document برای assistive technology چگونه ساختار یافته است» است، داستان read-side موازی tagged-PDF structure tree است، که ترتیب خواندن منطقی را به‌جای layout فیزیکی blockها expose می‌کند

پرس‌وجوهای region وقتی از قبل می‌دانید کجا باید نگاه کنید

گاهی اصلاً term جست‌وجو ندارید؛ یک rectangle دارید. یک form template همیشه شمارهٔ invoice را در گوشهٔ بالا-راست می‌گذارد، یا یک layout اسکن‌شده یک band ثابت برای یک table کنار گذاشته است. GetTextInAreaEx این case را پوشش می‌دهد. این همتای bounds-carryingِ GetTextInArea است: جایی که call قدیمی‌تر یک list تخت از stringها برای یک region برمی‌گرداند، این یکی rectangle هر blockِ نگه‌داشته‌شده را کنار متنش برمی‌گرداند، پس شما فقط نمی‌فهمید چه چیزی داخل box است، بلکه می‌فهمید هر line کجای آن نشسته است

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 روی page انتخاب‌شدهٔ فعلی کار می‌کند، پس ابتدا SelectPage را صدا بزنید؛ بر خلاف SearchText، range نمی‌گیرد. و یک block زمانی نگه داشته می‌شود که با rectangle جست‌وجو تلاقی کند، نه فقط وقتی که کاملاً داخل آن باشد، پس یک line که مرز را قطع می‌کند همچنان عبور می‌کند. این معمولاً همان چیزی است که برای یک selection box دستی می‌خواهید، اما اگر containment سخت‌گیرانه لازم دارید، می‌توانید rectangleهای برگردانده‌شده را خودتان filter کنید، چون حالا آن‌ها را در اختیار دارید

به‌کار بستن آن

نخ اصلی در هر سه call این است که geometry دیگر چیزی نیست که بعد از وقوع دوباره بسازید. یک search hit page و box خودش را می‌شناسد. یک page element rectangle خودش را و، برای text، font خودش را می‌شناسد. یک region query می‌گوید هر line کجا می‌افتد. این برای ساخت یک feature واقعی find-and-highlight، یک index قابل کلیک، یا یک extractor آگاه از layout کافی است، بدون این‌که زیر public API بروید یا pipeline استخراج text را با دست از نو بسازید

این APIهای query به‌عنوان بخشی از PDFlibPas Delphi PDF Library ship می‌شوند، در کنار کل لایهٔ text-block extraction که روی آن ساخته شده‌اند و بقیهٔ read-side introspection surface برای Delphi و C++Builder