Eliminar una página de un PDF no elimina sus fuentes, imágenes ni flujos de contenido. losLab PDF Library los recupera con un recolector mark-sweep que recorre el grafo de objetos hacia adelante desde las raíces del trailer y elimina todo objeto indirecto al que nada hace referencia. Se ejecuta en un guardado completo, está desactivado por defecto, y devuelve la cantidad de objetos que descartó
¿Por qué eliminar páginas de un PDF no reduce el tamaño del archivo?
Porque la eliminación de páginas es una edición de referencias, no una operación de almacenamiento. DeletePages(StartPage, PageCount) desvincula los objetos de página del árbol de páginas y repara las entradas de esquema (outline) que apuntaban a ellos. Lo que no puede hacer es decidir que el programa de fuente, el flujo de contenido y el XObject de imagen que usaban esas páginas ahora están muertos, porque en el momento de la eliminación nada en el archivo registra quién más podría seguir apuntando a ellos. Esos objetos permanecen en la lista de objetos del documento, y un guardado completo los vuelve a escribir a todos. El resultado es la queja que da inicio a la mayoría de estos hilos de soporte: un cliente elimina el noventa por ciento de las páginas, guarda, y el archivo se reduce apenas un dos por ciento. Peor aún, la fuga se acumula. Cargar, eliminar, guardar, volver a cargar, eliminar de nuevo, guardar de nuevo, y el archivo crece de forma monótona mientras la cantidad de páginas disminuye. Este es un problema distinto al que resuelve el subconjunto de fuentes y la reducción de resolución de imágenes, que hace más pequeños los objetos vivos. Aquí los objetos no son demasiado grandes. Simplemente ya no forman parte del documento
El conjunto raíz es el trailer, no el árbol de páginas
El grafo de objetos de PDF no tiene ningún campo de referencia inversa. El formato no define ningún conteo de referencias ni ninguna lista de punteros hacia atrás, y las claves /Parent que sí existen pertenecen a estructuras específicas como el árbol de páginas, no al grafo de objetos en su conjunto. Nada en un objeto indirecto te dice quién apunta a él, así que la pregunta "¿alguien sigue usando el objeto 47?" tiene exactamente una respuesta: recorrer hacia adelante desde una raíz conocida y ver si se llega hasta él. Por eso el recolector de losLab PDF Library es un recolector mark-sweep y no un esquema de conteo de referencias
Las raíces provienen del trailer del archivo (ISO 32000-1 §7.5.5). Tres claves las transportan: /Root, el catálogo del documento de §7.7.2 del cual cuelgan el árbol de páginas, los nombres, los esquemas (outlines), el AcroForm y los metadatos; /Info, el diccionario de información del documento; y /Encrypt, el diccionario de cifrado. Las otras dos claves del trailer son señuelos. /ID es un arreglo de dos cadenas de bytes, y /Prev es un desplazamiento de bytes entero hacia la sección de referencias cruzadas anterior. Ninguna de las dos es una referencia indirecta, así que ninguna aporta una raíz. losLab PDF Library encola el diccionario completo del trailer en lugar de tres claves nombradas, lo cual no cuesta nada y mantiene viva cualquier extensión privada del trailer
El recorrido en sí es iterativo, no recursivo. Cuando la travesía encuentra una referencia indirecta, registra solo el número de objeto y la generación, marca la ranura correspondiente y la coloca en una cola FIFO en lugar de desreferenciarla de inmediato, lo que mantiene los árboles de páginas profundos y las cadenas de esquema largas fuera de la pila de llamadas y evita que el mismo objeto se decodifique dos veces. Los diccionarios directos, los arreglos y los diccionarios de flujo van a una segunda cola protegida por un conjunto de visitados, porque los documentos reales contienen ciclos genuinos: el /Parent de una página apunta de vuelta a su nodo del árbol de páginas, y los elementos del esquema se encadenan mediante /Prev y /Next en ambas direcciones. Los números de generación son parte de la coincidencia, no un adorno. Una referencia solo se resuelve cuando el número de objeto y la generación coinciden; una referencia a un número que existe en una generación distinta se trata como el objeto nulo que exige la especificación, nunca como una arista viva
¿Cómo se habilita la recolección de basura al guardar?
La recolección de basura es opcional y pertenece al registro de opciones de guardado. Su valor por defecto es False porque el recolector es un paso destructivo sobre el grafo de objetos y ninguna biblioteca debería eliminar en silencio objetos que quien la invoca nunca pidió examinar
var
Pdf: TPDFlib;
Opt: TPDFlibSaveOptions;
begin
Pdf := TPDFlib.Create;
try
if Pdf.LoadFromFile('report-500pages.pdf', '') <> 1 then
Exit;
Pdf.DeletePages(11, 490); // keep the first ten pages
FillChar(Opt, SizeOf(Opt), 0);
Opt.CompressContent := True;
Opt.CompressFonts := True;
Opt.OptimizeContentStreams := True;
Opt.PackObjectStreams := True;
Opt.GarbageCollect := True; // drop everything the pages left behind
Pdf.SaveToFileOptions('report-10pages.pdf', Opt);
finally
Pdf.Free;
end;
end;
Hay otros dos puntos de entrada que llegan al mismo recolector. SetGarbageCollect(1) establece el indicador en el documento seleccionado para que un SaveToFile ordinario lo respete, y GarbageCollectObjects ejecuta el paso de inmediato y devuelve la cantidad de objetos indirectos huérfanos eliminados. La forma inmediata es la que conviene usar cuando se quiere un número para registrar o verificar, y vale la pena comprobarlo, porque un valor negativo de retorno no es un conteo
var
Removed: Integer;
begin
Pdf.DeletePages(11, 490);
Removed := Pdf.GarbageCollectObjects;
if Removed < 0 then
// The graph could not be fully decoded. Nothing was swept and the
// document is unchanged; save it without GC or reject the input.
LogWarning('object graph incomplete, GC skipped')
else
LogInfo(Format('reclaimed %d orphaned objects', [Removed]));
end;
Esa ruta de fallo importa más de lo que parece. Los objetos se decodifican de forma diferida, y un objeto que nunca se ha decodificado no expone ninguna referencia. Si el recolector tratara un objeto no decodificable como un nodo vacío, barrería todo lo que solo era alcanzable a través de él. Por eso el recorrido fuerza la decodificación al tocar cada objeto, y un solo error de decodificación aborta todo el paso con un resultado negativo y deja el documento idéntico byte a byte. Barrer un grafo que solo se entiende parcialmente es la manera en que un recolector convierte un archivo dañado en uno destruido
¿Qué rompe a un recolector de PDF ingenuo?
Dos detalles, y ambos fallan en silencio en lugar de hacerlo de forma ruidosa. El primero son los flujos de objetos (object streams). Desde PDF 1.5, un objeto que no es un flujo puede vivir comprimido dentro de un contenedor /ObjStm (§7.5.7), y su entrada de referencia cruzada es una entrada de tipo 2 que nombra el contenedor más un índice dentro de él. Un objeto comprimido, por lo tanto, solo es alcanzable a través de su contenedor. Marca el miembro, barre el contenedor porque nada lo referenciaba como objeto de documento, y habrás escrito un archivo cuya xref apunta a un objeto que ya no existe. El contenedor es almacenamiento estructural, no datos del documento, así que nunca aparece como una arista en el grafo de objetos que se recorre. losLab PDF Library maneja esto separando cada miembro comprimido sobreviviente de su contenedor de origen antes de que los contenedores desaparezcan, tras lo cual el guardado reempaqueta a los sobrevivientes en flujos de objetos nuevos. El segundo detalle es qué referencia realmente un objeto de flujo. Los bytes no forman parte del grafo. Un flujo de contenido que dibuja texto con /F1 12 Tf nombra una fuente por su nombre de recurso, y ese nombre se resuelve a través del diccionario /Resources de la página, de modo que la arista de alcanzabilidad recorre página → /Resources → /Font → objeto de fuente, nunca a través del contenido del flujo. Las únicas referencias que aporta un flujo provienen de su diccionario, donde /Length, /Filter y /DecodeParms pueden ser todas indirectas. Un recolector que analiza los bytes del flujo buscando referencias hace un trabajo costoso para nada; un recolector que omite los diccionarios de flujo pierde el objeto de longitud y corrompe el archivo
Qué sucede con los números de objeto que se liberan
Se convierten en entradas libres, y no se reutilizan en el mismo guardado. El barrido recorre la lista de objetos en orden descendente para que las eliminaciones mantengan estables los índices, reconstruye el índice de búsqueda una sola vez al final en lugar de después de cada eliminación, y para cada objeto eliminado registra el número en la lista de libres con su generación incrementada en uno, tal como especifica §7.5.4 para una entrada que podría reutilizarse más adelante. Una generación que ya está en 65535 permanece ahí, marcando ese número como retirado de forma permanente. Los números de objeto deliberadamente no se compactan. Después de una recolección, el archivo conserva huecos: el objeto 12 puede estar libre mientras 13 y 14 están en uso, y el /Size del trailer sigue reportando el número más alto más uno, en lugar de la cantidad de objetos que sobreviven. Eso es legal y normal. Renumerar ahorraría un puñado de bytes en la tabla de referencias cruzadas y exigiría reescribir cada referencia del documento, que es el tipo de cambio que invalida en silencio cualquier cosa que mantenga números de objeto desde afuera. El tamaño que se recupera proviene de los cuerpos de los objetos, no de la tabla xref
Cuándo no se debe ejecutar el recolector
Nunca en una actualización incremental. El recolector está restringido a los guardados completos y el indicador simplemente no se lee cuando se está anexando al documento, y esa restricción no es una limitación que haya que evitar. Una actualización incremental (§7.5.6) deja intactos los bytes originales y anexa una nueva sección de referencias cruzadas encadenada a la anterior mediante /Prev. Cada revisión anterior sigue apuntando a los objetos a los que siempre apuntó, así que un objeto que es inalcanzable en la revisión actual es perfectamente alcanzable en una más antigua. Eliminarlo rompería todas las revisiones menos la última, y la mecánica de por qué se explica en el artículo sobre actualizaciones incrementales y guardados en modo de anexado. El mismo razonamiento descarta la recolección de basura en un documento firmado, porque la reescritura completa que hace posible la recolección es precisamente lo que invalida la firma
También vale la pena aclarar qué no es la recolección. No es un sanitizador. El recolector elimina objetos a los que nada hace referencia; no tiene opinión sobre si su contenido era sensible, y un objeto al que todavía se hace referencia permanece tal como estaba. Si el objetivo es hacer la información irrecuperable en lugar de reducir el tamaño del archivo, el grafo de objetos es la capa equivocada, y la capa correcta es la redacción a nivel de instrucciones y el saneamiento de documentos. Ambas se combinan bien en ese orden: primero redactar y sanear, luego recolectar, para que los objetos que la redacción desprendió realmente abandonen el archivo. El mismo emparejamiento existe en la API de purga de recursos, donde pasar la opción de recolección de basura hace que la purga ejecute una recolección después y reporte los huérfanos que eliminó en OrphanObjectsRemoved
Un último hábito que vale la pena adoptar. Registra el valor de retorno de GarbageCollectObjects en el trabajo por lotes que haga tus eliminaciones de páginas, y obsérvalo durante unas semanas con documentos reales. Un cero en un archivo que acabas de reducir a la mitad significa que algo más arriba en el flujo todavía mantiene una referencia que no esperabas, normalmente una entrada de un árbol de nombres, un destino de esquema o un campo de AcroForm que sobrevivió a la página a la que estaba adjunto. El recolector es el depurador de alcanzabilidad más barato que tendrás jamás, porque responde la pregunta que el propio formato PDF se niega a responder
El recolector de basura, el registro de opciones de guardado y la API de purga de recursos descritos aquí forman parte de losLab PDF Library para Delphi y C++Builder, cuya página de producto incluye la referencia completa del flujo de guardado, incluida la interacción entre la recolección, el empaquetado de flujos de objetos y la linealización