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

รวมภาพสแกนเป็น PDF เดียวด้วย PDFium Component ใน Delphi

ทีมประมวลผลการเคลมต้องจัดการกับไฟล์กระดาษที่มีมานานถึงสามสิบปี ซึ่งกำลังถูกป้อนผ่านเครื่องสแกนเอกสาร เครื่องสแกนพ่นไฟล์ JPEG ออกมาหนึ่งไฟล์ต่อหนึ่งหน้าลงในโฟลเดอร์ โดยตั้งชื่อเป็น 0001.jpg, 0002.jpg ไปเรื่อย ๆ แต่สิ่งที่ฝ่ายจัดเก็บเอกสาร (archive) ต้องการจริง ๆ คือไฟล์ PDF หนึ่งไฟล์ต่อหนึ่งแฟ้มคดี โดยมีการจัดเรียงหน้าอย่างเป็นระเบียบ เพื่อให้ผู้ตรวจสอบสามารถเปิดอ่านเอกสารเพียงไฟล์เดียวแทนที่จะต้องคอยคลิกดูรูปขนาดย่อ (thumbnails) นับร้อยรูป ขั้นตอนสุดท้ายนี้เอง คือการเปลี่ยนกองภาพสแกนที่มีหมายเลขกำกับให้กลายเป็นไฟล์ PDF เดียวที่มีการจัดเรียง ซึ่งนั่นคืองานของเราในตอนนี้

PDFium Component สามารถจัดการเรื่องนี้ได้โดยตรง นอกเหนือจากการเรนเดอร์และการดึงข้อความ (text extraction) ตัวคอมโพเนนต์สามารถสร้างไฟล์ PDF ขึ้นมาจากศูนย์ได้: คือเริ่มสร้างเอกสารเปล่า เพิ่มหน้าว่างเข้าไปในขนาดใดก็ได้ตามต้องการ วางภาพลงบนหน้านั้นโดยใช้พิกัดของพื้นที่ผู้ใช้ (user-space coordinates) จากนั้นจึงทำการบันทึก ขั้นตอนการทำงาน (pipeline) ทั้งหมดอาศัยคอมโพเนนต์ TPdf ดังนั้นตัวแปลงไฟล์แบบชุด (batch converter) จึงเป็นเพียงแค่การวนซ้ำ (loop) บนชื่อไฟล์ ผนวกกับการโทรเรียกใช้งาน (calls) อีกเพียงหยิบมือเดียว

ไปป์ไลน์ batch ของ Delphi ใช้การเรียก AddPage, PageNumber, AddImage และ SaveAs ของ PDFium Component เปลี่ยนโฟลเดอร์สแกนที่รันเลขไว้เป็น PDF เรียงลำดับหนึ่งไฟล์
ภาพสแกนแต่ละภาพกลายเป็นหนึ่งหน้าที่สร้างด้วย AddPage และเล็งตำแหน่งด้วย PageNumber ก่อน AddImage จะวาดมัน SaveAs เขียนเอกสารที่เสร็จแล้วหนึ่งครั้ง

รูปแบบของการแปลงไฟล์

มีสามสิ่งที่ต้องเกิดขึ้นสำหรับแต่ละภาพสแกน คุณเป็นคนตัดสินใจขนาดหน้ากระดาษ คุณวางภาพลงไปในหน้ากระดาษโดยเว้นระยะขอบ (margin) ไว้ และคุณก็ก้าวไปยังหน้ากระดาษถัดไป PDFium Component มอบเมธอดสำหรับแต่ละงานเหล่านี้: AddPage สร้างหน้าว่างที่ขนาดตามกำหนด, AddImage (หรือ AddPicture หากคุณมี TPicture ถือไว้อยู่แล้ว) ทำหน้าที่วาดบิตแมปลงบนหน้ากระดาษปัจจุบัน และ PageNumber เป็นตัวบอกคอมโพเนนต์ว่าคำสั่งเรียกวาดในครั้งต่อไปจะเจาะจงเป้าหมายที่หน้ากระดาษใด

รายละเอียดหนึ่งที่มักจะทำให้ผู้คนสะดุดล้มก็คือระบบพิกัด พื้นที่ผู้ใช้ (user space) ของ PDF วางจุดกำเนิด (origin) ไว้ที่มุมซ้ายล่างของหน้ากระดาษ โดยที่แกน Y จะมีค่าเพิ่มขึ้นเมื่อชี้ขึ้นด้านบน ซึ่งตรงข้ามกับพิกัดหน้าจอที่นักพัฒนา Delphi มักจะคุ้นชินเป็นปฏิกิริยาสะท้อนกลับ ค่า X, Y ที่คุณส่งไปยัง AddImage คือมุมซ้ายล่างของสี่เหลี่ยมรูปภาพ และ Width, Height คือขนาดสำหรับการจัดวางในหน่วยพอยต์ (points) ไม่ใช่ขนาดพิกเซลของไฟล์ต้นฉบับ หากเข้าใจเรื่องนี้กลับหัวกลับหาง ภาพสแกนของคุณก็จะไปตกอยู่นอกหน้ากระดาษหรือกลับหัวเมื่อเทียบกับตำแหน่งที่คุณคาดหวังให้มันอยู่

การสร้างเอกสารและหนึ่งหน้ากระดาษต่อภาพสแกน

เริ่มต้นด้วยเอกสารเปล่า CreateDocument จัดสรรพื้นที่สำหรับไฟล์ PDF ใหม่และตั้งให้คอมโพเนนต์เริ่มทำงานเลย จึงไม่มีขั้นตอนการเปิดไฟล์ที่แยกต่างหาก จากนั้นคุณเดินตามรายการของไฟล์ที่สแกน และสำหรับแต่ละไฟล์คุณต้องเพิ่มหน้ากระดาษ ทำให้หน้านั้นเป็นหน้าปัจจุบัน และวางรูปภาพลงไป มิติขนาดของหน้ากระดาษที่ใช้ในที่นี้คือ A4 ในหน่วยพอยต์ (กว้าง 595 × สูง 842 ในแนวตั้ง) ซึ่งเป็นขนาดแผ่นมาตรฐานสำหรับการจัดเก็บเอกสารประเภทจดหมายโต้ตอบ

procedure TArchiveForm.ScansToPdf(const Files: TStrings; const OutputPath: string);
const
  PageW = 595.0;   // ความกว้าง A4 ในหน่วยพอยต์
  PageH = 842.0;   // ความสูง A4 ในหน่วยพอยต์
  Margin = 36.0;   // ขอบกระดาษครึ่งนิ้วรอบภาพสแกนแต่ละภาพ
var
  I: Integer;
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.CreateDocument;                       // ใหม่, ว่างเปล่า, เปิดทำงานอยู่แล้ว
    for I := 0 to Files.Count - 1 do
    begin
      Pdf.AddPage(I + 1, PageW, PageH);       // ดัชนีหน้าอ้างอิง 1 เป็นตัวแรก
      Pdf.PageNumber := I + 1;                // ทำให้หน้าปัจจุบันกลายเป็นหน้าใหม่
      PlaceScan(Pdf, Files[I], PageW, PageH, Margin);
    end;
    Pdf.SaveAs(OutputPath);
  finally
    Pdf.Free;
  end;
end;

การวนซ้ำในแต่ละรอบจะสร้างหน้ากระดาษขึ้นมา และตั้งค่า PageNumber ไปที่หน้านั้นในทันที บรรทัดที่สองนั้นมีความสำคัญ: AddPage จะแทรกหน้ากระดาษลงไปแต่บรรดาเมธอดการวาดจะกระทำกับหน้าใดก็ตามที่เป็นหน้าปัจจุบัน ดังนั้นการตั้งค่า PageNumber จึงเป็นสิ่งที่คอยเล็งเป้าหมายของ AddImage ไปยังหน้ากระดาษที่คุณเพิ่งจะสร้างขึ้น หากข้ามมันไป ภาพของคุณก็จะไปกองซ้อนกันอยู่บนหน้าที่บังเอิญถูกโหลดไว้ก่อนหน้านั้น

มีข้อสมมติฐานหนึ่งอย่างซ่อนอยู่ในลูปนั้น: ลำดับของ Files เครื่องสแกนเอกสารจะตั้งชื่อหน้ากระดาษตั้งแต่ 0001.jpg ไปจนถึง 0100.jpg แต่การอ่านรายชื่อไฟล์ในไดเรกทอรีอาจไม่ได้เรียงลำดับส่งกลับมาเสมอไป และในตอนที่คุณไปเจอ page9.jpg อยู่ติดกับ page10.jpg การเรียงลำดับสตริงแบบธรรมดาจะเอาหน้า 10 ไปไว้ก่อนหน้า 9 จงเรียงลำดับรายชื่ออย่างชัดเจนก่อนเข้าสู่ลูป และเลือกใช้รูปแบบชื่อที่มีเลขศูนย์นำหน้า (zero-padded names) ในตอนสแกนเพื่อที่ลำดับทางศัพท์ (lexical order) จะได้ตรงกันกับลำดับหน้ากระดาษ ลำดับของหน้ากระดาษเป็นสิ่งเดียวที่ผู้ตรวจสอบจะสังเกตเห็นได้ในทันที และมันเป็นความผิดพลาดที่มีต้นทุนต่ำที่สุดในการป้องกัน

การจัดวางภาพสแกนและรักษาอัตราส่วนภาพเอาไว้

ภาพสแกนนั้นแทบจะไม่เคยมีรูปทรงเดียวกับหน้ากระดาษ หากคุณยืดภาพเพื่อให้เต็มแผ่น คุณก็จะทำให้ข้อความบิดเบี้ยว; แต่หากคุณวางมันลงไปด้วยขนาดพิกเซลเต็มที่ มันก็จะล้นขอบ วิธีแก้คือการปรับสัดส่วน (scale) ด้วยอัตราส่วนที่เล็กกว่าระหว่างสองค่านี้ คือความพอดีแบบกว้าง (width-fit) หรือความพอดีแบบสูง (height-fit) และจัดกึ่งกลางให้กับพื้นที่ส่วนที่เหลือ เนื่องจากจุดกำเนิด (origin) ตั้งอยู่ที่มุมซ้ายล่าง การจัดกึ่งกลางจึงหมายถึงการแบ่งพื้นที่ที่เหลืออย่างเท่า ๆ กันและบวกเพิ่มเข้าไปในทั้ง X และ Y

แผนภาพหน้า A4 แสดงวิธีที่ AddImage ของ PDFium Component วางสแกนที่ปรับสเกลแล้วไว้ภายในระยะขอบ ด้วยโค้ด Delphi และจุดกำเนิดซ้ายล่าง
AddImage รับมุมล่างซ้ายของสี่เหลี่ยมวางภาพ การปรับให้พอดีและการจัดกึ่งกลางจึงถูกคำนวณเป็นพอยต์หน้าจากจุดกำเนิด
procedure TArchiveForm.PlaceScan(Pdf: TPdf; const FileName: string;
  PageW, PageH, Margin: Double);
var
  Pic: TPicture;
  AvailW, AvailH, Scale, DrawW, DrawH, X, Y: Double;
begin
  Pic := TPicture.Create;
  try
    Pic.LoadFromFile(FileName);              // BMP, JPG, PNG ฯลฯ ผ่านยูนิตกราฟิกของ VCL

    AvailW := PageW - 2 * Margin;
    AvailH := PageH - 2 * Margin;

    // ปรับให้พอดีภายในขอบโดยไม่บิดเบือนภาพสแกน
    Scale := Min(AvailW / Pic.Width, AvailH / Pic.Height);
    DrawW := Pic.Width * Scale;
    DrawH := Pic.Height * Scale;

    // จัดกึ่งกลาง: พื้นที่ที่เหลือแบ่งเท่า ๆ กัน Y วัดจากขอบล่างของหน้า
    X := (PageW - DrawW) / 2;
    Y := (PageH - DrawH) / 2;

    Pdf.AddImage(FileName, X, Y, DrawW, DrawH);
  finally
    Pic.Free;
  end;
end;

โค้ดนี้จะโหลดไฟล์หนึ่งครั้งเพื่ออ่านมิติขนาดพิกเซลของภาพ คำนวณหาค่าสเกลที่เป็นรูปแบบเดียวกัน (uniform) ค่าเดียว แล้วก็ส่งรูปสี่เหลี่ยมผืนผ้าที่เป็นจุดจัดวางภาพต่อไปยัง AddImage โดย AddImage จะยอมรับเส้นทางไฟล์โดยตรงและส่งมันไปตามเส้นทางภาพแบบเดียวกันกับ AddPicture ดังนั้นรูปแบบใดก็ตามที่ยูนิตกราฟิก (graphics units) ของ VCL รู้จักก็จะสามารถใช้งานได้โดยไม่ต้องมีกรณีพิเศษ (special-casing) หากคุณถอดรหัสภาพมาไว้ใน TPicture จากแผงหน้าต่างพรีวิวแล้ว ก็สามารถเรียกใช้งาน AddPicture(Pic, X, Y, DrawW, DrawH) ด้วยรูปสี่เหลี่ยมจัดวางพื้นที่เดียวกันนี้และข้ามการอ่านไฟล์รอบที่สองไปได้เลย

การข้ามขั้นตอนการถอดรหัสสำหรับภาพสแกนแบบ JPEG

เครื่องสแกนมักจะพ่นไฟล์ JPEG ออกมาแทบจะเสมอ การโหลด JPEG เข้าไปใน TPicture จะเป็นการถอดรหัสออกมาเป็นภาพบิตแมป และจากนั้น PDFium ก็จะทำการเข้ารหัสกลับเข้าไปใหม่ในตอนบันทึก ซึ่งทั้งสองรอบไปกลับนี้ทำให้เกิดการสูญเสียคุณภาพ (lossy round trips) ที่คุณไม่ต้องการเลย AddJpegImage จะทำการฝังไบต์ที่ถูกบีบอัดมาจากต้นฉบับตรงลงไปยังหน้ากระดาษผ่านทางสตรีม ซึ่งรวดเร็วกว่าและสะอาดตากว่าสำหรับการทำงานแบบชุดที่มีจำนวนข้อมูลมหาศาล

AddJpegImage ฝังไบต์ JPEG ดั้งเดิมลงหน้าของ PDFium Component ขณะที่ AddImage decode และ SaveAs เข้ารหัสพิกเซลใหม่ใน Delphi
AddJpegImage ฝังไบต์ที่บีบอัดจากเครื่องสแกนตามที่เป็น เลี่ยงขั้นตอน decode และ encode ใหม่ที่ AddImage และ SaveAs ทำ
var
  Stream: TFileStream;
begin
  // ... หลัง AddPage + PageNumber สำหรับหน้าปัจจุบัน ...
  Stream := TFileStream.Create(FileName, fmOpenRead);
  try
    // ฝังไบต์ JPEG ตามเดิม; ไม่มีรอบถอดรหัส/เข้ารหัสซ้ำ
    Pdf.AddJpegImage(Stream, X, Y, DrawW, DrawH);
  finally
    Stream.Free;
  end;
end;

คุณยังคงต้องคำนวณหา X, Y, DrawW และ DrawH ด้วยวิธีเดียวกัน เนื่องจากคุณต้องใช้มิติพิกเซลในการปรับสัดส่วน ให้อ่านค่าเหล่านั้นจากตัวไฟล์หรือใช้วิธีวิเคราะห์ข้อมูลส่วนหัวไฟล์ (header parse) อย่างรวดเร็ว จากนั้นก็ส่งสตรีมดิบนั้นให้กับ AddJpegImage สำหรับการสแกนแบบ PNG หรือ TIFF เส้นทางการเรียก AddImage ถือว่าเป็นทางที่ถูกต้องแล้ว; ขอให้สงวนทางลัดในการจัดการ JPEG ไว้กับรูปแบบที่มันรองรับใช้งานได้จริง ๆ ก็พอ

การติดป้ายกำกับในแต่ละหน้ากระดาษ

เอกสารภาพสแกนที่ถูกจัดเก็บถาวรจะถูกตรวจสอบประเมินได้ง่ายกว่าหากแต่ละหน้ากระดาษมีชื่อไฟล์ต้นทางติดอยู่ด้วย AddText ใช้สำหรับวาดสตริงข้อความที่พิกัดตามพื้นที่ของผู้ใช้ (user-space coordinate) ดังนั้นคำอธิบายภาพจะถูกจัดวางอยู่ด้านล่างภาพพอดิบพอดี อย่าลืมเรื่องแกน Y ที่กลับหัว: หากจะแปะป้ายกำกับไว้ใต้ภาพสแกน คุณต้องลบออกจากขอบล่างของภาพแทนที่จะบวกเพิ่มเข้าไป

// คำอธิบายภาพใต้ภาพสแกน: Y ลดลงเมื่อเข้าใกล้ขอบล่างของหน้า
Pdf.AddText('File: ' + ExtractFileName(FileName), 'Helvetica', 9,
  X, Y - 14, clGray);

ประเด็นสุดท้ายเกี่ยวกับการบันทึก SaveAs คือฟังก์ชันที่จะคืนค่าตรรกะ (Boolean) ออกมา ดังนั้นในโค้ดสำหรับการผลิตระดับโปรดักชัน โปรดตรวจสอบผลลัพธ์ของมันแทนที่จะด่วนสรุปไปเองว่าการเขียนไฟล์สำเร็จเสร็จสิ้นแล้ว; ฮาร์ดดิสก์ที่เต็มพิกัด หรือเส้นทางไฟล์ปลายทางที่ถูกล็อกเอาไว้ จะพังทลายล้มเหลวไปแบบเงียบ ๆ มิฉะนั้นแล้ว เมื่อการวนลูปสิ้นสุดลงและไฟล์ถูกเขียนเรียบร้อย คุณก็จะได้ไฟล์ที่ฝ่ายเก็บเอกสารต้องการอย่างแน่นอน นั่นคือ: ไฟล์ PDF ที่มีการเรียงลำดับหน้าหนึ่งไฟล์ต่อหนึ่งแฟ้มคดี หน้ากระดาษถูกปรับขนาดให้พอดี พร้อมที่จะเปิดอ่านในโปรแกรมดูไฟล์ตัวใดก็ได้

บล็อกส่วนประกอบในลักษณะเดียวกันครอบคลุมการทำงานอื่น ๆ ที่เกี่ยวโยงกัน ลองสลับกฎเกณฑ์ของการจัดขนาดแต่ละหน้ากระดาษ คุณก็จะได้สมุดภาพถ่ายที่มีหนึ่งภาพต่อกระดาษหนึ่งแผ่น; คงขั้นตอนลูปเอาไว้แต่เปลี่ยนไปอ่านจากแหล่งข้อมูลที่รองรับ TIFF หลายหน้าแทน และคุณก็จะได้ตัวแปลงสำหรับจัดเก็บเครื่องโทรสาร หากคุณต้องการเห็นภาพรวมที่กว้างกว่าเกี่ยวกับการสร้างเอกสาร PDF ผ่านการเขียนโปรแกรม ลองดู การสร้างเอกสาร PDF จากศูนย์ด้วย PDFium Component; หรือหากต้องการเรนเดอร์ผลลัพธ์กลับไปยังหน้าจอในภายหลัง ลองดู การแปลงหน้าเอกสาร PDF เป็นรูปภาพ JPEG ด้วย PDFium Component

PDFium Component จาก loslab.com ได้ทำการมัดรวมชุดเครื่องมือ API ทางด้านการสร้าง การเรนเดอร์ และการจัดการข้อความ ที่ถูกนำมาใช้ตลอดซีรีส์ชุดนี้