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

การพิมพ์เอกสาร PDF ด้วย PDFium Component ใน Delphi

พิกัดของ PDF อยู่ในหน่วยพอยต์ (points) ส่วนพิกัดของเครื่องพิมพ์อยู่ในหน่วยของอุปกรณ์ (device units) และทั้งสองอย่างนี้ไม่มีความเกี่ยวข้องกันเลยจนกว่าคุณจะแปลงค่ามันอย่างจงใจ ความไม่สอดคล้องกันตรงนี้คือต้นเหตุของผลลัพธ์การพิมพ์ที่แย่ที่สุดในแอปพลิเคชัน Delphi: โค้ดส่งไฟล์ที่ถูกต้องไป แต่หน้ากระดาษกลับออกมาถูกตัดแหว่ง ยืดผิดส่วน หรือเป็นหน้าขาว PDFium Component จัดการเรื่องการเรนเดอร์ได้อย่างหมดจด; ส่วนระบบท่อส่งไปยังเครื่องพิมพ์ก็เป็นมาตรฐานของ VCL สองสิ่งนี้จะประกอบเข้าด้วยกันด้วยโค้ดเพียงเล็กน้อยเมื่อคุณเข้าใจว่าแต่ละฝั่งต้องการอะไร

ท่อส่งข้อมูลการเรนเดอร์แล้วพิมพ์ (render-then-print) ทำงานอย่างไร

PDFium Component ไม่ได้คุยกับเครื่องพิมพ์โดยตรง รูปแบบการทำงานคือ: เรนเดอร์หน้ากระดาษไปที่ TBitmap ด้วยความละเอียดที่คุณต้องการ จากนั้นโอนบิตแมปนั้นไปยังแคนวาส (canvas) ของเครื่องพิมพ์ด้วย StretchDIBits TPdf.RenderPage จะส่งคืนบิตแมปที่ผู้เรียก (caller) เป็นเจ้าของ ดังนั้นคุณจึงควบคุมขนาดพิกเซลได้ ส่งค่า [rePrinting] เข้าไปในชุดตัวเลือก แล้ว PDFium จะเปลี่ยนเส้นทางการเรนเดอร์ไปเป็นโหมดที่ละทิ้งเอฟเฟกต์สำหรับหน้าจอเท่านั้น เช่น LCD subpixel hinting และจัดการกับ MediaBox ของหน้ากระดาษได้อย่างถูกต้องสำหรับผลลัพธ์การพิมพ์ หากละทิ้ง rePrinting สิ่งที่คุณส่งไปยังเครื่องพิมพ์จะเป็นผลลัพธ์แบบที่ใช้บนหน้าจอ ซึ่งดูดีบนมอนิเตอร์ แต่มีแนวโน้มที่จะให้ผลลัพธ์ที่นุ่มนวลเบลอเกินไปบนเครื่องพิมพ์ความละเอียดสูง (high-DPI) เพราะการตัดสินใจเลือก hinting สำหรับหน้าจอ 96 DPI ไม่เหมาะกับการพิมพ์ระดับ 300 หรือ 600 DPI

TPdf.Active เป็นประตูด่านเดียวที่ต้องตรวจสอบก่อนสัมผัสคุณสมบัติของหน้ากระดาษใด ๆ คอมโพเนนต์จะกลืนกินข้อผิดพลาดในการโหลดแบบเงียบ ๆ: การตั้งค่า Active := True กับไฟล์ที่เสียหายหรือติดรหัสผ่านจะไม่แจ้ง exception ขึ้นมา; แต่มันจะปล่อย Active ให้เป็น False เสมอ ต้องตรวจสอบมันทุกครั้งหลังการกำหนดค่า การอ่านค่า PageCount หรือ PageWidth บนเอกสารที่ไม่แอ็คทีฟจะคืนค่าศูนย์ ซึ่งก่อให้เกิด no-ops (ไม่มีการดำเนินการ) แบบเงียบ ๆ ที่ยากต่อการวิเคราะห์หาสาเหตุมากเมื่อมันถูกส่งไปถึงสปูลเลอร์ (spooler) แล้ว

ไปป์ไลน์เรนเดอร์แล้วพิมพ์ใน Delphi ด้วย PDFium Component: หน้า PDF ที่โหลดแล้วเรนเดอร์ผ่าน TPdf.RenderPage พร้อม flag rePrinting ลง TBitmap ขนาดเครื่องพิมพ์ แล้ว StretchDIBits โอนไปยัง canvas ของเครื่องพิมพ์ เทียบกับเอาต์พุตที่อ่อนนุ่มจากการเรนเดอร์แบบจอ เมื่อละ rePrinting ไว้
PDFium Component ไม่เคยพูดคุยกับ spooler โดยตรง RenderPage ผลิตบิตแมป StretchDIBits ย้ายมันขึ้น canvas เครื่องพิมพ์ และ rePrinting เลือกเส้นทางเรนเดอร์ที่มีไว้สำหรับกระดาษ

ลูปการพิมพ์แบบน้อยที่สุด (minimal print loop)

กรณีที่ใช้งานได้ง่ายที่สุดคือการโหลดไฟล์ เปิดงานพิมพ์ วนลูปหน้ากระดาษ และปิดงาน รายละเอียดที่ซับซ้อนมีเพียงเรื่องเดียวคือ Printer.NewPage ห้ามถูกเรียกก่อนหน้าแรก ดังนั้นจึงต้องมีแฟล็ก (flag) FirstPage การถ่ายโอน StretchDIBits ผ่านการเรียก GetDIBSizes และ GetDIB เพื่อดึงข้อมูลบิตที่เป็นอิสระจากอุปกรณ์ออกจากแฮนเดิล (handle) ของบิตแมป จากนั้นจึงระบายลงบนแคนวาสของเครื่องพิมพ์ด้วยขนาดหน้ากระดาษเต็มรูปแบบ:

procedure PrintPdfFile(const FileName: string);
var
  Pdf: TPdf;
  I: Integer;
  Bitmap: TBitmap;
  InfoHeaderSize, ImageSize: DWORD;
  InfoHeader: PBitmapInfo;
  Image: Pointer;
  FirstPage: Boolean;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;
    if not Pdf.Active then
      Exit;  // โหลดล้มเหลวโดยไม่รายงาน ให้หยุดทำงาน

    Printer.Title := Pdf.Title;
    Printer.BeginDoc;
    try
      FirstPage := True;
      for I := 1 to Pdf.PageCount do
      begin
        if FirstPage then
          FirstPage := False
        else
          Printer.NewPage;

        Pdf.PageNumber := I;

        // Render ที่ความละเอียด printer โดย rePrinting จะปรับ render path
        Bitmap := Pdf.RenderPage(
          0, 0,
          Printer.PageWidth,
          Printer.PageHeight,
          ro0,
          [rePrinting]
        );
        try
          GetDIBSizes(Bitmap.Handle, InfoHeaderSize, ImageSize);
          InfoHeader := AllocMem(InfoHeaderSize);
          try
            Image := AllocMem(ImageSize);
            try
              GetDIB(Bitmap.Handle, 0, InfoHeader^, Image^);
              StretchDIBits(
                Printer.Canvas.Handle,
                0, 0, Printer.PageWidth, Printer.PageHeight,
                0, 0, Bitmap.Width, Bitmap.Height,
                Image, InfoHeader^, DIB_RGB_COLORS, SRCCOPY
              );
            finally
              FreeMem(Image);
            end;
          finally
            FreeMem(InfoHeader);
          end;
        finally
          Bitmap.Free;
        end;
      end;
    finally
      Printer.EndDoc;
    end;
  finally
    Pdf.Active := False;
    Pdf.Free;
  end;
end;

การส่งค่า Printer.PageWidth และ Printer.PageHeight เป็นขนาดบิตแมป หมายความว่าคุณทำการเรนเดอร์ในระดับขนาดพิกเซลที่แท้จริงของเครื่องพิมพ์ ซึ่งครอบคลุมถึง DPI ของอุปกรณ์เรียบร้อยแล้ว การเรียก StretchDIBits หลังจากนั้นจะทำผังพิกเซลเหล่านั้นในอัตราส่วน 1:1 ลงบนหน้ากระดาษ วิธีนี้ช่วยให้ได้ความแม่นยำสูงที่สุดเท่าที่จะเป็นไปได้โดยไม่ต้องคิดคำนวณ DPI โดยตรง แต่มันจะใช้งานได้ก็ต่อเมื่อหน้า PDF และกระดาษจริงมีขนาดเท่ากันเท่านั้น เมื่อขนาดต่างกัน คุณจำเป็นต้องสเกล (scale) อย่างชัดเจน

การปรับสเกลเมื่อขนาดหน้าและกระดาษแตกต่างกัน

หน้า PDF ในรูปแบบ A4 แนวตั้งไม่ได้พอดีกับเครื่องพิมพ์ US Letter โดยอัตโนมัติ และหน้ากระดาษแนวนอนที่ถูกส่งไปยังเครื่องพิมพ์แนวตั้งก็จะถูกตัดส่วนขอบออก แนวทางมาตรฐานคือการคำนวณสัดส่วนสเกลที่สม่ำเสมอจากอัตราส่วนของพิกเซลเครื่องพิมพ์ต่อพอยต์ของ PDF แล้วปรับใช้กับทั้งสองแกนเพื่อรักษาสัดส่วนภาพ (aspect ratio) Pdf.PageWidth และ Pdf.PageHeight เปิดเผยขนาดหน้ากระดาษปัจจุบันเป็นหน่วยพอยต์ โดยหนึ่งพอยต์เท่ากับ 1/72 นิ้ว การคูณด้วยเป้าหมาย DPI แล้วหารด้วย 72 จะเป็นการแปลงหน่วยเป็นพิกเซลที่ความละเอียดนั้น ๆ นำ Min ของอัตราส่วนแกน X และ Y เพื่อให้ได้สเกลสูงสุดที่ยังคงพอดียกเข้าไปอยู่ในพื้นที่ที่สามารถพิมพ์ได้:

การปรับสเกลให้พอดีหน้าสำหรับการพิมพ์ PDF ใน Delphi: หน้า PDF ขนาด 595 คูณ 842 พอยต์วางอยู่แบบปรับสเกลภายในพื้นที่พิมพ์ได้ของพิกเซลเครื่องพิมพ์ แปลงที่พอยต์คูณ Dpi หาร 72, ปรับสเกลด้วยอัตราส่วนที่เล็กกว่าระหว่างกว้างกับสูง, กำกำให้เหลือแบบย่อพอดี และจัดขนาดลงบิตแมปปลายทาง
พอยต์กลายเป็นพิกเซลที่พอยต์คูณ Dpi หาร 72 และอัตราส่วนที่เล็กกว่าชนะ เพื่อไม่ให้มีอะไรถูกตัด การบีบจำกัดไว้ที่ 1.0 ทำให้การ fit เหลือเพียงย่อให้พอดีเท่านั้น
// จัด PDF page ให้พอดีกับพื้นที่พิมพ์โดยรักษา aspect ratio
var
  ScaleX, ScaleY, Scale: Double;
  DestWidth, DestHeight: Integer;
  Dpi: Integer;
begin
  Dpi := 300;  // ความละเอียด render เป้าหมาย
  Pdf.PageNumber := PageIndex;

  ScaleX := Printer.PageWidth  / (Pdf.PageWidth  * Dpi / 72);
  ScaleY := Printer.PageHeight / (Pdf.PageHeight * Dpi / 72);
  Scale  := Min(ScaleX, ScaleY);

  // Clamp to 1.0 for shrink-to-fit only (no enlargement)
  if Scale > 1.0 then Scale := 1.0;

  DestWidth  := Round(Pdf.PageWidth  * Dpi / 72 * Scale);
  DestHeight := Round(Pdf.PageHeight * Dpi / 72 * Scale);

  Bitmap := Pdf.RenderPage(0, 0, DestWidth, DestHeight, ro0,
    [rePrinting, reAnnotations]);
  // ... transfer ด้วย StretchDIBits เช่นเดียวกับด้านบน
end;

การเรนเดอร์ที่ Dpi = 300 เหมาะสมที่สุดสำหรับเครื่องพิมพ์ในสำนักงานทั่วไป ที่ระดับ 600 DPI บิตแมปสำหรับหน้ากระดาษ A4 หน้าเดียวจะมีขนาดประมาณ 34 ล้านพิกเซล ซึ่งคิดเป็นพื้นที่ประมาณ 100 MB หากบันทึกเป็นบิตแมป 32-bit; การยกระดับคุณภาพที่ได้มาสำหรับเอกสารข้อความธรรมดานั้นถือว่าน้อยมาก และยังกินหน่วยความจำต่อหน้ากระดาษอย่างมากอีกด้วย เก็บ 600 DPI ไว้ใช้สำหรับร้านรับพิมพ์งาน หรือแบบแปลนเชิงเทคนิคที่มีกราฟิกเวกเตอร์สูง ๆ ที่ต้องการรายละเอียดอย่างแท้จริง

แฟล็ก reAnnotations ในโค้ดบล็อกที่สองเป็นอิสระจาก rePrinting รวมมันเข้าไปเมื่อผู้ใช้คาดหวังว่าตราประทับ ไฮไลต์ และกล่องคอมเมนต์จะไปปรากฏบนหน้ากระดาษด้วย หากตัดทิ้งไปจะได้เฉพาะเนื้อหา แฟล็กทั้งสองสามารถนำมาใช้ร่วมกันได้อย่างอิสระ

การหมุนหน้ากระดาษ

PDFium เก็บการหมุนหน้ากระดาษใน PDF เป็นรายการ /Rotate สามารถเข้าถึงได้ผ่าน Pdf.PageRotation ซึ่งคืนค่าเป็นข้อมูลแบบ TRotation (ro0, ro90, ro180, ro270) ระบบพิกัดของเครื่องพิมพ์จะกลับด้านการหมุน 90 และ 270 องศาเมื่อเทียบกับหน้าจอ หากคุณส่งค่า PageRotation ดิบ ๆ ให้ RenderPage โดยตรงโดยไม่มีการปรับแต่ง หน้ากระดาษแนวนอนที่แทรกอยู่ในเอกสารแนวตั้งจะพิมพ์กลับหัวกลับหางบนไดรเวอร์เครื่องพิมพ์ส่วนใหญ่ของ Windows วิธีแก้คือสลับค่าง่าย ๆ ก่อนการเรียก render: แมป ro90 ให้กลายเป็น ro270 และ ro270 กลับมาเป็น ro90 ส่วน ro0 และ ro180 ปล่อยไว้ดังเดิม

ตรวจสอบพฤติกรรมนี้กับเครื่องพิมพ์เป้าหมายของคุณโดยเฉพาะก่อนการส่งมอบงาน พฤติกรรมของไดรเวอร์เกี่ยวกับเรื่องการหมุนหน้าไม่ได้เป็นมาตรฐานเดียวกันในทุกผู้ผลิต และไดรเวอร์บางตัวมีการแก้ไขทิศทางหมุนด้วยตัวเองในระดับ GDI ด้วย หากคุณเจออาการเอกสารหมุนซ้อนกันสองครั้ง (double rotation) ให้นำการสลับที่เขียนไว้ออก; แต่ถ้าไม่มีการแก้ไขทิศทางเลย ก็ให้ใส่เข้าไป เอกสารที่มีทิศทางหน้ากระดาษปะปนกัน (สลับแนวตั้งแนวนอนไปมา) คือวิธีทดสอบความผิดพลาดทั้งสองแบบได้รวดเร็วที่สุดในระหว่างช่วงทดสอบ

การบริหารจัดการหน่วยความจำผ่านงานพิมพ์ยาว ๆ

การเรียก RenderPage แต่ละครั้งจะจองพื้นที่บิตแมป TBitmap อันใหม่ที่ฝั่งเรียกเป็นเจ้าของ และต้องทำลายทิ้งด้วยตัวเอง ในลูปด้านบน โค้ดบล็อก try/finally Bitmap.Free จะจัดการเรื่องนี้ได้อย่างถูกต้องทีละหน้ากระดาษ อย่าสะสมบิตแมปผ่านการวนลูปหลาย ๆ หน้า: การเรนเดอร์ระดับ 300-DPI สำหรับเอกสารความยาว 200 หน้า จะเผาผลาญพื้นที่ขนาดหลายกิกะไบต์ก่อนที่หน้าแรกจะไปถึงสปูลเลอร์เสียด้วยซ้ำ จงทำลายบิตแมปในทุก ๆ หน้าระหว่างการวนลูป

คู่การเรียก AllocMem / FreeMem ที่อยู่ภายในบล็อกการถ่ายโอนบิตก็ใช้กฎแบบเดียวกัน GetDIBSizes จะบอกให้คุณรู้ว่าโครงสร้างหัวไฟล์ DIB และข้อมูลพิกเซลต้องการพื้นที่หน่วยความจำมากเท่าไหร่; คุณจัดสรร เติมเต็ม วาด และทำลายล้างทุกสิ่งทุกอย่างที่อยู่ในสโคปของหน้ากระดาษเดียว หากปล่อยให้บล็อกใดบล็อกหนึ่งรั่วไหล จะส่งผลให้งานพิมพ์สูบเอาพื้นที่ heap ทั้งหมดของโปรเซสซิงไปจนเกลี้ยงในการรับมือกับเอกสารที่ยาวกว่าสักหลายสิบหน้ากระดาษ

หากคุณต้องการทำงานพิมพ์ผ่านเธรดเบื้องหลัง (background thread) ให้รักษาทั้ง TPdf และทุกการเรียกเครื่องพิมพ์ในฝั่ง VCL ให้อยู่ในเธรดเดียวกัน TPdf ตัวของมันเองไม่ได้รองรับเรื่อง thread-safe ต่อการใช้งานร่วมกันข้ามอินสแตนซ์ที่จะไปเรียกสถานะร่วม (global state) ของ PDFium DLL; โมเดลการใช้งานที่ปลอดภัยที่สุดคือ สร้าง TPdf 1 ตัวต่อ 1 เธรด โดยที่แต่ละอันจะแยกโหลดเอกสารก๊อปปี้ของตัวเอง

API สำหรับการเรนเดอร์และเอกสารที่แสดงไว้ที่นี้ เป็นส่วนหนึ่งของ PDFium Component สำหรับ Delphi และ C++Builder