Artículo técnico

Dividir documentos PDF con PDFium Component en Delphi

Para un manual de usuario de 40 páginas cuyo primer marcador de primer nivel apunta a la página 5, un divisor que corta «de este marcador al siguiente» escribe ficheros de capítulo que suman 36 páginas. La portada, la página de copyright y una tabla de contenido de dos páginas van delante de toda entrada del esquema, así que nunca aterrizan en fichero alguno. Un esquema 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 vayan listadas en orden de páginas, así que un divisor fiable recoge las páginas de los marcadores como puntos de corte, las ordena y añade un rango explícito de material previo

Un solo bucle detrás de cada modo de división

PDFium Component divide un documento con los mismos cuatro pasos sobre un segundo TPdf, decida lo 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 fichero, y Active := False cierra de nuevo la salida. La división página a página, los trozos de tamaño fijo y las secciones por marcador difieren solo en el string de rango que se entrega a ImportPages

El reinicio al final de cada pasada no es opcional. CreateDocument se niega a correr sobre un componente que siga activo y lanza un EPdfError cuyo mensaje empieza por "Cannot perform this operation on an active", así que la segunda iteración de un bucle que olvida Active := False se para con una excepción después de haber escrito exactamente un fichero. Poner el reinicio en un bloque finally evita que una importación o guardado fallido dejen la salida abierta para la siguiente pasada. Una única instancia de TPdf sirve para toda la corrida, y solo un documento de salida está en memoria en cada momento

Diagrama del bucle de división de PDFium Component en Delphi: CreateDocument, ImportPages desde el origen de solo lectura, un SaveAs comprobado y el reinicio de Active antes de cada nueva iteración
Sea lo que sea que decida los grupos, el bucle es idéntico: importar las páginas, guardar comprobando el resultado y luego reiniciar Active para que el siguiente CreateDocument arranque desde un estado limpio

Abrir el origen merece su propia comprobación. Asignar Active := True jamás lanza: cuando el fichero falta, está dañado o necesita una contraseña que no se suministró, TPdf captura 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 solo asoma como un retorno False en la primera página y no da pista alguna de la causa. Abrirlo explícitamente y comprobar Active convierte eso en un error que nombra el fichero antes de que exista salida alguna:

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 fallo 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 siga activo
      try
        // El rango 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;          // cerrad 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 en el destino donde aterriza la primera página importada. En un documento vacío, 1 es la única posición que hay. La sintaxis del rango en sí (páginas sueltas, tramos cerrados como '5-10', listas por comas) y los fallos de posición que invierten un paquete fusionado están cubiertos en cómo fusionar varios ficheros 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 otra historia. ImportPages reporta un rango malo a través de su valor de retorno: un número de página que se pasa de Source.PageCount, un cero, un tramo invertido o texto que no parsea hacen que la llamada devuelva False, y ninguna de las páginas de ese string se copia. Dentro de un bucle, ese fallo llega en la cuarta entrada cuando ya existen tres ficheros de sección en disco. Comprobar cada entrada contra Source.PageCount antes del primer CreateDocument cuesta unas líneas de parseo y significa que una errata en la entrada cuarta no escribe nada, que es el comportamiento que espera quien reintenta el trabajo

Cuando la selección ya vive en un array de enteros, por ejemplo páginas marcadas 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 como rango se lee '1,3-5' se convierte en [0, 2, 3, 4]

¿Por qué las secciones por marcador salen cortas o desaparecen?

Las secciones por marcador salen mal cuando el código confía en que el esquema esté ordenadito. TPdf.Bookmarks devuelve las entradas de primer nivel en el orden en que el autor las dispuso, cada una como un record TBookmark con un Title y un PageNumber base 1. Cuatro cosas en ficheros reales rompen la regla ingenua de «de esta entrada a la siguiente»:

  • Un elemento del esquema puede llevar su destino como entrada /Dest o como acción GoTo en /A (ISO 32000-1 §12.3.3 y §12.6.4.2). PageNumber se rellena desde /Dest; en un elemento basado en acción se queda en 0 y la página está en ActionPageNumber en su lugar
  • El orden del esquema es el orden de lectura tal como lo vio el autor. Una entrada «Index» colocada segunda, o un apéndice listado antes de los capítulos, hace que «el marcador siguiente menos uno» sea menor que el inicio actual, y esa sección desaparece
  • Dos entradas de primer nivel 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 esquema tal cual
Diagrama que asigna los marcadores PDF de primer nivel a rangos de páginas calculados y archivos de salida al dividir con PDFium Component en Delphi, incluido un marcador fuera de rango que se omite
Una sección va desde la página de cada marcador de primer nivel hasta una página antes del marcador siguiente, y las entradas que apuntan más allá del final se omiten en lugar de producir archivos vacíos

La versión de abajo trata el esquema como una fuente de puntos de corte. Resuelve la página de cada entrada, suelta las entradas que no apuntan a ninguna parte o se pasan del final, conserva el primer título de una página compartida, ordena por número de página y añade una sección «Front matter» cuando el primer corte cae después de la página 1. El material previo recibe aquí su propio fichero en lugar de pegarse al capítulo 1, porque quien recibe el capítulo 1 espera que se abra en la página que nombraba el marcador, no en una tabla de contenido

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 primer nivel
  if Length(Bm) = 0 then
    Exit;                                // nada por lo que cortar
  SetLength(Starts, Length(Bm) + 1);     // +1 deja sitio para el material previo
  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;                              // ordenación 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, índice y todo lo anterior
  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]);
        // Declarad SafeFileName (mostrada 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 tras la ordenación, cada tramo calculado es válido y la guarda de ImportPages no debería dispararse jamás con un origen legible. Un documento sin marcadores sencillamente no produce ficheros en este modo; quien llama decide si eso recae en el troceado. 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 primer nivel en la misma forma TBookmarks, y la misma lógica de puntos de corte aplica a la lista combinada

Qué lleva cada fichero de salida

Cada fichero de salida lleva las páginas en sí y todo aquello a lo que sus diccionarios de página referencian: content streams, fuentes, imágenes y la lista de anotaciones de cada página. Los widgets de formulario vienen como anotaciones, pero el fichero nuevo no tiene AcroForm, así que dejan de ser campos operativos. El origen solo se lee. ImportPages copia objetos al documento nuevo y deja el origen abierto intacto, así que la misma instancia Source alimenta cada iteración

El esquema se queda atrás. Los marcadores viven en el catálogo del documento bajo /Outlines (ISO 32000-1 §7.7.2), fuera de toda página, así que un fichero construido desde páginas importadas empieza sin marcador alguno. Para ficheros de capítulo eso suele ir bien. Cuando cada parte necesita su propia navegación, el esquema hay que construirlo para la nueva numeración de páginas, que es el asunto de editar un esquema PDF y remapear sus páginas. Los enlaces internos se portan igual: un enlace «ver capítulo 7» en la página 12 apunta a una página que se fue a otro fichero, y en este fichero no tiene donde aterrizar

Los orígenes cifrados necesitan la contraseña antes de que arranque el bucle. Asignad Source.Password con el componente inactivo, y luego poned 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 escribe SaveAs es un documento nuevo sin diccionario de cifrado. Eso conviene a los ficheros 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 fichero, guardados fallidos y reejecuciones

Los títulos de marcador son cadenas de texto PDF y pueden contener cualquier cosa, incluidos caracteres que Windows rechaza en nombres de fichero. Las reglas vienen del sistema de ficheros, no del PDF: < > : " / \ | ? * y los caracteres de control son inválidos, los espacios y puntos finales los recortan las APIs de fichero Win32, y nombres como CON, PRN o COM1 son dispositivos reservados. El prefijo numerado de SplitByBookmarks ya se encarga de los nombres de dispositivo, mantiene los ficheros en orden de lectura en un listado de directorio, y evita que dos capítulos titulados ambos «Summary» se sobreescriban. El resto es una función pequeña que escribís vosotros:

// Ayudante 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);               // mantiene 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 fichero escribe a un fichero temporal en la carpeta de destino y lo cambia a su sitio solo después de que el guardado tenga éxito. El destino es, por tanto, o el fichero anterior o uno nuevo completo, nunca escrito a medias, así que reejecutar una división sobre una carpeta de salida existente es inofensivo. Los fallos vuelven por dos caminos. SaveAs devuelve False cuando el documento no puede serializarse, y el fichero temporal se elimina. Los problemas del sistema de ficheros, como una carpeta de salida que no existe, una ruta que el SO rechaza o un destino bloqueado por un visor, lanzan una excepción desde la capa de ficheros

Cómo reacciona un lote ante esos fallos es una elección, y para dividir difiere de fusionar. Un paquete fusionado al que le falta una parte es un documento equivocado, así que pararse es lo correcto ahí. Las salidas divididas son independientes entre sí: si section_147.pdf no puede escribirse porque un colega lo tiene abierto, las demás secciones siguen siendo buenas. Para trabajos de división, cazad las excepciones por fichero dentro del bucle, registrad el nombre de fichero y el mensaje, seguid, y comparad al acabar el bucle el número de ficheros escritos con el número de rangos previstos. Los ejemplos de arriba lanzan al primer fallo solo para ser breves. Un log que dice que se escribieron 199 de 200 ficheros y nombra el bloqueado se arregla con una reejecución, mientras que una corrida que abortó en el fichero 147 deja a alguien calculando cuáles 53 ficheros faltan

El bucle de división, los records de marcadores y el guardado cifrado usados aquí vienen todos con PDFium Component, la biblioteca PDF basada en PDFium para Delphi y C++Builder