مقاله فنی

استخراج متن از یک PDF بارگذاری‌شده در دلفی با HotPDF

کامپوننت HotPDF متن یونیکد را از هر PDF بارگذاری‌شده در دلفی از طریق دو فراخوانی استخراج می‌کند: متد ExtractLoadedPageText متن روان صفحه را برمی‌گرداند، و ExtractLoadedPageTextLayout (اضافه شده در نسخه v2.263.0) آرایش بصری صفحه را به عنوان متن ساده بازسازی می‌کند، به طوری که ستون‌ها، تورفتگی‌ها و تراز جدول در خروجی حفظ می‌شوند. هر دو متد روی اسنادی کار می‌کنند که با HotPDF ایجاد نشده‌اند، یعنی همان حالتی که واقعاً اهمیت دارد: فاکتوری که مشتری برای شما ایمیل کرده، گزارشی که یک دفتر اسکن تحویل داده یا قراردادی که توسط نرم‌افزاری تولید شده که دیگر کسی نامش را به خاطر نمی‌آورد

رسیدن به این نقطه به سازوکارهای بیشتری نسبت به آنچه از ظاهر این دو متد به نظر می‌رسد نیاز داشت، زیرا یک PDF متن را مانند یک فایل متنی ذخیره نمی‌کند. این مقاله هر دو حالت استخراج را بررسی کرده و سپس سه بخش زیرین آن را تشریح می‌کند — خواننده CMap، مفسر جریان محتوا و زنجیره پشتیبان رمزگشایی فونت — زیرا دانستن نحوه کارکرد این نقشه‌نگاری تفاوت بین شانه خالی کردن در برابر خروجی‌های نامفهوم و عیب‌یابی دقیق آن است

چرا استخراج متن سخت‌تر از خواندن رشته‌ها از فایل است؟

یک جریان محتوای PDF کدهای کاراکتر را ثبت می‌کند، نه خود کاراکترها را. عملگرهای Tj و TJ (استاندارد ISO 32000-1 §9.4.3) رشته‌هایی از بایت‌ها را حمل می‌کنند که معنای آن‌ها کاملاً به فونت انتخاب‌شده توسط Tf قبلی بستگی دارد: برای مثال بایت 0x41 ممکن است حرف A تحت WinAnsi باشد، یا یک گلیف دلخواه در یک فونت زیرمجموعه، یا نیمی از یک CID دو بایتی در یک فونت ترکیبی CJK. استاندارد ISO 32000-1 §9.10 استخراج متن را دقیقاً به عنوان همین مسئله رمزگشایی تعریف می‌کند — نگاشت هر کد به یونیکد با استفاده از هر اطلاعاتی که دیکشنری فونت ارائه می‌دهد — و استاندارد صراحتاً اعلام می‌کند که یک فایل منطبق ملزم به ارائه اطلاعات کافی برای انجام این کار نیست

این بند آخر، دلیل هر گزارش باگی مانند "چرا کپی-پیست از این PDF متنی نامفهوم تولید می‌کند" را که تا به حال دیده‌اید، توضیح می‌دهد. تولیدکننده‌ای که یک فونت زیرمجموعه بدون جدول /ToUnicode را جاسازی می‌کند، فایلی نوشته است که به خوبی رندر می‌شود اما استخراج آن بی‌معنی است، زیرا نگاشت کد-به-گلیف وجود دارد اما نگاشت کد-به-یونیکد هرگز ارسال نشده است. بنابراین، هر API استخراج صادقانه‌ای، زنجیره‌ای از راه‌حل‌های پشتیبان با حداکثر تلاش (best-effort) است و سوال مفید این است که این زنجیره تا چه حد عمیق است

استخراج روان متن با ExtractLoadedPageText

برای نمایه‌سازی جستجو، تطبیق کلمات کلیدی، یا تغذیه متن به یک خط لوله تحلیل، متد ExtractLoadedPageText همان چیزی است که می‌خواهید. تعریف آن به صورت function ExtractLoadedPageText(PageIndex: Integer; out AText: UnicodeString): boolean است — اندیس صفحات از صفر شروع می‌شوند، نتیجه به صورت یک UnicodeString بومی دلفی بازگردانده می‌شود و در صورتی که صفحه فاقد جریان محتوای قابل خواندن باشد، تابع به جای بروز خطا، مقدار False را برمی‌گرداند

var
  Pdf: THotPDF;
  PageCount, I: Integer;
  PageText, AllText: UnicodeString;
begin
  Pdf := THotPDF.Create(nil);
  try
    PageCount := Pdf.LoadFromFile('invoice.pdf');
    AllText := '';
    for I := 0 to PageCount - 1 do
      if Pdf.ExtractLoadedPageText(I, PageText) then
        AllText := AllText + PageText + #13#10;
    // اکنون AllText حاوی متن روان سند است
  finally
    Pdf.Free;
  end;
end;

شکست‌های خط در خروجی حاصل از یک اکتشاف ساده عمدی است: هنگامی که مبدا عمودی یک گلیف به اندازه بیش از نصف اندازه فونت فعلی جابجا شود — که نشانه گام‌های Td یا T* در جریان محتوا است — یک خط جدید درج می‌شود. کاراکترهایی که رمزگشا نمی‌تواند آن‌ها را حل کند، به جای ناپدید شدن به فاصله (space) تبدیل می‌شوند، بنابراین مرزهای کلمات حتی در صورت عدم شناسایی گلیف‌های فردی حفظ می‌شوند. آنچه این حالت تلاشی برای آن نمی‌کند، خوشه‌بندی ترتیب خواندن یا تشخیص چندستونی است: یک صفحه دو ستونی به ترتیب جریان محتوا خارج می‌شود که معمولاً، اما نه همیشه، همان ترتیب بصری است

چه زمانی باید به جای آن از استخراج با حفظ طرح‌بندی استفاده کنید؟

متد ExtractLoadedPageTextLayout انتخاب درستی است برای مواقعی که موقعیت معنای خاصی دارد: جداول، فرم‌ها، لیست‌های کد، هر چیزی که قصد دارید مقایسه (diff) کنید، جستجو (grep) کنید یا بر اساس ستون تجزیه نمایید. این متد به جای مسطح کردن گلیف‌ها در یک جریان، آن‌ها را در خطوط مبنا (baseline) خوشه‌بندی می‌کند، هر خط مبنا را بر اساس X مرتب می‌نماید و فضاهای خالی افقی و عمودی را روی یک شبکه کاراکتر تک‌فاصله (monospaced) که اندازه آن بر اساس میانگین پیشروی گلیف و اندازه فونت مشخص شده، بازسازی می‌کند. فاصله‌های زیاد بین متون در یک خط مبنا به فضاهای خالی تبدیل می‌شوند و فاصله‌های بزرگ بین خطوط مبنا به خطوط خالی تبدیل می‌گردند. در نتیجه، خروجی شبیه به خود صفحه خوانده می‌شود

var
  Grid: UnicodeString;
begin
  if Pdf.ExtractLoadedPageTextLayout(0, Grid) then
    TFile.WriteAllText('page1.txt', Grid, TEncoding.UTF8);
  // ستون‌ها، تورفتگی‌ها و تراز جدول به عنوان
  // فضاهای خالی و خطوط خالی در یک شبکه کاراکتر حفظ می‌شوند
end;

این دو حالت در تمام بایت‌های سازوکار رمزگشایی مشترک هستند و تفاوت آن‌ها فقط در نحوه چیدمان گلیف‌های رمزگشایی شده است، بنابراین این انتخاب تاثیری بر دقت کار ندارد. زمانی که فقط خود کلمات مهم هستند متد ExtractLoadedPageText و زمانی که چیدمان آن‌ها اهمیت دارد متد ExtractLoadedPageTextLayout را انتخاب کنید. تشخیص ترتیب خواندن چندستونی خارج از حوزه هر دو متد است — رندر شبکه‌ای یک صفحه دو ستونی، هر دو ستون را در کنار هم به صورت وفادارانه نشان می‌دهد که این موضوع برای مقایسه (diff) کاملاً درست و برای بازخوانی روان متن مناسب نیست

چگونه HotPDF کدهای کاراکتر را به یونیکد رمزگشایی می‌کند؟

کامپوننت HotPDF هر کد کاراکتر را از طریق یک زنجیره پشتیبان با اولویت مرتب‌شده حل می‌کند: ابتدا CMap جدول /ToUnicode جاسازی‌شده فونت، سپس ورودی /Encoding (جریان یا CMap نام‌گذاری‌شده)، سپس — برای فونت‌های ترکیبی — فایل‌های استاندارد CMap ادوبی برای مجموعه‌های کاراکتر مانند Adobe-GB1، Adobe-CNS1، Adobe-Japan1 و Adobe-KR، و در نهایت جداول داخلی WinAnsi و MacRoman برای فونت‌های ساده. هر استراتژی که نتواند پاسخی ارائه دهد، بدون نمایش خطا به سراغ استراتژی بعدی می‌رود و کدی که کل زنجیره را طی کند و به نتیجه نرسد به مقدار 0 حل می‌شود تا فراخوان بتواند موارد ناموفق را شمارش کند

جدول /ToUnicode CMap (استاندارد ISO 32000-1 §9.10.3) در اولویت اول قرار دارد زیرا این همان نگاشتی است که تولیدکننده مخصوص استخراج نوشته است. مسیر استاندارد CMap ادوبی برای اسناد CJK که از CMaps از پیش تعریف‌شده مانند UniGB-UTF16-H به جای جاسازی استفاده می‌کنند اهمیت دارد: HotPDF فایل‌های این مجموعه را در دایرکتوری resources\CMap خود ارائه می‌دهد، در زمان اجرا آن‌ها را نسبت به فایل اجرایی مکان‌یابی می‌کند و هر نقشه تجزیه‌شده را برای هر فرآیند کش می‌کند — دانستن این نکته مفید است زیرا بزرگترین آن‌ها، نقشه Adobe-GB1، تقریباً 2 مگابایت متن منبع است که نمی‌خواهید آن را برای هر صفحه دوباره تجزیه کنید. اگر این دایرکتوری وجود نداشته باشد، رمزگشا به سادگی از CMaps متکی به دیسک عبور کرده و با جداول جاسازی‌شده به علاوه رمزگذاری‌های داخلی کار می‌کند. این تصویر معکوس مشکل شکل‌دهی متن در سمت خواندن است که در مقاله شکل‌دهی متون با خطوط پیچیده با HotPDF پوشش داده شده است، جایی که در زمان نوشتن با همان تمایز کد در مقابل گلیف مواجه می‌شویم

دو تله نحوی CMap که ارزش دانستن دارند

فایل‌های CMap در ظاهر ساده به نظر می‌رسند اما اینطور نیستند، و دو جزئیات عامل بیشتر شکست‌ها در اولین تلاش برای ساخت تجزیه‌کننده (parser) هستند. اول اینکه تعداد رکوردها قبل از کلمه کلیدی بخش قرار می‌گیرد: یک بخش به صورت 2 beginbfchar، نه beginbfchar 2. تجزیه‌کننده‌ای که انتظار دارد شمارش بعد از کلمه کلیدی باشد، عدد را به عنوان یک نشانه (token) سرگردان مصرف می‌کند و سپس صفر ورودی در هر بخش پیدا می‌نماید. رویکرد قوی — رویکردی که خواننده HotPDF بر آن متکی است — این است که شمارش را کاملاً نادیده گرفته و تا زمانی که کلمه کلیدی متناظر endbfchar / endbfrange پیدا شود، حلقه را ادامه دهد، که این امر مزیت تحمل فایل‌های واقعی که شمارش آن‌ها اشتباه است را به همراه دارد

تله دوم این است که اهداف bfchar و bfrange رشته‌های UTF-16BE هستند، نه integers. مقصد <D83DDE00> به معنای U+1F600 است — یک جفت جانشین (surrogate pair) که باید در یک نقطه کد ترکیب شوند — و خواندن آن چهار بایت به عنوان یک عدد صحیح بیگ‌اندین (big-endian) مقداری بی‌معنی را برای هر نقطه کد خارج از Basic Multilingual Plane تولید می‌کند. ایموجی‌ها در PDFها دیگر عجیب و غریب نیستند، بنابراین رمزگشایی که از ترکیب جفت‌های جانشین چشم‌پوشی کند، در فایل‌هایی که کاربران شما واقعاً دارند با شکست مواجه می‌شود. HotPDF ابتدا لیترال هگز را به بایت‌های خام تجزیه می‌کند، سپس واحدهای کد UTF-16BE را دوباره ترکیب می‌نماید، که این امر شامل اهداف چند کاراکتری تولید شده توسط نگاشت‌های لیگاتور نیز می‌شود

رسیدن به سطح گلیف با ExtractLoadedPageGlyphs

هر دو فراخوانی متنی بر روی متد ExtractLoadedPageGlyphs ساخته شده‌اند و آرایه پایه THPDFGlyphArray برای کد شما نیز در دسترس است. هر THPDFGlyphRecord نقطه کد یونیکد حل‌شده را در کنار کد کاراکتر خام، عرض بایت کد (1، 2، یا 4 که توسط codespacerange جدول CMap تعیین می‌شود)، کلید و اندازه منبع فونت فعال، مبدا X و Y در فضای کاربر و پیشروی افقی را حمل می‌کند. این اطلاعات برای ساخت تشخیص مرز کلمات، هایلایت موقعیت‌یافته یا یک الگوریتم طرح‌بندی سفارشی بدون دستکاری جریان محتوا کافی است

var
  Glyphs: THPDFGlyphArray;
  I, Unresolved: Integer;
begin
  if Pdf.ExtractLoadedPageGlyphs(0, Glyphs) then
  begin
    Unresolved := 0;
    for I := 0 to High(Glyphs) do
      if Glyphs[I].Unicode = 0 then
        Inc(Unresolved);
    if Unresolved > 0 then
      ShowMessageFmt('%d of %d glyphs have no Unicode mapping',
        [Unresolved, Length(Glyphs)]);
  end;
end;

شمارش رکوردهای Unicode = 0 به صورت بالا، روشی صادقانه برای سنجش کیفیت استخراج در یک سند قبل از اعتماد به متن خروجی است. رکوردهای گلیف همچنین هر کاراکتر را به عملوند منبع در جریان محتوا متصل می‌کنند، که این امر امکان جستجو و جایگزینی متن در سند بارگذاری‌شده را در HotPDF بر روی همین پایه فراهم می‌سازد

کدام PDFها متن خود را ارائه نمی‌دهند؟

برخی فایل‌ها هر استخراج‌کننده‌ای را مغلوب می‌کنند و بهتر است به جای ارائه خروجی ناقص، آن‌ها را شناسایی کرد. اسناد اسکن شده واضح‌ترین حالت هستند: صفحه‌ای که یک تصویر بزرگ است هیچ عملگر متنی ندارد، بنابراین استخراج به درستی یک رشته خالی را برمی‌گرداند — راه‌حل این مشکل OCR است و استخراج تصاویر صفحه از PDF بارگذاری‌شده اولین قدم در این خط لوله است. فونت‌های زیرمجموعه بدون جدول /ToUnicode مورد سخت‌تری هستند: اگر مسیر /Encoding و CMaps استاندارد نیز خالی باشند، آن گلیف‌ها به 0 حل می‌شوند و به صورت فضاهای خالی در فراخوانی‌های متنی ظاهر می‌گردند. اسناد رمزگذاری‌شده به طور عادی استخراج می‌شوند مشروط بر اینکه آن‌ها را با رمز عبورشان از طریق متد LoadFromFile بارگذاری کنید تا جریان‌ها قبل از اینکه مفسر آن‌ها را ببیند، رمزگشایی شوند

ذکر یک محدودیت ظریف‌تر شایسته است: زنجیره رمزگشایی جریان‌های CMap و محتوا را از طریق مسیر Flate مربوط به HotPDF می‌خواند، بنابراین فونتی که جریان ToUnicode آن از فیلتر غیرمعمولی استفاده می‌کند به استراتژی بعدی تنزل می‌یابد به جای اینکه صفحه با شکست مواجه شود. در عمل، FlateDecode تقریباً تمام فایل‌های تولید شده در دو دهه گذشته را پوشش می‌دهد و این تنزل بر اساس طراحی بی سر و صدا است — شما بهترین متن ممکن را دریافت می‌کنید نه یک خطا. همان سیستم شیء سمت خواندن که دیکشنری‌های فونت را در اینجا حل می‌کند، ویرایش متادیتا در اسناد بارگذاری‌شده را نیز تامین می‌کند، بنابراین خط لوله دریافت سند می‌تواند استخراج، بازرسی و یادداشت‌گذاری را در یک مرحله انجام دهد

استخراج متن، رندر با حفظ طرح‌بندی، دسترسی در سطح گلیف و ویژگی‌های جستجو و جایگزینی ساخته شده بر اساس آن‌ها همگی بخشی از استاندارد کامپوننت HotPDF برای دلفی و C++Builder هستند — بدون نیاز به DLLهای خارجی، بدون سرویس‌های متنی سیستم‌عامل، فقط کد Object Pascal که می‌توانید در صورت مواجهه با یک فایل ناشناخته در صف خود، آن را خط‌به‌خط بررسی کنید