Per un manuale utente di 40 pagine il cui primo segnalibro di primo livello punta alla pagina 5, uno splitter che taglia "da questo segnalibro al successivo" scrive file di capitolo che sommano 36 pagine. Copertina, pagina di copyright e un indice di due pagine siedono davanti a ogni voce della outline, così non atterrano mai in nessun file. Una outline segna solo dove iniziano le sezioni. Non copre ciò che viene prima della prima voce, e niente nel formato esige che le voci siano elencate in ordine di pagina, quindi uno splitter affidabile raccoglie le pagine dei segnalibri come punti di taglio, le ordina e aggiunge un intervallo esplicito per il front matter
Un solo ciclo dietro ogni modalità di divisione
PDFium Component divide un documento con gli stessi quattro passi su un secondo TPdf, qualunque cosa decida i confini: CreateDocument avvia un PDF vuoto in memoria, ImportPages copia un intervallo di pagine dalla sorgente dentro di lui, SaveAs scrive il file, e Active := False richiude l'output. Divisione pagina per pagina, blocchi a dimensione fissa e sezioni per segnalibri differiscono solo nella stringa di intervallo consegnata a ImportPages
Il reset alla fine di ogni passata non è opzionale. CreateDocument si rifiuta di girare su un componente ancora attivo e solleva un EPdfError il cui messaggio inizia con "Cannot perform this operation on an active", così la seconda iterazione di un ciclo che dimentica Active := False si ferma con un'eccezione dopo esattamente un file scritto. Mettere il reset in un blocco finally impedisce a un import o un save falliti di lasciare l'output aperto per la passata successiva. Una sola istanza di TPdf serve l'intera esecuzione, e in memoria non viene mai tenuto più di un documento di output
Aprire la sorgente merita un controllo suo. Assegnare Active := True non solleva mai eccezioni: quando il file manca, è danneggiato o vuole una password non fornita, TPdf ingoia l'errore di caricamento e lascia Active a False. ImportPages prova anche ad attivare la sorgente da solo, il che significa che una sorgente non aperta compare solo come ritorno False alla prima pagina, senza indizi sulla causa. Aprirla esplicitamente e testare Active trasforma quello in un errore che nomina il file prima che esista qualsiasi output:
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; // un fallimento di caricamento lascia Active = False
if not Source.Active then
raise Exception.Create('Cannot open ' + SourceFile);
for I := 1 to Source.PageCount do
begin
PdfOut.CreateDocument; // solleva eccezione mentre PdfOut è ancora attivo
try
// Range è in base 1; la posizione 1 è l'inizio del documento vuoto
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; // richiudete l'output prima della passata successiva
end;
end;
finally
PdfOut.Free;
Source.Free;
end;
end;
Il terzo argomento di ImportPages è la posizione in base 1 della destinazione dove atterra la prima pagina importata. In un documento vuoto 1 è l'unica posizione che ci sia. La sintassi degli intervalli in sé (pagine singole, span chiusi come '5-10', liste con virgole) e gli errori di posizione che rovesciano un pacchetto unito sono trattati in unione di più file PDF in un solo documento, che è la stessa API guardata dal lato opposto
Blocchi a dimensione fissa e intervalli forniti dal chiamante
Tagliare un documento ogni N pagine è un problema di conteggio, e l'unico caso che richiede cura è la coda. Una scansione di 230 pagine divisa in parti da 25 pagine dà nove parti piene e una decima di cinque pagine, quindi la fine di ogni span viene limitata a 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; // l'ultima parte di solito è più corta
if FirstPage = LastPage then
Range := IntToStr(FirstPage) // una coda di una pagina come numero semplice
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;
Gli intervalli scritti da un utente sono un'altra storia. ImportPages riferisce un intervallo cattivo attraverso il proprio valore di ritorno: un numero di pagina oltre Source.PageCount, uno zero, uno span rovesciato o testo che non si interpreta fanno restituire False alla chiamata, e nessuna pagina di quella stringa viene copiata. Dentro un ciclo, quel fallimento arriva alla quarta voce dopo che tre file di sezione esistono già su disco. Controllare ogni voce contro Source.PageCount prima del primo CreateDocument costa poche righe di parsing e fa sì che un refuso alla quarta voce non scriva proprio niente, che è il comportamento che una persona che riprova il lavoro si aspetta
Quando la selezione vive già in un array di interi, per esempio pagine scelte in una vista a miniature, ImportPagesByIndex(Source, PageIndices, InsertAt) salta il passaggio per la stringa. Sia gli indici sia InsertAt sono in base zero lì, così la stessa selezione che si legge '1,3-5' come intervallo diventa [0, 2, 3, 4]
Perché le sezioni per segnalibri vengono fuori corte o mancanti?
Le sezioni per segnalibri vanno male quando il codice si fida che la outline sia in ordine. TPdf.Bookmarks restituisce le voci di primo livello nell'ordine in cui l'autore le ha disposte, ciascuna come record TBookmark con un Title e un PageNumber in base 1. Quattro cose nei file reali rompono la regola ingenua "questa voce fino alla successiva":
- Una voce di outline può portare la propria destinazione come voce
/Desto come azione GoTo in/A(ISO 32000-1 §12.3.3 e §12.6.4.2).PageNumberviene riempito da/Dest; per una voce basata su azione resta0e la pagina sta inActionPageNumber - L'ordine della outline è l'ordine di lettura così come l'ha visto l'autore. Una voce "Indice" piazzata seconda, o un appendice elencata prima dei capitoli, rende "segnalibro successivo meno uno" più piccolo dell'inizio corrente, e quella sezione sparisce
- Due voci di primo livello possono puntare alla stessa pagina, tipicamente un titolo di parte e il suo primo capitolo
- Una voce può puntare oltre l'ultima pagina quando le pagine sono state cancellate con uno strumento che ha lasciato la outline al suo posto
La versione qui sotto tratta la outline come fonte di punti di taglio. Risolve la pagina di ogni voce, butta le voci che non puntano da nessuna parte o oltre la fine, tiene il primo titolo su una pagina condivisa, ordina per numero di pagina e aggiunge una sezione "Front matter" quando il primo taglio viene dopo la pagina 1. Il front matter qui riceve un file proprio invece di essere incollato al capitolo 1, perché chi riceve il capitolo 1 si aspetta che si apra sulla pagina che il segnalibro nominava, non su un indice
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; // solo le voci di primo livello
if Length(Bm) = 0 then
Exit; // niente su cui tagliare
SetLength(Starts, Length(Bm) + 1); // +1 lascia spazio per il front matter
SetLength(Titles, Length(Bm) + 1);
N := 0;
for I := 0 to High(Bm) do
begin
P := Bm[I].PageNumber; // risolto da /Dest
if P < 1 then
P := Bm[I].ActionPageNumber; // risolto da un'azione GoTo in /A
if (P < 1) or (P > Source.PageCount) then
Continue; // non risolto, o oltre l'ultima pagina
Known := False;
for J := 0 to N - 1 do
if Starts[J] = P then
Known := True; // il primo titolo su una pagina vince
if Known then
Continue;
J := N; // insertion sort per numero di pagina
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 // copertina, indice e tutto ciò che viene prima
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]);
// Dichiarare SafeFileName (mostrata più sotto) prima di questa 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;
Siccome gli inizi sono strettamente crescenti dopo l'ordinamento, ogni span calcolato è valido e la guardia su ImportPages non dovrebbe mai scattare su una sorgente leggibile. Un documento senza segnalibri semplicemente non produce file in questa modalità; al chiamante decidere se fare ricadere sul chunking. Per tagliare un livello più in profondità, per esempio ai capitoli dentro le parti, BookmarkChildren[Bm[I]] restituisce i figli di una voce di primo livello nella stessa forma TBookmarks, e la stessa logica dei punti di taglio si applica all'elenco combinato
Cosa porta con sé ogni file di output
Ogni file di output porta le pagine stesse e tutto ciò che i loro dizionari di pagina referenziano: content stream, font, immagini e l'elenco di annotazioni di ogni pagina. I widget dei form arrivano come annotazioni, ma il nuovo file non ha AcroForm, quindi non sono più campi funzionanti. La sorgente viene solo letta. ImportPages copia gli oggetti nel nuovo documento e lascia intatta la sorgente aperta, così la stessa istanza Source alimenta ogni iterazione
La outline resta indietro. I segnalibri vivono nel catalogo del documento sotto /Outlines (ISO 32000-1 §7.7.2), fuori da ogni pagina, quindi un file costruito da pagine importate parte senza segnalibri affatto. Per i file di capitolo spesso va bene. Quando ogni parte ha bisogno della propria navigazione, la outline va costruita per la nuova numerazione di pagina, che è il tema di modifica di una outline PDF e rimappatura delle sue pagine. I link interni si comportano uguale: un link "vedi capitolo 7" alla pagina 12 punta a una pagina finita in un altro file, e in questo file non ha dove atterrare
Le sorgenti cifrate vogliono la password prima che il ciclo parta. Assegnate Source.Password mentre il componente è inattivo, poi impostate Active := True; con la password sbagliata il componente resta inattivo, e il controllo in SplitIntoPages lo trasforma in un errore. L'output scritto da SaveAs è un nuovo documento senza dizionario di cifratura. Sta bene per file divisi destinati a destinatari diversi, e quando ogni parte deve essere protetta di nuovo, SaveAsEncrypted(FileName, Options) prende un record TPdfEncryptOptions con le proprie password utente e owner
Nomi di file, salvataggi falliti e riesecuzioni
I titoli dei segnalibri sono stringhe di testo PDF e possono contenere qualsiasi cosa, compresi caratteri che Windows rifiuta nei nomi di file. Le regole vengono dal file system, non dal PDF: < > : " / \ | ? * e i caratteri di controllo sono invalidi, gli spazi e i punti finali vengono tolti dalle API file Win32, e nomi come CON, PRN o COM1 sono dispositivi riservati. Il prefisso numerato in SplitByBookmarks si occupa già dei nomi di dispositivo, tiene i file in ordine di lettura in un elenco di directory, e impedisce a due capitoli entrambi titolati "Summary" di sovrascriversi a vicenda. Il resto è una piccola funzione che scrivete voi:
// Helper lato reader, non fa parte di PDFium Component
function SafeFileName(const Title: string): string;
const
Forbidden = '<>:"/\|?*';
var
I: Integer;
begin
Result := Title;
if Length(Result) > 80 then
SetLength(Result, 80); // tenete i percorsi completi ben sotto 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 con un nome di file scrive in un file temporaneo nella cartella di destinazione e lo mette al posto giusto solo dopo che il salvataggio è riuscito. La destinazione è quindi o il file precedente o uno nuovo completo, mai mezzo scritto, così rieseguire una divisione su una cartella di output esistente è innocuo. I fallimenti tornano in due modi. SaveAs restituisce False quando il documento non può essere serializzato, e il file temporaneo viene rimosso. I problemi nel file system, come una cartella di output inesistente, un percorso che il sistema operativo rifiuta o una destinazione bloccata da un viewer, sollevano un'eccezione dal layer file
Come un batch reagisce a quei fallimenti è una scelta, e per la divisione è diversa dall'unione. Un pacchetto unito a cui manca una parte è un documento sbagliato, quindi fermarsi è corretto lì. Gli output di divisione sono indipendenti fra loro: se section_147.pdf non può essere scritto perché un collega ce l'ha aperto, le altre sezioni restano buone. Per i lavori di divisione, catturate le eccezioni per file dentro il ciclo, registrate nome del file e messaggio, continuate, e confrontate a fine ciclo il numero di file scritti con il numero di intervalli pianificati. Gli esempi qui sopra lanciano eccezione al primo fallimento solo per restare corti. Un log che dice che su 200 file ne sono stati scritti 199 e nomina quello bloccato si sistema con una riesecuzione, mentre un'esecuzione abortita al file 147 lascia qualcuno a capire quali 53 file mancano
Il ciclo di divisione, i record dei segnalibri e il salvataggio cifrato usati qui viaggiano tutti con PDFium Component, la libreria PDF basata su PDFium per Delphi e C++Builder