مقاله فنی

استخراج تصاویر از یک PDF بارگذاری‌شده در Delphi: HotPDF

شما یک PDF روی دیسک دارید، مشتری آن را از روی انبوهی از صورتحساب‌ها اسکن کرده، و کار شما این است که تصویرهای صفحه را به‌صورت bitmap بیرون بکشید تا برای OCR استفاده شوند. فایل را بارگذاری می‌کنید، XObjectهای تصویر را پیدا می‌کنید، و بعد به بخشی می‌رسید که هیچ‌کس درباره‌اش هشدار نمی‌دهد: بایت‌های داخل آن streamها پیکسل نیستند. آن‌ها یا یک codestream از JPEG هستند، یا یک blob از JPEG 2000 با فشرده‌سازی موجکی، یا یک عبور Group 4 fax، یا یک raster indexشده پشت یک palette و پشت فیلتر Flate. شیء تصویر عرض و ارتفاع را می‌داند، اما نمونه‌های واقعی درون هر فیلتری که تولیدکننده انتخاب کرده مهر و موم شده‌اند. به‌دست آوردن یک TBitmap قابل استفاده یعنی باید آن فیلتر را بازگردانید، و PDF تقریباً هشت راه مختلف برای مهر و موم شدن بایت‌ها ارائه می‌کند

این همان شکافی است که ExtractLoadedImage در HotPDF، مؤلفه بومی VCL PDF برای Delphi و C++Builder، آن را پر می‌کند. این مؤلفه XObjectهای تصویر را در سندی که بارگذاری کرده‌اید فهرست می‌کند، برای هر کدام گزارش می‌دهد چه چیزی است، و آن‌هایی را که می‌تواند به یک bitmap 24 بیتی دیکد می‌کند. بخش جالب، سطح API نیست که فقط سه متد دارد؛ بلکه این است که چرا اصلاً باید یک مسیر دیکد جداگانه وجود داشته باشد و چه چیزهایی را می‌تواند و چه چیزهایی را نمی‌تواند دوباره به پیکسل برگرداند

چرا تصاویر بارگذاری‌شده از قبل دیکد نشده‌اند

لودر HotPDF بر پایه وفاداریِ عبور-از-میان ساخته شده است. وقتی LoadFromFile را فراخوانی می‌کنید، streamهای تصویر دقیقاً همان‌طور که در فایل مبدأ هستند نگه داشته می‌شوند: فیلتر اصلی، بایت‌های فشرده اصلی، دیکشنری اصلی. این کار عمدی است. هدف اصلیِ بارگذاری یک سند معمولاً کپی کردن صفحه‌ها، ادغام فایل‌ها، مهر زدن آن‌ها، تغییر مجوزشان و نوشتن دوباره آن‌هاست، و برای همهٔ این کارها ارزان‌ترین و امن‌ترین کار این است که هر stream تصویر دست‌نخورده بماند. دیکد کردن هر تصویر به raster در زمان بارگذاری، حافظه و CPU را برای کاری می‌سوزاند که بیشتر فراخواننده‌ها هرگز به آن نیاز ندارند، و بازکدگذاری هنگام ذخیره تصاویری را که باید عیناً کپی می‌شدند خراب می‌کند

نتیجه این است که گراف شیء بارگذاری‌شده هیچ پیکسلی حمل نمی‌کند. یک XObject تصویر که /Filter فیلترش /DCTDecode JPEG bytes را در خود دارد؛ HotPDF هیچ‌وقت یک decoder JPEG روی آن اجرا نکرد، چون هیچ چیز در مسیر کپی و بازنویسی به آن نیاز نداشت. پس وقتی واقعاً پیکسل می‌خواهید، API استخراج باید خودش دیکد را از صفر برای هر فیلتری که آن تصویر مشخص استفاده می‌کند انجام دهد. همین دلیل است که codecهای سمت encode از loader مستقل‌اند: مقالهٔ افزودن تصویرهای JPEG 2000 به PDFها در Delphi توضیح می‌دهد موتور JPX چگونه به سمت ساخت وصل می‌شود، و آن موتور تا وقتی API استخراج به آن نیاز پیدا نکرده بود اصلاً به مسیر خواندن وصل نشده بود

API سه‌متدی

سطح کار کوچک است. GetLoadedImageCount تعداد XObjectهای تصویری سند بارگذاری‌شده را برمی‌گرداند. GetLoadedImageInfo یک رکورد توصیف‌گر را برای یکی از آن‌ها بر اساس اندیس پر می‌کند. ExtractLoadedImage bitmap دیکدشده را برمی‌گرداند، یا nil وقتی نتواند آن تصویر را دیکد کند. شمارش بر پایه اندیس است و برای یک بارگذاری مشخص پایدار می‌ماند: دروناً جدول شیءهای غیرمستقیم را طی می‌کند و هر streamی را که /Subtype به /Image resolve می‌شود جمع می‌کند، پس اندیسی که به GetLoadedImageInfo می‌دهید همان اندیسی است که به ExtractLoadedImage می‌دهید

var
  Pdf: THotPDF;
  Info: THPDFLoadedImageInfo;
  Bmp: TBitmap;
  I, Count: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('scanned-invoices.pdf', '') <= 0 then
      Exit;
    Count := Pdf.GetLoadedImageCount;
    for I := 0 to Count - 1 do
    begin
      if not Pdf.GetLoadedImageInfo(I, Info) then
        Continue;
      if not Info.Decodable then
        Continue;                       // filter or colour space not supported
      Bmp := Pdf.ExtractLoadedImage(I);
      if Bmp <> nil then
      try
        Bmp.SaveToFile(Format('img_%d.bmp', [I]));
      finally
        Bmp.Free;                       // caller owns the bitmap
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

دو جزئیات قراردادی اینجا مهم‌اند. اول، TBitmap bitmap بازگردانده‌شده متعلق به شماست و باید خودتان آن را آزاد کنید؛ سند آن را در cache نگه نمی‌دارد و مالک آن نیست. دوم، Decodable را قبل از فراخوانی بررسی کنید، و نتیجه را بعد از آن با nil مقایسه کنید. این متد روی یک filter پشتیبانی‌نشده exception نمی‌اندازد، بلکه nil را برمی‌گرداند، و یک nil خاموش در یک حلقهٔ دسته‌ای دقیقاً از همان چیزهایی است که می‌تواند یک صفحه از یک کار هزارصفحه‌ای را بدون اینکه کسی متوجه شود ببلعد

خواندن descriptor پیش از دیکد

THPDFLoadedImageInfo به شما می‌گوید یک تصویر چیست، بدون اینکه به یک دیکد کامل متعهد شوید. فیلدهایش مستقیم از دیکشنری تصویر می‌آیند: Width و Height بر حسب sample، BitsPerComponent، ColorComponents و ColorSpace که تفسیر پس از دیکد را توصیف می‌کنند (1 برای gray، 3 برای RGB، 4 برای CMYK)، Filterبه‌عنوان فشرده‌سازی نام‌گذاری‌شده، IsImageMaskبرای stencil maskها، ObjectNumberبرای شیء غیرمستقیم زیربنایی، و Decodable

آن پرچم آخر صادق‌ترین است. Decodable است Trueفقط زمانی که نسخهٔ در حال اجرا واقعاً می‌تواند این ترکیب مشخصِ فیلتر و فضای رنگ را به یک bitmap تبدیل کند. این جمله ماتریس پشتیبانی واقعی را کُد می‌کند، نه یک خواسته را: تصویری که Filterنسخهٔ فعلی آن را نمی‌فهمد، گزارش می‌شود Decodable = False، و می‌توانید بر اساس آن لاگ بگیرید، از آن بگذرید، یا به استخراج جریان خام خودتان برگردید. آن را پیش‌شرط بدانید، نه یک سرنخ

// Triage every image before committing to a decode.
var
  Pdf: THotPDF;
  Info: THPDFLoadedImageInfo;
  I: Integer;
begin
  // ... Pdf loaded ...
  for I := 0 to Pdf.GetLoadedImageCount - 1 do
  begin
    if not Pdf.GetLoadedImageInfo(I, Info) then
      Continue;
    if Info.Decodable then
      // ExtractLoadedImage(I) will return a TBitmap
    else
      // unsupported filter/colour space: log the object and skip
      Writeln(Format('Image %d obj %d: %dx%d %s/%s not decodable',
        [I, Info.ObjectNumber, Info.Width, Info.Height,
         String(Info.Filter), String(Info.ColorSpace)]));
  end;
end;

یک جزئیات پیاده‌سازی آدم‌هایی را که recordهای توصیف‌گر را دستی می‌سازند به دردسر می‌اندازد. THPDFLoadedImageInfo شامل دو AnsiString فیلد رشته‌ای، Filter و ColorSpace. این‌ها typeهای managed با reference counting هستند، پس صفر کردن یک record با FillChar(Info, SizeOf(Info), 0) در اینجا اشتباه است: این کار reference رشته را بدون کم کردنش overwrite می‌کند، که باعث leak یا فساد می‌شود. HotPDF این record را field به field مقداردهی اولیه می‌کند، دقیقاً به همین دلیل، و اگر شما هم این الگو را در کد خودتان کپی کردید، همین کار را بکنید

یک dispatcher، هشت مسیر فیلتر

علت اینکه این قابلیت به‌جای یک انتشار، در چند انتشار عرضه شد این است که PDF یک فرمت تصویر ندارد. PDF فیلتر دارد، و §8.9.5 از ISO 32000-1 اجازه می‌دهد یک XObject تصویر هرکدام از آن‌ها را در /Filter با تفسیر نمونه‌ای که جداگانه توسط /ColorSpace, /BitsPerComponent و یک /Decode اختیاری ExtractLoadedImage array کنترل می‌شود

  • رسترهای خام (FlateDecode، LZWDecode، یا بدون filter) در DeviceRGB یا DeviceGray هشت‌بیتی. بایت‌ها به یک raster فشرده باز می‌شوند، و تنها transform یک جابه‌جایی کانال است که پایین‌تر پوشش داده شده است
  • DCTDecode (JPEG). codestream به TJPEGImage decoder VCL سپرده می‌شود، که هندسه و رنگ را resolve می‌کند، و نتیجه در یک bitmap 24 بیتی قرار می‌گیرد
  • JPXDecode (JPEG 2000). از طریق backend OpenJPEG دیکد می‌شود، همان موتور توصیف‌شده در مقالهٔ JPEG 2000، با مؤلفه‌های با عمق بیت بالا که تا 8 بیت بازنمونه‌برداری می‌شوند
  • رنگ نمایه‌ای. palette از [/Indexed base hival lookup] array خوانده می‌شود و هر sample از طریق lookup table به true colour گسترش می‌یابد
  • DeviceCMYK. نمونه‌های چهارکاناله با فرمول استاندارد جوهر روی سفید به RGB تبدیل می‌شوند
  • DeviceGray و Indexed زیر 8 بیت در 1، 2 یا 4 بیت به ازای هر مؤلفه، sample به sample باز می‌شوند و به بازهٔ 0–255 مقیاس می‌یابند
  • CCITTFaxDecode, فیلترهای فکس Group 3 و Group 4، که توسط یک backend اختصاصی T.4/T.6 دیکد می‌شوند
  • JBIG2Decode, فیلتر bilevel با نسبت فشرده‌سازی بالا، که از طریق backend ثبت‌شدهٔ JBIG2 دیکد می‌شود که مقالهٔ فشرده‌سازی بومی JBIG2 مقالهٔ فشرده‌سازی بومی JBIG2 آن را از سمت encode پوشش می‌دهد

همه‌چیز به همان جا می‌رسد: یک bitmap 24‌بیتی BGR، چون این همان چیزی است که یک VCL TBitmap به‌طور بومی ذخیره می‌کند و هر مصرف‌کنندهٔ پایین‌دستی انتظارش را دارد

تبدیل‌هایی که بی‌صدا پیکسل‌ها را عوض می‌کنند

دو تا از این مسیرها شامل تبدیلی هستند که به‌سادگی می‌شود آن را ظریفانه غلط انجام داد، و حتی اگر خودتان هرگز decoder را لمس نکنید، فهمیدنش ارزش دارد. اولی جابه‌جایی ترتیب رنگ است. یک raster از DeviceRGB در PDF نمونه‌ها را به ترتیب قرمز-سبز-آبی، با ردیف بالایی در ابتدا، ذخیره می‌کند. یک scanline 24‌بیتی در VCL آن‌ها را به ترتیب آبی-سبز-قرمز ذخیره می‌کند. پس دیکد کردن یک تصویر RGB ساده memcpy نیست؛ بایت اول و سوم هر pixel در مسیر ورود به scanline جابه‌جا می‌شوند. اگر این را برعکس بگیرید، قرمزها و آبی‌ها جایشان عوض می‌شود، که روی یک تصویر خاکستری خوب به نظر می‌رسد و روی یک تصویر رنگی فاجعه است. ترتیب ردیف، به هر حال، مستقیم عبور می‌کند: rasterهای top-down در PDF با ScanLine[0] ردیف بصری بالایی VCL هم‌راستا هستند، پس نیازی به flip عمودی نیست

دومی CMYK است. تصویرهای DeviceCMYK چهار جوهر حمل می‌کنند، و تبدیل به RGB یک محاسبهٔ کانال‌به‌کانال است، نه یک lookup: هر channel خروجی (255 - ink) * (255 - K) / 255 است. این یک تقریبِ دستگاهی است، نه یک تبدیلِ مدیریت‌شدهٔ رنگ از طریق یک ICC profile، پس نتیجه برای نمایش و بازرستر کردن به‌اندازهٔ کافی درست است اما اگر رنگ دقیق برای چاپ می‌خواهید مسیر درستی نیست. اگر جریان کاری شما وفاداری می‌خواهد، bitmap استخراج‌شده را یک preview در نظر بگیرید و stream اصلی CMYK را برای pipeline مدیریت رنگ نگه دارید

مسیر Indexed دامِ تجزیهٔ خودش را پنهان کرده است. palette در یک /Indexed فضای رنگی می‌تواند به‌صورت یک رشتهٔ literal یا یک رشتهٔ hexadecimal ذخیره شود، و HotPDF مقدار یک hex string را به‌عنوان hex text، نه bytes دیکدشده. پس وقتی palette یک hex string است، lookup table باید اول از decode هگز به بایت عبور کند؛ یک literal string از قبل raw bytes است. آن شاخه را از دست بدهید و یک image نمایه‌ای چهاررنگ به آشغال تبدیل می‌شود، چون هر entry پالت از مرز بایت اشتباه خوانده می‌شود

زنجیره‌های فیلتر: آخرین فیلتر، خودِ image است

یک /Filter نامِ filter حالت ساده است. PDF همچنین یک chain از filterها را مجاز می‌داند، جایی که stream چند بار پشت سر هم از فیلترها گذشته و به‌ترتیب در یک /Filter array مثل [/ASCII85Decode /FlateDecode] یا [/ASCIIHexDecode /DCTDecode] (ISO 32000-1 §7.4). معنا دقیق است: فیلترها هنگام encode از چپ به راست اعمال می‌شوند، پس هنگام decode آن‌ها را از راست به چپ باز می‌کنید، و آخرین در آرایه همان چیزی است که واقعاً فرمت تصویر را تعریف می‌کند. فیلترهای ابتدایی فقط encodings حمل‌ونقل هستند که دور آن پیچیده شده‌اند

extractor این کار را با لایه‌برداری انجام می‌دهد. پیش از اجرای هر decoder تصویر، همهٔ filterهای زنجیره به‌جز آخرین، برای تولید ورودی‌ای که فیلتر نهایی انتظارش را دارد اعمال می‌شوند، و فقط بعد dispatch روی همان آخرین filter انجام می‌شود. پس [/ASCII85Decode /DCTDecode] ابتدا stream را از ASCII85 خارج می‌کند، سپس نتیجه را به مسیر JPEG می‌فرستد؛ [/FlateDecode] که دور یک raster خام پیچیده شده اول باز می‌شود و بعد مسیر raster اجرا می‌شود. این همان چیزی است که به هشت decoder اجازه می‌دهد ساده بمانند. هیچ‌کدامشان لازم نیست دربارهٔ ASCII85 یا wrapperهای حمل‌ونقل hex چیزی بدانند، چون تا وقتی decoder بایت‌ها را می‌بیند، wrapperها از قبل برداشته شده‌اند. این همچنین یعنی زنجیره‌ای که final filter آن پشتیبانی نمی‌شود همچنان در مرحلهٔ dispatch تمیز fail می‌شود، نه نیمه‌راه

جایی که extraction متوقف می‌شود، و بعد چه باید کرد

با خودتان دربارهٔ مرزها صادق باشید. تصویری که final filter آن بیرون از مجموعهٔ پشتیبانی‌شده است nil، و همین‌طور تصویری که فضای رنگی‌اش برای این build قابل تفسیر نیست. soft maskها و alphaها به bitmap بازسازی نمی‌شوند؛ شما تصویر پایه را می‌گیرید، نه یک نتیجهٔ مرکب را. عمق بیت بالاتر از 8 در JPEG 2000 به پایین بازنمونه‌برداری می‌شود، که عمداً lossی است و اگر به‌جای نمایش، در حال بازبایگانی باشید کار درستی نیست. و یک image mask، یک stencil یک‌بیتی بدون رنگِ خودش، در descriptor توصیف می‌شود اما چیز دیگری غیر از یک تصویر pictorial است؛ آن را با انتظار عکس دیکد کنید و غافلگیر می‌شوید

وقتی extraction کافی نیست، raw stream هنوز همان‌جا در گراف شیء بارگذاری‌شده هست، همراه با همهٔ filterها، و شما می‌توانید آن را بیت‌به‌بیت بیرون بکشید و به یک codec تخصصی خودتان بدهید. این همان fallbackی است که طراحی pass-through عمداً حفظ می‌کند: بایت‌های اصلی هرگز دور ریخته نمی‌شوند، پس بدترین حالت این است که خودتان آن‌ها را دیکد کنید، نه اینکه داده از بین رفته باشد. برای بیشتر کارهای واقعی، اما، آن هشت filter پشتیبانی‌شده همان چیزهایی را پوشش می‌دهند که اسکنرها، مجموعه‌های اداری، و موتورهای گزارش واقعاً تولید می‌کنند، و یک loop روی GetLoadedImageCount با یک Decodable guard می‌تواند در چند خط یک PDF بارگذاری‌شده را به یک پوشه از bitmapها تبدیل کند

API استخراج تصویر بارگذاری‌شده، همراه با مجموعهٔ کامل decode filterهایی که اینجا توصیف شدند، در HotPDF Component برای Delphi و C++Builder عرضه می‌شود