Artículo técnico

Dividir documentos PDF con PDFium Component en Delphi

PDFium Component le ofrece un método para la división de PDF: ImportPages. Todo lo demás, ya sea que aísle una sola página, corte en límites arbitrarios, o siga la propia estructura de marcadores del documento, son sólo formas diferentes de decidir qué números de página van en cada fichero de salida. La mecánica sigue siendo la misma. Entender esto desde el principio ahorra muchos caminos equivocados

Cómo funciona el bucle de división

El patrón es el mismo sin importar cómo divida el documento origen. Cree una instancia nueva de TPdf, llame a CreateDocument en ella para inicializar un PDF vacío en memoria, importe las páginas que desee con ImportPages, guarde el resultado, y luego restablezca Active a False antes de la siguiente iteración. Ese último paso es el que la gente pasa por alto: CreateDocument siempre inicia un documento nuevo, pero si Active sigue siendo True cuando vuelve a ejecutarse, descarta implícitamente el documento que todavía está en memoria, de modo que restablecerlo primero mantiene el estado limpio y bien definido. La instancia exterior TPdf se reutiliza a lo largo de todas las iteraciones, lo que mantiene baja la presión de asignación en trabajos grandes

Así es como se ve la división página por 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 para ImportPages es el mismo formato de cadena que PDFium usa internamente: una lista de números de página separados por comas o 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 en el documento destino; pasar 1 siempre coloca las páginas importadas al principio de un fichero por lo demás vacío, que es lo que usted quiere aquí

Dividir por rangos de páginas

Cuando el llamador suministra una lista como 1-12,13-24,25-36, usted la analiza en pares de inicio/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;
      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 que alcance ImportPages importa aquí. ImportPages devuelve False cuando un número de página en la cadena de rango excede Source.PageCount, pero no lanza una excepción y no produce un fichero de salida parcial que pueda detectar sólo por el nombre. Compruebe el valor de retorno de SaveAs y registre los fallos por separado; un rango que produce un fichero de salida vacío no es evidentemente erróneo hasta que alguien lo abre

Dividir en los límites de los marcadores

El tercer enfoque utiliza 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 abarca desde esa página hasta una antes de la página del siguiente marcador, o hasta el final del documento para 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 que no tiene marcadores no es una condición de error que valga la pena mostrar al usuario como tal; simplemente significa que este modo de división no tiene sobre qué trabajar. La guarda Length(Bm) = 0 maneja eso silenciosamente. Lo que sí vale la pena mostrar es cuando el número de página de un marcador está fuera del rango del documento, lo cual ocurre en ficheros mal formados donde el esquema nunca se actualizó después de que se borraron páginas. La comprobación de límites en StartPage y EndPage omite esas entradas en lugar de pasar un rango basura a ImportPages

Nomenclatura del fichero de salida y el restablecimiento de Active

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

La línea Active := False al final de cada iteración merece énfasis porque es el único requisito no obvio en el patrón. CreateDocument no cierra implícitamente lo que esté abierto. Si Active sigue siendo True cuando CreateDocument vuelve a ejecutarse, PDFium descarta el documento actual y comienza uno nuevo sin error, pero el comportamiento está definido por la implementación en casos extremos y la intención es más clara cuando usted lo restablece explícitamente. Piense en ello como el par de try/finally: el bloque finally libera el objeto exterior; el Active := False restablece el estado interno del documento entre las 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 mantiene más de un documento de salida en memoria a la vez. El documento origen permanece abierto y de solo lectura durante todo el proceso; ImportPages copia los datos de la página al documento nuevo sin modificar el origen. Si el origen está encriptado, ábralo con su contraseña antes del bucle y las páginas copiadas en cada fichero de salida estarán desencriptadas, lo que suele ser el comportamiento correcto para resultados divididos distribuidos a distintos destinatarios

Una cosa más sobre SaveAs: devuelve un Boolean. Un directorio de salida que no existe, una ruta con caracteres que el SO rechaza, o una condición de disco lleno causará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 ficheros de una sola 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 cuente los éxitos frente al total esperado cuando termine el bucle

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