Artículo técnico

Fusionar Archivos PDF en Delphi con el Componente PDFium

Un ciclo 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 archivo de la lista termina en la página 1, porque la posición 1 significa "insertar antes de la primera página actual", y cada fuente nueva empuja a las anteriores más atrás. No se pierde nada, que es exactamente por qué el error sobrevive a una verificación rápida de conteo de páginas y aparece cuando alguien de verdad lee el paquete

El ciclo página por 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 una fuente que abre cada archivo de entrada por turno. TPdf.ImportPages(Pdf, Range, PageNumber) copia las páginas nombradas en Range desde Pdf hacia el destino y devuelve True en caso de éxito. PageNumber es con 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 agrega al final

La forma más limpia de mantener los archivos en orden de lista es dejar de rastrear un cursor por completo y preguntarle al destino dónde termina. Un contador InsertAt corrido también funciona, pero tiene que avanzarse por el número de páginas realmente importadas, y ese número es fácil de errar en cuanto entran en juego los rangos. PdfDest.PageCount + 1 siempre está bien:

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 excepción ante fallas
      if not PdfSrc.Active then
        raise Exception.CreateFmt('Cannot open %s', [FileList[I]]);

      // posición con base 1: PageCount + 1 agrega 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 verificaciones en ese ciclo cargan peso real. Poner Active := True jamás lanza: si el archivo 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 archivo malo. Un ciclo de fusión que se salta la verificación, eso sí, simplemente produce una salida más corta. La segunda verificación es el resultado Boolean de ImportPages, que regresa False cuando la cadena de rango no parsea o la posición queda pasada PageCount + 1. Note también que FileName y Password solo pueden asignarse mientras el componente está inactivo, razón por la cual la fuente se cierra al final de cada pasada

Bucle de combinación de Delphi con PDFium Component: cada archivo de origen se abre, se copia mediante ImportPages en la posición InsertAt, y el documento de destino se escribe una sola 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 verifique Active

Cadenas de rango: lo que el parser acepta

El argumento de rango es una lista separada por comas de páginas sueltas y tramos cerrados, numerados desde 1 en el documento fuente. '1-3' toma las primeras tres páginas, '2,4,6' toma tres específicas, y '1,3-5' toma una portada más un resumen de tres páginas. Los números siempre se refieren al fuente; dónde esas páginas aterrizan 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 ambos extremos de un tramo y rechaza la cadena completa. Lo mismo pasa con un tramo invertido ('5-3'), un número de página 0, o cualquier página más allá del conteo de páginas del fuente. Un solo elemento malo falla la llamada entera: ImportPages devuelve False y ninguna página de esa cadena se copia. Por eso el ciclo de arriba construye '1-' + IntToStr(PdfSrc.PageCount) en lugar de escribir '1-'

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

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

Sí. TPdf.MergeFiles(Sources, Options, Report) toma un arreglo de records 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 vuelve un error por archivo en el reporte en lugar de un salto silencioso

Los valores predeterminados de TPdfBatchMergeOptions.Default valen la pena conocerlos antes de cambiar alguno. CreateDestination es True, así que un destino inactivo se crea por usted. InsertAt es -1, que significa agregar al final; cualquier otro valor es un índice con base 0, coincidiendo con ImportPagesByIndex y no con ImportPages. ErrorPolicy es bmepStop y RollbackOnCancelOrFailure es True, así que un archivo ilegible termina la corrida y elimina cada página que la corrida había agregado. CopyViewerPreferences copia las preferencias de visor de la primera fuente que importa 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;        // siga adelante pese a los archivos 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 error usar depende de lo que un archivo faltante signifique para la salida. Para un paquete de contrato o una corrida de facturas, un fajo sin una de sus partes es un documento equivocado: conserve el bmepStop predeterminado con rollback. Cambie a bmepSkip solo para colecciones de mejor esfuerzo, como un digest semanal de reportes, y aun así trate bmrsCompletedWithErrors en Report.Status como algo que una persona tiene que revisar. Escoja lo que escoja, lea Report.Issues: cada entrada carga el índice del fuente, el nombre del archivo y el mensaje, lo que responde la pregunta "qué archivo fue el culpable" que el ciclo escrito a mano tiene que responder con sus propias excepciones

Dos opciones más importan para corridas largas. OnProgress y CancellationToken le permiten a una UI mostrar el archivo actual y detener la corrida, y una corrida cancelada se revierte como una fallida. ResourcePolicy por defecto vale bmrpSourceScoped, que importa las páginas seleccionadas de cada fuente en una sola llamada nativa para que las fuentes e imágenes compartidas por esas páginas se copien una vez. bmrpChunked importa en lotes de PageChunkSize páginas, lo que da puntos de cancelación más finos al precio de que los recursos compartidos se copien otra vez por cada lote

Lo que una página importada carga, y lo que se queda en el fuente

Una página importada trae todo lo alcanzable desde su diccionario de página: el content stream, las fuentes, imágenes y form XObjects en sus resources, y sus anotaciones, incluidos comentarios, resaltados y tinta. Todo lo que vive a nivel de documento en el fuente 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 tiene ninguno de los Title, Author o Keywords de las fuentes. Las propiedades Title y Author de TPdf son de solo lectura, lo que las vuelve útiles para registrar en el log lo que cada fuente declaró antes de cerrarla
  • Los outlines (§12.3.3) se sueltan bajo el OutlinePolicy predeterminado de bmopDiscard; bmopReject convierte un fuente con outline en un error por archivo. Para conservar los bookmarks, la unidad FPdfMerge agrega MergeFilesWithNavigation, que reconstruye los outlines, renombra los named destinations 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 archivo fuente. Un fuente protegido abre con su Password, pero un destino hecho con CreateDocument está sin cifrar, y también las páginas copiadas en él. Si el archivo fusionado debe estar protegido, escríbalo con SaveAsEncrypted en lugar de SaveAs
ImportPages de PDFium transporta el contenido de página, las fuentes y las anotaciones hacia un PDF combinado, mientras los metadatos Info, el árbol de campos AcroForm y el cifrado se quedan atrás en cada archivo de origen
ImportPages mueve todo lo almacenado con la página, mientras que los metadatos a nivel de documento y el árbol de campos AcroForm se quedan atrás

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

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

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

Cuando los campos deben seguir rellenables, use MergeFilesWithForms de la unidad FPdfFormMerge. Importa el contenido de página, reconstruye los widgets desde el árbol de campos de cada fuente, e injerta esos campos en el formulario del destino. Los nombres de campos son la parte difícil. Bajo §12.7.3.2 dos campos con el mismo nombre completamente calificado son el mismo campo y comparten un valor, así que un campo Total en una factura y un campo Total en una nota de remisión se sobrescribirían entre sí. El CollisionPolicy predeterminado es pagcpReject, que detiene 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 cambie)
  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 los campos de cada fuente bajo su propio prefijo (Imported más el número de fuente), y pagcpMergeCompatible une campos homónimos solo cuando sus tipos y opciones coinciden. MergeFilesWithForms funciona con fuentes y destinos AcroForm sin campos de firma. Rechaza formularios XFA y archivos firmados sin más, porque reescribir un archivo firmado invalida el rango de bytes firmado descrito en ISO 32000-1 §12.8.1. Un destino abierto además debe estar sin cifrar: fusionar hacia un archivo protegido por contraseña falla, así que agregue la protección después con SaveAsEncrypted. Una página seleccionada dos veces en un mismo fuente, como en '1,1', necesita un RepeatedPagePolicy explícito

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

Guardar el documento fusionado

SaveAs tiene una sobrecarga de archivo y una de TStream, ambas con un TSaveOption opcional (por defecto saNone) y un TPdfVersion (por defecto pvUnknown, que deja el encabezado a la librería). Ambas devuelven un Boolean, y en un trabajo por lotes ese resultado merece la misma verificación que Active. Fije la versión con algo como pv17 solo cuando un sistema aguas abajo insista en un encabezado específico

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

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