Technical Article

Dividir documentos PDF en varios ficheros con PDFium VCL en Delphi

PDFium VCL ofrece un único método para dividir PDFs: ImportPages. Todo lo demás, ya sea aislar una sola página, cortar en límites arbitrarios o seguir la propia estructura de marcadores del documento, son solo formas distintas de decidir qué números de página van a cada fichero de salida. La mecánica es siempre la misma. Entender esto desde el principio evita muchos pasos en falso.

Cómo funciona el bucle de división

El patrón es el mismo independientemente de cómo se divida el documento de origen. Se crea una instancia nueva de TPdf, se llama a CreateDocument para inicializar un PDF vacío en memoria, se importan las páginas deseadas con ImportPages, se guarda el resultado y luego se restablece Active a False antes de la siguiente iteración. Este último paso es el que se suele olvidar: sin el restablecimiento, la siguiente llamada a CreateDocument añade contenido al documento que sigue en memoria en lugar de empezar limpio. La instancia exterior de TPdf se reutiliza en todas las iteraciones, lo que mantiene baja la presión de asignación en trabajos grandes.

Así es como queda la división página a página reducida a lo esencial:

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 is a 1-based page number string; insertion point 1 = first position
      PdfOut.ImportPages(Source, IntToStr(I), 1);

      OutFile := OutputDir + '\page_' + Format('%.4d', [I]) + '.pdf';
      PdfOut.SaveAs(OutFile);

      PdfOut.Active := False;   // reset before next CreateDocument
    end;
  finally
    PdfOut.Free;
  end;
end;

El parámetro Range de ImportPages tiene el mismo formato de cadena que PDFium usa internamente: una lista separada por comas de números de página o rangos delimitados por guiones, todos base 1. '3' importa la página 3. '1-5' importa las páginas 1 a 5 en orden. '2,5,8' importa esas tres páginas. El tercer parámetro es la posición de inserción base 1 en el documento de destino; pasar 1 coloca siempre las páginas importadas al principio de un fichero vacío, que es lo que se necesita aquí.

División por rangos de páginas

Cuando el llamante proporciona una lista como 1-12,13-24,25-36, se analiza en pares de inicio/fin y se ejecuta el mismo bucle, construyendo la cadena de rango 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;
      PdfOut.ImportPages(Source, RangeList[I], 1);
      OutFile := Format('%s\section_%d.pdf', [OutputDir, I + 1]);
      PdfOut.SaveAs(OutFile);
      PdfOut.Active := False;
    end;
  finally
    PdfOut.Free;
  end;
end;

La validación antes de llegar a ImportPages es importante aquí. ImportPages devuelve False cuando un número de página en la cadena de rango supera Source.PageCount, pero no lanza una excepción ni produce un fichero de salida parcial detectable solo por el nombre. Hay que comprobar el valor de retorno de SaveAs y registrar los fallos por separado; un rango que produce un fichero de salida vacío no es evidentemente incorrecto hasta que alguien lo abre.

División en los límites de marcadores

El tercer enfoque usa la propia estructura del documento en lugar de una lista suministrada externamente. Cada marcador de nivel superior lleva un número de página de destino; la sección que define va desde esa página hasta la anterior a la del siguiente marcador, o hasta el final del documento en la última entrada.

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;
      PdfOut.ImportPages(Source, RangeStr, 1);

      SafeTitle := StringReplace(Bm[I].Title, '/', '_', [rfReplaceAll]);
      SafeTitle := StringReplace(SafeTitle, ':', '_', [rfReplaceAll]);
      OutFile := Format('%s\%02d_%s.pdf', [OutputDir, I + 1, SafeTitle]);
      PdfOut.SaveAs(OutFile);

      PdfOut.Active := False;
    end;
  finally
    PdfOut.Free;
  end;
end;

Un documento sin marcadores no es una condición de error que merezca notificarse al usuario como tal; simplemente significa que este modo de división no tiene nada con lo que trabajar. La comprobación Length(Bm) = 0 lo gestiona silenciosamente. Lo que sí merece notificarse es cuando el número de página de un marcador está fuera del rango del documento, algo que ocurre en ficheros malformados donde el esquema nunca se actualizó tras eliminar páginas. La comprobación de límites en StartPage y EndPage omite esas entradas en lugar de pasar un rango incorrecto a ImportPages.

Nomenclatura de ficheros de salida y el restablecimiento de Active

La seguridad de los nombres de fichero derivados de marcadores requiere atención explícita. Los títulos de marcadores pueden contener caracteres válidos en una cadena PDF pero no en una ruta del sistema de ficheros. Como mínimo, hay que reemplazar la barra diagonal, la barra diagonal inversa y los dos puntos antes de construir la ruta de salida. En Windows, *, ?, ", <, > y | también están prohibidos; un bucle simple sobre un conjunto fijo los cubre sin necesidad de una expresión regular.

La línea Active := False al final de cada iteración merece énfasis porque es el único requisito no obvio del patrón. CreateDocument no cierra implícitamente lo que esté abierto. Si Active sigue en True cuando CreateDocument se ejecuta de nuevo, PDFium descarta el documento actual y comienza uno nuevo sin error, pero el comportamiento está definido por la implementación en casos límite y la intención es más clara cuando se restablece explícitamente. Hay que pensarlo como el par de try/finally: el bloque finally libera el objeto exterior; el Active := False restablece el estado del documento interior entre iteraciones del bucle.

El uso de memoria a lo largo de un gran trabajo de división se mantiene constante con este enfoque porque nunca se tiene más de un documento de salida en memoria a la vez. El documento de origen permanece abierto y de solo lectura durante todo el proceso; ImportPages copia los datos de página en el nuevo documento sin modificar el origen. Si el origen está cifrado, hay que abrirlo con su contraseña antes del bucle y las páginas copiadas en cada fichero de salida estarán sin cifrar, que generalmente es el comportamiento correcto para salidas divididas distribuidas a distintos destinatarios.

Una última cuestión sobre SaveAs: devuelve un Boolean. Un directorio de salida que no existe, una ruta con caracteres que el sistema operativo rechaza o una condición de disco lleno harán que SaveAs devuelva False sin lanzar una excepción. En un trabajo por lotes que divide un documento de 200 páginas en 200 ficheros de una sola página, un fallo silencioso en la página 147 es fácil de pasar por alto. Hay que comprobar el valor de retorno en cada llamada y contar los éxitos frente al total esperado cuando termina el bucle.

Los métodos ImportPages y CreateDocument mostrados aquí forman parte del componente PDFium VCL para Delphi y C++Builder.