Bài viết kỹ thuật

Gộp tệp PDF trong Delphi với PDFium Component

PDFium Component phơi bày việc gộp PDF qua một phương thức duy nhất: ImportPages. Khuôn mẫu lúc nào cũng như nhau: tạo một tài liệu đích rỗng, mở từng tệp nguồn, gọi ImportPages để chép các trang sang, đóng tệp nguồn, rồi lặp lại. Khi vòng lặp kết thúc, SaveAs ghi kết quả ra đĩa. Không có chế độ gộp đặc biệt nào, không có cấu hình nào phải bật. Phần phức tạp nằm ở các trường hợp biên, và có vài trường hợp cắn bạn mà chẳng báo trước

Vòng lặp cốt lõi

Hai thực thể TPdf là tất cả những gì bạn cần. Một cái giữ tài liệu đích, tạo rỗng bằng CreateDocument. Cái kia lần lượt mở từng tệp nguồn. Bên dưới là một thủ tục nhận một danh sách đường dẫn tệp và ghi đầu ra đã gộp ra một đường dẫn duy nhất:

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 dùng vị trí đích đếm từ 1

    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),  // khoảng trang của cả tài liệu
        InsertAt);

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

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

Có hai điều trong đoạn mã đó dễ bị bỏ qua khi đọc lần đầu. Thứ nhất là cách PDFium báo về việc nạp thất bại. Active := True không bao giờ ném ngoại lệ: nếu tệp thiếu, hỏng, hay được bảo vệ bằng mật khẩu, PDFium bắt lỗi ở bên trong và để ActiveFalse. Không có phép kiểm tra tường minh ở dòng 10, một tệp hỏng sẽ âm thầm rơi khỏi lần gộp mà chẳng để lại dấu hiệu nào trong đầu ra. Tệp PDF cuối cùng sẽ có ít trang hơn kỳ vọng và bạn sẽ không biết tệp nào là thủ phạm

Thứ hai là bộ đếm InsertAt. Tham số thứ ba của ImportPages là vị trí đếm từ 1 trong tài liệu đích nơi trang nhập vào đầu tiên rơi xuống. Bắt đầu ở 1 đặt tài liệu nguồn đầu tiên vào đầu một tệp vốn đang rỗng. Sau mỗi nguồn, bộ đếm tiến thêm PdfSrc.PageCount, nhờ đó lô trang tiếp theo nối vào sau lô trước. Quên tăng nó thì mọi nguồn sau đó đều ghi đè các trang ở vị trí 1, để lại cho bạn tài liệu cuối cùng trong danh sách và chẳng còn gì khác

Vòng lặp gộp trong Delphi với PDFium Component: từng tệp nguồn được mở, chép qua ImportPages tại vị trí InsertAt, và tài liệu đích được ghi ra một lần bằng SaveAs
ImportPages thả từng nguồn xuống vị trí InsertAt, và một tệp thiếu hay hỏng sẽ thất bại lặng lẽ trừ khi bạn kiểm tra Active

Chọn lọc khoảng trang

Bạn không nhất thiết phải lấy mọi trang từ một nguồn. Chuỗi khoảng trang truyền vào làm tham số thứ hai theo một định dạng dấu phẩy và gạch nối đơn giản: "1-3" lấy trang 1 tới 3, "2,4,6" chọn ba trang cụ thể, còn "1-" nghĩa là từ trang 1 tới hết tài liệu. Các khoảng có thể ghép trong một chuỗi, nên "1-3,5,7-" bỏ qua trang 4 và 6. Một điểm tinh tế quan trọng ở đây: các con số luôn chỉ tới trang trong tài liệu nguồn, bắt đầu từ 1, bất kể những trang ấy rốt cuộc nằm ở đâu trong tài liệu đích. Nếu bạn muốn lấy trang 40 tới 50 từ một catalog 200 trang, chuỗi khoảng là "40-50", không phải một vị trí so với những gì đã có trong tài liệu đích

// Lấy bìa cùng một bản tóm tắt ba trang từ một báo cáo dài
PdfSrc.FileName := 'annual-report.pdf';
PdfSrc.Active   := True;
if PdfSrc.Active then
begin
  // Trang 1 là bìa; trang 3-5 là phần tóm tắt
  PdfDest.ImportPages(PdfSrc, '1,3-5', InsertAt);
  Inc(InsertAt, 4);  // 1 trang bìa + 3 trang tóm tắt = thêm 4 trang
  PdfSrc.Active := False;
end;

Khi tính phần tăng cho InsertAt, hãy đếm số trang bạn thực sự nhập vào, không phải số trang của nguồn. Nếu bạn truyền '1,3-5' thì bạn đã nhập 4 trang, nên tiến thêm 4. Tiến thêm PdfSrc.PageCount sẽ để lại một khoảng hở gồm các vị trí trống trong tài liệu đích và đặt tài liệu nguồn tiếp theo vào sâu hơn trong tệp so với ý định

ImportPages giữ lại gì và không giữ lại gì

Các trang được ImportPages chép sang mang theo nội dung nhìn thấy được nguyên vẹn. Văn bản, đồ họa vector, ảnh raster, font nhúng và form XObject đều chuyển qua như một phần của content stream trang. Các annotation ở cấp trang, gồm chú thích, đánh dấu và nét vẽ tay, cũng đi cùng, bởi chúng được lưu bên trong dictionary của trang chứ không phải ở cấp tài liệu

Metadata ở cấp tài liệu lại là chuyện khác. Các chuỗi tiêu đề, tác giả, chủ đề và từ khóa trong Info dictionary của nguồn ở lại phía sau. Tài liệu đích khởi đầu với metadata rỗng sau CreateDocument, nên nếu đầu ra đã gộp cần điền những trường ấy, bạn phải gán chúng thẳng cho PdfDest trước khi gọi SaveAs. Các thuộc tính Title, Author, Subject, KeywordsCreator trên TPdf nhận chuỗi thuần và ghi vào Info dictionary lúc lưu

Trường biểu mẫu tương tác thì phức tạp hơn. Các định nghĩa trường AcroForm nằm trong một dictionary ở cấp tài liệu chứ không phải bên trong stream của từng trang. Khi ImportPages chép một trang có chứa trường biểu mẫu, diện mạo nhìn thấy được của những trường ấy chuyển qua vì nó được kết xuất vào content stream của trang, nhưng các widget trường vốn làm cho chúng tương tác được lại thuộc về cấu trúc AcroForm và không đi theo. Trong một lần gộp thông thường, một trường văn bản từ tài liệu nguồn sẽ hiển thị giá trị nó có tại thời điểm nhập, nhưng sẽ không sửa được trong tệp đã gộp. Nếu bạn cần các trường vẫn điền được, hãy làm phẳng chúng trong từng tài liệu nguồn trước khi nhập: việc đó nung các giá trị hiện tại vào content stream và gỡ bỏ lớp phủ tương tác, cho bạn một kết quả hình ảnh sạch sẽ mà không có widget hỏng trong đầu ra

ImportPages của PDFium mang nội dung trang, font và annotation vào tệp PDF đã gộp, còn metadata Info, widget AcroForm và mã hóa thì ở lại trong từng tệp nguồn
ImportPages dời mọi thứ được lưu cùng trang, còn metadata ở cấp tài liệu và tính tương tác của AcroForm thì ở lại phía sau

Tệp nguồn được mã hóa

Tài liệu nguồn được bảo vệ bằng mật khẩu mở ra theo đúng cách như tài liệu không mã hóa, chỉ thêm một thuộc tính phải đặt trước. Hãy gán mật khẩu cho PdfSrc.Password trước khi bật Active := True, rồi PDFium sẽ dùng nó trong lúc mở:

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;

Mật khẩu sai gây ra đúng kết cục Active = False lặng lẽ như một tệp bị thiếu, nên phép kiểm tra tường minh ở đây cũng cần thiết y như vậy. Phần mã hóa không chuyển sang tài liệu đích: các trang nhập từ một nguồn được bảo vệ rơi vào tài liệu đích dưới dạng nội dung không được bảo vệ. Nếu đầu ra đã gộp cũng cần mã hóa, hãy cấu hình nó trên PdfDest trước khi gọi SaveAs

Lưu kết quả

SaveAs trên TPdf nhận hoặc một đường dẫn tệp hoặc một TStream. Với phần lớn lần gộp, phiên bản nhận tệp là thứ bạn muốn:

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

Tham số thứ hai tùy chọn là một TSaveOption điều khiển chế độ lưu. Mặc định, saNone, ghi một bản cập nhật tăng dần nếu tài liệu được nạp từ một tệp, hoặc ghi lại toàn bộ nếu nó được tạo mới. Vì một tài liệu đích dựng bằng CreateDocument luôn là mới, đầu ra sẽ là một tệp gọn chỉ có một bản sửa đổi. Tham số thứ ba, TPdfVersion, cho bạn ghim phần đầu phiên bản PDF khi có bên tiêu thụ phía sau đòi một phiên bản cụ thể; để nó ở pvUnknown thì PDFium tự chọn dựa trên nội dung

Các phương thức ImportPagesSaveAs trình bày ở đây là một phần của PDFium Component cho Delphi và C++Builder