บทความเทคนิค

แปลงหน้า PDF เป็นภาพขาวดำ 1 บิตใน Delphi

เกตเวย์แฟกซ์ไม่ได้ต้องการผลเรนเดอร์หน้าแบบ 24 บิตของคุณ เช่นเดียวกับไปป์ไลน์เก็บถาวรที่รับบิลลักษณะเหมือนสแกนนับล้านฉบับ และฟรอนต์เอนด์ OCR ที่ตั้ง threshold ทุกอย่างให้เป็นขาวดำก่อนจะมองหาอักขระสักตัว ทั้งสามอย่างต้องการสิ่งเดียวกัน คือบิตแมป 1 บิตที่สะอาด หนึ่งบิตต่อพิกเซล โดยที่ทุกจุดเป็นเพียงหมึกหรือกระดาษ ถ้าคุณยื่น BMP สีเต็มให้ พวกมันก็จะทิ้ง 23 บิตต่อพิกเซลอยู่ดี และมักจะทำ dithering ได้แย่กว่าที่คุณทำเองด้วยซ้ำ คำถามที่น่าสนใจกว่าคือควรทำ down-conversion ตรงไหน และคำตอบใน PDFlibPas ก็ชี้ให้เห็นบางอย่างที่มีประโยชน์เกี่ยวกับการขยาย renderer ที่คุณไม่อยากเขียนใหม่

PDFlibPas คือไลบรารี PDF แบบ native Object Pascal สำหรับ Delphi และ C++Builder แกนเรนเดอร์ของมันจะแรสเตอร์หน้าเอกสารออกเป็นบิตแมป และส่งออกได้ทั้ง BMP, PNG, JPEG, WMF รวมถึงฟอร์แมตอื่นอีกเล็กน้อย สิ่งที่มันยังทำไม่ได้จนไม่นานมานี้คือการคืนบิตแมปขาวดำแท้จริง หรือการเรนเดอร์เพียงบางส่วนของหน้า ทั้งสองความสามารถมาถึงใน v3.83.0 และทั้งคู่ถูกสร้างเป็นชั้น convenience บาง ๆ วางทับบน renderer ที่มีอยู่ แทนที่จะไปแก้ rasterizer โดยตรง ข้อจำกัดนี้แหละคือเรื่องทั้งหมด

ทำไมต้องลดสีหลังเรนเดอร์ ไม่ใช่ใน renderer

วิธีที่ดูตรงที่สุดในการสร้างภาพ 1 บิตคือสั่งให้ rasterizer วาดออกมาเป็น 1 บิตเลย แต่ก็เป็นวิธีที่ทำให้ส่วนอื่นพังไปพร้อมกัน bitmap ภายในของ renderer ถูกสร้างด้วย PixelFormat := pf24bit แบบ hardcoded ใน constructor ของ PDFlibRenderer และพื้นผิว 24 บิตนั้นถูกแชร์โดยทุกเส้นทางเรนเดอร์ ไม่ว่าจะเป็นการส่งออก PNG, preview ผ่าน device context, หรือ JPEG output ถ้าคุณเปลี่ยนมันเป็น pf1bit ตั้งแต่ต้น คุณไม่ได้เพิ่มฟีเจอร์ monochrome คุณแค่ลดความเที่ยงตรงของสีให้กับทุก caller ในไลบรารี และผูกตัวเองไว้กับการไล่แก้ regression ปลายทางเป็นสิบรายการ

ดังนั้น RenderPageToMonochromeFile จึงเลือกเส้นทางตรงข้าม มันเรนเดอร์หน้าตามปกติลงเป็น BMP 24 บิตชั่วคราว แล้วจึงยุบลงเป็น 1 บิตในขั้น post-processing renderer เดิมไม่ถูกแตะเลย พฤติกรรม monochrome อยู่เฉพาะในเมธอด convenience ซึ่งหมายความว่ามันจะไม่กระทบใครที่ไม่ได้เรียกใช้งาน นี่คือ trade-off ที่ควรเรียกชื่อให้ชัด การทำ post-process ต้องจ่ายบิตแมปเพิ่มอีกหนึ่งก้อนและไฟล์ temp หนึ่งไฟล์ แต่แลกกับการเก็บ core ที่รับภาระสำคัญไว้โดยไม่ต้องแตะเลย สำหรับฟีเจอร์ที่มีไว้รองรับกรณีใช้งานแฟกซ์และงานเก็บถาวรแบบขอบเขตพิเศษ นี่คือฝั่งที่ถูกต้อง

var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.LoadFromFile('invoice.pdf');
    // 200 DPI is the classic Group 4 fax resolution; page index is 1-based
    Pdf.RenderPageToMonochromeFile(200, 1, 'invoice-page1.bmp');
  finally
    Pdf.Free;
  end;
end;

การยุบเป็น 1 บิตเกิดขึ้นจริงอย่างไร

การ down-conversion พึ่งพา GDI แทนที่จะเขียน threshold loop เอง และทางเลือกนี้มีผลต่อคุณภาพเอาต์พุต ภายในเมธอด บิตแมป 24 บิตชั่วคราวถูกโหลดเข้า TBitmap จากนั้นสร้าง TBitmap อีกตัวที่มี PixelFormat := pf1bit และขนาดเท่ากัน แล้วคัดลอกพิกเซลข้ามไปด้วย blit เพียงครั้งเดียว:

// inside RenderPageToMonochromeFile, after loading the 24-bit ColorBmp
MonoBmp.PixelFormat := pf1bit;
MonoBmp.Width  := ColorBmp.Width;
MonoBmp.Height := ColorBmp.Height;
// HALFTONE tells GDI to dither the 24-bit source down to 1-bit
SetStretchBltMode(MonoBmp.Canvas.Handle, HALFTONE);
StretchBlt(MonoBmp.Canvas.Handle, 0, 0, MonoBmp.Width, MonoBmp.Height,
  ColorBmp.Canvas.Handle, 0, 0, ColorBmp.Width, ColorBmp.Height, SRCCOPY);
MonoBmp.SaveToFile('out.bmp');

เคล็ดลับอยู่ที่ SetStretchBltMode ร่วมกับ HALFTONE แม้ source และ destination จะมีขนาดเท่ากันจึงไม่มีการ scaling เกิดขึ้น แต่ stretch mode ก็ยังเป็นตัวกำหนดว่า GDI จะ map สีลงในพาเลต 1 บิตอย่างไร HALFTONE ทำให้มันใช้ halftone dithering เปลี่ยนบริเวณสีเทาและขอบตัวอักษรที่มี antialiasing ให้กลายเป็นลวดลายของจุดดำและขาว แทนที่จะตัดแข็งไปยังสีที่ใกล้ที่สุดสองสี ถ้าคุณตัดการเรียกโหมดนี้ออก หรือใช้ค่าเริ่มต้น BLACKONWHITE เนื้อหาโทนเทาจะกลายเป็นชิ้นบล็อกแข็ง ๆ แบบ threshold สำหรับเอาต์พุตเอกสารสแกนและการเตรียมข้อมูล OCR ผลลัพธ์แบบ dithered แทบจะเป็นสิ่งที่ต้องการเสมอ

รายละเอียดหนึ่งที่ต่อรองไม่ได้และพลาดง่ายคือ render ชั่วคราวต้องเป็น BMP RenderPageToMonochromeFile เรียก renderer ทั่วไปด้วย options code เท่ากับ 0 ซึ่งก็คือ BMP อาร์กิวเมนต์ options ของ RenderPageToFile เป็น enum จำนวนเต็มขนาดเล็ก และค่าต่าง ๆ ใช้แทนกันไม่ได้ในกรณีนี้ 0 คือ BMP, 1 คือ JPEG, 2 คือ WMF, 3 คือ EMF, 5 คือ PNG และอื่น ๆ จากนั้นตัว down-converter จะเรียก TBitmap.LoadFromStream กับไฟล์ temp ถ้าคุณป้อน WMF เข้าไปด้วยการส่ง 2 การโหลดจะโยนข้อผิดพลาด "Bitmap image is not valid" เพราะ Windows Metafile เป็นสตรีมบันทึกเวกเตอร์ ไม่ใช่ DIB การ down-conversion แบบ monochrome เป็นงาน raster ตั้งแต่ต้นจนจบ ดังนั้นไฟล์กลางต้องเป็นฟอร์แมต raster

เรนเดอร์เฉพาะบางส่วนของหน้า

เมธอดที่สอง RenderPageRegionToFile จะเรนเดอร์เฉพาะสี่เหลี่ยมบนหน้าแทนที่จะเป็นทั้งหน้า กรณีใช้งานคุ้นเคยทันทีถ้าคุณเคยสร้าง document viewer มาก่อน เช่น การตัดบล็อกลายเซ็นออกจากสัญญา การสร้าง tile สำหรับแผนผังขนาดใหญ่ในมุมมองซูม หรือการดึงเฉพาะบริเวณที่ประทับตรามาใช้เป็น thumbnail โดยไม่ต้องจ่ายต้นทุนในการ rasterize ทั้งหน้าที่ DPI สูง ตัวอย่างเรียกใช้งานตรงไปตรงมา:

// Clip is "Left,Top,Width,Height" in PDF points (72 pt = 1 inch)
// Here: a 2.5in x 1in box, one inch in from the top-left of the page
Pdf.RenderPageRegionToFile(150, 1, '72,72,180,72', 'sig-block.bmp');

สตริง clip คือเลขทศนิยมแบบ double สี่ค่า คั่นด้วยจุลภาคในหน่วย PDF points และถูก parse ด้วยตนเองภายในเมธอดเพื่อเลี่ยงปัญหาจาก locale และความแปลกของ DelimitedText จากค่า width และ height เมธอดจะคำนวณขนาดบิตแมปเอาต์พุตเป็น Round(Width * DPI / 72) คูณ Round(Height * DPI / 72) จองบิตแมป pf24bit ในหน่วยความจำตามขนาดนั้นพอดี แล้วเรนเดอร์ลง device context ผ่าน RenderPageToDCClip ไฟล์ผลลัพธ์จึงมีเฉพาะสี่เหลี่ยมที่ถูกตัดออกมา และมีขนาดตามพื้นที่นั้น ไม่ใช่ทั้งหน้า

พารามิเตอร์ clip ที่เคยไม่ทำงาน

ตรงนี้เองที่งานจริงคมกว่าที่เห็น RenderPageToDCClip มีพารามิเตอร์ Clip ติดมานาน แต่เดิมมันไม่ได้ทำงานจริง การเรียกเมธอดยอมรับอาร์กิวเมนต์นั้น ส่งต่อไปยัง TPDFPageTree.RenderPageToDC แล้ว implementation ตรงนั้นก็เมินมันทั้งหมด ไม่เคยส่งต่อให้ renderer คุณจะใส่สี่เหลี่ยมแบบไหนก็ได้ และสุดท้ายก็ยังได้ทั้งหน้ากลับมา ใครที่ต่อ RenderPageToDCClip โดยคาดว่าจะได้การครอปก็มักจะได้รับการเรนเดอร์เต็มหน้าแทน และถ้าเลย์เอาต์ของตัวเองไม่ชัด อาจไม่ทันสังเกตด้วยซ้ำ

v3.83.0 จึงเชื่อมสายที่ขาดนั้น RenderPageToDC ตอนนี้ parse สี่เหลี่ยมพิกัดหน่วย point แบบ "Left,Top,Width,Height" ชุดเดียวกัน แล้วนำไปใช้เป็น GDI clip region จริงบน target device context ก่อนที่ renderer จะเริ่มวาด การแปลงจาก points เป็น device pixels ใช้ตัวคูณ DPI / 72 ตามปกติ และนำไปใช้กับทั้งสี่ขอบ ลำดับรอบ ๆ การเรนเดอร์คือชุด save/clip/restore แบบมาตรฐาน:

// inside TPDFPageTree.RenderPageToDC, when Clip is non-empty
ScaleFactor := DPI / 72;
SaveDC(TargetDC);
IntersectClipRect(TargetDC,
  Round(ClipLeft * ScaleFactor),
  Round(ClipTop * ScaleFactor),
  Round((ClipLeft + ClipWidth) * ScaleFactor),
  Round((ClipTop + ClipHeight) * ScaleFactor));
// ... renderer draws the page here ...
// in the finally block:
RestoreDC(TargetDC, -1);

SaveDC / RestoreDC(-1) คู่กันนี้เองที่ทำให้เรียกซ้ำได้อย่างปลอดภัย clip region จะถูกผลักขึ้นไปบนสแตกสถานะของ DC จากนั้นหน้าจะถูกวาด และ clip เดิมจะถูกดึงกลับมาทุกครั้งไม่ว่าการเรนเดอร์จะจบแบบใด RestoreDC(TargetDC, -1) จะกู้สถานะที่บันทึกล่าสุด ซึ่งเป็น idiom มาตรฐานของการ save/restore ให้สมดุล ถ้าคุณข้ามการ restore ไป caller ที่นำ DC เดิมไปใช้กับการเรนเดอร์เต็มหน้าในครั้งถัดไปจะพบว่ามันถูกครอปอย่างลึกลับให้เหลือเฉพาะบริเวณสุดท้าย การแก้พารามิเตอร์ที่ตายไปแล้วนี้ยังทำให้ RenderPageRegionToFile ใช้งานได้ถูกต้องไปฟรี ๆ เพราะเมธอดใหม่นั้นวิ่งผ่านเส้นทางเดียวกันเป๊ะ

จุดพฤติกรรมที่ต้องจำไว้คือ clip เป็นการครอป ไม่ใช่การ scale หน้ายังถูก rasterize ที่ DPI ที่คุณขอ ในตำแหน่งปกติของมัน และ clip region ก็แค่ทิ้งทุกอย่างนอกกรอบสี่เหลี่ยมออกไป คุณไม่ได้ซูมพื้นที่นั้นให้เต็มเอาต์พุต คุณกำลังตัดหน้าต่างออกมาจาก render ความละเอียดเต็ม หากต้องการให้บางส่วนใหญ่ขึ้น ให้เพิ่ม DPI พิกัดของสี่เหลี่ยมจะถูกตีความใน device space หลังการแปลง points เป็น pixels โดยวัดจากมุมซ้ายบนของพื้นผิวที่เรนเดอร์แล้ว ดังนั้นควรวาง Left และ Top ไล่ลงมาจากด้านบนของหน้า สำหรับการเจาะลึกว่าหมวด PDFlibPas ใช้ device context สำหรับเอาต์พุตบนหน้าจออย่างไร บทความคู่หูเรื่อง print preview และ device-context output จะพาไล่โครง DC เดียวกันจากฝั่งการแสดงผล

ขอบเขตที่ต้องบอกตรง ๆ: BMP 1 บิต ไม่ใช่ G4 TIFF

มันง่ายมากที่จะขายฟีเจอร์นี้เกินจริงว่าเป็น "fax-ready output" ดังนั้นขอบเขตที่แท้จริงควรพูดตรง ๆ RenderPageToMonochromeFile สร้าง BMP แบบ pf1bit เท่านั้น มันไม่ได้สร้าง CCITT Group 4 TIFF ซึ่งเป็นฟอร์แมตที่เวิร์กโฟลว์แฟกซ์จริงหรือคลัง TIFF มักต้องการ เหตุผลนี้เป็นข้อเท็จจริง ไม่ใช่การตกหล่น เพราะหน่วย CCITT ของ PDFlibPas ตอนนี้ถอดรหัสสตรีม G4 ได้ แต่ยังไม่มี G4 encoder ถ้าไม่มี encoder ก็ไม่มีที่ให้เขียน monochrome runs แบบบีบอัด เส้นทาง monochrome จึงหยุดอยู่ที่ DIB 1 บิตแบบไม่บีบอัด

ในทางปฏิบัตินี่ยังมีประโยชน์อยู่มาก BMP 1 บิตคือ pixel format ที่ถูกต้อง ผ่านการ dithering และพร้อมใช้งานแล้ว และชุดเครื่องมือสำหรับแฟกซ์ งานเก็บถาวร หรือ OCR ส่วนใหญ่จะนำเข้าไปใช้ต่อ หรือแปลงเป็น G4 เองด้วยขั้นตอนปลายทางอีกหนึ่งขั้นได้สบาย แต่ถ้าความต้องการของคุณคือ Group 4 TIFF ออกมาจากไลบรารีตรง ๆ นี่ก็ยังไม่ใช่ และคุณควรวางขั้นบีบอัดของตัวเองไว้ การรู้ว่าฟีเจอร์หยุดตรงไหนมีคุณค่าไม่น้อยไปกว่าการรู้ว่ามันทำอะไรได้

ทั้งสองเมธอดถูกทำให้เล็กโดยตั้งใจ และนั่นคือบทเรียนด้านการออกแบบที่ควรพกกลับไปจากหน้านี้ API แบบ convenience ที่วางอยู่บน renderer สามารถเพิ่มความสามารถจริงได้ ไม่ว่าจะเป็นเอาต์พุต monochrome หรือการครอป region โดยไม่ต้องเอื้อมลงไปแตะ rasterizer และทำให้ caller อื่นทุกตัวสั่นคลอน เมื่อคุณต้องเลือกระหว่าง rendering engine สำหรับ rasterization ชั้นล่าง บทความภาพรวมเรื่อง multi-engine PDF rendering in Delphi จะอธิบาย trade-off ไว้อย่างละเอียด ส่วนถ้าต้องการดูพื้นผิวการเรนเดอร์ทั้งหมดและ API ที่เหลือ หน้า product page ของ PDFlibPas Delphi PDF Library ก็มีภาพรวมครบถ้วน