Technical Article

Combinar varios ficheros PDF en un único documento con PDFium VCL

PDFium VCL expone la combinación de PDFs mediante un único método: ImportPages. El patrón es siempre el mismo: crear un documento de destino vacío, abrir cada fichero de origen, llamar a ImportPages para copiar las páginas, cerrar el origen y repetir. Cuando el bucle termina, SaveAs escribe el resultado en disco. No hay modo de fusión especial ni configuración que activar. La complejidad reside en los casos límite, y hay varios que dan problemas sin advertencia previa.

El bucle principal

Bastan dos instancias de TPdf. Una contiene el documento de destino, creado vacío con CreateDocument. La otra abre cada fichero de origen por turno. A continuación se muestra un procedimiento que toma una lista de rutas de fichero y escribe la salida combinada en una única ruta:

procedure MergeFiles(const FileList: TStrings; const OutputPath: string);
var
  PdfDest, PdfSrc: TPdf;
  InsertAt, I: Integer;
begin
  PdfDest := TPdf.Create(nil);
  PdfSrc  := TPdf.Create(nil);
  try
    PdfDest.CreateDocument;
    InsertAt := 1;  // ImportPages uses 1-based destination position

    for I := 0 to FileList.Count - 1 do
    begin
      PdfSrc.FileName := FileList[I];
      PdfSrc.Active   := True;

      if not PdfSrc.Active then
        raise Exception.CreateFmt('Cannot open: %s', [FileList[I]]);

      PdfDest.ImportPages(
        PdfSrc,
        '1-' + IntToStr(PdfSrc.PageCount),  // full document range
        InsertAt);

      Inc(InsertAt, PdfSrc.PageCount);
      PdfSrc.Active := False;
    end;

    PdfDest.SaveAs(OutputPath);
  finally
    PdfSrc.Free;
    PdfDest.Free;
  end;
end;

Hay dos cosas en ese código que es fácil pasar por alto en una primera lectura. La primera es cómo PDFium informa de los fallos de carga. Active := True nunca lanza una excepción: si el fichero no existe, está dañado o protegido con contraseña, PDFium captura el error internamente y deja Active en False. Sin la comprobación explícita de la línea 10, un fichero erróneo quedaría excluido silenciosamente de la combinación sin ninguna indicación en la salida. El PDF final tendría menos páginas de las esperadas y no se sabría qué fichero fue el culpable.

La segunda es el contador InsertAt. El tercer argumento de ImportPages es la posición base 1 en el destino donde aterriza la primera página importada. Comenzar en 1 coloca el primer documento de origen al principio de un fichero vacío. Tras cada origen, el contador avanza en PdfSrc.PageCount, de modo que el siguiente lote de páginas se añade a continuación del último. Olvidar incrementarlo hace que cada origen posterior sobreescriba las páginas en la posición 1, dando como resultado solo el último documento de la lista.

Rangos de páginas selectivos

No es obligatorio tomar todas las páginas de un origen. La cadena de rango que se pasa como segundo argumento sigue un formato sencillo de comas y guiones: "1-3" toma las páginas 1 a 3, "2,4,6" elige tres páginas concretas, y "1-" significa desde la página 1 hasta el final del documento. Los rangos se pueden combinar en una sola cadena, por lo que "1-3,5,7-" omite las páginas 4 y 6. Un matiz importante: los números siempre hacen referencia a páginas del documento de origen, empezando en 1, independientemente de dónde acaben esas páginas en el destino. Si se quieren las páginas 40 a 50 de un catálogo de 200 páginas, la cadena de rango es "40-50", no una posición relativa a lo que ya hay en el destino.

// Extract cover plus a three-page executive summary from a long report
PdfSrc.FileName := 'annual-report.pdf';
PdfSrc.Active   := True;
if PdfSrc.Active then
begin
  // Page 1 is the cover; pages 3-5 are the summary
  PdfDest.ImportPages(PdfSrc, '1,3-5', InsertAt);
  Inc(InsertAt, 4);  // 1 cover + 3 summary pages = 4 pages added
  PdfSrc.Active := False;
end;

Al calcular el incremento de InsertAt, hay que contar las páginas que se importaron realmente, no el recuento de páginas del origen. Si se pasa '1,3-5' se importaron 4 páginas, así que hay que avanzar en 4. Avanzar en PdfSrc.PageCount dejaría un hueco de posiciones de destino vacías y colocaría el siguiente documento de origen más adelante en el fichero de lo previsto.

Qué conserva ImportPages y qué no

Las páginas copiadas por ImportPages llevan su contenido visible intacto. Texto, gráficos vectoriales, imágenes rasterizadas, fuentes incrustadas y XObjects de formulario se transfieren como parte de los flujos de contenido de página. Las anotaciones a nivel de página, incluidos comentarios, resaltados y trazos de tinta, también se transfieren, porque están almacenadas dentro del diccionario de página y no a nivel de documento.

Los metadatos a nivel de documento son otra historia. El título, el autor, el asunto y las cadenas de palabras clave del diccionario Info del origen se quedan atrás. El documento de destino comienza con metadatos vacíos tras CreateDocument, por lo que si la salida combinada necesita esos campos rellenos hay que asignarlos directamente a PdfDest antes de llamar a SaveAs. Las propiedades Title, Author, Subject, Keywords y Creator de TPdf aceptan cadenas simples y las escriben en el diccionario Info al guardar.

Los campos de formulario interactivos son más complicados. Las definiciones de campos AcroForm residen en un diccionario a nivel de documento y no dentro de los flujos de páginas individuales. Cuando ImportPages copia una página que contiene campos de formulario, la apariencia visual de esos campos se transfiere porque está renderizada en el flujo de contenido de la página, pero los widgets de campo que los hacen interactivos son parte de la estructura AcroForm y no siguen. En una combinación típica, un campo de texto de un documento de origen mostrará el valor que tenía en el momento de la importación, pero no será editable en el fichero combinado. Si se necesita que los campos sigan siendo rellenables, hay que aplanarlos en cada documento de origen antes de importar: eso incorpora los valores actuales al flujo de contenido y elimina la capa interactiva, dando un resultado visual limpio sin widgets rotos en la salida.

Ficheros de origen cifrados

Los documentos de origen protegidos con contraseña se abren igual que los no cifrados, con una propiedad adicional que establecer primero. Se asigna la contraseña a PdfSrc.Password antes de activar Active := True, y PDFium la usará durante la apertura:

PdfSrc.Password := 'user-password';
PdfSrc.FileName := 'protected.pdf';
PdfSrc.Active   := True;
if not PdfSrc.Active then
  raise Exception.Create('Wrong password or file cannot be opened');

PdfDest.ImportPages(PdfSrc, '1-' + IntToStr(PdfSrc.PageCount), InsertAt);
Inc(InsertAt, PdfSrc.PageCount);
PdfSrc.Active := False;

Una contraseña incorrecta produce el mismo resultado silencioso de Active = False que un fichero ausente, por lo que la comprobación explícita es igual de necesaria aquí. El cifrado no se transfiere al destino: las páginas importadas de un origen protegido llegan al destino como contenido sin proteger. Si la salida combinada también necesita cifrado, hay que configurarlo en PdfDest antes de llamar a SaveAs.

Guardado del resultado

SaveAs en TPdf acepta tanto una ruta de fichero como un TStream. Para la mayoría de las combinaciones, la sobrecarga de fichero es la adecuada:

PdfDest.SaveAs('merged-output.pdf');

El segundo argumento opcional es un TSaveOption que controla el modo de guardado. El valor por defecto, saNone, escribe una actualización incremental si el documento se cargó desde un fichero, o una reescritura completa si se creó desde cero. Como un destino construido con CreateDocument es siempre nuevo, la salida será un fichero compacto de revisión única. El tercer argumento, TPdfVersion, permite fijar la cabecera de versión PDF cuando hay consumidores posteriores que requieren una versión específica; dejarlo en pvUnknown hace que PDFium elija en función del contenido.

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