Dos minutos para copiar tres páginas de un PDF de 40 páginas no es un problema de ajuste de rendimiento. Es una señal de que se está usando la ruta de API incorrecta. Cuando vi por primera vez este tiempo en un ejemplo de copia de páginas del HotPDF Component, mi instinto fue mirar primero la estructura del documento y después el código. Ese orden resultó ser importante.
Qué era realmente lento
El PDF en cuestión era un documento de referencia de 40 páginas con un árbol de páginas no trivial: varios nodos /Pages intermedios en lugar de un único array plano. El código de ejemplo original llamaba a LoadFromFile, luego construía un nuevo documento con BeginDoc, iteraba sobre los números de página seleccionados y en cada iteración volvía a cargar el documento fuente desde disco para obtener una página. Ese es el coste completo del análisis multiplicado por cuantas páginas se quieran. Un fichero de 12 MB accedía al disco seis veces para una extracción de tres páginas, porque nadie comprobó si el fichero necesitaba permanecer abierto entre iteraciones.
El segundo factor era invisible en el código: LoadFromFile de HotPDF resuelve toda la tabla de referencias cruzadas y descomprime todos los flujos de objetos al cargar. Ese es el comportamiento correcto para un documento que se va a modificar, pero supone más trabajo del necesario si solo se quiere el recuento de páginas y un subconjunto de ellas. Para el acceso de solo lectura a la estructura, DAOpenFileReadOnly evita deserializar el árbol de objetos completo, lo que importa en ficheros comprimidos con grandes recursos de imagen.
Ninguno de estos es un error de la biblioteca. Ambos son casos en que quien llama elige la API diseñada para un trabajo y la usa para otro diferente.
Usar InsertPagesFromDocument para la extracción de páginas
La ruta correcta para copiar un rango de páginas de un documento HotPDF a otro es InsertPagesFromDocument, llamado después de LoadFromFile en el origen. Se carga el origen una vez, se carga o crea el destino una vez, se mueven las páginas y se guarda. El origen permanece en memoria durante todas las inserciones de páginas:
procedure ExtractPages(const SourceFile, DestFile: string;
const PageRange: string);
var
Source, Dest: THotPDF;
begin
Source := THotPDF.Create(nil);
Dest := THotPDF.Create(nil);
try
// Load source once: full parse happens here and only here
Source.LoadFromFile(SourceFile);
// Build a minimal destination document
Dest.FileName := DestFile;
Dest.BeginDoc;
// Copy the requested range; '1-3' inserts pages 1 through 3
// starting at position 1 in the destination
Dest.InsertPagesFromDocument(Source, PageRange, 1);
Dest.EndDoc;
finally
Source.Free;
Dest.Free;
end;
end;
El parámetro PageRange acepta el mismo formato que el ejemplo de línea de comandos: una lista separada por comas de números de página o rangos como '1-3' o '1,5,7-9'. Las páginas son base 1. InsertPagesFromDocument copia flujos de contenido, diccionarios de recursos y geometría de página sin tocar metadatos, marcadores ni archivos adjuntos incrustados, a menos que sean referenciados desde las páginas copiadas. Para una extracción de tres páginas de un documento de 40, eso es un conjunto de trabajo pequeño.
El tiempo sobre el mismo fichero de 12 MB que antes tardaba dos minutos: menos de 1,5 segundos con este patrón. La mayor parte de ese tiempo es la única llamada a LoadFromFile. La estructura del documento es irrelevante una vez que la tabla de objetos se resuelve por primera vez.
Cuando LoadFromFile es demasiado: la API de acceso directo a ficheros
Si solo se necesita contar páginas, inspeccionar información del documento o copiar un fichero sin tocar su contenido, la API de acceso directo a ficheros evita el análisis completo por completo. DAOpenFileReadOnly mapea la tabla de referencias cruzadas sin descomprimir los flujos de objetos, por lo que el recuento de páginas es O(tamaño de xref) en lugar de O(tamaño de fichero):
procedure InspectPDF(const FileName: string);
var
Pdf: THotPDF;
Handle, PageCount: Integer;
begin
Pdf := THotPDF.Create(nil);
try
Handle := Pdf.DAOpenFileReadOnly(FileName, '');
if Handle <= 0 then
Exit;
try
PageCount := Pdf.DAGetPageCount(Handle);
Writeln('Pages: ', PageCount);
// DACopyFile is a byte-preserving copy, no re-serialization
Pdf.DACopyFile(FileName, 'archive-copy.pdf');
finally
Pdf.DACloseFile(Handle);
end;
finally
Pdf.Free;
end;
end;
El matiz: DAOpenFileReadOnly acepta un parámetro de contraseña pero recurre a un análisis completo para entradas cifradas, porque el descifrado requiere el árbol de objetos para resolver el diccionario de cifrado. Si los ficheros fuente están cifrados, hay que descifrarlos primero con DecryptFile para obtener una copia sin cifrar y luego abrir esa con la API de acceso directo a ficheros. La función DecryptFile a nivel de fichero toma una ruta de reescritura AES-256 directa para el cifrado estándar y es más rápida que LoadFromFile seguido de SaveLoadedDocument para ficheros grandes, porque no construye el modelo de objetos completo en memoria.
Memoria durante el procesamiento por lotes de gran volumen
Los trabajos por lotes que procesan docenas de ficheros en un bucle tienen un patrón que parece correcto pero acumula memoria: crear THotPDF dentro del bucle, llamar a LoadFromFile, hacer el trabajo, llamar a Free. Eso es estructuralmente correcto. El problema es cuando el trabajo interior asigna objetos temporales, captura excepciones y deja esos objetos temporales activos en las rutas de error. El gestor de memoria de Delphi no compacta, por lo que cien fugas en rutas de error a lo largo de una ejecución por lotes pueden elevar la memoria lo suficiente como para ralentizar la asignación de todo lo demás.
La solución no es exótica. Cada THotPDF y cada TStream o TBitmap intermedio que participa en el trabajo con PDF pertenece a un bloque try/finally donde Free es la última instrucción. Hay que establecer los punteros locales a nil antes del try para que la rama finally pueda usar if Assigned(x) then x.Free de forma segura cuando la inicialización falla a mitad de camino. Esta es la disciplina estándar de propiedad de Delphi y es la historia completa para esta clase de problema.
Una cosa más que comprobar en contextos por lotes: AddImage registra imágenes en una lista interna que persiste durante la vida útil de la instancia THotPDF. Si se reutiliza una única instancia en muchos documentos llamando a LoadFromFile repetidamente, los registros de imágenes de documentos anteriores permanecen en la lista. Hay que crear una instancia nueva por documento o llamar a la ruta de limpieza de la lista de imágenes entre documentos.
Medir antes de cambiar nada
Antes de recurrir a cualquiera de estos patrones, hay que medir. El TStopwatch de Delphi de System.Diagnostics envuelve QueryPerformanceCounter y es suficientemente preciso para el perfilado de reloj de pared de E/S de ficheros. Envolved solo LoadFromFile y ved cuánto tiempo consume. Si es el 90% del tiempo total, la solución es la API de acceso directo a ficheros o reducir cuántas veces se analiza el mismo fichero. Si es inferior al 20%, el cuello de botella está en otro lugar y se está siguiendo la pista equivocada.
La extracción de dos minutos que inició este artículo resultó ser enteramente el patrón de carga repetida. La estructura del documento no aportó nada; un árbol de páginas plano habría funcionado de la misma manera. Cambiar a un único LoadFromFile seguido de una llamada a InsertPagesFromDocument lo redujo a 1,3 segundos en el mismo hardware sin tocar nada más.
La API de manipulación de páginas mostrada aquí forma parte del HotPDF Component para Delphi y C++Builder.