Para un manual de usuario de 40 páginas cuyo primer marcador de nivel superior apunta a la página 5, un divisor que corta "de este marcador al siguiente" escribe archivos de capítulo que suman 36 páginas. La portada, la página de copyright y una tabla de contenidos de dos páginas van delante de cada entrada del outline, así que nunca aterrizan en archivo alguno. Un outline solo marca dónde empiezan las secciones. No cubre lo que va antes de la primera entrada, y nada en el formato exige que las entradas estén listadas en orden de páginas, así que un divisor confiable recolecta las páginas de los marcadores como puntos de corte, las ordena y agrega un rango explícito de material inicial
Un bucle detrás de cada modo de división
PDFium Component divide un documento con los mismos cuatro pasos sobre un segundo TPdf, sea lo que sea que decida los límites: CreateDocument arranca un PDF vacío en memoria, ImportPages copia un rango de páginas del origen hacia él, SaveAs escribe el archivo, y Active := False cierra la salida de nuevo. La división página por página, los trozos de tamaño fijo y las secciones por marcador difieren solo en el string de rango que se le entrega a ImportPages
El reinicio al final de cada pasada no es opcional. CreateDocument se niega a correr sobre un componente que sigue activo y lanza un EPdfError cuyo mensaje empieza con "Cannot perform this operation on an active", así que la segunda iteración de un bucle que olvida Active := False se detiene con una excepción después de que se escribió exactamente un archivo. Poner el reinicio en un bloque finally evita que una importación o un guardado fallido deje la salida abierta para la siguiente pasada. Una instancia de TPdf sirve para toda la corrida, y solo un documento de salida se mantiene en memoria
Abrir el origen merece su propia comprobación. Asignar Active := True nunca lanza: cuando el archivo falta, está dañado o necesita una contraseña que no se suministró, TPdf atrapa el error de carga y deja Active en False. ImportPages también intenta activar el origen por su cuenta, lo que significa que un origen sin abrir se muestra solo como un retorno False en la primera página sin ninguna pista sobre la causa. Abrirlo explícitamente y probar Active convierte eso en un error que nombra el archivo antes de que exista cualquier salida:
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; // una falla de carga deja Active = False
if not Source.Active then
raise Exception.Create('Cannot open ' + SourceFile);
for I := 1 to Source.PageCount do
begin
PdfOut.CreateDocument; // lanza mientras PdfOut sigue activo
try
// Range es base 1; la posición 1 es el inicio del documento vacío
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; // cierre la salida antes de la siguiente pasada
end;
end;
finally
PdfOut.Free;
Source.Free;
end;
end;
El tercer argumento de ImportPages es la posición base 1 del destino donde aterriza la primera página importada. En un documento vacío 1 es la única posición que hay. La sintaxis de rangos misma (páginas sueltas, tramos cerrados como '5-10', listas con comas) y los errores de posición que invierten un paquete fusionado están cubiertos en fusionar varios archivos PDF en un documento, que es la misma API apuntada al revés
Trozos de tamaño fijo y rangos suministrados por quien llama
Cortar un documento cada N páginas es un problema de conteo, y el único caso que necesita cuidado es la cola. Un escaneo de 230 páginas dividido en partes de 25 páginas da nueve partes completas y una décima de cinco páginas, así que el final de cada tramo se recorta 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; // la última parte suele ser más corta
if FirstPage = LastPage then
Range := IntToStr(FirstPage) // una cola de una página como número plano
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;
Los rangos tecleados por un usuario son otro asunto. ImportPages reporta un rango malo mediante su valor de retorno: un número de página mayor que Source.PageCount, un cero, un tramo invertido o texto que no parsea hace que la llamada devuelva False, y ninguna de las páginas de ese string se copia. Dentro de un bucle, esa falla llega en la cuarta entrada cuando ya existen tres archivos de sección en disco. Verificar cada entrada contra Source.PageCount antes del primer CreateDocument cuesta unas cuantas líneas de parseo y significa que un error de tecleo en la entrada cuatro no escribe nada, que es el comportamiento que espera quien reintenta el trabajo
Cuando la selección ya vive en un arreglo de enteros, por ejemplo páginas elegidas en una vista de miniaturas, ImportPagesByIndex(Source, PageIndices, InsertAt) se salta el paso del string. Tanto los índices como InsertAt son base cero allí, así que la misma selección que se lee '1,3-5' como rango se vuelve [0, 2, 3, 4]
¿Por qué las secciones por marcador salen cortas o faltan?
Las secciones por marcador salen mal cuando el código confía en que el outline esté ordenado. TPdf.Bookmarks devuelve las entradas de nivel superior en el orden en que el autor las arregló, cada una como un record TBookmark con un Title y un PageNumber base 1. Cuatro cosas en archivos reales rompen la regla ingenua de "esta entrada a la siguiente":
- Un elemento del outline puede llevar su objetivo como entrada
/Desto como acción GoTo en/A(ISO 32000-1 §12.3.3 y §12.6.4.2).PageNumberse llena desde/Dest; para un elemento basado en acción queda en0y la página está enActionPageNumber - El orden del outline es el orden de lectura tal como lo vio el autor. Una entrada "Index" colocada en segundo lugar, o un apéndice listado antes de los capítulos, hace que "el siguiente marcador menos uno" sea menor que el inicio actual, y esa sección desaparece
- Dos entradas de nivel superior pueden apuntar a la misma página, típicamente un título de parte y su primer capítulo
- Una entrada puede apuntar más allá de la última página cuando se borraron páginas con una herramienta que dejó el outline tranquilo
La versión de abajo trata el outline como una fuente de puntos de corte. Resuelve la página de cada entrada, descarta las entradas que no apuntan a nada o que apuntan más allá del final, conserva el primer título en una página compartida, ordena por número de página y agrega una sección "Front matter" cuando el primer corte es después de la página 1. El material inicial recibe su propio archivo aquí en lugar de pegarse al capítulo 1, porque quien recibe el capítulo 1 espera que se abra en la página que el marcador nombró, no en una tabla de contenidos
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 entradas de nivel superior
if Length(Bm) = 0 then
Exit; // nada sobre qué cortar
SetLength(Starts, Length(Bm) + 1); // +1 deja lugar para el material inicial
SetLength(Titles, Length(Bm) + 1);
N := 0;
for I := 0 to High(Bm) do
begin
P := Bm[I].PageNumber; // resuelto desde /Dest
if P < 1 then
P := Bm[I].ActionPageNumber; // resuelto desde una acción GoTo en /A
if (P < 1) or (P > Source.PageCount) then
Continue; // sin resolver, o más allá de la última página
Known := False;
for J := 0 to N - 1 do
if Starts[J] = P then
Known := True; // el primer título de una página gana
if Known then
Continue;
J := N; // ordenamiento por inserción 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 // portada, TOC y cualquier cosa antes de ellos
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]);
//// Declarar SafeFileName (mostrado más abajo) antes de esta rutina
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 los inicios quedan estrictamente crecientes después del ordenamiento, cada tramo calculado es válido y la guarda de ImportPages nunca debería dispararse sobre un origen legible. Un documento sin marcadores simplemente no produce archivos en este modo; quien llama decide si eso cae a la división por trozos. Para cortar un nivel más profundo, por ejemplo en capítulos dentro de partes, BookmarkChildren[Bm[I]] devuelve los hijos de una entrada de nivel superior en la misma forma TBookmarks, y la misma lógica de puntos de corte aplica a la lista combinada
Qué lleva cada archivo de salida
Cada archivo de salida lleva las páginas mismas y todo lo que sus diccionarios de página referencian: streams de contenido, fuentes, imágenes y la lista de anotaciones de cada página. Los widgets de formulario vienen como anotaciones, pero el archivo nuevo no tiene AcroForm, así que dejan de ser campos funcionales. El origen es de solo lectura. ImportPages copia objetos al documento nuevo y deja el origen abierto intacto, así que la misma instancia Source alimenta cada iteración
El outline se queda atrás. Los marcadores viven en el catálogo del documento bajo /Outlines (ISO 32000-1 §7.7.2), fuera de cualquier página, así que un archivo construido a partir de páginas importadas empieza sin marcador alguno. Para archivos de capítulos eso suele estar bien. Cuando cada parte necesita su propia navegación, el outline tiene que construirse para la nueva numeración de páginas, que es el tema de editar un outline PDF y remapear sus páginas. Los enlaces internos se comportan igual: un enlace "vea el capítulo 7" en la página 12 apunta a una página que fue a parar a otro archivo, y en este archivo no tiene dónde aterrizar
Los orígenes cifrados necesitan la contraseña antes de que empiece el bucle. Asigne Source.Password mientras el componente está inactivo, y luego ponga Active := True; con la contraseña equivocada el componente se queda inactivo, lo que la comprobación de SplitIntoPages convierte en un error. La salida que SaveAs escribe es un documento nuevo sin diccionario de cifrado. Eso conviene a los archivos divididos pensados para destinatarios distintos, y cuando cada parte tiene que protegerse de nuevo, SaveAsEncrypted(FileName, Options) recibe un record TPdfEncryptOptions con sus propias contraseñas de usuario y propietario
Nombres de archivo, guardados fallidos y reejecuciones
Los títulos de marcadores son cadenas de texto PDF y pueden contener cualquier cosa, incluidos caracteres que Windows rechaza en nombres de archivo. Las reglas vienen del sistema de archivos, no del PDF: < > : " / \ | ? * y los caracteres de control son inválidos, los espacios y puntos finales se recortan por las APIs de archivo Win32, y nombres como CON, PRN o COM1 son dispositivos reservados. El prefijo numérico de SplitByBookmarks ya se encarga de los nombres de dispositivo, mantiene los archivos en orden de lectura en un listado de directorio, y evita que dos capítulos ambos titulados "Summary" se sobrescriban entre sí. El resto es una función pequeña que usted escribe:
//// Helper del lado del lector, no forma parte de PDFium Component
function SafeFileName(const Title: string): string;
const
Forbidden = '<>:"/\|?*';
var
I: Integer;
begin
Result := Title;
if Length(Result) > 80 then
SetLength(Result, 80); // mantenga las rutas completas bien por debajo 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;
SaveAs con un nombre de archivo escribe a un archivo temporal en la carpeta de destino y lo intercambia a su lugar solo después de que el guardado tuvo éxito. El destino es por tanto el archivo anterior o uno nuevo completo, nunca a medio escribir, así que reejecutar una división sobre una carpeta de salida existente es inofensivo. Las fallas regresan de dos maneras. SaveAs devuelve False cuando el documento no se puede serializar, y el archivo temporal se elimina. Los problemas del sistema de archivos, como una carpeta de salida que no existe, una ruta que el OS rechaza o un objetivo bloqueado por un visor, lanzan una excepción desde la capa de archivos
Cómo reacciona un lote ante esas fallas es una elección, y para dividir difiere de fusionar. Un paquete fusionado con una parte faltante es un documento equivocado, así que detenerse es lo correcto allí. Las salidas divididas son independientes entre sí: si section_147.pdf no se puede escribir porque un colega lo tiene abierto, las otras secciones siguen siendo buenas. Para trabajos de división, atrape las excepciones por archivo dentro del bucle, registre el nombre del archivo y el mensaje, continúe, y compare el número de archivos escritos con el número de rangos planeados cuando el bucle termine. Los ejemplos de arriba lanzan en la primera falla solo para quedarse cortos. Un registro que dice que se escribieron 199 de 200 archivos y nombra el que estaba bloqueado toma una reejecución para arreglarse, mientras que una corrida que abortó en el archivo 147 deja a alguien averiguando cuáles 53 archivos faltan
El bucle de división, los records de marcadores y el guardado cifrado usados aquí vienen todos con el PDFium Component, la biblioteca PDF basada en PDFium para Delphi y C++Builder