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

บั๊กการหมุนซ้ำและ Fit-Zoom ของ PDFium ใน Delphi

ฟังก์ชัน FPDF_RenderPageBitmap ของ PDFium Component รับอาร์กิวเมนต์ rotate ที่ PDFium บวกเข้าไปเสมอทับการหมุนใดก็ตามที่หน้ามีอยู่แล้วใน entry /Rotate ของมันเอง ดังนั้นการอ่านค่าการหมุนที่เก็บไว้ของหน้าแล้วป้อนค่าเดียวกันนั้นกลับเข้าไปในการเรียก render จะหมุนหน้านั้นสองครั้ง ข้อผิดพลาดแบบเดียวกันเป๊ะปรากฏในคณิตศาสตร์ fit-zoom การกำหนดขนาด thumbnail จากความกว้างและความสูงที่ไม่ได้หมุนของหน้าจะสร้างอัตราส่วนภาพที่ผิด ทุกครั้งที่ /Rotate เป็น 90 หรือ 270 องศา เพราะบิตแมปที่ render ออกมาจะมีความกว้างและความสูงสลับกัน

ความล้มเหลวนี้สังเกตได้ง่ายเมื่อคุณรู้ว่าต้องมองหาอะไร และพลาดได้ง่ายจนกว่าจะถึงตอนนั้น ชุดใบแจ้งหนี้ที่สแกนมาถึงพร้อมต้นฉบับผสมทั้งแนวตั้งและแนวนอน มีใครสักคนปรับให้ตรงครึ่งหนึ่งด้วยการหมุน 90 องศาใน Acrobat ก่อนที่จะเก็บถาวร แล้วแถบ thumbnail ใน Delphi viewer ที่สร้างบน PDFium ก็ render หน้าเหล่านั้นออกมาเอียงข้าง กลับหัว หรือถูกบีบเข้าไปในกล่องที่มีรูปทรงสำหรับทิศทางที่ผิด ไม่มีอะไร throw exception เลย ไม่มีอะไร log error เลย พิกเซลแค่ผิดเฉยๆ และเฉพาะสำหรับเซตย่อยของหน้าที่ใครสักคนหมุนไว้ทีหลังเท่านั้น เป็นบั๊กประเภทที่รอดผ่านการ QA เต็มรูปแบบกับ PDF ทดสอบที่ไม่ได้หมุน แล้วก็ปรากฏขึ้นใน production ที่หน้า 47 ของไฟล์จริง

ทำไม PDFium ถึงหมุนหน้าสองครั้ง

PDFium ใช้ค่า /Rotate ของหน้าเองโดยอัตโนมัติทุกครั้งที่มัน render บิตแมป ไม่ว่าจะส่งอะไรเข้าไปให้ renderer พารามิเตอร์ rotate ของ FPDF_RenderPageBitmap ซึ่งเปิดใน PDFiumPas เป็นค่า TRotation คือ ro0, ro90, ro180 และ ro270 บน TPdf.RenderPage, TPdf.RenderTile และ TPdf.RenderPageThumbnail ไม่ได้ตั้งมุมที่หน้าควรลงเอย พารามิเตอร์ rotate ตั้งว่าจะซ้อนการหมุนเพิ่มเติมเท่าใดทับสิ่งที่ page dictionary ระบุไว้แล้ว ซึ่งเป็นเหตุผลที่ทุก method เหล่านั้นตั้งค่าเริ่มต้นเป็น ro0

TPdf.PageRotation อ่านค่า /Rotate เดียวกันนั้นผ่าน FPDFPage_GetRotation และโค้ดแอปพลิเคชันมักต้องการมันด้วยเหตุผลที่ไม่เกี่ยวกับการ render เลย เช่น การตัดสินใจว่าจะจัดวาง annotation ในพื้นที่หน้าอย่างไร กับดักอยู่ที่บรรทัดเดียว การส่ง PageRotation เข้าไปในอาร์กิวเมนต์ Rotation ของ RenderPage โดยคาดหวังว่าการเรียกจะทำให้หน้าตรงขึ้น หน้าที่บันทึกไว้แล้วด้วย /Rotate 90 แสดงถูกต้อง หมุนแล้ว ใน viewer ที่เป็นไปตามมาตรฐานใดๆ รวมถึง PDFium ด้วย บวก ro90 เข้าไปอีกทับสิ่งนั้น หน้าจะเหวี่ยงไปที่ 180 องศาแทนที่จะเป็น 90 องศาที่ตั้งใจ ในขณะที่หน้าที่ไม่มีการหมุนเลยจะถูกหมุนหนึ่งในสี่รอบที่ไม่ต้องการโดยไม่มีเหตุผล

// Wrong: PageRotation already reflects /Rotate, and PDFium applies
// it automatically on every render -- passing it again as Rotation
// doubles the angle
Bitmap := Pdf.RenderPage(0, 0, TargetW, TargetH, Pdf.PageRotation, []);

// Right: leave Rotation at its ro0 default and let PDFium apply the
// page's own /Rotate exactly once
Bitmap := Pdf.RenderPage(0, 0, TargetW, TargetH, ro0, []);

พารามิเตอร์ Rotation มีไว้สำหรับอะไรจริงๆ

พารามิเตอร์ Rotation ได้ที่ทางใน API สำหรับงานที่ต่างออกไปโดยแท้จริง คือการเพิ่มการหมุนสำหรับการดูอย่างเดียวที่ไม่เกี่ยวอะไรกับทิศทางที่เก็บไว้ของหน้าเลย ประเภทที่ปุ่ม toolbar หมุน-มุมมองใช้โดยไม่แตะไฟล์ต้นฉบับเลย TPdfView เก็บสองแนวคิดนี้ไว้เป็นสอง property แยกกันด้วยเหตุผลนี้พอดี TPdfView.PageRotation สะท้อน /Rotate ของหน้าเอง และผ่าน FPDFPage_SetRotation สามารถเขียนค่าใหม่กลับเข้าไปในเอกสารได้ TPdfView.Rotation เป็น property แบบชั่วคราวสำหรับการดูอย่างเดียว ค่าเริ่มต้นคือ ro0 และไม่เคยแตะไฟล์เลย การอ่าน property แรกแล้วเขียนมันเข้า property ที่สอง คือบั๊กทั้งหมดในหนึ่งประโยค

// View-only: rotates what the user sees, changes nothing in the file
procedure TViewerForm.RotateViewClick(Sender: TObject);
begin
  case PdfView.Rotation of
    ro0:   PdfView.Rotation := ro90;
    ro90:  PdfView.Rotation := ro180;
    ro180: PdfView.Rotation := ro270;
    ro270: PdfView.Rotation := ro0;
  end;
end;

// Persistent: rewrites the page's own /Rotate entry in the document
procedure TViewerForm.RotatePageClick(Sender: TObject);
begin
  case PdfView.PageRotation of
    ro0:   PdfView.PageRotation := ro90;
    ro90:  PdfView.PageRotation := ro180;
    ro180: PdfView.PageRotation := ro270;
    ro270: PdfView.PageRotation := ro0;
  end;
end;

ทำไมการกำหนดขนาด fit-zoom ถึงพังแบบเดียวกัน

การกำหนดขนาด fit-zoom พังด้วยเหตุผลที่เป็นภาพสะท้อนกัน การคำนวณเริ่มจากตัวเลขคู่ที่ผิดแทนที่จะเป็นมุมที่ผิด วิธีทั่วไปในการกำหนดขนาดกล่อง thumbnail คือขอความกว้างและความสูงของหน้าจาก PDFium เปรียบเทียบอัตราส่วนภาพนั้นกับกล่องที่มีอยู่ และคำนวณสี่เหลี่ยมที่ใหญ่ที่สุดที่พอดีภายในมัน ซึ่งทำงานได้อย่างสะอาดสำหรับหน้าที่ไม่ได้หมุน การคำนวณเดียวกันล้มเหลวอย่างเงียบๆ สำหรับหน้า /Rotate 90 หรือ /Rotate 270 เมื่อความกว้างและความสูงมาจากการเรียกที่รายงานขนาดที่แท้จริงและไม่ได้หมุนของหน้า หน้า A4 แนวตั้งที่มี /Rotate 90 ยังคงรายงานประมาณ 595 คูณ 842 point แม้ว่า PDFium จะ render มัน อย่างถูกต้อง ที่ประมาณ 842 คูณ 595 เมื่อการหมุนมีผลแล้ว และกล่อง fit ที่คำนวณจากคู่ที่ไม่ได้หมุนก็ลงเอยด้วยรูปทรงสำหรับทิศทางที่ผิดโดยสิ้นเชิง

FPDF_GetPageSizeByIndex เป็นตัวอย่างที่เป็นรูปธรรมของการเรียกที่รายงานขนาดที่แท้จริงและไม่ได้หมุนนั้นโดยการออกแบบ ซึ่งทำให้มันสะดวกสำหรับการสแกนมิติของหน้าโดยไม่ต้องโหลดทุกหน้า และเสี่ยงสำหรับคณิตศาสตร์ fit-zoom ที่ลืมคำนึงถึงมัน ทางแก้ตามมาโดยตรงจากการระบุปัญหา ตรวจสอบการหมุนของหน้าก่อนทำเลขคณิต fit สลับความกว้างกับความสูงทุกครั้งที่การหมุนนั้นเป็น 90 หรือ 270 องศา คำนวณกล่อง fit จากคู่ที่สลับแล้ว และยังคงส่ง ro0 ไปยังการเรียก render จริง เพราะ PDFium ยังคงเป็นตัวที่ใช้การหมุนจริง

การได้ thumbnail ที่ถูกต้องโดยไม่ต้องคิดค้นคณิตศาสตร์ fit ใหม่

TPdf.RenderPageThumbnail พกทางแก้นี้อยู่แล้ว ดังนั้นเส้นทางที่สั้นที่สุดไปยัง thumbnail ที่ถูกต้องคือเรียกมัน แทนที่จะประกอบ logic fit-และ-rotate ใหม่ด้วยมือ เมื่อได้ดัชนีหน้าแบบเริ่มที่ 1 และความกว้างกับความสูงสูงสุด RenderPageThumbnail คำนวณกล่อง fit แก้ไขมันสำหรับ /Rotate 90 หรือ 270 ภายใน และคืนบิตแมปที่ผู้เรียกเป็นเจ้าของโดยไม่รบกวนหน้าปัจจุบันของเอกสารหรือยิง event OnPageChange ซึ่งสำคัญสำหรับแถบ thumbnail ที่สร้างควบคู่กับ viewer แบบ live บน instance TPdf เดียวกัน

// PageW, PageH are a page's own (unrotated) dimensions in points, for
// example from FPDF_GetPageSizeByIndex, which reports size before
// /Rotate is applied
function FitBox(PageW, PageH: Double; Rotation: TRotation;
  MaxW, MaxH: Integer; out FitW, FitH: Integer): Boolean;
var
  PgW, PgH, Swap: Integer;
begin
  PgW := Round(PageW);
  PgH := Round(PageH);
  if PgW < 1 then PgW := 1;
  if PgH < 1 then PgH := 1;

  if Rotation in [ro90, ro270] then
  begin
    Swap := PgW;
    PgW := PgH;
    PgH := Swap;
  end;

  Result := (MaxW > 0) and (MaxH > 0);
  if not Result then
    Exit;

  if PgW * MaxH > PgH * MaxW then
  begin
    FitW := MaxW;
    FitH := (MaxW * PgH) div PgW;
  end
  else
  begin
    FitH := MaxH;
    FitW := (MaxH * PgW) div PgH;
  end;
end;

helper FitBox ควรค่าแก่การเก็บไว้ต่ออยู่ดี เพราะ RenderPageThumbnail ครอบคลุมแค่กรณีบิตแมปเดียวเท่านั้น grid thumbnail แบบกำหนดเอง, แถบ print-preview หรือ dialog เลือกหน้าที่จัดวางหลายหน้าเทียบกับกล่องที่เป็นอิสระต่อกัน ต้องการคณิตศาสตร์ fit ที่รู้เรื่องการหมุนแบบเดียวกัน โดยไม่จำเป็นต้องอยากได้บิตแมปใหม่สำหรับทุก tile และโหมด zoom แบบ fit-page และ fit-width ของ TPdfView เองก็พึ่งพาแนวคิดเดียวกันนี้ภายใน โดยเลือกระหว่างความกว้างกับความสูงของหน้าสำหรับการคำนวณอัตราส่วน zoom ตามการหมุนปัจจุบันของ view ก่อนที่จะเปรียบเทียบมันกับพื้นที่ client ที่มีอยู่ ถ้าประสิทธิภาพของ zoom และการ scroll ใน viewer ประเภทนั้นเป็นปัญหาถัดไปในรายการ บทความคู่กันเรื่องการ cache การ render และ zoom ที่ลื่นไหลใน Delphi viewer ที่อิง PDFium จะรับช่วงต่อพอดีจากจุดที่การกำหนดขนาดที่ถูกต้องทิ้งไว้

การสังเกตเห็นการหมุนซ้ำก่อนที่ลูกค้าจะเจอ

การหมุนซ้ำมีลายเซ็นทางภาพที่เชื่อถือได้หนึ่งอย่าง หน้าที่ถูกหมุน 90 องศาตอนเข้ามาจะออกมาดูเหมือนหมุน 180 เทียบกับส่วนที่เหลือของเอกสาร ไม่ใช่ 90 เพราะ ro90 พิเศษซ้อนทับ ro90 ของหน้าเองแทนที่จะแทนที่มัน fixture ทดสอบที่สร้างจากหน้า /Rotate 0 เท่านั้นจะไม่มีวันจับสิ่งนี้ได้เลย เพราะการบวก ro0 เข้ากับ ro0 ก็ยังคงเป็น ro0 และบั๊กยังคงมองไม่เห็น fixture ต้องมีอย่างน้อยหนึ่งหน้าที่บันทึกด้วย /Rotate 90 และหนึ่งหน้าด้วย /Rotate 270 ก่อนที่ code path ของ thumbnail หรือ fit-zoom จะเชื่อถือได้

pipeline การแปลงหน้าเป็นบิตแมปพื้นฐานที่ครอบคลุมในการ render หน้า PDF เป็น JPEG ด้วย PDFium Component render หน้าที่หมุนแล้วได้ถูกต้องอยู่แล้วโดยไม่มีโค้ดกรณีพิเศษเลย เพราะมันปล่อยให้ Rotation อยู่ที่ค่าเริ่มต้น ro0 ของมันและปล่อยให้ PDFium ใช้ /Rotate เอง บั๊กการหมุนซ้ำปรากฏขึ้นก็ต่อเมื่อโค้ดแอปพลิเคชันเริ่มอ่าน PageRotation กลับออกมาและป้อนมันไปยังที่ที่มันไม่ควรอยู่เท่านั้น

การเรียก render ที่รู้เรื่องการหมุนและการกำหนดขนาด thumbnail ที่อธิบายในบทความนี้เป็นส่วนหนึ่งของPDFium Componentสำหรับ Delphi และ C++Builder ควบคู่ไปกับ API ด้านการ render, การดู และการดึงข้อความที่เหลือซึ่งสร้างบนคลาส TPdf และ TPdfView เดียวกัน