Artículo técnico

Dividir documentos PDF con PDFium Component en Delphi

PDFium Component le da un único método para dividir un PDF: ImportPages. Todo lo demás, tanto si está aislando una sola página, cortando por límites arbitrarios o siguiendo la propia estructura de marcadores del documento, no son más que formas distintas de decidir qué números de página van a cada archivo de salida. La mecánica no cambia. Entenderlo pronto ahorra muchos caminos equivocados

Cómo funciona el bucle de división

El patrón es el mismo con independencia de cómo divida el documento de origen. Cree una instancia nueva de TPdf, llame en ella a CreateDocument para inicializar un PDF vacío en memoria, importe las páginas que quiera con ImportPages, guarde el resultado y luego reinicie Active a False antes de la siguiente iteración. Ese último paso es el que la gente pasa por alto: CreateDocument no cierra implícitamente el documento que sigue en memoria, así que debe guardar su salida y reiniciar Active := False de forma explícita antes de volver a llamarlo; reiniciar primero mantiene el estado limpio y bien definido. La instancia exterior de TPdf se reutiliza en todas las iteraciones, lo que mantiene baja la presión de reserva de memoria en trabajos grandes

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

Así 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 es una cadena de números de página en base 1; punto de inserción 1 = primera posición
      if not PdfOut.ImportPages(Source, IntToStr(I), 1) then
        raise Exception.CreateFmt('Failed to import page %d', [I]);

      OutFile := OutputDir + '\page_' + Format('%.4d', [I]) + '.pdf';
      if not PdfOut.SaveAs(OutFile) then
        raise Exception.Create('Failed to save ' + OutFile);

      PdfOut.Active := False;   // reiniciar antes del siguiente CreateDocument
    end;
  finally
    PdfOut.Free;
  end;
end;

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

División por rangos de páginas

Cuando quien llama suministra una lista como 1-12,13-24,25-36, la analiza para obtener pares de inicio y fin y ejecuta el mismo bucle, construyendo la cadena de rango a partir 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;
      if not PdfOut.ImportPages(Source, RangeList[I], 1) then
        raise Exception.Create('Invalid page range: ' + RangeList[I]);
      OutFile := Format('%s\section_%d.pdf', [OutputDir, I + 1]);
      if not PdfOut.SaveAs(OutFile) then
        raise Exception.Create('Failed to save ' + OutFile);
      PdfOut.Active := False;
    end;
  finally
    PdfOut.Free;
  end;
end;

Aquí importa validar antes de llegar a ImportPages. ImportPages devuelve False cuando un número de página de la cadena de rango supera Source.PageCount, pero no lanza una excepción ni produce un archivo de salida parcial que pueda detectar solo por el nombre. Compruebe el valor de retorno de SaveAs y registre los fallos por separado; un rango que produce un archivo de salida vacío no resulta evidentemente erróneo hasta que alguien lo abre

División en los límites de los marcadores

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

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
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;
      if not PdfOut.ImportPages(Source, RangeStr, 1) then
      begin
        PdfOut.Active := False;
        Continue;   // omitir una sección mal formada en vez de escribir un archivo vacío
      end;

      SafeTitle := StringReplace(Bm[I].Title, '/', '_', [rfReplaceAll]);
      SafeTitle := StringReplace(SafeTitle, ':', '_', [rfReplaceAll]);
      OutFile := Format('%s\%02d_%s.pdf', [OutputDir, I + 1, SafeTitle]);
      if not PdfOut.SaveAs(OutFile) then
        raise Exception.Create('Failed to save ' + OutFile);

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

Un documento que no tiene marcadores no es una condición de error que merezca presentarse al usuario como tal; simplemente significa que este modo de división no tiene nada con lo que trabajar. La guarda Length(Bm) = 0 lo resuelve en silencio. Lo que sí merece presentarse es que el número de página de un marcador quede fuera del rango del documento, algo que ocurre en archivos mal formados donde el esquema nunca se actualizó tras borrar páginas. La comprobación de límites sobre StartPage y EndPage omite esas entradas en lugar de pasar un rango basura a ImportPages

Nombrado de los archivos de salida y el reinicio de Active

La seguridad de los nombres de archivo derivados de marcadores exige atención explícita. Los títulos de marcador pueden contener caracteres válidos en una cadena PDF pero no en una ruta del sistema de archivos. Como mínimo, sustituya la barra, la barra invertida y los dos puntos antes de construir la ruta de salida. En Windows también están prohibidos *, ?, ", <, > y |; un bucle sencillo sobre un conjunto fijo los cubre sin necesidad de recurrir a una expresión regular

La línea Active := False del final de cada iteración merece énfasis porque es el único requisito no evidente del patrón. CreateDocument no cierra implícitamente lo que esté abierto. Si Active sigue siendo True cuando CreateDocument se ejecuta de nuevo, el documento que quedaba en memoria nunca se cerró ni se guardó como es debido, y no puede confiar en un comportamiento bien definido en ese estado, así que guarde y reinicie de forma explícita antes de empezar el siguiente documento. Piénselo como la pareja de try/finally: el bloque finally libera el objeto exterior; el Active := False reinicia el estado del documento interior entre iteraciones del bucle

El uso de memoria a lo largo de un trabajo de división grande se mantiene plano con este enfoque, porque nunca 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 al documento nuevo sin modificar el origen. Si el origen está cifrado, ábralo con su contraseña antes del bucle y las páginas copiadas en cada archivo de salida quedarán sin cifrar, que suele ser el comportamiento correcto para una salida dividida que se distribuye a destinatarios distintos

Una cosa más 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 todos que SaveAs devuelva False sin lanzar una excepción. En un trabajo por lotes que divide un documento de 200 páginas en 200 archivos de una página, un fallo silencioso en la página 147 es fácil de pasar por alto. Compruebe el valor de retorno en cada llamada y contraste los aciertos con el total esperado cuando termine el bucle

Los métodos ImportPages y CreateDocument mostrados aquí forman parte de PDFium Component para Delphi y C++Builder