คุณมี PDF อยู่บนดิสก์ ลูกค้าสแกนมันมาจากกองใบแจ้งหนี้ และงานของคุณคือดึงรูปประจำหน้ากลับออกมาเป็นบิตแมปสำหรับขั้นตอน OCR เมื่อคุณโหลดไฟล์ พบ image XObjects แล้ว คุณจะพบจุดที่ไม่มีใครบอกไว้ก่อนว่า bytes ในสตรีมเหล่านั้นไม่ใช่พิกเซล มันอาจเป็น JPEG codestream, blob ของ JPEG 2000 ที่บีบอัดด้วย wavelet, ชุดแฟกซ์ Group 4 หรือ raster แบบ indexed ที่ซ่อนอยู่หลังพาเล็ตและฟิลเตอร์ Flate ตัว image object รู้แค่ความกว้างกับความสูง แต่ตัวอย่างจริงถูกปิดอยู่ภายในฟิลเตอร์ที่ผู้สร้างไฟล์เลือกไว้ การจะได้ TBitmap ที่ใช้งานได้จึงต้องย้อนฟิลเตอร์นั้นกลับ และ PDF ก็มีวิธีปิด bytes ไว้หลายแบบพอสมควร
นี่คือช่องว่างที่ ExtractLoadedImage เข้ามาเติมใน HotPDF คอมโพเนนต์ PDF แบบ VCL ดั้งเดิมสำหรับ Delphi และ C++Builder มันไล่ image XObjects ในเอกสารที่คุณโหลดมา รายงานว่าแต่ละรายการคืออะไร และถอดรายการที่ถอดได้กลับเป็นบิตแมป 24 บิต จุดที่น่าสนใจไม่ใช่หน้าตา API ซึ่งมีอยู่แค่สามเมธอด แต่เป็นเหตุผลว่าทำไมต้องมีเส้นทางถอดรหัสแยกต่างหาก และมันจะพากลับไปเป็นพิกเซลได้แค่ไหน
ทำไมรูปภาพที่โหลดมาแล้วจึงยังไม่ถูกถอดรหัส
ตัวโหลดของ HotPDF ออกแบบตามแนวคิด pass-through fidelity เมื่อคุณเรียก LoadFromFile สตรีมภาพจะถูกเก็บไว้ตามที่ปรากฏในไฟล์ต้นฉบับทุกอย่าง ทั้งฟิลเตอร์เดิม ไบต์ที่บีบอัดเดิม และดิกชันนารีเดิม นั่นทำไว้โดยตั้งใจ เพราะจุดประสงค์ของการโหลดเอกสารมักจะเป็นการคัดลอกหน้า ผสานไฟล์ ประทับตรา ปรับสิทธิ์ใหม่ แล้วเขียนออกไปอีกครั้ง และสำหรับงานเหล่านั้น สิ่งที่ถูกและปลอดภัยที่สุดคือปล่อยให้แต่ละ image stream อยู่เดิม การถอดทุกภาพเป็น raster ตอนโหลดจะกินหน่วยความจำกับ CPU ไปกับงานที่ผู้ใช้ส่วนใหญ่ไม่ต้องใช้ และการเข้ารหัสซ้ำตอนบันทึกจะทำให้ภาพที่ควรถูกคัดลอกแบบตรงตัวด้อยคุณภาพลง
ผลที่ตามมาคือ object graph ที่โหลดเข้ามาไม่มีพิกเซลเก็บไว้เลย image XObject ที่ /Filter เป็น /DCTDecode จะเก็บเฉพาะ JPEG bytes ไว้ HotPDF ไม่ได้รัน JPEG decoder ใด ๆ ใส่มัน เพราะเส้นทางคัดลอกและเขียนกลับไม่ต้องใช้มัน ดังนั้นเมื่อคุณต้องการพิกเซลจริง ๆ API สำหรับ extraction จึงต้องถอดรหัสเองทั้งหมด ตั้งแต่ต้น สำหรับฟิลเตอร์ที่ภาพนั้นใช้ นี่คือเหตุผลเดียวกับที่ codec ฝั่ง encode แยกจาก loader ออกมา บทความเรื่อง การเพิ่มรูปภาพ JPEG 2000 ลงใน PDF ด้วย Delphi อธิบายว่าเอนจิน JPX เสียบเข้าไปที่ฝั่งสร้างเอกสารอย่างไร และเอนจินนั้นก็ไม่ได้ถูกผูกเข้ากับเส้นทางอ่านจนกระทั่ง API สำหรับ extraction ต้องใช้มัน
API สามเมธอด
หน้าตา API สั้นมาก GetLoadedImageCount คืนจำนวน image XObjects ที่เอกสารที่โหลดเข้ามามีอยู่ GetLoadedImageInfo เติม record คำอธิบายให้หนึ่งรายการตาม index ExtractLoadedImage คืนบิตแมปที่ถอดรหัสแล้ว หรือ nil เมื่อมันถอดภาพนั้นไม่ได้ การไล่รายการใช้ index เป็นหลักและมีความคงที่สำหรับการโหลดครั้งนั้น ในเชิงภายในมันเดินตาราง indirect-object แล้วเก็บทุกสตรีมที่ /Subtype resolve เป็น /Image ดังนั้น index ที่คุณส่งให้ GetLoadedImageInfo คือ index เดียวกับที่คุณส่งให้ 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 ที่ส่งกลับมาเป็นของคุณและคุณต้อง free เอง เอกสารจะไม่แคชหรือเป็นเจ้าของมัน ข้อสองให้เช็ก Decodable ก่อนเรียก และเช็กผลลัพธ์เทียบกับ nil หลังเรียก เมธอดนี้ไม่ raise เมื่อเจอฟิลเตอร์ที่ไม่รองรับ แต่มันคืน nil และ nil เงียบ ๆ ในลูปงานชุดใหญ่ก็คือชนิดของปัญหาที่ทำให้หน้าหนึ่งในงานพันหน้าหายไปโดยไม่มีใครสังเกต
อ่าน descriptor ก่อนถอดรหัส
THPDFLoadedImageInfo บอกได้ว่าภาพหนึ่งคืออะไรโดยยังไม่ต้องถอดรหัสเต็มรูปแบบ ฟิลด์ของมันอ่านมาจาก image dictionary ตรง ๆ ได้แก่ Width และ Height เป็นหน่วย samples, BitsPerComponent, ColorComponents และ ColorSpace ที่บอกความหมายหลังถอดรหัส (1 สำหรับ gray, 3 สำหรับ RGB, 4 สำหรับ CMYK), Filter เป็นชื่อการบีบอัด, IsImageMask สำหรับ stencil masks, ObjectNumber สำหรับ indirect object ต้นทาง และ Decodable
ธงตัวสุดท้ายนี่แหละที่ตรงไปตรงมา Decodable จะเป็น True ก็ต่อเมื่อ build ที่รันอยู่สามารถแปลงคู่ฟิลเตอร์กับ color space ชุดนี้เป็นบิตแมปได้จริง มันสะท้อนตารางรองรับจริง ไม่ใช่ความหวัง ภาพที่ Filter ที่ build ปัจจุบันไม่เข้าใจจะรายงาน Decodable = False และคุณจะใช้ค่านั้นเพื่อ log, ข้าม หรือย้อนกลับไปดึง raw stream เองก็ได้ ให้มองมันเป็นเงื่อนไขก่อนใช้งาน ไม่ใช่คำใบ้
// 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;
รายละเอียด implementation อย่างหนึ่งทำให้คนที่สร้าง record descriptor ด้วยมือสะดุด THPDFLoadedImageInfo เก็บฟิลด์ AnsiString สองตัวคือ Filter และ ColorSpace พวกนี้เป็น managed types ที่มี reference counting ดังนั้นสัญชาตญาณที่จะล้าง record ด้วย FillChar(Info, SizeOf(Info), 0) จึงผิดในกรณีนี้ เพราะมันเขียนทับ string reference โดยไม่ decrement ซึ่งทำให้รั่วหรือพัง HotPDF initialises record ทีละฟิลด์ด้วยเหตุผลนี้เอง และถ้าคุณเอาแพตเทิร์นนี้ไปใช้ในโค้ดของตัวเอง ก็ควรทำเหมือนกัน
ตัว dispatch เดียว กับเส้นทางฟิลเตอร์แปดแบบ
เหตุผลที่ฟีเจอร์นี้ต้องออกเป็นหลายรุ่นแทนที่จะจบในรุ่นเดียว คือ PDF ไม่มีฟอร์แมตภาพในตัว มันมีฟิลเตอร์ และ §8.9.5 ของ ISO 32000-1 เปิดให้ image XObject ระบุฟิลเตอร์ใดก็ได้ใน /Filter โดยการตีความ samples จะถูกกำกับแยกต่างหากด้วย /ColorSpace, /BitsPerComponent และ /Decode array ที่เป็นตัวเลือก ExtractLoadedImage จะอ่านชื่อฟิลเตอร์แล้วส่งต่อไปยัง decoder เฉพาะทางสำหรับแต่ละกรณี ชุดที่รองรับซึ่งค่อย ๆ เพิ่มใน v2.229 ถึง v2.231 ตอนนี้ครอบคลุมแปดเส้นทาง
- ราสเตอร์ดิบ (FlateDecode, LZWDecode หรือไม่มีฟิลเตอร์) ใน DeviceRGB หรือ DeviceGray แบบ 8 บิต ไบต์จะขยายกลับเป็น packed raster และ transform เดียวที่มีคือการสลับช่องสี ซึ่งจะอธิบายด้านล่าง
- DCTDecode (JPEG) codestream จะถูกส่งให้
TJPEGImageของ VCL ซึ่งจัดการ geometry กับสี แล้วผลลัพธ์จะถูกใส่ลงในบิตแมป 24 บิต - JPXDecode (JPEG 2000) ถอดผ่าน backend ของ OpenJPEG เอนจินเดียวกับที่อธิบายในบทความ JPEG 2000 โดยคอมโพเนนต์ที่มีความลึกสูงกว่าจะถูก resample ลงเหลือ 8 บิต
- Indexed colour พาเล็ตจะถูกอ่านจากอาร์เรย์
[/Indexed base hival lookup]แล้ว sample แต่ละตัวจะถูกขยายผ่าน lookup table ไปเป็น true colour - DeviceCMYK sample สี่ช่องจะถูกแปลงเป็น RGB ด้วยสูตรมาตรฐานแบบ ink-on-white
- Sub-8-bit DeviceGray and Indexed ที่ 1, 2 หรือ 4 บิตต่อ component จะถูก unpack ทีละ sample แล้วสเกลไปยังช่วง 0-255
- CCITTFaxDecode ตัวกรองแฟกซ์ Group 3 และ Group 4 จะถูกถอดด้วย backend T.4/T.6 เฉพาะทาง
- JBIG2Decode ฟิลเตอร์ bilevel อัตราสูง จะถูกถอดผ่าน backend JBIG2 ที่ลงทะเบียนไว้ ซึ่งบทความเรื่อง บทความการบีบอัด JBIG2 แบบเนทีฟ อธิบายไว้จากฝั่ง encode
ทุกอย่างลงเอยที่ที่เดียวกันคือบิตแมป BGR 24 บิต เพราะนี่คือรูปแบบที่ TBitmap ของ VCL เก็บโดยกำเนิด และเป็นสิ่งที่ consumer ปลายทางทุกตัวคาดหวัง
ตัวแปลงที่เปลี่ยนพิกเซลเงียบ ๆ
สองเส้นทางนี้มี transform ที่ทำพลาดได้ง่ายแบบละเอียด และควรเข้าใจแม้คุณจะไม่เคยแตะ decoder เองก็ตาม อย่างแรกคือการสลับลำดับสี raster แบบ DeviceRGB ของ PDF จะเก็บ sample ในลำดับ red-green-blue โดยเริ่มจากแถวบนสุด ส่วน scanline แบบ 24 บิตของ VCL จะเก็บเป็น blue-green-red ดังนั้นการถอดภาพ RGB ธรรมดาจึงไม่ใช่ memcpy เพราะ byte แรกกับ byte ที่สามของแต่ละพิกเซลถูกสลับกันระหว่างทางเข้า scanline ถ้ากลับด้านนี้ สีแดงกับสีน้ำเงินจะสลับกัน ซึ่งบนภาพทดสอบระดับเทาดูเหมือนจะใช้ได้ แต่บนภาพสีจริงจะผิดแบบรุนแรง ส่วนลำดับแถว ถ้าพูดให้ตรงก็ตรงกันพอดี raster แบบ top-down ของ PDF จะตรงกับ ScanLine[0] ของ VCL ในฐานะแถวภาพบนสุด จึงไม่ต้องกลับภาพในแนวตั้ง
อย่างที่สองคือ CMYK ภาพ DeviceCMYK ของ PDF มีหมึกสี่ช่อง และการแปลงเป็น RGB เป็นการคำนวณรายช่อง ไม่ใช่การ lookup แต่ละช่องผลลัพธ์คือ (255 - ink) * (255 - K) / 255 นี่เป็นการประมาณตาม device ไม่ใช่การแปลงแบบจัดการสีผ่าน ICC profile ดังนั้นผลลัพธ์จึงพอใช้ได้สำหรับการแสดงผลและการ raster ซ้ำ แต่ไม่ใช่เส้นทางที่ถูกต้องถ้าคุณต้องการสีเที่ยงตรงระดับพิมพ์ ถ้าเวิร์กโฟลว์ของคุณต้องการความแม่นยำ ให้มองบิตแมปที่ดึงออกมาเป็นเพียง preview และเก็บสตรีม CMYK ต้นฉบับไว้สำหรับไปป์ไลน์ที่จัดการสีจริง
เส้นทาง Indexed มีจุดดักการ parse ของมันเอง พาเล็ตใน color space /Indexed สามารถเก็บเป็น literal string หรือ hexadecimal string ได้ และ HotPDF เก็บค่าของ hex string เป็น hex text ไม่ใช่ bytes ที่ถอดรหัสแล้ว ดังนั้นเมื่อพาเล็ตเป็น hex string ตาราง lookup ต้องถูก decode จาก hex เป็น bytes ก่อน ส่วน literal string นั้นเป็น raw bytes อยู่แล้ว ถ้าพลาด branch นี้ ภาพ indexed สี่สีจะกลายเป็นขยะ เพราะทุก entry ในพาเล็ตถูกอ่านจากขอบ byte ที่ผิดไป
ชุดฟิลเตอร์: ฟิลเตอร์สุดท้ายคือของภาพเอง
ชื่อ /Filter เดี่ยวเป็นกรณีที่ง่ายกว่า แต่ PDF ยังอนุญาตให้มี chain ของฟิลเตอร์ด้วย โดยสตรีมถูกผ่านหลายตัวตามลำดับ และระบุไว้เป็นลำดับในอาร์เรย์ /Filter เช่น [/ASCII85Decode /FlateDecode] หรือ [/ASCIIHexDecode /DCTDecode] (ISO 32000-1 §7.4) ความหมายตรงนี้ชัดเจน ฟิลเตอร์จะทำงานจากซ้ายไปขวาตอน encode ดังนั้นตอน decode คุณต้องย้อนกลับจากขวาไปซ้าย และฟิลเตอร์ ตัวสุดท้าย ในอาร์เรย์คือตัวที่กำหนดรูปแบบภาพจริง ๆ ฟิลเตอร์ด้านหน้าเป็นแค่ transport encoding ที่หุ้มมันไว้
ตัว extractor จัดการเรื่องนี้ด้วยการลอกชั้นออก ก่อนที่ decoder ภาพตัวใดจะเริ่มทำงาน ฟิลเตอร์ทุกตัวใน chain ยกเว้นตัวสุดท้ายจะถูกใช้เพื่อสร้าง input ที่ฟิลเตอร์ตัวสุดท้ายคาดหวัง แล้วจึงค่อย dispatch ไปที่ฟิลเตอร์ตัวสุดท้ายนั้น ดังนั้น [/ASCII85Decode /DCTDecode] จะถอด ASCII85 ออกจากสตรีมก่อน แล้วค่อยส่งผลลัพธ์ไปยังเส้นทาง JPEG; [/FlateDecode] ที่หุ้ม raster ดิบไว้จะขยายข้อมูลแล้วค่อยวิ่งเส้นทาง raster นี่คือสิ่งที่ทำให้ decoder ทั้งแปดตัวเรียบง่าย ไม่มีตัวไหนต้องรู้เรื่อง wrapper แบบ ASCII85 หรือ hex transport เพราะตอนที่ decoder เห็น bytes ชั้นหุ้มเหล่านั้นหายไปแล้ว มันยังหมายความว่า chain ที่ฟิลเตอร์ตัวสุดท้ายไม่รองรับจะล้มเหลวอย่างสะอาดที่ขั้น dispatch แทนที่จะพังกลางทาง
ขอบเขตของการ extraction และสิ่งที่ควรทำต่อ
จงซื่อสัตย์กับตัวเองเรื่องขอบเขต ภาพที่ฟิลเตอร์ตัวสุดท้ายอยู่นอกชุดที่รองรับจะคืน nil และภาพที่ color space ของมัน build ไม่สามารถตีความได้ก็จะคืน nil เช่นกัน soft mask และ alpha จะไม่ถูกประกอบกลับเข้าไปในบิตแมป คุณจะได้ base image ไม่ใช่ผลลัพธ์ที่ composited แล้ว bit depth สูงกว่า 8 จาก JPEG 2000 จะถูก resample ลง ซึ่งเป็นการสูญเสียข้อมูลโดยตั้งใจ และไม่ใช่สิ่งที่ควรทำถ้าคุณกำลังเก็บถาวรใหม่แทนการแสดงผล และ image mask ที่เป็น stencil 1 บิตไม่มีสีของตัวเอง จะถูกอธิบายไว้ใน descriptor แต่เป็นคนละสิ่งกับภาพแบบพรรณนา ถ้าคุณถอดมันโดยคาดว่าจะได้รูปถ่าย คุณจะประหลาดใจ
เมื่อ extraction ยังไม่พอ สตรีมดิบก็ยังอยู่ใน object graph ที่โหลดมาแล้วพร้อมฟิลเตอร์ครบทุกตัว และคุณสามารถดึงมันออกมา byte ต่อ byte แล้วส่งให้ codec เฉพาะทางของคุณเอง นั่นคือ fallback ที่แนวคิด pass-through ตั้งใจเก็บไว้ bytes ต้นฉบับไม่เคยถูกทิ้ง ดังนั้นกรณีแย่ที่สุดคือคุณถอดมันเองแทนที่ข้อมูลจะหายไป สำหรับงานจริงส่วนใหญ่ ฟิลเตอร์ทั้งแปดที่รองรับก็ครอบคลุมสิ่งที่เครื่องสแกน ชุด office และเอนจินรายงานปล่อยออกมาจริง และลูปที่วิ่งผ่าน GetLoadedImageCount พร้อม guard ของ Decodable ก็แปลง PDF ที่โหลดเข้ามาให้กลายเป็นโฟลเดอร์บิตแมปได้ในไม่กี่บรรทัด
API สำหรับการดึงภาพที่โหลดมา พร้อมชุดฟิลเตอร์ถอดรหัสทั้งหมดที่อธิบายไว้ที่นี่ มาพร้อมกับ HotPDF Component สำหรับ Delphi และ C++Builder