El PDFium Component para Delphi (PDFiumPas) encoge archivos PDF con tres APIs de reescritura sobre TPdf: SaveAsReachable suelta los objetos que nada referencia, SaveAsDeduplicated fusiona fuentes, imágenes y Form XObjects idénticos byte a byte, y SaveAsFlateRecompressed recodifica los streams Flate existentes a máxima compresión. Cada una devuelve un record de informe para que veas qué se eliminó y cuántos bytes ahorró
El disparador habitual es un ticket de soporte que dice "borramos veinte páginas y el archivo creció". Alguien abrió un contrato, quitó un anexo, fusionó una portada y guardó. Cada paso fue una actualización incremental, así que los objetos viejos siguen en el archivo, y cuando ese documento se carga después y se escribe con FPDF_SaveAsCopy salen otra vez tal cual. PDFium serializa lo que esté en su tabla de objetos en memoria; no tiene llamada pública para listar objetos indirectos, borrar uno ni reescribir referencias. Todo lo de este artículo corre por tanto en la capa wrapper de PDFiumPas encima de los bytes que produce PDFium
¿Por qué FPDF_SaveAsCopy mantiene en el archivo las páginas borradas?
FPDF_SaveAsCopy conserva los objetos huérfanos porque PDFium carga en su documento toda entrada viva de la tabla de cross-reference, le apunte algo o no. ISO 32000-1 §7.5.6 describe las actualizaciones incrementales como añadir versiones nuevas de objetos y una sección xref nueva al final del archivo. Borrar una página solo edita el árbol de páginas; el diccionario de la página, su content stream y las fuentes o imágenes que solo usaba esa página siguen listados como objetos en uso. Un guardado completo no incremental no cambia eso, porque desde el punto de vista de la tabla de objetos esos objetos son perfectamente válidos
Los mismos restos aparecen tras fusiones de páginas, aplanados de formularios hechos por otras herramientas y guardados de reparación de archivos dañados. Son invisibles en todo visor y no aportan nada más que bytes. En documentos escaneados una sola imagen de página huérfana puede pesar varios megabytes
Mark-and-sweep con SaveAsReachable
TPdf.SaveAsReachable quita huérfanos con una pasada clásica de mark-and-sweep: deja que PDFium escriba una copia no incremental del documento actual, parsea esa copia en un grafo de objetos, marca todo lo alcanzable desde las raíces y escribe una única revisión clásica que contiene solo los objetos marcados. Los objetos dentro de object streams se materializan como objetos indirectos ordinarios por el camino, así que la salida usa siempre una tabla xref simple
Elegir las raíces es donde una implementación ingenua se equivoca. La semilla obvia es el Catalog nombrado por el trailer /Root. Desde ahí el recorrido alcanza el árbol de páginas, los recursos de página, fuentes, imágenes, Form XObjects, el AcroForm, el árbol de estructura, los outlines, los name trees, el contenido opcional, los metadatos XMP, los output intents y los archivos incrustados. Pero el diccionario de información del documento lo referencia la entrada /Info del trailer (ISO 32000-1 §7.5.5), no el Catalog. Siembra solo desde /Root y borras en silencio el Title, Author y Producer de cada archivo que proceses. PDFiumPas siembra el recorrido con /Root y /Info, y el trailer reconstruido solo arrastra /Info hacia adelante cuando ese objeto sobrevivió de verdad
El walker es un recorrido en profundidad con pila explícita y un conjunto de visitados, así que los ciclos de referencias (enlaces de padre en el árbol de páginas, punteros hacia atrás /P de anotaciones) no pueden colgarlo. Una referencia a un número de objeto ya libre simplemente no se sigue en lugar de tratarse como error, lo que mantiene la pasada tolerante con entrada ligeramente dañada
Los números de objeto se conservan, no se renumeran
TPdf.SaveAsReachable deja cada objeto superviviente en su número y generación de objeto originales. Los objetos barridos se escriben como entradas libres en la tabla xref (ISO 32000-1 §7.5.4), los huecos entre números vivos se rellenan con entradas libres, y el objeto 0 sigue siendo la cabeza de la lista libre. Como no se renumera nada, toda referencia que sobrevive sigue siendo correcta sin reescribir un solo byte, y no hay mapeo de viejo a nuevo que poder equivocar. El coste son unas cuantas líneas xref sin usar, 20 bytes cada una, que es un intercambio que merece la pena
uses
PDFium, FPdfCompress;
procedure PruneOrphans(const SourcePdf, TargetPdf: string);
var
Pdf: TPdf;
Report: TPdfPruneReport;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := SourcePdf;
Pdf.Active := True;
if not Pdf.SaveAsReachable(TargetPdf, Report) then
raise Exception.Create(Report.ErrorMessage);
Writeln(Format('%d bytes -> %d bytes, kept %d object(s), pruned %d',
[Report.SourceByteCount, Report.OutputByteCount,
Report.KeptObjectCount, Report.PrunedObjectCount]));
finally
Pdf.Free;
end;
end;
TPdfPruneReport lleva SourceByteCount (el tamaño de la copia no incremental que produjo PDFium), OutputByteCount, KeptObjectCount, PrunedObjectCount, Succeeded y ErrorMessage. La sobrecarga de nombre de archivo escribe a un archivo temporal junto al destino y solo sustituye el destino cuando la reescritura triunfó, así que una ejecución fallida nunca deja detrás un PDF truncado
¿Qué archivos rechazarán estas APIs?
Las tres APIs de reescritura rechazan documentos cifrados y, por defecto, los firmados. Una entrada cifrada falla con Succeeded = False y un mensaje de error en lugar de una excepción: una reescritura de revisión única no puede mantener los números de objeto, los rangos de bytes y las claves por objeto de los que depende el diccionario de cifrado, así que la salida sería ilegible. Un documento con firmas digitales lanza EPdfError salvo que pases AllowSignedDocument = True, porque una reescritura completa invalida siempre el rango de bytes firmado. Fijar ese flag significa aceptar un archivo cuyas firmas se reportarán como rotas; es la decisión correcta para una copia de archivo, jamás para un contrato que estás a punto de devolver
¿Cómo decide SaveAsDeduplicated que dos imágenes son la misma?
TPdf.SaveAsDeduplicated trata dos recursos como idénticos solo cuando sus diccionarios, sus subgrafos alcanzables completos y sus bytes de stream en bruto coinciden todos. Ejecuta primero el mismo recorrido de alcanzabilidad, luego toma la huella de cada fuente, image XObject y Form XObject alcanzable, fusiona cada grupo de coincidencias sobre el miembro con el número de objeto más bajo y reescribe cada referencia para que apunte a ese superviviente. Los duplicados fusionados se vuelven entradas xref libres igual que los huérfanos. Este es el arreglo para el informe clásico de "logo incrustado 400 veces" que producen las herramientas de mail merge que re-incrustan los mismos recursos en cada página
La huella es un hash FNV-1a de 64 bits calculado recursivamente. Para cada candidato el walker toma el cuerpo del objeto, sustituye cada referencia N G R por la huella del objeto al que apunta y hashea el resultado. Eso hace al hash independiente de la numeración de objetos: dos copias de la misma fuente cuyos descriptores resultan ser los objetos 12 y 87 producen igualmente el mismo valor. Los resultados se memoizan para que los subobjetos compartidos se hasheen una vez, y un conjunto de visitados devuelve un sentinel fijo para los arcos hacia atrás, así los grafos cíclicos terminan
El bug sutil se sienta en el payload del stream. Para encontrar referencias con seguridad, PDFiumPas escanea una copia de cada objeto en la que los datos del stream, las cadenas y los comentarios se han blanqueado con espacios; de lo contrario, una ronda de datos binarios de píxeles que por casualidad parezca 12 0 R se trataría como referencia. Hashea solo esa copia higienizada y dos imágenes con diccionarios idénticos (mismos /Width, /Height, /BitsPerComponent, /Filter, /Length) pero píxeles distintos se colapsan en una sola huella, y la segunda foto de la página se sustituye en silencio por la primera. SaveAsDeduplicated añade los bytes en bruto entre stream y endstream a los datos hasheados, así los píxeles forman parte de la identidad. Los candidatos además se agrupan por categoría antes de comparar, así que una rara colisión de hash entre una fuente y una imagen jamás puede fusionarlas
La reescritura de referencias reutiliza el mismo truco del escaneo higienizado al revés: las referencias se localizan en la copia blanqueada pero los bytes se copian del cuerpo original, así que solo se modifica la estructura de diccionarios y arrays y los payloads binarios pasan intactos
procedure MergeDuplicateResources(Pdf: TPdf; const TargetPdf: string);
var
Report: TPdfDedupReport;
begin
if not Pdf.SaveAsDeduplicated(TargetPdf, Report) then
raise Exception.Create(Report.ErrorMessage);
Writeln(Format('fonts %d, images %d, forms %d merged; %d -> %d bytes',
[Report.MergedFontCount, Report.MergedImageCount,
Report.MergedFormXObjectCount,
Report.SourceByteCount, Report.OutputByteCount]));
end;
Lee TPdfDedupReport con cuidado. Junto a los tres campos Merged*Count tiene KeptObjectCount y PrunedObjectCount, y en este informe PrunedObjectCount cuenta solo duplicados fusionados. Los objetos inalcanzables se siguen soltando, pero no entran en ese número. Si necesitas el recuento de huérfanos para logs, tómalo de una ejecución aparte de SaveAsReachable. El otro límite es que el casado es exacto al byte sobre el stream codificado: el mismo logo comprimido por dos productores distintos a dos niveles Flate distintos no se fusionará
Recomprimir streams Flate al nivel máximo
TPdf.SaveAsFlateRecompressed infla cada stream FlateDecode alcanzable y lo desinfla otra vez al nivel de compresión más alto, conservando el resultado solo cuando es más pequeño y pasa tus umbrales. Muchos productores escriben Flate a un nivel por defecto rápido, así que los content streams y los programas de fuente incrustados suelen encoger aún más. Los números y generaciones de objeto se quedan quietos; solo cambian el payload y su /Length directo
TPdfFlateRecompressOptions tiene dos campos y ningún constructor por defecto, así que fija ambos. MinSavingBytes es el ahorro absoluto mínimo por stream, y MinSavingRatio es una fracción de la longitud original (0.05 significa 5%); un ratio de 0 desactiva el test de ratio. El stream reconstruido se escribe con stream, CRLF, el payload nuevo, CRLF y endstream, así que el resultado no depende de los finales de línea del archivo original
procedure RecompressFlate(Pdf: TPdf; const TargetPdf: string);
var
Options: TPdfFlateRecompressOptions;
Report: TPdfFlateRecompressReport;
begin
Options.MinSavingBytes := 256; // ignora ganancias diminutas
Options.MinSavingRatio := 0.05; // y todo lo que quede bajo el 5%
if not Pdf.SaveAsFlateRecompressed(TargetPdf, Options, Report) then
raise Exception.Create(Report.ErrorMessage);
Writeln(Format('recompressed %d, skipped %d, failed %d; streams %d -> %d bytes',
[Report.RecompressedCount, Report.SkippedCount, Report.FailedCount,
Report.OriginalStreamBytes, Report.RecompressedStreamBytes]));
end;
Los casos de borde son conservadores por diseño. Un stream cuyo /Length es una referencia indirecta se salta, porque la reescritura debe almacenar un entero directo. Un payload que no se puede inflar cuenta en FailedCount y se copia sin cambios. Un stream Flate que no llega a los umbrales cuenta en SkippedCount. Las imágenes DCT, JPX, JBIG2 y CCITT jamás entran en la lista de candidatos, así que esta pasada nunca toca la calidad de JPEG. Para ahorros con pérdidas en imágenes usa el remuestreo adaptativo de imágenes en PDFiumPas antes del guardado final
Una trampa de portabilidad por escribir aquel test de umbral: el primer borrador comparaba Single(Saving)/Single(OrigLen) con Saving declarado como Int64. Delphi lo acepta, pero FPC 3.2.2 se para con Illegal type conversion: "Int64" to "Single", porque en FPC esa sintaxis es un typecast y no una conversión numérica. El Saving / OrigLen simple es división real en ambos compiladores y compara directamente contra un Single, así que los casts sobraban
¿En qué orden deberían correr las tres APIs?
Corre SaveAsDeduplicated primero, recarga la salida y corre después SaveAsFlateRecompressed. La desduplicación ya incluye la pasada de alcanzabilidad, así que una pasada aparte de SaveAsReachable delante de ella es redundante; reserva SaveAsReachable para trabajos donde solo quieras limpieza de huérfanos y un recuento exacto de podados. Recomprimir en segundo lugar significa que el trabajo caro de inflar y desinflar corre una vez por stream superviviente en lugar de una vez por duplicado. La pasada de recompresión además vuelve a recorrer el grafo desde /Root y /Info, lo que limpia los descriptores de fuente o archivos de fuente a los que solo apuntaba una fuente duplicada fusionada
procedure ShrinkPdf(const SourcePdf, TargetPdf: string);
var
Pdf: TPdf;
TempPdf: string;
begin
TempPdf := ChangeFileExt(TargetPdf, '.dedup.pdf');
Pdf := TPdf.Create(nil);
try
Pdf.FileName := SourcePdf;
Pdf.Active := True;
MergeDuplicateResources(Pdf, TempPdf); // poda + fusión
Pdf.Active := False;
Pdf.FileName := TempPdf;
Pdf.Active := True;
RecompressFlate(Pdf, TargetPdf); // re-desinfla los supervivientes
finally
Pdf.Free;
DeleteFile(TempPdf);
end;
end;
Si además aplicas redacciones, hazlo antes de todo esto, porque eliminar contenido físicamente es lo que crea huérfanos nuevos en primer lugar; el artículo sobre redacción a nivel de operador y poda de recursos cubre el lado de la redacción. Y recarga siempre el archivo final como comprobación de cordura en tu pipeline: un recuento de páginas que cuadre con el origen es un seguro barato antes de sobreescribir cualquier cosa que le importe a un cliente
FAQ
¿SaveAsReachable renumera objetos o cambia referencias?
No. TPdf.SaveAsReachable deja cada objeto superviviente en su número y generación originales y marca los objetos eliminados como entradas xref libres, así que no hay que reescribir ninguna referencia
¿Puede SaveAsDeduplicated fusionar por error dos imágenes distintas?
No solo con diccionarios coincidentes. La huella incluye los bytes del stream en bruto y el subgrafo referenciado completo, y los candidatos solo se comparan dentro de la misma categoría (fuente, imagen, Form XObject)
¿Estas APIs mantendrán válidas mis firmas digitales?
No. Cada API realiza una reescritura completa, que rompe el rango de bytes firmado, así que los archivos firmados se rechazan salvo que pases AllowSignedDocument = True y aceptes firmas inválidas en la salida
Las tres APIs vienen con el PDFium Component para Delphi y C++Builder, junto a las funciones de redacción y remuestreo de imágenes cubiertas en otros artículos de este blog