CollateDocumentsEx en la librería PDF para Delphi PDFlibPas combina varios documentos abiertos en un único documento intercalado. Añade GroupSize páginas de cada fuente por ronda, acepta una lista de rangos de páginas por fuente y trata un rango descendente como 3-1 como una inversión de esa fuente. Una sola llamada convierte una pila de anversos y una pila de reversos invertida en orden de lectura
El escenario detrás de esa API es mundano y extremadamente común. Un escáner con alimentador de hojas y ruta de un solo lado procesa toda la pila boca abajo, y después el operador le da la vuelta a la pila y la vuelve a procesar. El resultado son dos PDF: los anversos en orden y los reversos en orden inverso. Lo que el usuario quiere es un único archivo, página 1 anverso, página 1 reverso, página 2 anverso, y así sucesivamente. Este artículo trata sobre el problema de ordenación y la trampa de duplicación de recursos que subyace a él. Si tu preocupación es el rendimiento de la concatenación en bruto, consulta combinación rápida de PDF mediante desplazamiento de referencias a nivel de byte; si los archivos de entrada son demasiado grandes para caber en memoria, consulta combinar y dividir PDF de varios gigabytes con acceso directo
El escáner produce dos pilas, una de ellas al revés
Compaginar no es combinar. Una combinación concatena rangos de páginas; una compaginación los intercala, y el patrón de intercalado es una propiedad del dispositivo físico que produjo la entrada. Si el patrón está mal, el archivo no queda ligeramente mal, queda ilegible: cada segunda página pertenece a una hoja distinta. Tres variables describen casi cualquier caso real: cuántas fuentes participan en la rotación, cuántas páginas aporta cada fuente por ronda y si alguna fuente hay que leerla al revés. CollateDocuments cubre las dos primeras con un array plano de manejadores de documento y un entero GroupSize. CollateDocumentsEx añade la tercera aceptando una lista de rangos de páginas separada por punto y coma, un segmento por fuente, donde un segmento vacío significa todas las páginas de esa fuente y un rango descendente la invierte. Ambas funciones añaden al final del documento actualmente seleccionado y devuelven 1 en caso de éxito, 0 ante cualquier rechazo
¿Por qué la compaginación ingenua multiplica el tamaño del archivo?
Porque el mapa de importación que asocia números de objeto de origen con números de objeto de destino se reconstruye en cada llamada de copia, y todo lo que sea accesible desde más de un fragmento se importa una vez por fragmento. Dentro de PDFlibPas, TPDFDocument.CopyPagesFromDoc reinicia su NewIndObjList al principio de cada invocación. Esa lista es la única memoria que tiene el copiador de lo que ya ha trasladado. Llámala una vez con un rango de diez páginas y una fuente tipográfica compartida por las diez páginas se incrusta una sola vez. Llámala diez veces con una página cada vez y esa misma fuente se incrusta diez veces. Esto importa mucho más en escaneos que en documentos de texto, porque una página escaneada es un único XObject de imagen de gran tamaño y los objetos compartidos son los que tienen peso real: un perfil ICC incrustado, una cadena /DecodeParms compartida, un XObject de formulario de sello o marca de agua aplicado a cada hoja, la fuente de la capa de texto OCR. La forma obvia de escribir una compaginación round-robin es un bucle sobre las rondas, y ese bucle es exactamente el caso patológico
// Do not do this. Each CopyPageRanges call rebuilds the import map,
// so anything the two sources share internally is imported once per
// round instead of once per source.
var
RoundIndex: Integer;
begin
for RoundIndex := 1 to 12 do
begin
PDF.CopyPageRanges(Fronts, IntToStr(RoundIndex));
PDF.CopyPageRanges(Backs, IntToStr(13 - RoundIndex));
end;
end;
Doce rondas, dos fuentes, veinticuatro mapas de importación. Nada te avisa. El orden de páginas es correcto, todas las páginas se renderizan, y el único síntoma es un archivo varias veces más grande que la suma de sus entradas. En un lote de 300 páginas el multiplicador no es un error de redondeo, es la diferencia entre un archivo que cabe en el presupuesto de retención y uno que no
Importar una sola vez y luego reordenar el árbol de páginas
La solución consiste en separar las dos responsabilidades que el bucle ingenuo había fundido en una sola. Copiar decide qué objetos existen en el destino; ordenar decide dónde se sitúan las páginas en el árbol de páginas. CollateDocumentsEx copia cada fuente exactamente una vez, en una única llamada a CopyPagesFromDoc con el rango completo de esa fuente, de modo que cada fuente obtiene un solo mapa de importación y los recursos compartidos se escriben una sola vez. Solo después de que todas las fuentes hayan llegado tiene lugar el intercalado, y ocurre enteramente a través de TPDFPageTree.MovePage
Los movimientos de página son gratuitos en el sentido que aquí importa. La norma ISO 32000-1 §7.7.3 define el árbol de páginas como una estructura balanceada de diccionarios de nodo cuyos arrays /Kids contienen referencias indirectas, con /Count llevando el total de hojas en cada nodo. Reubicar una página significa eliminar una referencia indirecta de un array /Kids, insertarla en otro, ajustar ambos valores de /Count y volver a apuntar el /Parent de la página. No se toca ningún flujo de contenido, no se duplica ningún recurso, no se crea ningún objeto. El objeto de página conserva su número de objeto, que es también el motivo por el que los números de objeto se mantienen estables del mismo modo que en la sustitución de páginas que preserva los números de objeto. Hay un detalle adicional que un movimiento de página ingenuo hace mal y que MovePage no hace mal. La norma ISO 32000-1 §7.7.3.4 permite que /Resources, /MediaBox, /CropBox y /Rotate se hereden de un nodo ancestro en lugar de indicarse en la propia página. Una página que hereda sus recursos del nodo A y luego se mueve bajo el nodo B hereda silenciosamente algo distinto, o nada en absoluto. Por eso MovePage resuelve el valor heredado y lo escribe en el diccionario de la página antes de la reubicación, de modo que la página lleva consigo sus propios atributos a través del movimiento
¿Qué hace exactamente el paso de reordenación?
Ejecuta una ordenación por selección frente a una semántica de inserción. El orden relativo al bloque deseado se calcula primero: se recorren las fuentes en rotación, se toman hasta GroupSize índices de cada una, se salta una fuente agotada, y se repite hasta colocar todas las páginas. Eso produce una permutación sobre el bloque añadido. Aplicarla es la parte incómoda, porque MovePage es una inserción, no un intercambio, así que cada movimiento desplaza en uno todo lo que hay entre la posición antigua y la nueva
La implementación mantiene un array Current que modela dónde se encuentra actualmente cada página añadida, explora hacia delante desde la posición K en busca de la página que le corresponde a K, emite el movimiento y luego desliza las entradas del array para reflejar lo que el movimiento hizo en el árbol. Es O(n al cuadrado) en operaciones de array y cero en copias de objetos, que es la compensación correcta para esta carga de trabajo: una compaginación de 500 páginas son un cuarto de millón de reordenaciones de enteros y ni un solo byte de datos de imagen duplicados. Los rangos descendentes y las páginas repetidas no necesitan ningún tratamiento especial en este paso porque PLParsePageRangeList se invoca con la ordenación desactivada y los duplicados permitidos, de modo que el orden solicitado sobrevive intacto al análisis
Rangos invertidos y la combinación dúplex de una sola llamada
Con la inversión expresada como un rango, el caso del doble paso por escáner plano se reduce a una sola llamada. Los anversos quieren su orden natural y los reversos quieren 12-1, y el primer segmento vacío antes del punto y coma indica que la primera fuente aporta todas sus páginas
var
PDF: TPDFlib;
Target, Fronts, Backs: Integer;
begin
PDF := TPDFlib.Create;
try
Target := PDF.NewDocument;
if PDF.LoadFromFile('fronts.pdf', '') <> 1 then
Exit;
Fronts := PDF.SelectedDocument;
if PDF.LoadFromFile('backs.pdf', '') <> 1 then
Exit;
Backs := PDF.SelectedDocument;
PDF.SelectDocument(Target);
// fronts 1..12 in order, backs scanned in reverse: F1 B12 F2 B11 ...
if PDF.CollateDocumentsEx([Fronts, Backs], ';12-1', 1) = 1 then
PDF.SaveToFile('duplex.pdf');
finally
PDF.Free;
end;
end;
Vale la pena señalar explícitamente dos comportamientos de ese fragmento. Las páginas compaginadas se añaden al documento seleccionado, así que un documento creado con NewDocument aporta su página en blanco inicial por delante de ellas, y hay que borrarla si no se quiere conservar. Y las fuentes pueden ser desiguales: con GroupSize 2 sobre una fuente de tres páginas y otra de cinco, las rondas salen A1 A2 B1 B2, luego A3 B3 B4 cuando A está casi agotada, y luego B5 sola, porque una fuente agotada simplemente se salta en lugar de rellenarse
Reversión, campos de formulario y lo que no se traslada
Todos los argumentos se validan antes de tocar el destino. Un manejador de documento ausente, el documento seleccionado listado como su propia fuente, un GroupSize inferior a uno, un número de segmentos que no coincide con el número de fuentes, un rango que nombra una página que la fuente no tiene: todo esto devuelve 0 con el destino sin modificar. El fallo durante la copia es el caso más difícil, y se gestiona a través del DeletePages público en lugar del PageTree.DeletePages en bruto. El motivo es concreto. La copia se ejecuta con MergeFormData activado, así que los campos de formulario de la fuente ya se han añadido al array /AcroForm /Fields del destino en el momento en que una fuente posterior falla. Borrar las páginas a nivel del árbol de páginas eliminaría las páginas con widgets y dejaría esas referencias de campo colgando; la ruta pública desvincula las referencias de campo, esquema e hilo de artículo junto con las páginas
if PDF.CollateDocumentsEx([Fronts, Backs], ';12-1', 1) = 0 then
// Nothing was appended and the target is byte-identical to before.
// 412 is the copy failure; 0 means the arguments were rejected
// during validation, before any page was touched.
Log(Format('collate rejected, LastErrorCode=%d', [PDF.LastErrorCode]));
Sé honesto con tus usuarios respecto a los límites. La compaginación traslada páginas, sus anotaciones y sus campos de formulario, y combina la lista de campos AcroForm, el array de orden de cálculo y el diccionario de recursos por defecto. No traslada los marcadores de origen: el árbol de esquema de una pila de anversos escaneada casi siempre está vacío, así que en el caso dúplex no se pierde nada, pero si compaginas dos documentos con autoría propia sus esquemas se quedan atrás y tendrás que reconstruir tú mismo la navegación. Los destinos con nombre que solo vivían en el catálogo de origen están en la misma situación. Prevé esto antes de prometerle a un cliente una compaginación sin pérdidas
PDFlibPas incluye las funciones de compaginación junto con el resto de su superficie de ensamblado de páginas, de modo que el flujo de trabajo del escáner, la extracción basada en rangos y las rutas para archivos grandes conviven todos bajo un único componente en Delphi y C++Builder. La referencia completa de la API y una versión de prueba están en la página del producto losLab Delphi PDF library