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
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).PageNumberwordt uit/Destgevuld; bij een action-gebaseerd item blijft hij0en staat de pagina inActionPageNumber - 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
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