مقال تقني

استخراج الصور من PDF محمّل في Delphi: HotPDF

You have a PDF on disk, a customer scanned it from a stack of invoices, and your job is to pull the page images back out as bitmaps for an OCR pass. You load the file, you find the image XObjects, and then you discover the part nobody warns you about: the bytes in those streams are not pixels. They are a JPEG codestream, or a wavelet-compressed JPEG 2000 blob, or a Group 4 fax run, or an indexed raster behind a palette behind a Flate filter. The image object knows its width and height, but the actual samples are sealed inside whatever filter the producer chose. Getting a usable TBitmap means undoing that filter, and PDF gives you roughly eight different ways the bytes can be sealed

This is the gap that ExtractLoadedImage fills in HotPDF, the native VCL PDF component for Delphi and C++Builder. It enumerates the image XObjects in a document you loaded, reports what each one is, and decodes the ones it can back into a 24-bit bitmap. The interesting part is not the API surface, which is three methods. It is why a separate decode path has to exist at all, and what it can and cannot turn back into pixels

لماذا لا تُفك الصور المحمّلة بالفعل

HotPDF's loader is built around pass-through fidelity. When you call LoadFromFile, the image streams are kept exactly as they appear in the source file: the original filter, the original compressed bytes, the original dictionary. That is deliberate. The whole point of loading a document is usually to copy pages, merge files, stamp them, re-permission them, and write them back out, and for all of that the cheapest and safest thing to do is leave each image stream untouched. Decoding every image to a raster on load would burn memory and CPU on work most callers never need, and re-encoding on save would degrade images that should have been copied verbatim

The consequence is that the loaded object graph carries no pixels. An image XObject whose /Filter is /DCTDecode holds JPEG bytes; HotPDF never ran a JPEG decoder against it, because nothing in the copy-and-rewrite path needed to. So when you actually want pixels, the extraction API has to do the decoding itself, from scratch, for whichever filter that particular image happens to use. This is the same reason the encode-side codecs are independent of the loader: the article on adding JPEG 2000 images to PDFs in Delphi describes how the JPX engine plugs into the creation side, and that engine simply was not wired into the read path until the extraction API needed it

واجهة API ذات الطرق الثلاث

The surface is small. GetLoadedImageCount returns how many image XObjects the loaded document contains. GetLoadedImageInfo fills a descriptor record for one of them by index. ExtractLoadedImage returns the decoded bitmap, or nil when it cannot decode that image. Enumeration is index-based and stable for a given load: internally it walks the indirect-object table and collects every stream whose /Subtype resolves to /Image, so the index you pass to GetLoadedImageInfo is the same index you pass to 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;

Two contract details matter here. First, the returned TBitmap is yours to free; the document does not cache or own it. Second, check Decodable before you call, and check the result against nil after. The method does not raise on an unsupported filter, it returns nil, and a silent nil in a batch loop is exactly the kind of thing that swallows a page of a thousand-page job without anyone noticing

قراءة الوصف قبل فك الترميز

THPDFLoadedImageInfo tells you what an image is without committing to a full decode. Its fields come straight off the image dictionary: Width and Height in samples, BitsPerComponent, ColorComponents and ColorSpace describing the post-decode interpretation (1 for gray, 3 for RGB, 4 for CMYK), Filter as the named compression, IsImageMask for stencil masks, ObjectNumber for the underlying indirect object, and Decodable

That last flag is the honest one. Decodable is True only when the running build can actually turn this specific filter-and-colour-space combination into a bitmap. It encodes the real support matrix, not a wish: an image whose Filter the current build does not understand reports Decodable = False, and you can branch on that to log, skip, or fall back to extracting the raw stream yourself. Treat it as a precondition, not a hint

// 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;

One implementation detail bites people who build descriptor records by hand. THPDFLoadedImageInfo holds two AnsiString fields, Filter and ColorSpace. These are managed types with reference counting, so the reflex of zeroing a record with FillChar(Info, SizeOf(Info), 0) is wrong here: it overwrites the string reference without decrementing it, which leaks or corrupts. HotPDF initialises the record field by field for exactly that reason, and if you ever copy this pattern in your own code, do the same

موزّع واحد، وثمانية مسارات للمرشحات

The reason this feature took a series of releases rather than one is that PDF does not have an image format. It has filters, and §8.9.5 of ISO 32000-1 lets an image XObject name any of them in /Filter, with the sample interpretation governed separately by /ColorSpace, /BitsPerComponent and an optional /Decode array. ExtractLoadedImage reads the filter name and routes to a dedicated decoder for each case. The supported set, built up across v2.229 through v2.231, now covers eight distinct paths

  • Raw rasters (FlateDecode, LZWDecode, or no filter) in 8-bit DeviceRGB or DeviceGray. The bytes inflate to a packed raster, and the only transform is a channel swap, covered below
  • DCTDecode (JPEG). The codestream is handed to the VCL's TJPEGImage, which resolves geometry and colour, and the result is assigned into a 24-bit bitmap
  • JPXDecode (JPEG 2000). Decoded through the OpenJPEG backend, the same engine described in the JPEG 2000 article, with high-bit-depth components resampled down to 8 bits
  • Indexed colour. The palette is read from the [/Indexed base hival lookup] array and each sample is expanded through the lookup table to true colour
  • DeviceCMYK. Four-channel samples are converted to RGB with the standard ink-on-white formula
  • Sub-8-bit DeviceGray and Indexed at 1, 2, or 4 bits per component, unpacked sample by sample and scaled to the 0–255 range
  • CCITTFaxDecode, the Group 3 and Group 4 fax filters, decoded by a dedicated T.4/T.6 backend
  • JBIG2Decode, the high-ratio bilevel filter, decoded through the registered JBIG2 backend that the مقالة الضغط الأحادي JBIG2 تغطيه من جهة الإنشاء

كل شيء ينتهي في المكان نفسه: bitmap BGR 24-bit، لأن هذا هو ما يخزّنه VCL TBitmap طبيعيًا وما يتوقعه كل مستهلك لاحق

التحويلات التي تغيّر البكسلات من دون أن تلاحظ

هناك مساران من هذه المسارات يتضمنان تحويلًا يسهل أن يخطئ المرء فيه بخفة، ويستحق الفهم حتى لو لم تلمس المفكك بنفسك قط. الأول هو تبديل ترتيب الألوان. صورة PDF DeviceRGB تخزن العينات بترتيب أحمر-أخضر-أزرق، وصفّها العلوي أولًا. أما scanline 24-bit في VCL فيخزنها بترتيب أزرق-أخضر-أحمر. لذلك فإن فك صورة RGB عادية ليس memcpy؛ بل يُبادَل البايتان الأول والثالث لكل بكسل في الطريق إلى الـ scanline. أعكس ذلك وستتبادل الأحمرات والزرق، وهو ما يبدو مقبولًا على صورة اختبار رمادية لكنه يخرج كارثيًا على صورة ملوّنة. أما ترتيب الصفوف، فهو يطابق مباشرة، لأن rasters PDF من الأعلى إلى الأسفل تصطف مع ScanLine[0] بصفتها الصف البصري العلوي، فلا حاجة إلى قلب عمودي

الثاني هو CMYK. صور PDF DeviceCMYK تحمل أربعة أحبار، والتحويل إلى RGB حساب لكل قناة، لا بحثًا في جدول: كل قناة خرج هي (255 - ink) * (255 - K) / 255. هذا تقريب على مستوى الجهاز، لا تحويلًا مُدار الألوان عبر ICC profile، لذا فالنتيجة دقيقة بما يكفي للعرض وإعادة rasterize، لكنها ليست المسار المناسب إذا كنت تحتاج لونًا مطابقًا للطباعة. وإذا كان سير عملك يطلب الدقة، فتعامل مع الـ bitmap المستخرج كمعاينة، واحتفظ بتدفق CMYK الأصلي للمسار المُدار الألوان

مسار Indexed يخبئ فخًا تحليليًا خاصًا به. اللوحة في مساحة ألوان /Indexed يمكن أن تُخزَّن كسلسلة حرفية أو كسلسلة سداسية، وHotPDF يخزن قيمة السلسلة السداسية على أنها النص السداسي نص، لا البايتات المفكوكة. لذلك عندما تكون اللوحة سلسلة سداسية، يجب تمرير جدول البحث عبر فك من hex إلى bytes أولًا؛ أما السلسلة الحرفية فهي بايتات خام أصلًا. إذا فاتك هذا الفرع، ستخرج الصورة المفهرسة ذات الألوان الأربعة خرابًا، لأن كل إدخال في اللوحة يُقرأ من حد بايت خاطئ

سلاسل المرشحات: آخر فلتر هو فلتر الصورة نفسه

اسم واحد لـ /Filter هو الحالة الأسهل. لكن PDF يتيح أيضًا سلسلة من المرشحات، حيث يمر التدفق عبر عدة مرشحات على التوالي، وتُذكر بالترتيب في مصفوفة /Filter مثل [/ASCII85Decode /FlateDecode] أو [/ASCIIHexDecode /DCTDecode] (ISO 32000-1 §7.4). الدلالة دقيقة: المرشحات تُطبَّق من اليسار إلى اليمين عند الترميز، لذلك عند فك الترميز تعكسها من اليمين إلى اليسار، والأخير هو المرشح الذي يعرّف صيغة الصورة فعلًا. أما المرشحات الأولى فليست سوى ترميزات نقل ملفوفة حوله

يعالج المفكك هذا الأمر بالتقشير. قبل تشغيل أي مفكك صورة، يُطبَّق كل فلتر في السلسلة باستثناء الأخير لإنتاج المدخل الذي يتوقعه الفلتر النهائي، وعندها فقط يحدث التوجيه إلى ذلك الفلتر الأخير. لذلك [/ASCII85Decode /DCTDecode] يزيل ASCII85 من التدفق أولًا، ثم يوجّه الناتج إلى مسار JPEG؛ [/FlateDecode] الملفوف حول raster خام يفك ثم يشغّل مسار raster. هذا ما يجعل المفككات الثمانية تبقى بسيطة. لا يحتاج أي منها إلى معرفة ASCII85 أو أغلفة النقل السداسية، لأن الأغلفة تكون قد اختفت بحلول الوقت الذي يرى فيه المفكك البايتات. وهذا يعني أيضًا أن سلسلة ينتهي آخر فلاترها بفلتر غير مدعوم تفشل نظيفًا عند خطوة التوجيه بدل أن تتعطل في المنتصف

أين يتوقف الاستخراج، وماذا تفعل بعد ذلك

كن صريحًا مع نفسك بشأن الحدود. الصورة التي يكون فلترها النهائي خارج المجموعة المدعومة تعيد nil، وكذلك الصورة التي لا تستطيع مساحة ألوانها أن تُفسَّر بواسطة البناء الحالي. الأقنعة الناعمة والشفافية لا يُعاد بناؤها داخل bitmap؛ ستحصل على الصورة الأساسية، لا على نتيجة مركّبة. الأعماق الأعلى من 8 في JPEG 2000 يُعاد تحجيمها نزوليًا، وهذا lossy مقصود وخيار خاطئ إذا كنت تعيد أرشفة لا عرضًا. وقناع الصورة، وهو stencil أحادي البت لا لون له، موصوف في الوصف لكنه شيء مختلف عن صورة تصويرية؛ إذا فُكّ على أنه صورة فوتوغرافية فسيفاجئك

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

واجهة API لاستخراج الصور المحمّلة، مع المجموعة الكاملة من مرشحات الفك الموصوفة هنا، متاحة في HotPDF Component لـ Delphi وC++Builder