Eliminar una página de un PDF no elimina sus fuentes, imágenes ni streams de contenido. losLab PDF Library los recupera con un recolector mark-sweep que recorre el grafo de objetos hacia delante desde las raíces del trailer y elimina cada objeto indirecto al que nada llega. Se ejecuta en un guardado completo, está desactivado por defecto, y devuelve el número de objetos que descartó
¿Por qué eliminar páginas PDF no reduce el fichero?
Porque la eliminación de página es una edición de referencias, no una operación de almacenamiento. DeletePages(StartPage, PageCount) desenlaza objetos de página del árbol de páginas y repara las entradas del esquema que apuntaban a ellos. Lo que no puede hacer es decidir que el programa de fuente, el stream de contenido y el XObject de imagen que esas páginas usaban están ahora muertos, porque en el momento de la eliminación nada en el fichero registra quién más podría seguir apuntando a ellos. Esos objetos se quedan en la lista de objetos del documento, y un guardado completo los vuelve a escribir todos. El resultado es la queja con la que empiezan la mayoría de estos hilos de soporte: un cliente elimina el noventa por ciento de las páginas, guarda, y el fichero se reduce un dos por ciento. Peor aún, la fuga se acumula. Carga, elimina, guarda, carga de nuevo, elimina de nuevo, guarda de nuevo, y el fichero crece monótonamente mientras el recuento de páginas cae. Este es un problema distinto del que resuelven el subsetting de fuentes y el submuestreo de imágenes, que hacen 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 contador de referencias ni ninguna lista de retropunteros, 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 "¿todavía usa alguien el objeto 47?" tiene exactamente una respuesta: recorrer hacia delante desde una raíz conocida y ver si llegas. Por eso el recolector en losLab PDF Library es un recolector mark-sweep y no un esquema de conteo de referencias
Las raíces vienen del trailer del fichero (ISO 32000-1 §7.5.5). Tres claves las llevan: /Root, el catálogo de documento de §7.7.2 del que cuelgan el árbol de páginas, los nombres, los esquemas, el AcroForm y los metadatos; /Info, el diccionario de información del documento; y /Encrypt, el diccionario de cifrado. Las dos claves de trailer restantes son señuelos. /ID es un array de dos cadenas de bytes, y /Prev es un entero de offset de bytes a la sección de referencias cruzadas previa. Ninguna de las dos es una referencia indirecta, así que ninguna aporta una raíz. losLab PDF Library encola el diccionario de trailer completo en lugar de tres claves con nombre, lo cual no cuesta nada y mantiene viva cualquier extensión privada del trailer
El recorrido en sí es iterativo en lugar de recursivo. Cuando el recorrido encuentra una referencia indirecta registra solo el número de objeto y la generación, marca el slot correspondiente y lo empuja a una cola FIFO en lugar de desreferenciarlo inmediatamente, lo que mantiene los árboles de página 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, arrays y diccionarios de stream 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 de esquema se encadenan mediante /Prev y /Next en ambas direcciones. Los números de generación son parte de la coincidencia, no decoración. Una referencia resuelve solo cuando el número de objeto y la generación concuerdan ambos; una referencia a un número que existe con una generación distinta se trata como el objeto nulo que exige la especificación, nunca como un borde vivo
¿Cómo se activa la recolección de basura en un guardado?
La recolección de basura es opt-in y pertenece al registro de opciones de guardado. Por defecto es False porque el recolector es una pasada destructiva sobre el grafo de objetos y ninguna librería debería eliminar silenciosamente objetos que un llamante 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;
Otros dos puntos de entrada llegan al mismo recolector. SetGarbageCollect(1) establece el flag en el documento seleccionado para que un SaveToFile ordinario lo respete, y GarbageCollectObjects ejecuta la pasada de inmediato y devuelve el número de objetos indirectos huérfanos eliminados. La forma inmediata es la que hay que usar cuando quieres un número que registrar o comprobar, y merece la pena comprobarlo, porque un retorno negativo no es un recuento
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 perezosa, y un objeto que nunca se ha decodificado no expone ninguna referencia en absoluto. Si el recolector tratara un objeto no decodificado como un nodo vacío barrería todo lo alcanzable solo a través de él. Así que el recorrido fuerza la decodificación a medida que toca cada objeto, y un único error de decodificación aborta toda la pasada con un resultado negativo y deja el documento idéntico byte a byte. Barrer un grafo que solo entiendes parcialmente es cómo un recolector convierte un fichero dañado en uno destruido
¿Qué rompe a un recolector de PDF ingenuo?
Dos detalles, y ambos fallan silenciosamente en lugar de ruidosamente. El primero son los streams de objeto. Desde PDF 1.5 un objeto que no es un stream 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 tanto solo es alcanzable a través de su contenedor. Marca el miembro, barre el contenedor porque nada lo referenció como objeto de documento, y habrás escrito un fichero cuyo xref apunta hacia un objeto que ya no existe. El contenedor es almacenamiento estructural, no datos de documento, así que nunca aparece como un borde en el grafo de objetos que estás recorriendo. losLab PDF Library maneja esto separando cada miembro comprimido superviviente de su contenedor de origen antes de que los contenedores desaparezcan, tras lo cual el guardado reempaqueta a los supervivientes en streams de objeto frescos. El segundo detalle es qué referencia realmente un objeto de stream. Los bytes no son parte del grafo. Un stream de contenido que dibuja texto con /F1 12 Tf nombra una fuente por nombre de recurso, y ese nombre se resuelve a través del diccionario /Resources de la página, así que el borde de alcanzabilidad corre página → /Resources → /Font → objeto de fuente, nunca a través del contenido del stream. Las únicas referencias que aporta un stream vienen de su diccionario, donde /Length, /Filter y /DecodeParms pueden todos ser indirectos. Un recolector que analiza los bytes del stream buscando referencias está haciendo trabajo costoso para nada; un recolector que se salta los diccionarios de stream pierde el objeto de longitud y corrompe el fichero
Qué pasa con los números de objeto que liberas
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 tras cada eliminación, y para cada objeto eliminado registra el número en la lista libre con su generación incrementada en uno, exactamente como especifica §7.5.4 para una entrada que puede reutilizarse más adelante. Una generación que ya está en 65535 se queda ahí, marcando ese número como retirado permanentemente. Los números de objeto deliberadamente no se compactan. Tras una recolección el fichero conserva huecos: el objeto 12 puede estar libre mientras el 13 y el 14 están en uso, y el /Size del trailer sigue reportando el número más alto más uno en lugar del recuento superviviente. 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 en el documento, que es el tipo de cambio que invalida silenciosamente cualquier cosa que mantenga números de objeto desde fuera. El tamaño que recuperas viene de los cuerpos de objeto, no de la tabla xref
Cuándo no debes ejecutar el recolector
Nunca en una actualización incremental. El recolector está condicionado a los guardados completos y el flag simplemente no se lee cuando el documento se está ampliando, y esa condición no es una limitación que sortear. Una actualización incremental (§7.5.6) deja los bytes originales intactos y añade 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 muy alcanzable en una más antigua. Eliminarlo rompería cada revisión menos la última, y la mecánica de por qué se cubre en el artículo sobre actualizaciones incrementales y guardados en modo append. 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 merece la pena dejar claro qué no es la recolección. No es un sanitizador. El recolector elimina objetos que nada referencia; no tiene ninguna opinión sobre si su contenido era sensible, y un objeto que todavía se referencia se queda como estaba. Si el objetivo es hacer irrecuperable la información en lugar de hacer más pequeño el fichero, el grafo de objetos es la capa equivocada y la redacción a nivel de instrucción y el saneamiento de documentos es la correcta. Ambas componen bien en ese orden: redacta y sanea primero, después recolecta, así que los objetos que la redacción desenganchó realmente abandonan el fichero. 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 merece la pena adoptar. Registra el valor de retorno de GarbageCollectObjects en cualquier job por lotes que haga tus eliminaciones de página, y obsérvalo durante unas semanas de documentos reales. Un cero en un fichero que acabas de reducir a la mitad significa que algo río arriba todavía retiene una referencia que no esperabas, normalmente una entrada de á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 jamás tendrás, porque responde a 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 pipeline de guardado, incluida la interacción entre la recolección, el empaquetado de streams de objeto y la linearización