Artykuł techniczny

Łączenie wielu plików PDF w jeden dokument za pomocą komponentu PDFium

Komponent PDFium udostępnia łączenie plików PDF za pośrednictwem pojedynczej metody: ImportPages. Wzorzec jest zawsze ten sam: utwórz pusty dokument docelowy, otwórz każdy plik źródłowy, wywołaj ImportPages, aby skopiować strony, zamknij źródło i powtórz. Po zakończeniu pętli, SaveAs zapisuje wynik na dysk. Nie ma specjalnego trybu łączenia, nie ma konfiguracji do przełączania. Złożoność tkwi w przypadkach brzegowych, a kilka z nich może zaskoczyć bez ostrzeżenia

Główna pętla

Dwie instancje TPdf to wszystko, czego potrzebujesz. Jedna przechowuje dokument docelowy, utworzony jako pusty za pomocą CreateDocument. Druga po kolei otwiera każdy plik źródłowy. Poniżej znajduje się procedura, która pobiera listę ścieżek do plików i zapisuje połączony wynik do pojedynczej ścieżki:

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;

Dwie rzeczy w tym kodzie są łatwe do przeoczenia przy pierwszym czytaniu. Pierwszą z nich jest to, jak PDFium zgłasza błędy ładowania. Active := True nigdy nie zgłasza wyjątku: jeśli brakuje pliku, jest uszkodzony lub chroniony hasłem, PDFium przechwytuje błąd wewnętrznie i pozostawia Active jako False. Bez jawnego sprawdzenia w linii 10, zły plik bezgłośnie wypadłby z łączenia bez żadnego wskazania w wyniku. Końcowy PDF miałby mniej stron niż oczekiwano, a ty nie wiedziałbyś, który plik był winny

Drugą jest licznik InsertAt. Trzeci argument dla ImportPages to pozycja w miejscu docelowym oparta na jedynce (1-based), w której ląduje pierwsza importowana strona. Rozpoczęcie od 1 umieszcza pierwszy dokument źródłowy na początku pustego pliku. Po każdym źródle licznik zwiększa się o PdfSrc.PageCount, więc kolejna partia stron jest dołączana po ostatniej. Jeśli zapomnisz go zwiększyć, każde kolejne źródło nadpisze strony na pozycji 1, dając ci tylko ostatni dokument z listy

Selektywne zakresy stron

Nie musisz pobierać każdej strony ze źródła. Ciąg zakresu przekazany jako drugi argument ma prosty format oddzielony przecinkami i myślnikami: "1-3" pobiera strony od 1 do 3, "2,4,6" wybiera trzy określone strony, a "1-" oznacza stronę 1 do końca dokumentu. Zakresy można łączyć w jednym ciągu, więc "1-3,5,7-" pomija strony 4 i 6. W tym miejscu ma znaczenie jedna subtelność: liczby zawsze odnoszą się do stron w dokumencie źródłowym, począwszy od 1, niezależnie od tego, gdzie te strony znajdą się w dokumencie docelowym. Jeśli chcesz strony od 40 do 50 z 200-stronicowego katalogu, ciąg zakresu to "40-50", a nie pozycja względem tego, co jest już w dokumencie docelowym

// 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;

Obliczając przyrost dla InsertAt, policz strony, które faktycznie zaimportowałeś, a nie liczbę stron źródła. Jeśli przekażesz '1,3-5', zaimportowałeś 4 strony, więc zwiększ o 4. Zwiększenie o PdfSrc.PageCount pozostawiłoby lukę pustych pozycji docelowych i umieściłoby następny dokument źródłowy dalej w pliku, niż zamierzano

Co zachowuje ImportPages, a czego nie

Strony skopiowane przez ImportPages przenoszą swoją widoczną zawartość w stanie nienaruszonym. Tekst, grafika wektorowa, obrazy rastrowe, osadzone czcionki i obiekty Form XObject są przenoszone jako część strumieni zawartości strony. Adnotacje na poziomie strony, w tym komentarze, wyróżnienia i pociągnięcia odręczne, również są przenoszone, ponieważ są przechowywane w słowniku strony, a nie na poziomie dokumentu

Metadane na poziomie dokumentu to inna historia. Ciągi znaków tytułu, autora, tematu i słów kluczowych w słowniku informacji (Info) źródła zostają w tyle. Dokument docelowy zaczyna się od pustych metadanych po CreateDocument, więc jeśli połączony wynik potrzebuje wypełnienia tych pól, musisz je przypisać bezpośrednio do PdfDest przed wywołaniem SaveAs. Właściwości Title, Author, Subject, Keywords i Creator obiektu TPdf przyjmują zwykłe ciągi znaków i zapisują je do słownika Info podczas zapisu

Interaktywne pola formularzy są bardziej skomplikowane. Definicje pól AcroForm znajdują się w słowniku na poziomie dokumentu, a nie w poszczególnych strumieniach stron. Kiedy ImportPages kopiuje stronę zawierającą pola formularza, wizualny wygląd tych pól jest przenoszony, ponieważ jest renderowany w strumieniu zawartości strony, ale widżety pól, które czynią je interaktywnymi, są częścią struktury AcroForm i nie podążają za stroną. W typowym łączeniu pole tekstowe z dokumentu źródłowego wyświetli wartość, jaką miało w momencie importu, ale nie będzie go można edytować w połączonym pliku. Jeśli chcesz, aby pola pozostały do wypełnienia, spłaszcz (flatten) je w każdym dokumencie źródłowym przed zaimportowaniem: spowoduje to wtopienie bieżących wartości w strumień zawartości i usunięcie interaktywnej nakładki, dając czysty wynik wizualny bez uszkodzonych widżetów w dokumencie wynikowym

Zaszyfrowane pliki źródłowe

Chronione hasłem dokumenty źródłowe otwierają się tak samo, jak te nieszyfrowane, z jedną dodatkową właściwością, którą należy najpierw ustawić. Przypisz hasło do PdfSrc.Password przed przestawieniem Active := True, a PDFium użyje go podczas otwierania:

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;

Złe hasło powoduje ten sam cichy wynik Active = False, co brak pliku, więc jawne sprawdzenie jest tutaj równie konieczne. Szyfrowanie nie jest przenoszone do dokumentu docelowego: strony zaimportowane z chronionego źródła lądują w dokumencie docelowym jako niechroniona treść. Jeśli połączony plik wyjściowy również wymaga szyfrowania, skonfiguruj je w obiekcie PdfDest przed wywołaniem SaveAs

Zapisywanie wyniku

SaveAs na TPdf przyjmuje albo ścieżkę do pliku, albo TStream. W większości łączeń to przeciążenie pliku jest tym, czego potrzebujesz:

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

Opcjonalnym drugim argumentem jest TSaveOption, który kontroluje tryb zapisu. Wartość domyślna, saNone, zapisuje aktualizację przyrostową (incremental update), jeśli dokument został wczytany z pliku, lub całkowite przepisanie, jeśli został utworzony od nowa. Ponieważ miejsce docelowe utworzone za pomocą CreateDocument jest zawsze nowe, wynikiem będzie kompaktowy plik z jedną wersją. Trzeci argument, TPdfVersion, pozwala przypiąć nagłówek wersji PDF, gdy masz klientów podrzędnych, którzy wymagają konkretnej wersji; pozostawienie go jako pvUnknown pozwala PDFium wybrać na podstawie zawartości

Przedstawione tu metody ImportPages i SaveAs są częścią komponentu PDFium Component dla Delphi i C++Builder