Artigo Técnico

Dividindo Documentos PDF com o Componente PDFium em Delphi

Para um manual de usuário de 40 páginas cujo primeiro bookmark de nível superior aponta para a página 5, um divisor que corta "deste bookmark até o próximo" grava arquivos de capítulo que somam 36 páginas. A capa, a página de copyright e um sumário de duas páginas ficam na frente de toda entrada de outline, então nunca caem em arquivo algum. Um outline só marca onde as seções começam. Ele não cobre o que vem antes da primeira entrada, e nada no formato exige que as entradas estejam listadas em ordem de página, então um divisor confiável coleta as páginas dos bookmarks como pontos de corte, ordena-as e acrescenta uma faixa explícita de front matter

Um único loop por trás de todo modo de divisão

O PDFium Component divide um documento com os mesmos quatro passos num segundo TPdf, seja o que for que decida as fronteiras: CreateDocument inicia um PDF vazio em memória, ImportPages copia um intervalo de páginas da origem para ele, SaveAs grava o arquivo, e Active := False fecha a saída de novo. Divisão página a página, pedaços de tamanho fixo e seções por bookmark diferem só na string de intervalo entregue ao ImportPages

O reset no fim de cada passada não é opcional. O CreateDocument se recusa a rodar num componente que ainda está ativo e levanta um EPdfError cuja mensagem começa com "Cannot perform this operation on an active", então a segunda iteração de um loop que esquece Active := False para com exceção depois de exatamente um arquivo gravado. Pôr o reset num bloco finally evita que uma importação ou um salvamento que falhem deixem a saída aberta para a próxima passada. Uma instância de TPdf serve a execução inteira, e só um documento de saída é mantido em memória em cada momento

Diagrama do loop de divisão do PDFium Component em Delphi: CreateDocument, ImportPages a partir da origem somente leitura, um SaveAs verificado e o reset de Active antes de cada nova iteração
Seja o que for que decida os grupos, o loop permanece idêntico: importe as páginas, salve com o resultado conferido, depois resete o Active para que o próximo CreateDocument comece de um estado limpo

Abrir a origem merece checagem própria. Atribuir Active := True nunca levanta exceção: quando o arquivo está ausente, danificado ou precisa de uma senha que não foi fornecida, o TPdf captura o erro de carga e deixa o Active em False. O ImportPages também tenta ativar a origem por conta própria, o que significa que uma origem não aberta aparece apenas como um retorno False na primeira página, sem pista sobre a causa. Abrir explicitamente e testar o Active transforma isso num erro que nomeia o arquivo antes de qualquer saída existir:

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;               // uma falha de carga deixa Active = False
    if not Source.Active then
      raise Exception.Create('Cannot open ' + SourceFile);

    for I := 1 to Source.PageCount do
    begin
      PdfOut.CreateDocument;             // levanta exceção enquanto PdfOut ainda está ativo
      try
        // Range é base 1; a posição 1 é o início do documento vazio
        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;          // fecha a saída antes da próxima passada
      end;
    end;
  finally
    PdfOut.Free;
    Source.Free;
  end;
end;

O terceiro argumento do ImportPages é a posição base 1 no destino em que a primeira página importada aterrissa. Num documento vazio 1 é a única posição que existe. A sintaxe de intervalo em si (páginas únicas, trechos fechados como '5-10', listas com vírgula) e os enganos de posição que invertem um pacote mesclado estão cobertos em mesclagem de vários arquivos PDF num único documento, que é a mesma API apontada para o outro lado

Pedaços de tamanho fixo e intervalos fornecidos pelo chamador

Cortar um documento a cada N páginas é um problema de contagem, e o único caso que pede cuidado é a cauda. Uma digitalização de 230 páginas dividida em partes de 25 páginas dá nove partes cheias e uma décima de cinco páginas, então o fim de cada trecho é limitado 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;    // a última parte costuma ser menor
      if FirstPage = LastPage then
        Range := IntToStr(FirstPage)     // uma cauda de uma página como número puro
      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;

Intervalos digitados por um usuário são outra história. O ImportPages reporta um intervalo ruim pelo valor de retorno: um número de página além de Source.PageCount, um zero, um trecho invertido ou texto que não decodifica fazem a chamada retornar False, e nenhuma das páginas daquela string é copiada. Dentro de um loop, essa falha chega na quarta entrada depois de três arquivos de seção já existirem no disco. Conferir cada entrada contra o Source.PageCount antes do primeiro CreateDocument custa algumas linhas de decodificação e significa que um erro de digitação na entrada quarta não grava coisa alguma, que é o comportamento que quem repetir o trabalho espera

Quando a seleção já mora num array de inteiros, por exemplo páginas escolhidas numa visão de miniaturas, o ImportPagesByIndex(Source, PageIndices, InsertAt) pula o passo da string. Tanto os índices quanto o InsertAt são base zero ali, então a mesma seleção que lê '1,3-5' como intervalo vira [0, 2, 3, 4]

Por que seções por bookmark saem curtas ou sumem?

Seções por bookmark dão errado quando o código confia que o outline está arrumado. O TPdf.Bookmarks devolve as entradas de nível superior na ordem em que o autor as arrumou, cada uma como um record TBookmark com um Title e um PageNumber base 1. Quatro coisas em arquivos reais quebram a regra ingênua "desta entrada até a próxima":

  • Um item de outline pode carregar o alvo como uma entrada /Dest ou como uma ação GoTo em /A (ISO 32000-1 §12.3.3 e §12.6.4.2). O PageNumber é preenchido de /Dest; para um item baseado em ação ele fica 0, e a página está em ActionPageNumber em vez disso
  • A ordem do outline é a ordem de leitura como o autor a via. Uma entrada "Índice" colocada em segundo, ou um apêndice listado antes dos capítulos, faz "o próximo bookmark menos um" ficar menor que o início atual, e aquela seção desaparece
  • Duas entradas de nível superior podem apontar para a mesma página, tipicamente um título de parte e o primeiro capítulo dela
  • Uma entrada pode apontar para além da última página quando páginas foram apagadas com uma ferramenta que deixou o outline quieto
Diagrama que mapeia bookmarks PDF de nível superior a intervalos de páginas calculados e arquivos de saída ao dividir com o PDFium Component em Delphi, incluindo um bookmark fora da faixa que é pulado
Uma seção vai da página de cada bookmark de nível superior até uma página antes do próximo bookmark, e entradas que apontam além do fim são puladas em vez de produzir arquivos vazios

A versão abaixo trata o outline como fonte de pontos de corte. Ela resolve a página de cada entrada, descarta entradas que não apontam a lugar nenhum ou além do fim, mantém o primeiro título numa página compartilhada, ordena por número de página e acrescenta uma seção "Front matter" quando o primeiro corte é depois da página 1. O front matter ganha arquivo próprio aqui em vez de ser colado no capítulo 1, porque quem recebe o capítulo 1 espera que ele abra na página que o bookmark nomeou, não num sumário

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;                // só entradas de nível superior
  if Length(Bm) = 0 then
    Exit;                                // nada pelo que cortar
  SetLength(Starts, Length(Bm) + 1);     // +1 deixa espaço para front matter
  SetLength(Titles, Length(Bm) + 1);
  N := 0;
  for I := 0 to High(Bm) do
  begin
    P := Bm[I].PageNumber;               // resolvido de /Dest
    if P < 1 then
      P := Bm[I].ActionPageNumber;       // resolvido de uma ação GoTo em /A
    if (P < 1) or (P > Source.PageCount) then
      Continue;                          // não resolvido, ou além da última página
    Known := False;
    for J := 0 to N - 1 do
      if Starts[J] = P then
        Known := True;                   // o primeiro título de uma página vence
    if Known then
      Continue;
    J := N;                              // insertion sort por número de página
    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                  // capa, sumário e tudo antes deles
  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]);
        // Declare SafeFileName (mostrado mais abaixo) antes desta rotina
        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;

Como os inícios são estritamente crescentes depois da ordenação, todo trecho calculado é válido e a guarda do ImportPages nunca deveria disparar numa origem legível. Um documento sem bookmarks simplesmente não produz arquivos neste modo; o chamador decide se isso recua para pedaços de tamanho fixo. Para cortar um nível abaixo, por exemplo nos capítulos dentro das partes, o BookmarkChildren[Bm[I]] devolve os filhos de uma entrada de nível superior na mesma forma TBookmarks, e a mesma lógica de pontos de corte vale para a lista combinada

O que cada arquivo de saída carrega

Cada arquivo de saída carrega as próprias páginas e tudo a que os dicionários de página delas referenciam: content streams, fontes, imagens e a lista de anotações de cada página. Widgets de formulário vêm junto como anotações, mas o novo arquivo não tem AcroForm, então deixam de ser campos funcionais. A origem é só lida. O ImportPages copia objetos para o novo documento e deixa a origem aberta intocada, então a mesma instância Source alimenta toda iteração

O outline fica para trás. Bookmarks moram no catálogo do documento sob /Outlines (ISO 32000-1 §7.7.2), fora de qualquer página, então um arquivo construído de páginas importadas começa sem bookmark algum. Para arquivos de capítulo isso costuma estar bom. Quando cada parte precisa da navegação própria, o outline precisa ser construído para a nova numeração de páginas, que é o assunto de edição de outline PDF e remapeamento de páginas dele. Links internos se comportam do mesmo jeito: um link "veja o capítulo 7" na página 12 aponta para uma página que foi para outro arquivo, e neste arquivo não tem onde aterrissar

Origens criptografadas precisam da senha antes de o loop começar. Atribua Source.Password enquanto o componente está inativo, depois defina Active := True; com a senha errada o componente permanece inativo, o que a checagem no SplitIntoPages transforma num erro. A saída gravada pelo SaveAs é um documento novo sem dicionário de criptografia. Isso serve para arquivos divididos destinados a destinatários diferentes, e quando cada parte precisa ser protegida de novo, o SaveAsEncrypted(FileName, Options) recebe um record TPdfEncryptOptions com senhas de usuário e de proprietário próprias

Nomes de arquivo, salvamentos que falham e reexecuções

Títulos de bookmark são strings de texto PDF e podem conter qualquer coisa, inclusive caracteres que o Windows rejeita em nomes de arquivo. As regras vêm do sistema de arquivos, não do PDF: < > : " / \ | ? * e caracteres de controle são inválidos, espaços e pontos finais são removidos pelas APIs de arquivo Win32, e nomes como CON, PRN ou COM1 são dispositivos reservados. O prefixo numerado no SplitByBookmarks já cuida dos nomes de dispositivo, mantém os arquivos em ordem de leitura numa listagem de diretório e impede que dois capítulos ambos intitulados "Summary" sobrescrevam um ao outro. O resto é uma pequena função que você mesmo escreve:

// Helper do lado do leitor, não faz parte do PDFium Component
function SafeFileName(const Title: string): string;
const
  Forbidden = '<>:"/\|?*';
var
  I: Integer;
begin
  Result := Title;
  if Length(Result) > 80 then
    SetLength(Result, 80);               // mantém caminhos completos bem abaixo de 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;

O SaveAs com um nome de arquivo grava num arquivo temporário na pasta de destino e o troca para o lugar só depois de o salvamento ter sucesso. O destino é, portanto, ou o arquivo anterior ou um novo completo, nunca escrito pela metade, então reexecutar uma divisão sobre uma pasta de saída existente é inofensivo. Falhas voltam de duas maneiras. O SaveAs retorna False quando o documento não pode ser serializado, e o arquivo temporário é removido. Problemas no sistema de arquivos, como uma pasta de saída que não existe, um caminho que o SO rejeita ou um alvo travado por um viewer, levantam exceção da camada de arquivos

Como um lote reage a essas falhas é uma escolha, e para divisão ela difere da mesclagem. Um pacote mesclado com uma parte faltando é um documento errado, então parar é o correto ali. Saídas de divisão são independentes entre si: se o section_147.pdf não pode ser gravado porque um colega o tem aberto, as outras seções continuam boas. Para trabalhos de divisão, capture exceções por arquivo dentro do loop, registre o nome do arquivo e a mensagem, siga em frente, e compare o número de arquivos gravados com o número de intervalos planejados quando o loop terminar. Os exemplos acima levantam exceção na primeira falha só para ficarem curtos. Um log que diz que 199 de 200 arquivos foram gravados e nomeia o que estava travado se conserta com uma reexecução, enquanto uma execução que abortou no arquivo 147 deixa alguém descobrindo quais 53 arquivos estão faltando

O loop de divisão, os records de bookmark e o salvamento criptografado usados aqui vêm todos com o PDFium Component, a biblioteca PDF baseada em PDFium para Delphi e C++Builder