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

การรวมไฟล์ PDF หลายไฟล์เป็นเอกสารเดียวด้วย PDFium Component

PDFium Component เปิดเผยการรวมไฟล์ PDF ผ่านเมธอดเดียวคือ: ImportPages รูปแบบการทำงานจะเหมือนเดิมเสมอ: สร้างเอกสารปลายทางที่ว่างเปล่า เปิดไฟล์ต้นฉบับแต่ละไฟล์ เรียกใช้ ImportPages เพื่อคัดลอกหน้ากระดาษข้ามมา ปิดไฟล์ต้นฉบับ แล้วทำซ้ำไปเรื่อย ๆ เมื่อลูปจบลง SaveAs จะเขียนผลลัพธ์ลงในดิสก์ ไม่มีโหมดการรวมไฟล์แบบพิเศษ ไม่มีการตั้งค่าใด ๆ ให้ต้องสลับไปมา ความซับซ้อนจะไปอยู่ในกรณีเฉพาะ (edge cases) ต่าง ๆ และมีบางกรณีที่อาจกัดคุณได้โดยไม่มีการเตือนล่วงหน้า

ลูปแกนหลัก (The core loop)

สิ่งที่คุณต้องมีคืออินสแตนซ์ TPdf เพียงสองตัว ตัวหนึ่งใช้เก็บเอกสารปลายทาง ซึ่งสร้างขึ้นมาให้ว่างเปล่าด้วย CreateDocument อีกตัวหนึ่งใช้เปิดไฟล์ต้นฉบับแต่ละไฟล์ตามลำดับ ด้านล่างนี้คือโพรซีเยอร์ที่รับรายการเส้นทางไฟล์ (file paths) และเขียนผลลัพธ์ที่รวมแล้วไปยังเส้นทางเดียว:

procedure MergeFiles(const FileList: TStrings; const OutputPath: string);
var
  PdfDest, PdfSrc: TPdf;
  InsertAt, I: Integer;
begin
  PdfDest := TPdf.Create(nil);
  PdfSrc  := TPdf.Create(nil);
  try
    PdfDest.CreateDocument;
    InsertAt := 1;  // ImportPages uses 1-based destination position

    for I := 0 to FileList.Count - 1 do
    begin
      PdfSrc.FileName := FileList[I];
      PdfSrc.Active   := True;

      if not PdfSrc.Active then
        raise Exception.CreateFmt('Cannot open: %s', [FileList[I]]);

      PdfDest.ImportPages(
        PdfSrc,
        '1-' + IntToStr(PdfSrc.PageCount),  // full document range
        InsertAt);

      Inc(InsertAt, PdfSrc.PageCount);
      PdfSrc.Active := False;
    end;

    PdfDest.SaveAs(OutputPath);
  finally
    PdfSrc.Free;
    PdfDest.Free;
  end;
end;

มีสองสิ่งในโค้ดนั้นที่อาจถูกมองข้ามได้ง่ายเมื่ออ่านครั้งแรก อย่างแรกคือวิธีที่ PDFium รายงานความล้มเหลวในการโหลด Active := True จะไม่ทำให้เกิด exception: หากไฟล์หายไป เสียหาย หรือติดรหัสผ่าน PDFium จะดักจับข้อผิดพลาดอยู่ภายในและปล่อยให้ Active เป็น False หากไม่มีการตรวจสอบอย่างชัดเจนในบรรทัดที่ 10 ไฟล์ที่เสียก็จะหลุดออกจากการรวมไฟล์ไปเงียบ ๆ โดยไม่มีอะไรบ่งชี้ในผลลัพธ์ ไฟล์ PDF สุดท้ายจะมีจำนวนหน้าน้อยกว่าที่คาดไว้ และคุณก็จะไม่รู้เลยว่าไฟล์ไหนเป็นต้นเหตุ

อย่างที่สองคือตัวนับ InsertAt อาร์กิวเมนต์ตัวที่สามของ ImportPages คือตำแหน่งในไฟล์ปลายทาง (นับจาก 1) ที่หน้ากระดาษที่ถูกนำเข้าหน้าแรกจะไปวางลง การเริ่มต้นที่ 1 จะทำให้เอกสารต้นฉบับชุดแรกไปอยู่ที่จุดเริ่มต้นของไฟล์ที่ว่างเปล่า หลังจากผ่านต้นฉบับแต่ละชุด ตัวนับจะขยับไปข้างหน้าตามจำนวน PdfSrc.PageCount เพื่อให้หน้ากระดาษชุดต่อไปไปต่อท้ายหน้าชุดสุดท้าย หากลืมบวกเพิ่มเข้าไป เอกสารต้นฉบับชุดถัดมาทุกตัวก็จะไปเขียนทับหน้ากระดาษที่ตำแหน่ง 1 ทำให้คุณได้ผลลัพธ์เป็นเอกสารสุดท้ายในรายการเพียงตัวเดียวเท่านั้น

การเลือกช่วงหน้ากระดาษ

คุณไม่จำเป็นต้องนำหน้ากระดาษทุกหน้ามาจากต้นฉบับ สตริงช่วงหน้า (range string) ที่ส่งไปเป็นอาร์กิวเมนต์ตัวที่สองใช้รูปแบบลูกน้ำและยัติภังค์ที่เรียบง่าย: "1-3" หมายถึงเอาหน้า 1 ถึง 3, "2,4,6" คือเลือกเฉพาะสามหน้านั้น และ "1-" หมายถึงหน้า 1 ไปจนถึงหน้าสุดท้ายของเอกสาร ช่วงหน้ากระดาษสามารถรวมกันได้ในสตริงเดียว เช่น "1-3,5,7-" จะข้ามหน้า 4 และ 6 มีจุดเล็ก ๆ ที่สำคัญตรงนี้: ตัวเลขจะอ้างอิงถึงหน้าในเอกสารต้นฉบับเสมอ โดยเริ่มนับจาก 1 ไม่ว่าหน้าเหล่านั้นจะไปจบลงที่ตรงไหนในปลายทาง หากคุณต้องการหน้า 40 ถึง 50 จากแคตตาล็อกความยาว 200 หน้า สตริงช่วงคือ "40-50" ไม่ใช่ตำแหน่งที่อิงตามสิ่งที่อยู่ในไฟล์ปลายทางแล้ว

// Extract cover plus a three-page executive summary from a long report
PdfSrc.FileName := 'annual-report.pdf';
PdfSrc.Active   := True;
if PdfSrc.Active then
begin
  // Page 1 is the cover; pages 3-5 are the summary
  PdfDest.ImportPages(PdfSrc, '1,3-5', InsertAt);
  Inc(InsertAt, 4);  // 1 cover + 3 summary pages = 4 pages added
  PdfSrc.Active := False;
end;

เมื่อคำนวณส่วนที่เพิ่มให้กับ InsertAt ให้นับหน้ากระดาษที่คุณนำเข้าจริง ๆ ไม่ใช่จำนวนหน้าทั้งหมดของแหล่งต้นฉบับ หากคุณส่ง '1,3-5' หมายความว่าคุณนำเข้าหน้ากระดาษ 4 หน้า ดังนั้นต้องบวกเพิ่มไป 4 การบวกด้วย PdfSrc.PageCount จะทำให้เกิดช่องว่างของหน้าเปล่าในเอกสารปลายทาง และทำให้เอกสารต้นฉบับชุดต่อไปถูกจัดวางลึกเข้าไปในไฟล์เกินกว่าที่ตั้งใจไว้

สิ่งที่ ImportPages เก็บรักษาไว้และสิ่งที่ไม่ใช่

หน้ากระดาษที่คัดลอกด้วย ImportPages จะพกเนื้อหาที่มองเห็นได้มาอย่างสมบูรณ์ ข้อความ กราฟิกเวกเตอร์ ภาพแรสเตอร์ ಫอนต์ที่ฝังไว้ และฟอร์ม XObject ทั้งหมดจะถูกส่งข้ามมาในฐานะส่วนหนึ่งของสตรีมเนื้อหาหน้า คำอธิบายประกอบระดับหน้ากระดาษ (page-level annotations) ซึ่งรวมถึงความคิดเห็น ไฮไลต์ และรอยหมึกก็ข้ามมาด้วยเช่นกัน เพราะพวกมันถูกเก็บไว้ภายในพจนานุกรมของหน้ากระดาษแทนที่จะอยู่ในระดับเอกสาร

ข้อมูลเมตา (metadata) ระดับเอกสารกลับเป็นอีกเรื่องหนึ่ง สตริงสำหรับชื่อเรื่อง ผู้แต่ง หัวข้อ และคำสำคัญในพจนานุกรม Info ของไฟล์ต้นฉบับจะถูกทิ้งไว้เบื้องหลัง เอกสารปลายทางจะเริ่มต้นด้วยข้อมูลเมตาที่ว่างเปล่าหลังจาก CreateDocument ดังนั้นหากผลลัพธ์ที่รวมไฟล์แล้วต้องการให้เติมข้อมูลในช่องเหล่านั้น คุณจะต้องกำหนดค่าให้กับ PdfDest โดยตรงก่อนเรียก SaveAs คุณสมบัติ Title, Author, Subject, Keywords และ Creator บน TPdf จะรับเป็นสตริงปกติและเขียนลงในพจนานุกรม Info ตอนที่บันทึก

ฟิลด์ฟอร์มโต้ตอบ (interactive form fields) นั้นซับซ้อนกว่า การประกาศฟิลด์ AcroForm จะอาศัยอยู่ในพจนานุกรมระดับเอกสารมากกว่าจะอยู่ภายในสตรีมเนื้อหาของแต่ละหน้า เมื่อ ImportPages คัดลอกหน้าที่ประกอบด้วยฟิลด์ฟอร์ม รูปลักษณ์ที่มองเห็นได้ของฟิลด์เหล่านั้นจะตามมาด้วย เพราะมันถูกเรนเดอร์ลงในสตรีมเนื้อหาหน้าแล้ว แต่วิดเจ็ต (widgets) ของฟิลด์ที่ทำให้มันโต้ตอบได้นั้นเป็นส่วนหนึ่งของโครงสร้าง AcroForm และจะไม่ตามมาด้วย ในการรวมไฟล์โดยทั่วไป ฟิลด์ข้อความจากเอกสารต้นฉบับจะแสดงค่าตามตอนที่นำเข้ามา แต่มันจะไม่สามารถแก้ไขได้ในไฟล์ที่รวมเสร็จแล้ว หากคุณต้องการให้ฟิลด์ยังคงกรอกข้อมูลได้ ให้ทำการแบน (flatten) พวกมันในแต่ละไฟล์ต้นฉบับก่อนจะนำเข้า: วิธีนี้จะอบ (bake) ค่าปัจจุบันเข้าไปในสตรีมเนื้อหาและดึงส่วนทับซ้อนที่โต้ตอบได้ออกไป ทำให้คุณได้ผลลัพธ์ทางภาพที่สะอาดตาโดยไม่มีวิดเจ็ตที่พังในผลลัพธ์

ไฟล์ต้นฉบับที่เข้ารหัส

เอกสารต้นฉบับที่ป้องกันด้วยรหัสผ่านจะเปิดด้วยวิธีเดียวกับที่ไม่ได้เข้ารหัส เพียงแต่มีคุณสมบัติเพิ่มเติมให้ตั้งค่าก่อน กำหนดรหัสผ่านให้กับ PdfSrc.Password ก่อนที่จะสลับ Active := True แล้ว PDFium จะใช้รหัสผ่านนั้นในระหว่างการเปิด:

PdfSrc.Password := 'user-password';
PdfSrc.FileName := 'protected.pdf';
PdfSrc.Active   := True;
if not PdfSrc.Active then
  raise Exception.Create('Wrong password or file cannot be opened');

PdfDest.ImportPages(PdfSrc, '1-' + IntToStr(PdfSrc.PageCount), InsertAt);
Inc(InsertAt, PdfSrc.PageCount);
PdfSrc.Active := False;

รหัสผ่านที่ผิดจะส่งผลให้ Active = False แบบเงียบ ๆ เหมือนกับตอนที่ไฟล์หายไป ดังนั้นการตรวจสอบอย่างชัดเจนจึงมีความจำเป็นเช่นเดียวกันที่นี่ การเข้ารหัสจะไม่ถ่ายโอนไปยังไฟล์ปลายทาง: หน้ากระดาษที่นำเข้าจากต้นฉบับที่ได้รับการป้องกันจะลงเอยในไฟล์ปลายทางในฐานะเนื้อหาที่ไม่ได้รับการป้องกัน หากผลลัพธ์ที่รวมไฟล์แล้วจำเป็นต้องเข้ารหัสด้วย ให้กำหนดค่าที่ PdfDest ก่อนที่จะเรียกใช้ SaveAs

การบันทึกผลลัพธ์

SaveAs บน TPdf ยอมรับทั้งเส้นทางไฟล์หรือ TStream สำหรับงานรวมไฟล์ส่วนใหญ่ การพึ่งพาไฟล์แบบโอเวอร์โหลดคือสิ่งที่คุณต้องการ:

PdfDest.SaveAs('merged-output.pdf');

อาร์กิวเมนต์ที่สองที่เป็นตัวเลือกเสริมคือ TSaveOption ซึ่งควบคุมโหมดการบันทึก ค่าเริ่มต้นคือ saNone ซึ่งจะเขียนเป็นการอัปเดตแบบเพิ่มหน่วย (incremental update) หากเอกสารถูกโหลดมาจากไฟล์ หรือเป็นการเขียนทับใหม่ทั้งหมด (complete rewrite) หากมันถูกสร้างขึ้นมาใหม่ เนื่องจากไฟล์ปลายทางที่สร้างด้วย CreateDocument เป็นของใหม่เสมอ ผลลัพธ์จึงจะเป็นไฟล์กะทัดรัดแบบแก้ไขรอบเดียว (single-revision file) อาร์กิวเมนต์ตัวที่สาม TPdfVersion ให้คุณตอกหมุดระบุเฮดเดอร์เวอร์ชันของ PDF ได้ เมื่อคุณมีผู้ใช้งานปลายทางที่ต้องการเวอร์ชันแบบเจาะจง การปล่อยไว้เป็น pvUnknown จะให้ PDFium เป็นคนเลือกให้โดยพิจารณาจากเนื้อหา

เมธอด ImportPages และ SaveAs ที่แสดงไว้ที่นี้ เป็นส่วนหนึ่งของ PDFium Component สำหรับ Delphi และ C++Builder