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
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
/Destou como uma ação GoTo em/A(ISO 32000-1 §12.3.3 e §12.6.4.2). OPageNumberé preenchido de/Dest; para um item baseado em ação ele fica0, e a página está emActionPageNumberem 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
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