Technisch artikel

PDF-documenten splitsen met PDFium Component in Delphi

Een gebruikershandleiding van 40 pagina's waarvan de eerste top-level bladwijzer naar pagina 5 wijst: een splitter die snijdt "van deze bladwijzer tot de volgende" schrijft hoofdstukbestanden die samen 36 pagina's tellen. De omslag, de copyrightpagina en een inhoudsopgave van twee pagina's zitten vóór elke outline-entry, dus ze belanden nooit in een bestand. Een outline markeert alleen waar secties beginnen. Hij dekt niet wat vóór de eerste entry komt, en niets in het formaat vereist dat de entries in paginaorde staan, dus een betrouwbare splitter verzamelt de bladwijzerpagina's als snijpunten, sorteert ze en voegt een expliciet front-matter-bereik toe

Eén lus achter elke splitsmodus

PDFium Component splitst een document met dezelfde vier stappen op een tweede TPdf, wat de grenzen ook bepaalt: CreateDocument start een lege PDF in het geheugen, ImportPages kopieert een reeks pagina's uit de bron erin, SaveAs schrijft het bestand, en Active := False sluit de uitvoer weer af. Splitsen per pagina, stukken van vaste grootte en bladwijzersecties verschillen alleen in de bereikstring die ImportPages meekrijgt

De reset aan het einde van elke pas is niet optioneel. CreateDocument weigert te draaien op een component die nog actief is en gooit een EPdfError waarvan de melding begint met "Cannot perform this operation on an active", dus de tweede iteratie van een lus die Active := False vergeet stopt met een exceptie nadat precies één bestand is weggeschreven. De reset in een finally-blok zetten voorkomt dat een mislukte import of save de uitvoer open laat voor de volgende pas. Eén TPdf-instantie bedient de hele run, en er staat nooit meer dan één uitvoerdocument in het geheugen

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

Het openen van de bron verdient een eigen controle. Active := True toewijzen gooit nooit: ontbreekt het bestand, is het beschadigd of heeft het een wachtwoord dat niet is meegegeven, dan vangt TPdf de laadfout op en laat Active op False staan. ImportPages probeert de bron ook zelf te activeren, dus een niet-geopende bron toont zich alleen als een False-retour op de eerste pagina en geeft geen hint over de oorzaak. Hem expliciet openen en Active testen maakt daarvan een fout die het bestand benoemt voordat er uitvoer bestaat:

procedure SplitIntoPages(const SourceFile, OutputDir: string);
var
  Source, PdfOut: TPdf;
  I: Integer;
  OutFile: string;
begin
  Source := TPdf.Create(nil);
  PdfOut := TPdf.Create(nil);
  try
    Source.FileName := SourceFile;
    Source.Active := True;               // een laadfaling laat Active op False staan
    if not Source.Active then
      raise Exception.Create('Cannot open ' + SourceFile);

    for I := 1 to Source.PageCount do
    begin
      PdfOut.CreateDocument;             // gooit terwijl PdfOut nog actief is
      try
        // Bereik is 1-based; positie 1 is het begin van het lege document
        if not PdfOut.ImportPages(Source, IntToStr(I), 1) then
          raise Exception.CreateFmt('Cannot import page %d', [I]);
        OutFile := IncludeTrailingPathDelimiter(OutputDir) +
          Format('page_%.4d.pdf', [I]);
        if not PdfOut.SaveAs(OutFile) then
          raise Exception.Create('Cannot save ' + OutFile);
      finally
        PdfOut.Active := False;          // sluit de uitvoer vóór de volgende pas
      end;
    end;
  finally
    PdfOut.Free;
    Source.Free;
  end;
end;

Het derde argument van ImportPages is de 1-based positie in het doel waar de eerste geïmporteerde pagina belandt. In een leeg document is 1 de enige positie die er is. De bereiksyntaxis zelf (enkele pagina's, gesloten reeksen zoals '5-10', communalijsten) en de positiefouten die een samengevoegde bundel omdraaien worden behandeld in meerdere PDF-bestanden samenvoegen tot één document, dezelfde API andersom

Stukken van vaste grootte en bereiken van de aanroeper

Een document elke N pagina's snijden is een telpatroon, en het enige geval dat zorg vraagt is de staart. Een scan van 230 pagina's gesplitst in delen van 25 pagina's geeft negen volledige delen en een tiende van vijf pagina's, dus het einde van elke reeks wordt afgekapt op Source.PageCount:

procedure SplitEvery(Source: TPdf; ChunkSize: Integer; const OutputDir: string);
var
  PdfOut: TPdf;
  FirstPage, LastPage, Part: Integer;
  Range, OutFile: string;
begin
  if ChunkSize < 1 then
    raise Exception.Create('ChunkSize must be at least 1');
  PdfOut := TPdf.Create(nil);
  try
    Part := 0;
    FirstPage := 1;
    while FirstPage <= Source.PageCount do
    begin
      LastPage := FirstPage + ChunkSize - 1;
      if LastPage > Source.PageCount then
        LastPage := Source.PageCount;    // het laatste deel is meestal korter
      if FirstPage = LastPage then
        Range := IntToStr(FirstPage)     // een staart van één pagina als blote getal
      else
        Range := Format('%d-%d', [FirstPage, LastPage]);
      Inc(Part);

      PdfOut.CreateDocument;
      try
        if not PdfOut.ImportPages(Source, Range, 1) then
          raise Exception.Create('Cannot import pages ' + Range);
        OutFile := IncludeTrailingPathDelimiter(OutputDir) +
          Format('part_%.3d.pdf', [Part]);
        if not PdfOut.SaveAs(OutFile) then
          raise Exception.Create('Cannot save ' + OutFile);
      finally
        PdfOut.Active := False;
      end;
      FirstPage := LastPage + 1;
    end;
  finally
    PdfOut.Free;
  end;
end;

Bereiken die een gebruiker typt zijn een ander verhaal. ImportPages meldt een slecht bereik via zijn retourwaarde: een paginanummer voorbij Source.PageCount, een nul, een omgekeerde reeks of tekst die niet parseert laat de aanroep False teruggeven, en geen van de pagina's in die string wordt gekopieerd. Binnen een lus komt die faling binnen op de vierde entry terwijl er al drie sectiebestanden op schijf staan. Elke entry tegen Source.PageCount controleren vóór de eerste CreateDocument kost een paar regels parsewerk en betekent dat een typefout in entry vier helemaal niets wegschrijft, en dat is het gedrag dat iemand die de taak opnieuw draait verwacht

Zit de selectie al in een integer-array, bijvoorbeeld pagina's gekozen in een miniatuurweergave, dan slaat ImportPagesByIndex(Source, PageIndices, InsertAt) de stringstap over. Zowel de indices als InsertAt zijn daar zero-based, dus dezelfde selectie die als bereik '1,3-5' leest wordt [0, 2, 3, 4]

Waarom komen bladwijzersecties kort uit of ontbreken ze?

Bladwijzersecties gaan mis wanneer de code vertrouwt dat de outline netjes is. TPdf.Bookmarks geeft de top-level entries terug in de volgorde waarin de auteur ze arrangeerde, elk als een TBookmark-record met een Title en een 1-based PageNumber. Vier dingen in echte bestanden breken de naïeve regel "van deze entry tot de volgende":

  • Een outline-item kan zijn doel dragen als /Dest-entry of als GoTo-action in /A (ISO 32000-1 §12.3.3 en §12.6.4.2). PageNumber wordt uit /Dest gevuld; bij een action-gebaseerd item blijft hij 0 en staat de pagina in ActionPageNumber
  • Outlinevolgorde is leesvolgorde zoals de auteur die zag. Een "Index"-entry op de tweede plaats, of een bijlage vóór de hoofdstukken, maakt "volgende bladwijzer min één" kleiner dan de huidige start, en die sectie verdwijnt
  • Twee top-level entries kunnen naar dezelfde pagina wijzen, typisch een deelstitel en zijn eerste hoofdstuk
  • Een entry kan voorbij de laatste pagina wijzen wanneer pagina's zijn verwijderd met een tool die de outline met rust liet
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

De versie hieronder behandelt de outline als bron van snijpunten. Hij lost de pagina van elke entry op, gooit entries weg die nergens of voorbij het einde wijzen, houdt de eerste titel op een gedeelde pagina, sorteert op paginanummer en voegt een sectie "Front matter" toe wanneer de eerste snede na pagina 1 valt. Front matter krijgt hier zijn eigen bestand in plaats van vastgelijmd aan hoofdstuk 1, want wie hoofdstuk 1 ontvangt verwacht dat het opent op de pagina die de bladwijzer benoemde, niet op een inhoudsopgave

procedure SplitByBookmarks(Source: TPdf; const OutputDir: string);
var
  Bm: TBookmarks;
  Starts: array of Integer;
  Titles: array of string;
  I, J, K, N, P, EndPage: Integer;
  Known: Boolean;
  PdfOut: TPdf;
  OutFile: string;
begin
  Bm := Source.Bookmarks;                // alleen top-level entries
  if Length(Bm) = 0 then
    Exit;                                // niets om op te snijden
  SetLength(Starts, Length(Bm) + 1);     // +1 laat ruimte voor front matter
  SetLength(Titles, Length(Bm) + 1);
  N := 0;
  for I := 0 to High(Bm) do
  begin
    P := Bm[I].PageNumber;               // opgelost uit /Dest
    if P < 1 then
      P := Bm[I].ActionPageNumber;       // opgelost uit een GoTo-action in /A
    if (P < 1) or (P > Source.PageCount) then
      Continue;                          // onopgelost, of voorbij de laatste pagina
    Known := False;
    for J := 0 to N - 1 do
      if Starts[J] = P then
        Known := True;                   // de eerste titel op een pagina wint
    if Known then
      Continue;
    J := N;                              // insertion sort op paginanummer
    while (J > 0) and (Starts[J - 1] > P) do
    begin
      Starts[J] := Starts[J - 1];
      Titles[J] := Titles[J - 1];
      Dec(J);
    end;
    Starts[J] := P;
    Titles[J] := Bm[I].Title;
    Inc(N);
  end;
  if N = 0 then
    Exit;
  if Starts[0] > 1 then                  // omslag, inhoudsopgave en alles ervoor
  begin
    for J := N downto 1 do
    begin
      Starts[J] := Starts[J - 1];
      Titles[J] := Titles[J - 1];
    end;
    Starts[0] := 1;
    Titles[0] := 'Front matter';
    Inc(N);
  end;

  PdfOut := TPdf.Create(nil);
  try
    for K := 0 to N - 1 do
    begin
      if K < N - 1 then
        EndPage := Starts[K + 1] - 1
      else
        EndPage := Source.PageCount;
      PdfOut.CreateDocument;
      try
        if not PdfOut.ImportPages(Source,
          Format('%d-%d', [Starts[K], EndPage]), 1) then
          raise Exception.CreateFmt('Cannot import pages %d-%d',
            [Starts[K], EndPage]);
        // Declareer SafeFileName (verderop getoond) vóór deze routine
        OutFile := IncludeTrailingPathDelimiter(OutputDir) +
          Format('%.2d_%s.pdf', [K + 1, SafeFileName(Titles[K])]);
        if not PdfOut.SaveAs(OutFile) then
          raise Exception.Create('Cannot save ' + OutFile);
      finally
        PdfOut.Active := False;
      end;
    end;
  finally
    PdfOut.Free;
  end;
end;

Omdat de starts na de sortering strikt oplopen is elke berekende reeks geldig en hoort de bewaking op ImportPages bij een leesbare bron nooit af te gaan. Een document zonder bladwijzers produceert in deze modus simpelweg geen bestanden; de aanroeper beslist of dat terugvalt op stukken van vaste grootte. Om één niveau dieper te snijden, bijvoorbeeld bij hoofdstukken binnen delen, geeft BookmarkChildren[Bm[I]] de kinderen van een top-level entry terug in dezelfde TBookmarks-vorm, en dezelfde snijpuntlogica geldt voor de gecombineerde lijst

Wat elk uitvoerbestand meedraagt

Elk uitvoerbestand draagt de pagina's zelf en alles waarnaar hun pagedictionaries refereren: content streams, fonts, afbeeldingen en de annotatielijst van elke pagina. Formwidgets komen mee als annotaties, maar het nieuwe bestand heeft geen AcroForm, dus het zijn geen werkende velden meer. De bron wordt alleen gelezen. ImportPages kopieert objecten in het nieuwe document en laat de geopende bron onaangeroerd, dus dezelfde Source-instantie voedt elke iteratie

De outline blijft achter. Bladwijzers wonen in de documentcatalogus onder /Outlines (ISO 32000-1 §7.7.2), buiten elke pagina, dus een bestand gebouwd uit geïmporteerde pagina's begint zonder enige bladwijzer. Voor hoofdstukbestanden is dat vaak prima. Wanneer elk deel zijn eigen navigatie nodig heeft moet de outline voor de nieuwe paginanummering worden gebouwd, en dat is het onderwerp van een PDF-outline bewerken en zijn pagina's hermapperen. Interne links gedragen zich hetzelfde: een "zie hoofdstuk 7"-link op pagina 12 wijst naar een pagina die in een ander bestand is beland, en in dit bestand heeft hij niets om op te landen

Versleutelde bronnen willen het wachtwoord hebben voordat de lus begint. Wijs Source.Password toe terwijl de component inactief is, en zet dan Active := True; bij een verkeerd wachtwoord blijft de component inactief, wat de controle in SplitIntoPages tot een fout maakt. De uitvoer die SaveAs schrijft is een nieuw document zonder versleutelingswoordenboek. Dat past bij splitsbestanden voor verschillende ontvangers, en wanneer elk deel opnieuw beschermd moet worden neemt SaveAsEncrypted(FileName, Options) een TPdfEncryptOptions-record met eigen gebruikers- en eigenaarswachtwoorden

Bestandsnamen, mislukte saves en hernieuwde runs

Bladwijzertitels zijn PDF-tekststrings en kunnen alles bevatten, ook tekens die Windows weigert in bestandsnamen. De regels komen uit het bestandssysteem, niet uit PDF: < > : " / \ | ? * en control characters zijn ongeldig, spaties en punten aan het eind worden door de Win32-bestands-API's gestript, en namen zoals CON, PRN of COM1 zijn gereserveerde apparaten. Het genummerde prefix in SplitByBookmarks regelt de apparaatnamen al, houdt de bestanden in leesvolgorde in een directoryoverzicht, en voorkomt dat twee hoofdstukken die allebei "Summary" heten elkaar overschrijven. De rest is een kleine functie die u zelf schrijft:

// Helper aan de lezerkant, geen deel van PDFium Component
function SafeFileName(const Title: string): string;
const
  Forbidden = '<>:"/\|?*';
var
  I: Integer;
begin
  Result := Title;
  if Length(Result) > 80 then
    SetLength(Result, 80);               // houd volledige paden ruim onder MAX_PATH
  for I := 1 to Length(Result) do
    if (Ord(Result[I]) < 32) or (Pos(Result[I], Forbidden) > 0) then
      Result[I] := '_';
  while (Result <> '') and
    ((Result[Length(Result)] = '.') or (Result[Length(Result)] = ' ')) do
    SetLength(Result, Length(Result) - 1);
  if Result = '' then
    Result := 'section';
end;

SaveAs met een bestandsnaam schrijft naar een tijdelijk bestand in de doelmap en wisselt hem pas in zijn plek nadat de save is geslaagd. Het doel is daardoor of het vorige bestand of een compleet nieuw, nooit half geschreven, dus een splitsrun opnieuw draaien over een bestaande uitvoermap is ongevaarlijk. Falingen komen op twee manieren terug. SaveAs geeft False terug wanneer het document niet kan worden geserialiseerd, en het tijdelijke bestand wordt verwijderd. Problemen in het bestandssysteem, zoals een uitvoermap die niet bestaat, een pad dat het OS weigert of een doel dat door een viewer is vergrendeld, gooien een exceptie uit de bestandslaag

Hoe een batch op die falingen reageert is een keuze, en bij splitsen verschilt hij van samenvoegen. Een samengevoegde bundel met een ontbrekend deel is een verkeerd document, dus stoppen is daar correct. Splitsuitvoer is onderling onafhankelijk: kan section_147.pdf niet worden weggeschreven omdat een collega hem open heeft staan, dan zijn de andere secties nog steeds goed. Vang bij splistaken per-bestandsexcepties binnen de lus op, noteer de bestandsnaam en de melding, ga door, en vergelijk aan het eind het aantal weggeschreven bestanden met het aantal geplande bereiken. De voorbeelden hierboven gooien bij de eerste faling alleen om kort te blijven. Een log die zegt dat 199 van 200 bestanden zijn geschreven en de vergrendelde benoemt kost één hernieuwde run, terwijl een run die bij bestand 147 afbrak iemand achterlaat die moet uitzoeken welke 53 bestanden ontbreken

De splitslus, de bladwijzerrecords en de versleutelde save die hier worden gebruikt komen allemaal mee met PDFium Component, de op PDFium gebaseerde PDF-bibliotheek voor Delphi en C++Builder