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
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
TitleyAuthordeTPdfson 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
OutlinePolicypor defectobmopDiscard;bmopRejectconvierte un origen con outline en un error por fichero. Para conservar los bookmarks, la unidadFPdfMergeañadeMergeFilesWithNavigation, que reconstruye los outlines, renombra los destinos con nombre en conflicto con un prefijoImported_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 conCreateDocumentva sin cifrar, y también las páginas copiadas dentro. Si el archivo fusionado debe ir protegido, escríbelo conSaveAsEncrypteden lugar deSaveAs
¿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