Articolo tecnico

Dividere documenti PDF con PDFium Component in Delphi

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

Diagramma del ciclo di divisione di PDFium Component in Delphi: CreateDocument, ImportPages dalla sorgente in sola lettura, un SaveAs verificato e il reset di Active prima di ogni nuova iterazione
Qualunque cosa decida i gruppi, il ciclo resta identico: importate le pagine, salvate con il risultato verificato, poi riportate Active a False così il prossimo CreateDocument parte da uno stato pulito

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 /Dest o come azione GoTo in /A (ISO 32000-1 §12.3.3 e §12.6.4.2). PageNumber viene riempito da /Dest; per una voce basata su azione resta 0 e la pagina sta in ActionPageNumber
  • 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
Diagramma che mappa i segnalibri PDF di primo livello su intervalli di pagine calcolati e file di output quando si divide con PDFium Component in Delphi, compreso un segnalibro fuori intervallo che viene saltato
Una sezione va dalla pagina di ogni segnalibro di primo livello fino a una pagina prima del segnalibro successivo, e le voci che puntano oltre la fine vengono saltate invece di produrre file vuoti

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