Artigo Técnico

Dividir Documentos PDF em Delphi com o PDFium Component

O PDFium Component dá-lhe um método para dividir PDF: ImportPages. Tudo o resto, quer esteja a isolar uma única página, a cortar em fronteiras arbitrárias, ou a seguir a própria estrutura de marcadores do documento, são apenas maneiras diferentes de decidir que números de página vão para cada ficheiro de saída. A mecânica mantém-se a mesma. Perceber isso cedo poupa muitos caminhos errados

Como funciona o ciclo de divisão

O padrão é o mesmo independentemente de como divide o documento de origem. Crie uma instância TPdf nova, chame nela CreateDocument para inicializar um PDF vazio em memória, importe as páginas que quer com ImportPages, guarde o resultado, e depois reponha Active a False antes da iteração seguinte. Esse último passo é o que as pessoas deixam escapar: CreateDocument não fecha implicitamente o documento que ainda está em memória, pelo que tem de guardar a saída e repor Active := False de forma explícita antes de o chamar outra vez; repor primeiro mantém o estado limpo e bem definido. A instância TPdf exterior é reutilizada em todas as iterações, o que mantém baixa a pressão de alocação em trabalhos grandes

Diagrama do ciclo de divisão do PDFium Component em Delphi: CreateDocument, ImportPages a partir da origem só de leitura, um SaveAs verificado, e a reposição de Active antes de cada nova iteração
Seja o que for que decide os grupos, o ciclo mantém-se idêntico: importar as páginas, guardar com o resultado verificado, e depois repor Active para que o CreateDocument seguinte parta de um estado limpo

Eis o aspeto da divisão página a página reduzida ao essencial:

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 é uma string de números de página a começar em 1; posição 1 = primeira
      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;   // repor antes do próximo CreateDocument
    end;
  finally
    PdfOut.Free;
  end;
end;

O parâmetro Range de ImportPages tem o mesmo formato de string que o PDFium usa internamente: uma lista separada por vírgulas de números de página ou de intervalos delimitados por hífen, todos a começar em 1. '3' importa a página 3. '1-5' importa as páginas 1 a 5 por ordem. '2,5,8' importa essas três páginas. O terceiro parâmetro é a posição de inserção no documento de destino, também a começar em 1; passar 1 coloca sempre as páginas importadas no início de um ficheiro de outro modo vazio, que é o que aqui se pretende

Dividir por intervalos de páginas

Quando quem chama fornece uma lista como 1-12,13-24,25-36, analisa-a em pares de início e fim e corre o mesmo ciclo, construindo a string de intervalo a partir de cada par:

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;

A validação antes de chegar a ImportPages importa aqui. ImportPages devolve False quando um número de página na string de intervalo excede Source.PageCount, mas não levanta exceção e não produz um ficheiro de saída parcial que consiga detetar só pelo nome. Verifique o valor de retorno de SaveAs e registe as falhas em separado; um intervalo que produz um ficheiro de saída vazio não é obviamente errado até alguém o abrir

Dividir nas fronteiras dos marcadores

A terceira abordagem usa a estrutura do próprio documento em vez de uma lista fornecida do exterior. Cada marcador de nível de topo transporta um número de página de destino; a secção que define vai dessa página até uma antes da página do marcador seguinte, ou até ao fim do documento no caso da última entrada

Diagrama a mapear marcadores PDF de nível de topo para intervalos de páginas calculados e ficheiros de saída ao dividir com o PDFium Component em Delphi, incluindo um marcador fora do intervalo que é ignorado
Uma secção vai da página de cada marcador de nível de topo até uma página antes do marcador seguinte, e as entradas que apontam para além do fim são ignoradas em vez de produzirem ficheiros vazios
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;   // ignora uma secção malformada em vez de escrever ficheiro vazio
      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;

Um documento sem marcadores não é uma condição de erro que valha a pena apresentar ao utilizador como tal; significa apenas que este modo de divisão não tem por onde pegar. A guarda Length(Bm) = 0 trata disso em silêncio. O que vale a pena apresentar é quando o número de página de um marcador cai fora do intervalo do documento, o que acontece em ficheiros malformados onde a estrutura de tópicos nunca foi atualizada depois de páginas terem sido apagadas. A verificação de limites em StartPage e EndPage ignora essas entradas em vez de passar um intervalo lixo a ImportPages

Nomes dos ficheiros de saída e a reposição de Active

A segurança dos nomes de ficheiro derivados de marcadores exige atenção explícita. Os títulos de marcadores podem conter carateres válidos numa string PDF mas não num caminho do sistema de ficheiros. No mínimo, substitua a barra, a barra invertida e os dois pontos antes de construir o caminho de saída. No Windows, *, ?, ", <, > e | também são proibidos; um ciclo simples sobre um conjunto fixo trata deles sem trazer uma expressão regular

A linha Active := False no fim de cada iteração merece ênfase porque é o único requisito não óbvio do padrão. CreateDocument não fecha implicitamente o que estiver aberto. Se Active ainda estiver a True quando CreateDocument voltar a correr, o documento que continua em memória nunca foi devidamente fechado nem guardado, e não pode contar com comportamento bem definido nesse estado, pelo que deve guardar e repor de forma explícita antes de começar o documento seguinte. Pense nisso como o par do try/finally: o bloco finally liberta o objeto exterior; o Active := False repõe o estado do documento interior entre iterações do ciclo

O uso de memória ao longo de um grande trabalho de divisão mantém-se plano com esta abordagem, porque nunca tem mais do que um documento de saída em memória de cada vez. O documento de origem permanece aberto e só de leitura do princípio ao fim; ImportPages copia os dados das páginas para o documento novo sem modificar a origem. Se a origem estiver cifrada, abra-a com a respetiva palavra-passe antes do ciclo e as páginas copiadas em cada ficheiro de saída ficarão sem cifra, o que costuma ser o comportamento certo para saída dividida distribuída por destinatários diferentes

Mais uma coisa sobre o SaveAs: devolve um Boolean. Um diretório de saída que não existe, um caminho com carateres que o sistema operativo rejeita, ou uma condição de disco cheio fazem todos com que SaveAs devolva False sem levantar exceção. Num trabalho em lote que divide um documento de 200 páginas em 200 ficheiros de uma página, uma falha silenciosa na página 147 passa facilmente despercebida. Verifique o valor de retorno em cada chamada e conte os sucessos contra o total esperado quando o ciclo terminar

Os métodos ImportPages e CreateDocument aqui mostrados fazem parte do PDFium Component para Delphi e C++Builder