El PDFium Component for Delphi (PDFiumPas) reduce archivos PDF con tres APIs de reescritura sobre TPdf: SaveAsReachable tira los objetos que nada referencia, SaveAsDeduplicated fusiona fuentes, imágenes y Form XObjects byte a byte idénticos, y SaveAsFlateRecompressed re-codifica los streams Flate existentes a compresión máxima. Cada una devuelve un record de reporte para que usted vea qué fue removido y cuántos bytes ahorró
El detonante usual es un ticket de soporte que dice "borramos veinte páginas y el archivo creció". Alguien abrió un contrato, quitó un apéndice, 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 se siente en su tabla de objetos en memoria; no tiene ninguna llamada pública para listar objetos indirectos, borrar uno o reescribir referencias. Todo en este artículo corre por lo tanto en la capa de envoltorio PDFiumPas encima de los bytes que PDFium produce
¿Por qué FPDF_SaveAsCopy conserva las páginas borradas en el archivo?
FPDF_SaveAsCopy conserva los objetos huérfanos porque PDFium carga cada entrada viva de la tabla de cross-reference dentro de su documento, haya o no algo que todavía apunte a ella. ISO 32000-1 §7.5.6 describe las actualizaciones incrementales como agregar nuevas versiones de objeto y una nueva sección xref al final del archivo. Quitar una página solo edita el árbol de páginas; el diccionario de página, su content stream, y las fuentes o imágenes usadas solo por 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 después de fusiones de páginas, de flattening de formularios hecho por otras herramientas y de 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 remueve 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 en el camino, así que la salida siempre usa una tabla xref plana
Elegir las raíces es donde una implementación ingenua se equivoca. La semilla obvia es el Catalog nombrado por el /Root del trailer. Desde ahí el recorrido alcanza el árbol de páginas, los recursos de página, las fuentes, las imágenes, los 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 es referenciado por la entrada /Info del trailer (ISO 32000-1 §7.5.5), no por el Catalog. Semille solo desde /Root y borrará silenciosamente el Title, Author y Producer de cada archivo que procese. PDFiumPas semilla el recorrido con /Root y /Info a la vez, y el trailer reconstruido solo arrastra /Info hacia adelante cuando ese objeto realmente sobrevivió
El walker es un recorrido depth-first con una pila explícita y un conjunto de visitados, así que los ciclos de referencias (los enlaces de padre en el árbol de páginas, los back-pointers /P de anotaciones) no pueden colgarlo. Una referencia a un número de objeto que ya está libre simplemente no se sigue en lugar de tratarse como error, lo que mantiene la pasada tolerante ante entradas ligeramente dañadas
Los números de objeto se preservan, no se renumeran
TPdf.SaveAsReachable mantiene cada objeto sobreviviente 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 llenan con entradas libres, y el objeto 0 sigue siendo la cabeza de la lista libre. Como nada se renumera, cada referencia que sobrevive sigue siendo correcta sin reescribir un solo byte, y no hay mapeo de viejo-a-nuevo que equivocar. El costo son unas cuantas líneas xref sin usar, 20 bytes cada una, un intercambio que vale 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 carga 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 objetivo y solo reemplaza al objetivo cuando la reescritura tuvo éxito, así que una corrida fallida nunca deja un PDF truncado atrás
¿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 una sola revisión no puede conservar los números de objeto, rangos de bytes y llaves 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 usted pase AllowSignedDocument = True, porque una reescritura completa siempre invalida el rango de bytes firmado. Fijar esa bandera significa que usted acepta un archivo cuyas firmas reportarán como rotas; es la llamada correcta para una copia de archivo, nunca para un contrato que está por 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 crudos de stream coinciden todos. Corre primero el mismo recorrido de alcanzabilidad, luego calcula el fingerprint 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 apuntar a ese sobreviviente. Los duplicados fusionados se vuelven entradas xref libres igual que los huérfanos. Este es el arreglo para el reporte clásico de "logo incrustado 400 veces" producido por herramientas de mail-merge que re-incrustan los mismos recursos en cada página
El fingerprint es un hash FNV-1a de 64 bits calculado recursivamente. Para cada candidato el walker toma el cuerpo del objeto, reemplaza cada referencia N G R por el fingerprint del objeto al que apunta, y hace hash del resultado. Eso hace al hash independiente de la numeración de objetos: dos copias de la misma fuente cuyos descriptores casualmente son los objetos 12 y 87 igual producen el mismo valor. Los resultados se memoizan así los subobjetos compartidos se hashean una vez, y un conjunto de visitados devuelve un sentinel fijo para las back edges, de modo que los grafos cíclicos terminan
El bug sutil se sienta en el payload del stream. Para encontrar referencias de forma segura, PDFiumPas escanea una copia de cada objeto en la que los datos de stream, las cadenas y los comentarios fueron borrados con espacios; de lo contrario, una corrida de datos binarios de píxeles que casualmente se vea como 12 0 R sería tratada como una referencia. Hashee solo esa copia sanitizada y dos imágenes con diccionarios idénticos (mismos /Width, /Height, /BitsPerComponent, /Filter, /Length) pero píxeles distintos colapsan a un solo fingerprint, y la segunda foto de la página es silenciosamente reemplazada por la primera. SaveAsDeduplicated agrega los bytes crudos entre stream y endstream a los datos hasheados, así que los píxeles son parte de la identidad. Los candidatos además se agrupan por categoría antes de comparar, así que una colisión de hash rara entre una fuente y una imagen nunca puede fusionarlas
La reescritura de referencias reusa el mismo truco del escaneo sanitizado al revés: las referencias se localizan en la copia borrada pero los bytes se copian del cuerpo original, así que solo se modifica estructura de diccionarios y arreglos 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;
Lea TPdfDedupReport con cuidado. Junto a los tres campos Merged*Count carga KeptObjectCount y PrunedObjectCount, y en este reporte PrunedObjectCount cuenta solo duplicados fusionados. Los objetos inalcanzables igual se tiran, pero no se incluyen en ese número. Si necesita el conteo de huérfanos para el log, tómelo de una corrida separada de SaveAsReachable. El otro límite es que el matching es exacto byte a byte sobre el stream codificado: el mismo logo comprimido por dos productores distintos a dos niveles de Flate distintos no se fusionará
Recomprimir streams Flate al nivel máximo
TPdf.SaveAsFlateRecompressed infla cada stream FlateDecode alcanzable y lo desinfla de nuevo al nivel de compresión más alto, conservando el resultado solo cuando es más pequeño y pasa sus umbrales. Muchos productores escriben Flate a un nivel default rápido, así que los content streams y los programas de fuentes incrustados a menudo encogen más. Los números y generaciones de objeto se quedan en su sitio; solo el payload y su /Length directo cambian
TPdfFlateRecompressOptions tiene dos campos y ningún constructor default, así que fije ambos. MinSavingBytes es el ahorro absoluto mínimo por stream, y MinSavingRatio es una fracción de la longitud original (0.05 significa 5%); una razón de 0 desactiva la prueba de razón. 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; // ignore ganancias diminutas
Options.MinSavingRatio := 0.05; // y cualquier cosa 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 a propósito. Un stream cuyo /Length es una referencia indirecta se omite, porque la reescritura debe almacenar un entero directo. Un payload que falla al inflar cuenta hacia FailedCount y se copia sin cambios. Un stream Flate que no cumple los umbrales cuenta hacia SkippedCount. Las imágenes DCT, JPX, JBIG2 y CCITT nunca entran a la lista de candidatos, así que esta pasada nunca toca la calidad de un JPEG. Para ahorros de imagen con pérdida use resampling adaptativo de imágenes en PDFiumPas antes del guardado final
Una trampa de portabilidad de escribir esa prueba de umbral: el primer borrador comparaba Single(Saving)/Single(OrigLen) con Saving declarado como Int64. Delphi lo acepta, pero FPC 3.2.2 se detiene 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 plano es división real en ambos compiladores y se compara directamente contra un Single, así que los casts simplemente sobraban
¿En qué orden deben correr las tres APIs?
Corra SaveAsDeduplicated primero, recargue la salida, y luego corra SaveAsFlateRecompressed. La deduplicación ya incluye el barrido de alcanzabilidad, así que una pasada separada de SaveAsReachable delante de ella es redundante; reserve SaveAsReachable para trabajos donde solo quiere limpieza de huérfanos y un conteo de poda exacto. Recomprimir en segundo lugar significa que el trabajo caro de inflar y desinflar corre una vez por stream sobreviviente en lugar de una vez por duplicado. La pasada de recompresión además recorre el grafo desde /Root y /Info de nuevo, 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-deflate a los sobrevivientes
finally
Pdf.Free;
DeleteFile(TempPdf);
end;
end;
Si además aplica redacciones, haga eso antes de todo esto, ya que remover 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 siempre recargue el archivo final como chequeo de sanidad en su pipeline: un conteo de páginas que coincide con la fuente es un seguro barato antes de sobreescribir algo que a un cliente le importa
Preguntas frecuentes
¿SaveAsReachable renumera objetos o cambia referencias?
No. TPdf.SaveAsReachable mantiene cada objeto sobreviviente en su número y generación originales y marca los objetos removidos como entradas xref libres, así que ninguna referencia tiene que reescribirse
¿Puede SaveAsDeduplicated fusionar dos imágenes distintas por error?
No solo con diccionarios coincidentes. El fingerprint incluye los bytes crudos del stream y el subgrafo referenciado completo, y los candidatos se comparan solo dentro de la misma categoría (fuente, imagen, Form XObject)
¿Estas APIs mantendrán válidas mis firmas digitales?
No. Cada API ejecuta una reescritura completa, que rompe el rango de bytes firmado, así que los archivos firmados se rechazan salvo que usted pase AllowSignedDocument = True y acepte firmas inválidas en la salida
Las tres APIs vienen con el PDFium Component para Delphi y C++Builder, junto con las funciones de redacción y resampling de imágenes cubiertas en otros artículos de este blog