Technisch artikel

PDF-documenten splitsen met PDFium Component in Delphi

PDFium Component geeft u één methode om een PDF te splitsen: ImportPages. Al het andere, of u nu één pagina isoleert, op willekeurige grenzen knipt of de eigen bladwijzerstructuur van het document volgt, is niet meer dan een andere manier om te bepalen welke paginanummers in welk uitvoerbestand belanden. De mechaniek blijft dezelfde. Dat vroeg doorhebben bespaart een hoop verkeerde afslagen

Hoe de splitslus werkt

Het patroon is hetzelfde ongeacht hoe u het brondocument verdeelt. Maak een verse instantie van TPdf, roep daarop CreateDocument aan om een lege PDF in het geheugen te initialiseren, importeer de gewenste pagina's met ImportPages, sla het resultaat op, en zet Active vóór de volgende iteratie terug op False. Die laatste stap is degene die men mist: CreateDocument sluit het document dat nog in het geheugen zit niet impliciet, dus u moet uw uitvoer opslaan en Active := False expliciet terugzetten voordat u het opnieuw aanroept; eerst resetten houdt de toestand schoon en welgedefinieerd. De buitenste instantie van TPdf wordt over alle iteraties hergebruikt, wat de druk van geheugentoewijzing bij grote taken laag houdt

Diagram van de splitslus van PDFium Component in Delphi: CreateDocument, ImportPages uit de alleen-lezen bron, een gecontroleerde SaveAs en het resetten van Active voor elke nieuwe iteratie
Wat de groepen ook bepaalt, de lus blijft identiek: importeer de pagina's, sla op met een gecontroleerd resultaat en reset daarna Active zodat de volgende CreateDocument vanuit een schone toestand start

Zo ziet splitsen per pagina eruit wanneer het tot de kern is teruggebracht:

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 een 1-gebaseerde tekenreeks met paginanummers; invoegpunt 1 = eerste positie
      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 voor de volgende CreateDocument
    end;
  finally
    PdfOut.Free;
  end;
end;

De parameter Range van ImportPages heeft dezelfde tekenreeksvorm die PDFium intern gebruikt: een door komma's gescheiden lijst van paginanummers of met een koppelteken afgebakende bereiken, allemaal 1-gebaseerd. '3' importeert pagina 3. '1-5' importeert de pagina's 1 tot en met 5 op volgorde. '2,5,8' importeert die drie pagina's. De derde parameter is de 1-gebaseerde invoegpositie in het doeldocument; 1 doorgeven plaatst de geïmporteerde pagina's altijd aan het begin van een verder leeg bestand, en dat is wat u hier wilt

Splitsen op paginabereiken

Levert de aanroeper een lijst als 1-12,13-24,25-36, dan parseert u die tot paren van begin en eind en draait u dezelfde lus, waarbij u uit elk paar de bereiktekenreeks opbouwt:

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;

Validatie voordat u bij ImportPages aankomt telt hier. ImportPages geeft False terug wanneer een paginanummer in de bereiktekenreeks groter is dan Source.PageCount, maar het werpt geen uitzondering op en het levert geen gedeeltelijk uitvoerbestand op dat u aan de naam alleen kunt herkennen. Controleer de retourwaarde van SaveAs en log mislukkingen apart; een bereik dat een leeg uitvoerbestand oplevert, is pas duidelijk fout wanneer iemand het opent

Splitsen op bladwijzergrenzen

De derde aanpak gebruikt de eigen structuur van het document in plaats van een extern aangeleverde lijst. Elke bladwijzer op het hoogste niveau draagt een doelpaginanummer; de sectie die hij afbakent, loopt van die pagina tot één pagina vóór de pagina van de volgende bladwijzer, of tot het einde van het document bij de laatste vermelding

Diagram dat PDF-bladwijzers op het hoogste niveau afbeeldt op berekende paginabereiken en uitvoerbestanden bij het splitsen met PDFium Component in Delphi, inclusief een bladwijzer buiten het bereik die wordt overgeslagen
Een sectie loopt van de pagina van elke bladwijzer op het hoogste niveau tot één pagina vóór de volgende bladwijzer, en vermeldingen die voorbij het einde wijzen worden overgeslagen in plaats van lege bestanden op te leveren
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;   // sla een misvormde sectie over in plaats van een leeg bestand te schrijven
      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;

Een document zonder bladwijzers is geen foutsituatie die het waard is als zodanig aan de gebruiker te tonen; het betekent alleen dat deze splitsmodus niets heeft om mee te werken. De bewaking Length(Bm) = 0 handelt dat stilzwijgend af. Wat wél het tonen waard is, is een bladwijzer waarvan het paginanummer buiten het bereik van het document valt, wat gebeurt in misvormde bestanden waarin de overzichtsstructuur nooit is bijgewerkt nadat er pagina's waren verwijderd. De grenscontrole op StartPage en EndPage slaat die vermeldingen over in plaats van een onzinnig bereik aan ImportPages door te geven

Uitvoerbestanden benoemen en het resetten van Active

De veiligheid van bestandsnamen die uit bladwijzers zijn afgeleid vraagt expliciete aandacht. Bladwijzertitels kunnen tekens bevatten die in een PDF-tekenreeks geldig zijn maar niet in een pad in het bestandssysteem. Vervang minstens de schuine streep, de omgekeerde schuine streep en de dubbele punt voordat u het uitvoerpad opbouwt. Op Windows zijn ook *, ?, ", <, > en | verboden; een eenvoudige lus over een vaste verzameling dekt die zonder dat u een reguliere expressie binnenhaalt

De regel Active := False aan het einde van elke iteratie verdient nadruk, want zij is de enige niet voor de hand liggende eis in het patroon. CreateDocument sluit niet impliciet wat er open staat. Is Active nog True wanneer CreateDocument opnieuw draait, dan is het document dat nog in het geheugen zit nooit netjes gesloten of opgeslagen, en op welgedefinieerd gedrag kunt u in die toestand niet rekenen, dus sla expliciet op en reset voordat u aan het volgende document begint. Zie het als de tegenhanger van try/finally: het finally-blok geeft het buitenste object vrij; de Active := False reset de toestand van het binnenste document tussen de lusiteraties

Het geheugengebruik over een grote splitstaak blijft met deze aanpak vlak, want u houdt nooit meer dan één uitvoerdocument tegelijk in het geheugen. Het brondocument blijft de hele tijd geopend en alleen-lezen; ImportPages kopieert paginagegevens naar het nieuwe document zonder de bron te wijzigen. Is de bron versleuteld, open hem dan vóór de lus met zijn wachtwoord en de gekopieerde pagina's in elk uitvoerbestand zijn onversleuteld, wat meestal het juiste gedrag is voor gesplitste uitvoer die naar verschillende ontvangers gaat

Nog iets over SaveAs: het geeft een Boolean terug. Een uitvoermap die niet bestaat, een pad met tekens die het besturingssysteem weigert, of een volle schijf zorgen er alle voor dat SaveAs False teruggeeft zonder een uitzondering op te werpen. In een batchtaak die een document van 200 pagina's in 200 losse bestanden splitst, is een stille mislukking op pagina 147 makkelijk over het hoofd te zien. Controleer de retourwaarde bij elke aanroep en tel na afloop van de lus de geslaagde pogingen af tegen het verwachte totaal

De hier getoonde methoden ImportPages en CreateDocument maken deel uit van PDFium Component voor Delphi en C++Builder