การเรนเดอร์หน้า PDF เป็น JPEG คือการทำงานสองขั้นตอนที่ผู้คนมักจะรันรวมกันแล้วค่อยแยกดีบักทีหลัง ขั้นแรกคุณต้องแปลงหน้าเอกสารเป็นบิตแมปพิกเซลในความละเอียดที่คุณเลือก จากนั้นจึงส่งบิตแมปนั้นไปให้ตัวเข้ารหัส JPEG และเลือกคุณภาพ PDFium Component รับผิดชอบครึ่งแรกผ่าน RenderPage ส่วนครึ่งหลังคือ VCL แบบดั้งเดิม คือ TJPEGImage จาก Vcl.Imaging.jpeg รอยต่อระหว่างสองสิ่งนี้คือจุดที่ต้องตัดสินใจอย่างน่าสนใจ เพราะความละเอียดที่คุณเลือกในฝั่งเรนเดอร์และคุณภาพที่คุณเลือกในฝั่งเข้ารหัสจะต้องแลกเปลี่ยนซึ่งกันและกัน และยังส่งผลต่อขนาดไฟล์ในรูปแบบที่เกิดข้อผิดพลาดได้ง่าย
สิ่งที่ต้องทำความเข้าใจก่อนเริ่มเขียนโค้ดคือ: หน้า PDF ไม่มีพิกเซล มันถูกอธิบายเป็นพอยต์ (points) โดยที่ 1 พอยต์เท่ากับ 1/72 นิ้ว และหน้าเอกสารคือภาพวาดเวกเตอร์ที่วัดด้วยพอยต์เหล่านั้น เมื่อคุณสั่งให้ PDFium ทำการเรนเดอร์ คุณกำลังเลือกว่าจะฉายภาพวาดนั้นลงบนกี่พิกเซล และตัวเลือกนั้นก็คือ DPI หากคำนวณผิด คุณอาจจะได้รูปตัวอย่าง (thumbnail) เบลอๆ ทั้งที่คุณต้องการไฟล์ต้นฉบับสำหรับพิมพ์ หรือคุณอาจจัดสรรบิตแมปขนาด 200 ล้านพิกเซลให้กับสิ่งที่จะนำไปเป็นแค่ภาพพรีวิวขนาด 120 พิกเซล
จาก DPI ไปเป็นขนาดพิกเซล
RenderPage ต้องการ Width และ Height เป็นพิกเซลแบบจำนวนเต็ม (integer) ไม่ใช่ DPI ดังนั้นงานแรกคือการแปลงค่า หน้าเอกสารจะรายงานขนาดเป็นพอยต์ผ่าน PageWidth และ PageHeight (ทั้งคู่เป็น Double) และการแปลงค่าก็เป็นวิธีเดียวกับที่ตัวทำแรสเตอร์ (rasterizer) ทุกตัวใช้: พิกเซลเท่ากับพอยต์คูณด้วย DPI เป้าหมายหารด้วย 72 หน้ากระดาษ US Letter มีขนาด 612 คูณ 792 พอยต์ ที่ 150 DPI จะกลายเป็น 1275 คูณ 1650 พิกเซล; ส่วนที่ 72 DPI จะยังคงเป็น 612 คูณ 792 หรือหนึ่งพิกเซลต่อหนึ่งพอยต์ ซึ่งเป็นกรณีเอกลักษณ์ที่คนมักลืม
// Pdf.PageNumber must already point at the page you want.
PixelW := Round(Pdf.PageWidth * Dpi / 72);
PixelH := Round(Pdf.PageHeight * Dpi / 72);
Bitmap := Pdf.RenderPage(0, 0, PixelW, PixelH, ro0, [], clWhite);
// ... use Bitmap ...
Bitmap.Free; // the function-form RenderPage hands you ownership
รายละเอียดสองจุดในสี่บรรทัดนั้นเป็นตัวตัดสินว่าโค้ดถูกต้องหรือไม่ ข้อแรกคือ RenderPage ในรูปแบบฟังก์ชันจะคืนค่า TBitmap ซึ่งคุณเป็นเจ้าของ PDFium จัดสรรหน่วยความจำให้แล้วก็ปล่อยไป; หากคุณไม่เรียก Free ในทุกลูป การรันแบบชุดทีละหลายร้อยหน้าจะทำให้มีบิตแมปหลุดรั่ว (leak) หลายร้อยตัว และโปรเซสจะบวมขึ้นเรื่อยๆ จนกระทั่งระบบล่ม ข้อที่สองคืออาร์กิวเมนต์ Color ซึ่งในที่นี้คือ clWhite ปกติแล้วหน้า PDF จะถูกวาดโดยสมมติว่ามีพื้นหลังสีขาวทึบ และหน้าที่มีความโปร่งใสหากถูกเรนเดอร์ลงบนสีพื้นหลังที่ผิดจะทำให้เกิดขอบเบลอๆ หรือมีรัศมีสีเข้มแปลกๆ สีขาวเป็นค่าเริ่มต้นที่ถูกต้องสำหรับเอกสารเกือบทุกประเภท; พารามิเตอร์นี้มีไว้สำหรับกรณีที่พบได้ยากซึ่งไม่ได้เป็นแบบนี้
ค่า 0, 0 คือออฟเซ็ต Left และ Top เข้าไปในหน้ากระดาษ ในพื้นที่พิกัดที่ถูกสเกลแล้ว และคุณควรปล่อยให้เป็นศูนย์เว้นแต่ว่าคุณจะทำการครอบตัด (crop) ส่วน ro0 คือการหมุน (rotation): ปล่อยไว้ที่ศูนย์แล้ว PDFium จะทำตามค่าการหมุนที่หน้ากระดาษระบุไว้ในเอนทรี /Rotate อยู่แล้ว ดังนั้นหน้าที่ถูกสร้างมาเป็นแนวนอนก็จะออกมาเป็นแนวนอนโดยที่คุณไม่ต้องทำอะไรเลย
การเข้ารหัสบิตแมปเป็น JPEG
เมื่อมีบิตแมปแล้ว ส่วนของการจัดการ JPEG ก็เป็นเรื่องง่าย และเป็นเพียง Delphi ล้วนๆ คำสั่ง TJPEGImage.Assign จะคัดลอกบิตแมปเข้ามา CompressionQuality จะตั้งค่าคุณภาพในสเกล 1 ถึง 100 และ SaveToFile จะเขียนไฟล์ กฎลำดับข้อเดียวที่มีคือต้องตั้งค่าคุณภาพก่อนที่จะเซฟ เพราะมันจะเป็นตัวควบคุมการเข้ารหัสที่ SaveToFile เรียกใช้งาน
uses
Vcl.Graphics, Vcl.Imaging.jpeg, PDFium;
procedure SavePageAsJpeg(Pdf: TPdf; PageNumber, Dpi, Quality: Integer;
const FileName: string);
var
Bitmap: TBitmap;
Jpeg: TJPEGImage;
begin
Pdf.PageNumber := PageNumber;
Bitmap := Pdf.RenderPage(0, 0,
Round(Pdf.PageWidth * Dpi / 72),
Round(Pdf.PageHeight * Dpi / 72),
ro0, [], clWhite);
try
Jpeg := TJPEGImage.Create;
try
Jpeg.Assign(Bitmap);
Jpeg.CompressionQuality := Quality; // 1..100
Jpeg.SaveToFile(FileName);
finally
Jpeg.Free;
end;
finally
Bitmap.Free;
end;
end;
โค้ด try/finally ซ้อนทับกันนั้นอาจดูจุกจิกสำหรับฟังก์ชันตัวช่วยที่ทำแค่หน้าเดียว แต่มันถูกต้องอย่างยิ่งสำหรับการทำแบบชุด บล็อกด้านในจะปลดปล่อยตัวเข้ารหัส บล็อกด้านนอกปลดปล่อยบิตแมป และหากมีอันไหนเกิดข้อยกเว้น (exception) ขึ้น มันก็จะยังคงคืนสิ่งที่เป็นเจ้าของอยู่ หากยุบรวมทั้งสองบล็อกเป็นอันเดียว ข้อยกเว้นที่เกิดขึ้นระหว่างการเข้ารหัสอาจทำให้บิตแมปตกค้างได้ เมื่อต้องทำงานระยะยาว นี่คือความแตกต่างระหว่างโปรแกรมแปลงไฟล์ที่ทำงานเสร็จสมบูรณ์ กับโปรแกรมที่ตายกลางทางที่หน้า 300 พร้อมกับไฟล์เสียและหน้าต่างแจ้งเตือนหน่วยความจำเต็ม
การเลือก DPI และคุณภาพควบคู่กัน
ตัวปรับสองตัวนี้ไม่ได้เป็นอิสระจากจุดประสงค์ของผลลัพธ์ และข้อผิดพลาดที่พบได้บ่อยคือการปรับให้สูงทั้งคู่เพื่อความเผื่อขาด รูปตัวอย่าง (thumbnail) สำหรับเว็บที่ถูกเรนเดอร์ด้วย 300 DPI และเซฟด้วยคุณภาพ 95 จะมีขนาดหลายร้อยกิโลไบต์โดยแสร้งทำตัวเป็นภาพขนาด 120 พิกเซล; เบราว์เซอร์จะทิ้งข้อมูลเกือบทั้งหมดตอนที่ทำการลดขนาดภาพ คุณควรปรับความละเอียดให้ตรงกับพิกเซลที่ผลลัพธ์ต้องการจริงๆ จากนั้นเลือกคุณภาพที่ผ่านการบีบอัดแบบสูญเสียข้อมูล (lossy compression) ของ JPEG โดยไม่เกิดร่องรอยความผิดเพี้ยนที่มองเห็นได้
| ผลลัพธ์ | DPI | คุณภาพ JPEG |
|---|---|---|
| รูปตัวอย่างในรายการ | 72 | 60-70 |
| พรีวิวบนหน้าจอ | 96-150 | 80-85 |
| ดูแบบความละเอียดสูง | 200-300 | 85-95 |
| ไฟล์ต้นฉบับสำหรับพิมพ์ | 300-600 | 90-100 |
เรื่องคุณภาพ JPEG เองก็มีข้อควรระวัง มันไม่ใช่ตัวปรับแบบเชิงเส้น (linear) การก้าวกระโดดจาก 70 เป็น 85 จะช่วยให้ภาพสวยขึ้นอย่างเห็นได้ชัดในขณะที่ขนาดไฟล์เพิ่มขึ้นเพียงเล็กน้อย แต่การขยับจาก 95 เป็น 100 จะทำให้ขนาดไฟล์เพิ่มขึ้นเกือบสองเท่าโดยที่แทบไม่มีใครสังเกตเห็นความต่าง เพราะที่คุณภาพ 100 ก็ยังไม่ใช่แบบไม่สูญเสียข้อมูล (lossless) แค่มันลดการตัดข้อมูลทิ้งลงเท่านั้น สำหรับหน้าเอกสารที่มีตัวอักษรเยอะ การบีบอัดแบบบล็อกของ JPEG จะทำให้ขอบคมๆ ของตัวอักษรกลายเป็นรอยก้อง (ringing) จางๆ ซึ่งเป็นเหตุผลว่าทำไมคุณภาพที่ต่ำกว่า 80 จึงทำให้ข้อความบนสิ่งพิมพ์ที่ควรจะคมชัดดูเหมือนข้อความที่ถูกสแกน หากหน้าเอกสารส่วนใหญ่เป็นข้อความและคุณเปลี่ยนฟอร์แมตได้ การใช้ PNG จะเรนเดอร์ข้อความนั้นโดยไม่เกิดรอยก้อง; JPEG จะเหมาะสมเมื่อใช้กับภาพถ่ายหรือคอนเทนต์แบบผสม ซึ่งการบีบอัดของมันจะทำให้ไฟล์เล็กกว่าจริงๆ
รูปตัวอย่าง (thumbnails) ที่เร็วและเล็กกว่า
เมื่อเป้าหมายคือรูปตัวอย่างไม่ใช่การจำลองภาพที่เหมือนต้นฉบับเป๊ะๆ คุณสามารถสั่งให้ตัวเรนเดอร์ทำงานน้อยลงได้ พารามิเตอร์ Options รับค่าแฟล็กชุด TRenderOption และมีสองสามตัวในนั้นที่ยอมลดความเที่ยงตรงเพื่อแลกกับความเร็วในแบบที่พรีวิวขนาดเล็กต้องการ reGrayscale จะตัดสีทิ้ง ซึ่งช่วยให้ทั้งเรนเดอร์เร็วขึ้นและได้บิตแมปขนาดเล็กกว่าสำหรับเข้ารหัส ส่วน reNoSmoothImage และ reNoSmoothPath จะข้ามการทำ anti-aliasing ซึ่งยังไงก็มองไม่เห็นอยู่แล้วในขนาดรูปตัวอย่าง
function RenderThumbnail(Pdf: TPdf; PageNumber, MaxW, MaxH: Integer): TBitmap;
var
Scale: Double;
begin
Pdf.PageNumber := PageNumber;
// Fit the page inside MaxW x MaxH while preserving aspect ratio.
Scale := Min(MaxW / Pdf.PageWidth, MaxH / Pdf.PageHeight);
Result := Pdf.RenderPage(0, 0,
Round(Pdf.PageWidth * Scale),
Round(Pdf.PageHeight * Scale),
ro0, [reGrayscale, reNoSmoothImage], clWhite);
end;
กรณีของการทำรูปตัวอย่างยังแสดงให้เห็นถึงวิธีคิดเกี่ยวกับการปรับขนาดที่สะอาดตากว่า แทนที่จะคำนวณผ่าน DPI ให้คำนวณค่าสเกลแฟกเตอร์เดี่ยวที่จัดหน้าเอกสารให้พอดีกับกรอบ (bounding box) และยังคงอัตราส่วนภาพเอาไว้ ซึ่งนี่คือสิ่งที่ฟังก์ชัน Min ของสองอัตราส่วนช่วยทำให้ หน้าเอกสารแนวตั้งและแนวนอนจะถูกนำมาวางในกรอบเดียวกันโดยไม่บิดเบี้ยว และคุณก็ไม่ต้องมานั่งคิดว่า DPI ไหนถึงจะตรงกับ "จัดให้พอดี 200 คูณ 280" ข้อควรระวังอย่างหนึ่งของ reGrayscale คือ: มันแปลงเนื้อหาภาพแรสเตอร์เป็นสีเทา แต่พวกลวดลายเวกเตอร์และข้อความจะยังเก็บค่าสีของตัวเองไว้ในเอนจิน ดังนั้นหน้าที่ส่วนใหญ่เป็นเวกเตอร์อาร์ตอาจออกมาดูเป็นโมโนโครมน้อยกว่าที่ชื่อแฟล็กบอกเอาไว้ หากต้องการผลลัพธ์ที่เป็นสีเทาล้วนจริงๆ การนำบิตแมปที่เรนเดอร์แล้วไปแปลงด้วย GrayscalePdfBitmap จะเป็นวิธีที่เชื่อถือได้มากกว่า
การทำแบตช์ตลอดทั้งเอกสาร
การนำมารวมกันเพื่อให้ทำงานกับเอกสารทั้งฉบับคือการลูปผ่าน PageCount โดยเปลี่ยน PageNumber ไปทีละหน้า เลขหน้าจะเริ่มที่ 1: หน้าหนึ่งคือ PageNumber := 1 และลูปจะรันไปจนถึง PageCount (รวมค่านี้ด้วย) ไม่ใช่ PageCount - 1 อีกสิ่งที่การทำแบตช์ต้องเคารพคือข้อตกลงเรื่องการโหลดแบบเงียบ (silent-load) การตั้งค่า Active := True จะไม่มีทางแจ้งข้อยกเว้นสำหรับไฟล์ที่เสียหายหรือพาสเวิร์ดผิด; แต่มันแค่ปล่อยให้ Active อยู่ที่ False ให้ตรวจสอบค่านี้ก่อนที่คุณจะเรนเดอร์สักหน้า มิฉะนั้น RenderPage ครั้งแรกก็จะไปทำงานกับเอกสารที่ไม่เคยถูกเปิดขึ้นมาเลย
procedure ExportAllPages(const PdfPath, OutDir: string; Dpi, Quality: Integer);
var
Pdf: TPdf;
I, Digits: Integer;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := PdfPath;
Pdf.Active := True;
if not Pdf.Active then
raise Exception.Create('Could not open ' + PdfPath);
Digits := Length(IntToStr(Pdf.PageCount)); // zero-pad so files sort right
for I := 1 to Pdf.PageCount do
SavePageAsJpeg(Pdf, I, Dpi, Quality,
Format('%s\page_%.*d.jpg', [OutDir, Digits, I]));
finally
Pdf.Active := False;
Pdf.Free;
end;
end;
การเติมศูนย์นำหน้า (zero-padding) ด้วย Digits เป็นเรื่องเล็กๆ ที่จะช่วยประหยัดเวลาคุณไปได้หลายชั่วโมงในภายหลัง หากตั้งชื่อไฟล์ตั้งแต่ page_1.jpg ไปจนถึง page_10.jpg เครื่องมือใดๆ ก็ตามที่เรียงลำดับไฟล์เหล่านั้นในฐานะสตริงก็จะนำ page_10 ไปต่อท้าย page_1 ทันที ซึ่งทำให้ลำดับเละเทะ การเติมความยาวอักขระให้เท่ากับตัวเลขหน้าเอกสารที่สูงที่สุด ซึ่งจะทำให้เอกสารความยาว 300 หน้าได้ไฟล์ page_001.jpg จะช่วยรักษาลำดับตามตัวอักษรและลำดับหน้าให้ตรงกันในขั้นตอนต่อๆ ไป
สำหรับเอกสารที่ใหญ่จนใช้เวลาแปลงไฟล์นานอย่างเห็นได้ชัด ให้รันมันแยกจากเธรด UI หรือทำการ pump เมสเซจระหว่างทำแต่ละหน้าเพื่อให้แอปพลิเคชันยังคงตอบสนองได้ และควรมีปุ่มให้ผู้ใช้กดหยุดได้ หากคุณกำลังเรนเดอร์หน้าที่มีขนาดใหญ่มากๆ และต้องการให้ยกเลิกได้กลางคัน แทนที่จะทำได้แค่ตอนเปลี่ยนหน้า PDFium Component มีฟีเจอร์การเรนเดอร์แบบก้าวหน้า (progressive render) พร้อมด้วย cancellation token; ซึ่งนั่นเป็นกลไกที่ค่อนข้างหนักเกินความจำเป็นสำหรับการเอกซ์พอร์ตแบบแบตช์ส่วนใหญ่ แต่มันก็มีให้ใช้งานเวลาที่หน้ากระดาษแผ่นเดียวที่ความละเอียด 600 DPI ทำงานช้าเกินไปจนบล็อกระบบ
สิ่งสุดท้ายที่ควรทราบเกี่ยวกับการใช้งานควบคู่กัน การทำแรสเตอร์บนหน้าเอกสารจะเป็นการทิ้งชั้นข้อความ (text layer) ไป: ไฟล์ JPEG จะเป็นพิกเซล และคำต่างๆ ในนั้นจะไม่สามารถเลือก (selectable) หรือค้นหาได้อีกต่อไป เมื่อคุณต้องการทั้งรูปภาพและข้อความที่อยู่ด้านล่าง ให้เรนเดอร์เอาภาพแล้วจึงดึงข้อความแยกออกมาต่างหาก ซึ่งมีอธิบายในบทความคู่มือเกี่ยวกับการสกัดข้อความจากเอกสาร PDF ด้วย PDFium Component การโอเวอร์โหลด RenderPage และตัวเลือกในการเรนเดอร์ที่แสดงไว้ที่นี่เป็นส่วนหนึ่งของ PDFium Component สำหรับ Delphi และ C++Builder