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

การแยกเอกสาร PDF ด้วย PDFium Component ใน Delphi

PDFium Component ให้คุณมีเครื่องมือสำหรับแยก PDF เพียงเมธอดเดียวคือ ImportPages ส่วนอื่นๆ ทั้งหมด ไม่ว่าคุณจะดึงเพียงหน้าเดียว, ตัดแบ่งตามขอบเขตที่ต้องการ, หรือทำตามโครงสร้างบุ๊กมาร์กของตัวเอกสารเอง ทั้งหมดนี้เป็นเพียงวิธีการที่แตกต่างกันในการตัดสินใจว่าหมายเลขหน้าใดจะเข้าสู่ไฟล์ผลลัพธ์แต่ละไฟล์ กลไกการทำงานยังคงเหมือนเดิม การทำความเข้าใจจุดนี้ตั้งแต่เนิ่นๆ จะช่วยประหยัดเวลาไม่ให้ต้องหลงทางไปลองวิธีผิดๆ ได้มาก

ลูปการแยกส่วนทำงานอย่างไร

รูปแบบการทำงานจะเหมือนกันไม่ว่าคุณจะแบ่งเอกสารต้นฉบับอย่างไร สร้างอินสแตนซ์ของ TPdf ขึ้นมาใหม่, เรียกใช้ CreateDocument เพื่อเริ่มเอกสาร PDF ว่างๆ ในหน่วยความจำ, นำเข้าหน้ากระดาษที่คุณต้องการด้วย ImportPages, บันทึกผลลัพธ์, จากนั้นรีเซ็ต Active ให้เป็น False ก่อนการวนลูปในรอบถัดไป ขั้นตอนสุดท้ายนั่นแหละคือสิ่งที่ผู้คนมักมองข้าม: CreateDocument ไม่ได้ปิดเอกสารที่อยู่ในหน่วยความจำโดยปริยาย ดังนั้นคุณจะต้องบันทึกผลลัพธ์ของคุณแล้วรีเซ็ต Active := False อย่างชัดเจนก่อนที่จะเรียกใช้งานอีกครั้ง; การรีเซ็ตเป็นสิ่งแรกจะช่วยรักษาให้สถานะสะอาดและกำหนดไว้อย่างชัดเจน อินสแตนซ์ของ TPdf ภายนอกจะถูกนำมาใช้ซ้ำในการทำซ้ำทั้งหมด ซึ่งช่วยรักษาแรงกดดันในการจัดสรรหน่วยความจำให้อยู่ในระดับต่ำในงานขนาดใหญ่

นี่คือรูปร่างหน้าตาของการแยกหน้าต่อหน้าที่ถูกตัดทอนมาเฉพาะส่วนที่จำเป็น:

procedure SplitIntoPages(Source: TPdf; const OutputDir: string);
var
  I: Integer;
  PdfOut: TPdf;
  OutFile: string;
begin
  PdfOut := TPdf.Create(nil);
  try
    for I := 1 to Source.PageCount do
    begin
      PdfOut.CreateDocument;

      // Range is a 1-based page number string; insertion point 1 = first position
      if not PdfOut.ImportPages(Source, IntToStr(I), 1) then
        raise Exception.CreateFmt('Failed to import page %d', [I]);

      OutFile := OutputDir + '\page_' + Format('%.4d', [I]) + '.pdf';
      if not PdfOut.SaveAs(OutFile) then
        raise Exception.Create('Failed to save ' + OutFile);

      PdfOut.Active := False;   // reset before next CreateDocument
    end;
  finally
    PdfOut.Free;
  end;
end;

พารามิเตอร์ Range สำหรับ ImportPages เป็นรูปแบบสตริงเดียวกับที่ PDFium ใช้เป็นการภายใน: รายการตัวเลขหน้ากระดาษคั่นด้วยลูกน้ำหรือขอบเขตคั่นด้วยเครื่องหมายยัติภังค์ (hyphen) ทั้งหมดใช้ระบบอิงฐาน 1 (1-based) '3' นำเข้าหน้าที่ 3 '1-5' นำเข้าหน้าที่ 1 ถึง 5 ตามลำดับ '2,5,8' นำเข้าสามหน้านั้น พารามิเตอร์ตัวที่สามคือตำแหน่งในการแทรกในเอกสารปลายทางที่อิงระบบฐาน 1; การส่งผ่านเลข 1 จะเป็นการจัดวางหน้ากระดาษที่นำเข้ามาไว้ที่จุดเริ่มต้นของไฟล์ที่ว่างเปล่าอยู่เสมอ ซึ่งนั่นคือสิ่งที่คุณต้องการในที่นี้

การแยกตามช่วงหน้า

เมื่อผู้เรียก (caller) ให้รายการลักษณะเช่น 1-12,13-24,25-36, คุณจะต้องแยกวิเคราะห์ (parse) ออกเป็นคู่เริ่มต้น/สิ้นสุด แล้วเรียกใช้ลูปเดียวกัน โดยสร้างสตริงบอกช่วง (range string) จากแต่ละคู่:

procedure SplitByRanges(Source: TPdf; const RangeList: array of string;
  const OutputDir: string);
var
  I: Integer;
  PdfOut: TPdf;
  OutFile: string;
begin
  PdfOut := TPdf.Create(nil);
  try
    for I := 0 to High(RangeList) do
    begin
      PdfOut.CreateDocument;
      if not PdfOut.ImportPages(Source, RangeList[I], 1) then
        raise Exception.Create('Invalid page range: ' + RangeList[I]);
      OutFile := Format('%s\section_%d.pdf', [OutputDir, I + 1]);
      if not PdfOut.SaveAs(OutFile) then
        raise Exception.Create('Failed to save ' + OutFile);
      PdfOut.Active := False;
    end;
  finally
    PdfOut.Free;
  end;
end;

การตรวจสอบความถูกต้องก่อนที่คุณจะไปถึง ImportPages นั้นมีความสำคัญในที่นี้ ImportPages จะส่งกลับค่า False เมื่อหมายเลขหน้าในสตริงช่วงพารามิเตอร์สูงเกินกว่าจำนวนของ Source.PageCount แต่มันจะไม่แสดงข้อผิดพลาดใดๆ ออกมา และมันก็ไม่ได้สร้างไฟล์ผลลัพธ์บางส่วนให้คุณตรวจพบได้จากชื่อเพียงอย่างเดียว ให้ตรวจสอบค่าส่งกลับของ SaveAs และบันทึกความล้มเหลวแยกต่างหาก; ช่วงที่สร้างไฟล์ผลลัพธ์ที่ว่างเปล่าออกมาจะไม่ถือว่าผิดปกติอย่างเห็นได้ชัดจนกว่าจะมีคนเปิดดู

การแยกที่ขอบเขตบุ๊กมาร์ก

แนวทางที่สามใช้โครงสร้างของตัวเอกสารเองแทนที่จะใช้รายการที่มีการส่งมาจากภายนอก บุ๊กมาร์กชั้นบนสุด (top-level bookmark) แต่ละอันจะระบุหมายเลขหน้าเป้าหมายเอาไว้; ส่วนที่มันกำหนดจะเริ่มทำงานจากหน้านั้นไปจนถึงหน้าก่อนบุ๊กมาร์กอันถัดไป หรือจนถึงหน้าสุดท้ายของเอกสารสำหรับรายการตัวสุดท้าย

procedure SplitByBookmarks(Source: TPdf; const OutputDir: string);
var
  Bm: TBookmarks;
  I, StartPage, EndPage: Integer;
  PdfOut: TPdf;
  RangeStr, OutFile, SafeTitle: string;
begin
  Bm := Source.Bookmarks;
  if Length(Bm) = 0 then
    Exit;

  PdfOut := TPdf.Create(nil);
  try
    for I := 0 to High(Bm) do
    begin
      StartPage := Bm[I].PageNumber;
      if I < High(Bm) then
        EndPage := Bm[I + 1].PageNumber - 1
      else
        EndPage := Source.PageCount;

      if (StartPage < 1) or (EndPage < StartPage) then
        Continue;

      RangeStr := Format('%d-%d', [StartPage, EndPage]);

      PdfOut.CreateDocument;
      if not PdfOut.ImportPages(Source, RangeStr, 1) then
      begin
        PdfOut.Active := False;
        Continue;   // skip a malformed section instead of writing an empty file
      end;

      SafeTitle := StringReplace(Bm[I].Title, '/', '_', [rfReplaceAll]);
      SafeTitle := StringReplace(SafeTitle, ':', '_', [rfReplaceAll]);
      OutFile := Format('%s\%02d_%s.pdf', [OutputDir, I + 1, SafeTitle]);
      if not PdfOut.SaveAs(OutFile) then
        raise Exception.Create('Failed to save ' + OutFile);

      PdfOut.Active := False;
    end;
  finally
    PdfOut.Free;
  end;
end;

เอกสารที่ไม่มีบุ๊กมาร์กไม่ใช่เงื่อนไขข้อผิดพลาดที่สมควรจะเปิดเผยต่อผู้ใช้งานในฐานะของข้อผิดพลาด แต่มันหมายความเพียงแค่ว่าโหมดการแยกหน้านี้ไม่มีสิ่งที่ต้องทำงานด้วย ตัวป้องกัน Length(Bm) = 0 จะทำหน้าที่จัดการกับเรื่องนี้อย่างเงียบๆ สิ่งที่สมควรนำมาเปิดเผยคือในกรณีที่หมายเลขหน้าของบุ๊กมาร์กอยู่นอกระยะการประมวลผลของเอกสาร ซึ่งมักจะเกิดขึ้นในไฟล์ที่เกิดความผิดปกติจากเค้าโครงที่ไม่เคยได้รับการอัปเดตเลยหลังจากที่มีการลบหน้ากระดาษไปแล้ว การตรวจสอบขอบเขตใน StartPage และ EndPage จะทำการข้ามรายการเหล่านั้นแทนที่จะส่งข้อมูลที่ผิดพลาดเข้าไปยัง ImportPages

การตั้งชื่อไฟล์ผลลัพธ์และการรีเซ็ต Active

ความปลอดภัยของการตั้งชื่อไฟล์ที่มาจากบุ๊กมาร์กจำเป็นต้องได้รับความใส่ใจอย่างชัดเจน ชื่อบุ๊กมาร์กอาจประกอบด้วยตัวอักขระที่สามารถใช้ในสตริง PDF ได้ แต่ไม่สามารถใช้กับพาธของระบบไฟล์ อย่างน้อยที่สุด ให้แทนที่เครื่องหมายทับขวา (forward slash), เครื่องหมายทับซ้าย (backslash), และเครื่องหมายทวิภาค (colon) ก่อนที่คุณจะสร้างพาธของไฟล์ผลลัพธ์ สำหรับระบบปฏิบัติการ Windows ตัวอักขระอย่าง *, ?, ", <, >, และ | ก็เป็นสิ่งต้องห้ามด้วยเช่นกัน การใช้ลูปเรียบง่ายกับชุดอักขระคงที่ก็ครอบคลุมปัญหาเหล่านั้นได้ทั้งหมดโดยไม่ต้องใช้ regex (regular expression)

บรรทัดคำสั่ง Active := False ในตอนท้ายของการทำซ้ำแต่ละรอบเป็นเรื่องที่ควรเน้นย้ำ เพราะมันเป็นเพียงแค่ความต้องการที่ไม่อาจเห็นได้ชัดเจนในรูปแบบ CreateDocument ไม่ได้ปิดสิ่งที่เปิดอยู่โดยปริยาย หาก Active ยังคงเป็น True อยู่เมื่อ CreateDocument ทำงานขึ้นมาอีกครั้ง นั่นหมายความว่าเอกสารที่ยังคงอยู่ในหน่วยความจำไม่เคยถูกปิดและบันทึกอย่างถูกต้อง และคุณจะไม่สามารถพึ่งพาพฤติกรรมในสถานะนั้นเพื่อผลลัพธ์ที่ดีได้ ดังนั้นคุณควรบันทึกและรีเซ็ตให้ชัดเจนก่อนเริ่มต้นเอกสารชุดใหม่ ให้มองว่าคำสั่งนี้เป็นเหมือนคู่หูของ try/finally: บล็อก finally จะช่วยคืนหน่วยความจำสำหรับอ็อบเจ็กต์ภายนอก ส่วนคำสั่ง Active := False จะรีเซ็ตสถานะเอกสารภายในระหว่างที่มีการทำงานแบบวนซ้ำ (loop iterations)

การใช้หน่วยความจำในงานแยกขนาดใหญ่จะคงที่เมื่อใช้วิธีนี้ เพราะคุณจะไม่ต้องถือเอกสารผลลัพธ์เกินหนึ่งไฟล์ไว้ในหน่วยความจำในครั้งเดียว เอกสารต้นฉบับจะยังคงเปิดอยู่และมีสถานะอ่านได้อย่างเดียว (read-only) ตลอดการทำงาน ImportPages จะคัดลอกข้อมูลหน้ากระดาษเข้าไปในเอกสารใหม่โดยไม่มีการดัดแปลงต้นฉบับ หากต้นฉบับถูกเข้ารหัสไว้ คุณสามารถเปิดไฟล์โดยใช้รหัสผ่านก่อนเข้าสู่ลูป และหน้ากระดาษที่ถูกคัดลอกในไฟล์ผลลัพธ์แต่ละไฟล์จะไม่มีการเข้ารหัส ซึ่งมักจะเป็นพฤติกรรมที่เหมาะสมสำหรับเอาต์พุตที่ถูกแยกไปแจกจ่ายให้กับผู้รับหลายราย

อีกสิ่งหนึ่งเกี่ยวกับ SaveAs: มันจะส่งคืนค่าบูลีน (Boolean) ออกมา โฟลเดอร์ที่เก็บไฟล์ผลลัพธ์ไม่มีอยู่จริง พาธที่มีตัวอักขระที่ระบบปฏิบัติการ (OS) ปฏิเสธ หรือสภาพดิสก์เต็ม ล้วนแล้วแต่จะทำให้ SaveAs ส่งคืนค่า False ได้โดยไม่แสดงข้อผิดพลาด ในกรณีของงานแบบเป็นชุดที่ทำการแยกเอกสาร 200 หน้าออกเป็นไฟล์ละหน้าจำนวน 200 ไฟล์ ความล้มเหลวแบบเงียบๆ ในหน้าที่ 147 เป็นสิ่งที่คุณมองข้ามได้ง่ายมากๆ ดังนั้นคุณควรตรวจสอบค่าที่ถูกส่งคืนในแต่ละการเรียกใช้ และนับจำนวนความสำเร็จเพื่อนำไปเทียบกับยอดรวมที่คาดหวังเมื่อสิ้นสุดการวนซ้ำ

The ImportPages and CreateDocument methods shown here are part of PDFium Component for Delphi and C++Builder