Artículo técnico

Combinar ficheros PDF en Delphi con PDFium Component

Un bucle de fusión que pasa 1 como posición de inserción en cada llamada produce un archivo con el número correcto de páginas y los documentos en orden inverso. El último fichero de la lista acaba en la página 1, porque la posición 1 significa "insertar antes de la primera página actual", y cada origen nuevo empuja a los anteriores más atrás. No se pierde nada, que es justo por lo que el error sobrevive a una comprobación rápida del número de páginas y aparece cuando alguien lee de verdad el paquete

El bucle página a página con ImportPages

PDFium Component construye un PDF fusionado en Delphi a partir de dos instancias de TPdf: un destino creado vacío con CreateDocument, y un origen que abre cada fichero de entrada por turno. TPdf.ImportPages(Pdf, Range, PageNumber) copia las páginas nombradas en Range desde Pdf hacia el destino y devuelve True si va bien. PageNumber es base 1 y por defecto vale 1. Nombra la posición de destino donde aterriza la primera página importada, así que 1 antepone y PageCount + 1 añade al final

La forma más limpia de mantener los ficheros en orden de lista es dejar de llevar un cursor del todo y preguntar al destino dónde acaba. Un contador InsertAt corriendo también funciona, pero hay que avanzarlo por el número de páginas realmente importadas, y ese número es fácil de equivocar en cuanto entran en juego los rangos. PdfDest.PageCount + 1 es siempre correcto:

procedure MergeFileList(const FileList: TStrings; const OutputPath: string);
var
  PdfDest, PdfSrc: TPdf;
  I: Integer;
begin
  PdfDest := TPdf.Create(nil);
  PdfSrc  := TPdf.Create(nil);
  try
    PdfDest.CreateDocument;
    for I := 0 to FileList.Count - 1 do
    begin
      PdfSrc.FileName := FileList[I];
      PdfSrc.Active   := True;            // no lanza en caso de fallo
      if not PdfSrc.Active then
        raise Exception.CreateFmt('Cannot open %s', [FileList[I]]);

      // posición base 1: PageCount + 1 añade después de la última página
      if not PdfDest.ImportPages(PdfSrc,
        '1-' + IntToStr(PdfSrc.PageCount), PdfDest.PageCount + 1) then
        raise Exception.CreateFmt('Page import failed for %s', [FileList[I]]);

      PdfSrc.Active := False;             // FileName solo puede cambiar mientras está inactivo
    end;

    if not PdfDest.SaveAs(OutputPath) then
      raise Exception.Create('Cannot write ' + OutputPath);
  finally
    PdfSrc.Free;
    PdfDest.Free;
  end;
end;

Dos comprobaciones de ese bucle llevan peso de verdad. Poner Active := True nunca lanza: si el fichero falta, está dañado o necesita contraseña, TPdf captura el error de carga y deja Active en False. El componente se queda en un estado cerrado consistente y un formulario no se estrella con un fichero malo. Un bucle de fusión que se salta la comprobación, eso sí, simplemente produce una salida más corta. La segunda comprobación es el resultado booleano de ImportPages, que vuelve False cuando la cadena de rango no se parsea o la posición cae más allá de PageCount + 1. Fíjate también en que FileName y Password solo se pueden asignar con el componente inactivo, razón por la que el origen se cierra al final de cada pasada

Bucle de fusión en Delphi con PDFium Component: cada fichero de origen se abre, se copia por ImportPages en la posición InsertAt, y el documento destino se escribe una vez con SaveAs
ImportPages aterriza cada origen en la posición InsertAt, y un archivo ausente o dañado falla en silencio salvo que se compruebe Active

Cadenas de rango: lo que acepta el parser

El argumento de rango es una lista separada por comas de páginas sueltas y tramos cerrados, numerados desde 1 en el documento de origen. '1-3' toma las tres primeras páginas, '2,4,6' toma tres concretas, y '1,3-5' toma una portada más un resumen de tres páginas. Los números siempre se refieren al origen; dónde caen esas páginas en el destino lo decide solo el argumento de posición

Los tramos abiertos son donde la gente tropieza. Una cadena como '7-' parece "de la página 7 al final", pero el parser de rangos de PDFium exige los dos extremos de un tramo y rechaza la cadena entera. Lo mismo pasa con un tramo invertido ('5-3'), un número de página 0, o cualquier página más allá del recuento del origen. Un solo elemento malo hace fallar la llamada entera: ImportPages devuelve False y no se copia ninguna página de esa cadena. Por eso el bucle de arriba construye '1-' + IntToStr(PdfSrc.PageCount) en lugar de escribir '1-'

Cuando ya tienes los índices de página en un array, ImportPagesByIndex(Source, PageIndices, InsertAt) se salta el paso de la cadena. Ojo al cambio de convención, eso sí: tanto los índices como InsertAt son base 0 ahí, así que añadir al final es InsertAt = PdfDest.PageCount y la selección portada-más-resumen se convierte en [0, 2, 3, 4]. Pasar un array vacío importa todas las páginas

¿Hay una fusión de una sola llamada para una lista de ficheros?

Sí. TPdf.MergeFiles(Sources, Options, Report) toma un array de registros TPdfBatchMergeSource, cada uno construido con TPdfBatchMergeSource.FromFile(FileName, PageRange, Password), los abre de uno en uno, y llena un TPdfBatchMergeReport con lo que pasó. Un PageRange vacío significa todas las páginas. La sintaxis de rango es el mismo formato de comas y tramos, y un rango malformado se convierte en un error por fichero en el informe en lugar de un salto silencioso

Los valores por defecto de TPdfBatchMergeOptions.Default conviene conocerlos antes de cambiar ninguno. CreateDestination es True, así que un destino inactivo se crea por ti. InsertAt es -1, que significa añadir al final; cualquier otro valor es un índice base 0, igual que ImportPagesByIndex y a diferencia de ImportPages. ErrorPolicy es bmepStop y RollbackOnCancelOrFailure es True, así que un fichero ilegible termina la ejecución y elimina todas las páginas que hubiera añadido. CopyViewerPreferences copia las preferencias de visor del primer origen que importe con éxito

procedure BuildBundle(const OutputPath: string; Log: TStrings);
var
  Dest: TPdf;
  Sources: TPdfBatchMergeSources;
  Options: TPdfBatchMergeOptions;
  Report: TPdfBatchMergeReport;
  I: Integer;
begin
  SetLength(Sources, 3);
  Sources[0] := TPdfBatchMergeSource.FromFile('cover.pdf');
  Sources[1] := TPdfBatchMergeSource.FromFile('annual-report.pdf', '1,3-5');
  Sources[2] := TPdfBatchMergeSource.FromFile('appendix.pdf', '', 'user-password');

  Options := TPdfBatchMergeOptions.Default;
  Options.ErrorPolicy := bmepSkip;        // sigue adelante ante ficheros ilegibles

  Dest := TPdf.Create(nil);               // inactivo: MergeFiles lo crea
  try
    if not Dest.MergeFiles(Sources, Options, Report) then
      raise Exception.Create('Merge failed: ' + Report.ErrorMessage);

    for I := 0 to High(Report.Issues) do
      Log.Add(Format('%s: %s',
        [Report.Issues[I].FileName, Report.Issues[I].MessageText]));
    Log.Add(Format('%d pages from %d of %d files',
      [Report.ImportedPageCount, Report.ImportedSourceCount, Report.SourceCount]));

    Dest.SaveAs(OutputPath);
  finally
    Dest.Free;
  end;
end;

Qué política de errores usar depende de lo que un fichero ausente signifique para la salida. Para un paquete de contrato o una tanda de facturas, un paquete sin una de sus partes es un documento equivocado: conserva el bmepStop por defecto con rollback. Cambia a bmepSkip solo para colecciones de mejor esfuerzo, como un resumen semanal de informes, y entonces trata bmrsCompletedWithErrors en Report.Status como algo que una persona tiene que mirar. Escojas lo que escojas, lee Report.Issues: cada entrada lleva el índice del origen, el nombre del fichero y el mensaje, lo que responde a la pregunta "qué fichero fue el culpable" que el bucle escrito a mano tiene que responder con sus propias excepciones

Dos opciones más importan en ejecuciones largas. OnProgress y CancellationToken dejan que una UI muestre el fichero actual y pare la ejecución, y una ejecución cancelada se revierte como una fallida. ResourcePolicy vale por defecto bmrpSourceScoped, que importa las páginas seleccionadas de cada origen en una llamada nativa, de modo que las fuentes e imágenes compartidas por esas páginas se copian una vez. bmrpChunked importa en tandas de PageChunkSize páginas, lo que da puntos de cancelación más finos al precio de que los recursos compartidos se vuelven a copiar en cada tanda

Qué lleva una página importada y qué se queda en el origen

Una página importada trae todo lo alcanzable desde su diccionario de página: el stream de contenido, las fuentes, imágenes y XObjects de formulario de sus recursos, y sus anotaciones, incluidos comentarios, resaltados y tinta. Todo lo que viva a nivel de documento en el origen no viene, porque ISO 32000-1 lo mantiene fuera del árbol de páginas

  • El diccionario de información del documento (ISO 32000-1 §14.3.3) se queda atrás, así que el archivo fusionado no lleva nada del Title, Author o Keywords de los orígenes. Las propiedades Title y Author de TPdf son de solo lectura, lo que las hace prácticas para registrar en el log lo que cada origen declaró antes de cerrarlo
  • Los outlines (§12.3.3) se descartan con el OutlinePolicy por defecto bmopDiscard; bmopReject convierte un origen con outline en un error por fichero. Para conservar los bookmarks, la unidad FPdfMerge añade MergeFilesWithNavigation, que reconstruye los outlines, renombra los destinos con nombre en conflicto con un prefijo Imported_ por defecto y remapea los enlaces entre páginas; el artículo de edición de outlines y remapeo de páginas cubre cómo se reescriben esos destinos
  • El cifrado (§7.6) pertenece al fichero de origen. Un origen protegido se abre con su Password, pero un destino creado con CreateDocument va sin cifrar, y también las páginas copiadas dentro. Si el archivo fusionado debe ir protegido, escríbelo con SaveAsEncrypted en lugar de SaveAs
PDFium ImportPages transporta el contenido de páginas, fuentes y anotaciones hacia un PDF fusionado, mientras los metadatos Info, el árbol de campos AcroForm y el cifrado se quedan atrás en cada fichero de origen
ImportPages mueve todo lo almacenado con la página, mientras que los metadatos de nivel de documento y el árbol de campos AcroForm se quedan atrás

¿Por qué los campos de formulario dejan de funcionar tras una fusión?

Los campos de formulario están repartidos entre dos niveles de un PDF. La anotación widget que dibuja un cuadro de texto vive en la página, mientras que el campo en sí, con su nombre y su valor, está listado en el array /Fields del diccionario de formulario interactivo del catálogo (ISO 32000-1 §12.7.2). Una importación de páginas nativa copia el widget junto con las demás anotaciones de página, pero nada lo añade al árbol de campos del destino. El cuadro sigue dibujando su última apariencia. Un visor que construye su modelo de formulario desde /AcroForm, eso sí, no tiene ningún campo al que atar el widget, así que el valor no se puede editar y cualquier operación de formulario que recorra el árbol de campos lo salta

TPdfBatchMergeOptions.FormPolicy da tres respuestas para una fusión llana. bmfpPreserveNative, el valor por defecto, conserva el resultado de importación nativa descrito arriba. bmfpFlatten genera apariencias y aplana en memoria las páginas seleccionadas de cada origen con formulario antes de importarlas, así que los valores pasan a ser contenido ordinario de página; el fichero de origen en disco no se modifica. bmfpReject convierte cualquier origen con formulario AcroForm o XFA en un error por fichero

Cuando los campos tienen que seguir siendo rellenables, usa MergeFilesWithForms de la unidad FPdfFormMerge. Importa el contenido de página, reconstruye los widgets desde el árbol de campos de cada origen, e injerta esos campos en el formulario del destino. Los nombres de campo son la parte difícil. Bajo §12.7.3.2 dos campos con el mismo nombre completo cualificado son el mismo campo y comparten un valor, así que un campo Total en una factura y un campo Total en un albarán se sobreescribirían. La CollisionPolicy por defecto es pagcpReject, que para la fusión en el primer choque:

uses
  PDFium, FPdfCompress, FPdfFormMerge;

procedure MergeFillableForms(Dest: TPdf;
  const Sources: TPdfBatchMergeSources; Log: TStrings);
var
  Options: TPdfFormMergeOptions;
  Report: TPdfFormMergeReport;
begin
  Options := TPdfFormMergeOptions.Default;
  // Los nombres en conflicto reciben el RenamePrefix ('Imported_' salvo que lo cambies)
  Options.GraftOptions.CollisionPolicy := pagcpRename;

  if Dest.MergeFilesWithForms(Sources, Options, Report) then
    Log.Add(Format('%d fields grafted, %d renamed',
      [Report.GraftedFieldCount, Report.RenamedFieldCount]))
  else
    Log.Add('Form merge failed: ' + Report.ErrorMessage);
end;

pagcpNamespace pone en cambio los campos de cada origen bajo su propio prefijo (Imported más el número de origen), y pagcpMergeCompatible une campos con el mismo nombre solo cuando sus tipos y opciones coinciden. MergeFilesWithForms trabaja con orígenes y destinos AcroForm sin campos de firma. Rechaza frontalmente formularios XFA y ficheros firmados, porque reescribir un fichero firmado invalida el rango de bytes firmado que describe ISO 32000-1 §12.8.1. Un destino abierto debe además ir sin cifrar: fusionar hacia un fichero protegido con contraseña falla, así que añade la protección después con SaveAsEncrypted. Una página seleccionada dos veces en un mismo origen, como en '1,1', necesita un RepeatedPagePolicy explícito

El documento de destino solo se reemplaza después de que el resultado preparado se haya reabierto y comprobado. Nombres, valores y tipos de campo se comparan página a página, y una fusión de formularios fallida deja Dest como estaba. El artículo del injerto AcroForm profundiza en el manejo de colisiones

Guardar el documento fusionado

SaveAs tiene una sobrecarga con fichero y otra con TStream, ambas con un TSaveOption opcional (por defecto saNone) y un TPdfVersion (por defecto pvUnknown, que deja la cabecera a la biblioteca). Las dos devuelven un booleano, y en un trabajo por lotes ese resultado merece la misma comprobación que Active. Fija la versión con algo como pv17 solo cuando un sistema aguas abajo insiste en una cabecera concreta

El trabajo inverso, cortar un fichero en varios, usa las mismas cadenas de rango y las mismas posiciones base 1; el artículo de división lo recorre paso a paso

ImportPages, MergeFiles y la fusión consciente de formularios descrita aquí vienen con el PDFium Component, que lleva estas operaciones de página a las aplicaciones Delphi y C++Builder