Artículo técnico

Auditoría de tamaño de PDF en Delphi: desglose de bytes por categoría

Para averiguar a dónde va realmente el tamaño de archivo de un PDF, losLab PDF Library expone AuditDocumentSpace, que clasifica cada objeto indirecto en doce categorías — imágenes, programas de fuente, diccionarios de fuente, flujos de contenido, XObjects de formulario, flujos de objetos, archivos incrustados, metadatos, árbol de estructura, anotaciones, árbol de páginas, otros — y reporta el conteo de objetos, los bytes almacenados y el porcentaje de participación de cada una

La situación para la que existe es familiar. Un informe de 40 páginas sale de tu generador con 80 MB, el cliente pregunta por qué, y todo lo que puedes ofrecer es una suposición. Probablemente las imágenes. Tal vez las fuentes. Así que activas el submuestreo, lo envías, y el archivo termina en 74 MB porque el peso real estaba en otro lugar por completo. Nuestro artículo complementario sobre subconjuntos de fuentes y submuestreo de imágenes cubre cómo reducir un PDF; este cubre el paso que debería venir primero, que es medir lo que estás a punto de reducir

¿Por qué medir antes de comprimir?

Porque los tres pases de optimización estándar tienen retornos muy distintos en cualquier archivo dado, y nada en el archivo te dice cuál aplica hasta que cuentas. Hacer subconjuntos de fuentes en un documento cuyas fuentes ya son el 2% de sus bytes es una tarde gastada moviendo un error de redondeo. Submuestrear imágenes en un archivo cuyo grueso son flujos de contenido sin comprimir produce la misma decepción. El optimizador no es la parte difícil — cada librería tiene uno. Saber a cuál optimizador apuntar en este archivo es la parte difícil, y esa es una pregunta de contabilidad, no de compresión. Una auditoría también detecta los casos donde ningún optimizador es la respuesta: un archivo que resulta ser 60% archivos adjuntos incrustados no necesita mejor compresión, necesita una conversación sobre si esos adjuntos pertenecen al documento, y un archivo que es 30% árbol de estructura está pagando por el etiquetado de accesibilidad, que suele ser un costo deliberado que no deberías eliminar silenciosamente. Una vez que los bytes están atribuidos estás tomando una decisión de producto con números detrás en vez de recurrir al interruptor más cercano

Qué contiene el reporte de doce categorías

AuditDocumentSpace devuelve un handle de lista de cadenas en vez de un registro, así que el reporte sobrevive intacto a través de las fachadas DLL plana y COM. La lista contiene una línea de resumen Total,Objects,Bytes,100.0 seguida de exactamente doce líneas Category,Objects,Bytes,Percent en un orden fijo que es parte del contrato: Images, Font programs, Font dictionaries, Content streams, Form XObjects, Object streams, Embedded files, Metadata, Structure tree, Annotations, Page tree, Other. Trece líneas, siempre, incluso cuando una categoría está vacía

var
  Lib: TPDFlib;
  ListID, I: Integer;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('report.pdf', '') <> 1 then
      Exit;
    ListID := Lib.AuditDocumentSpace;   // 0 when no document is selected
    if ListID = 0 then
      Exit;
    try
      // GetStringListItem is 1-based: items run 1..GetStringListCount
      for I := 1 to Lib.GetStringListCount(ListID) do
        Memo1.Lines.Add(Lib.GetStringListItem(ListID, I));
    finally
      Lib.ReleaseStringList(ListID);
    end;
  finally
    Lib.Free;
  end;
end;

Un detalle de Delphi en ese bucle te va a morder exactamente una vez. GetStringListItem usa índices de elemento basados en uno, coincidiendo con GetStringListCount, y un índice fuera de rango devuelve una cadena vacía en vez de lanzar una excepción. Escribe el bucle for I := 0 to Count - 1 por costumbre y obtienes una primera línea en blanco, una última línea eliminada silenciosamente, y ninguna excepción en ningún lado que te avise que la indexación está mal. El reporte en sí se verá casi correcto, que es el peor modo de fallo que puede tener una herramienta de diagnóstico

¿Por qué la auditoría usa la longitud almacenada en vez del tamaño decodificado?

Porque la longitud almacenada es tanto el número que quieres como el número que es barato de obtener. Cada objeto indirecto lleva TPDFIndObj.FLength, la longitud de bytes cruda que ocupa el objeto en el archivo tal como fue analizado. Usarla significa que una imagen DCTDecode de 900 KB se reporta como 900 KB — los bytes que te cuesta en disco — en vez de los 40 MB de muestras RGB a los que se decodifica. También significa que la auditoría nunca tiene que decodificar nada: los objetos cargados perezosamente permanecen perezosos, los filtros permanecen sin ejecutar, y auditar un archivo de 500 MB es un pase sobre encabezados de objeto en vez de un ciclo completo de descompresión

La segunda regla es una defensa contra el doble conteo. Cuando un objeto vive dentro de un flujo de objetos comprimido, indicado por un FObjStrNum distinto de cero, su conteo de bytes se registra como cero. Su almacenamiento ya se pagó una vez por el flujo contenedor, que ISO 32000-1 §7.5.7 define como un flujo /Type /ObjStm que contiene muchos objetos en una sola carga útil comprimida con Flate. Cobrar a cada miembro su propia parte y luego cobrar al contenedor de nuevo inflaría el total más allá del tamaño real del archivo. Esto tiene una consecuencia directa en cómo lees la salida, cubierta más abajo y con mayor profundidad en nuestro artículo sobre flujos de objetos y flujos de referencia cruzada

¿Por qué un programa de fuente no puede clasificarse a sí mismo?

Porque un archivo de fuente TrueType incrustado en un PDF no tiene ningún marcador que lo indique. ISO 32000-1 §9.8.1 define el programa de fuente incrustado como el valor de /FontFile, /FontFile2 o /FontFile3 en un descriptor de fuente, y el diccionario de flujo al otro extremo de esa referencia lleva /Length1 y claves de filtro pero ningún /Type ni /Subtype que lo identifique como una fuente. Visto de forma aislada es un flujo binario anónimo. Solo el descriptor que apunta a él sabe qué es. La misma asimetría aparece con las anotaciones: §12.5.2 hace que /Type /Annot sea opcional en un diccionario de anotación, así que la señal confiable es la membresía en un arreglo /Annots de página, no el diccionario en sí

Así que la clasificación se ejecuta dos veces. El primer pase lee el propio /Type y /Subtype de cada objeto y toma las victorias fáciles: /ObjStm, /Subtype /Image, /Subtype /Form, /Type /Font y /Type /FontDescriptor, /Metadata, /EmbeddedFile y /Filespec, /StructTreeRoot y /StructElem, /Annot, /Page y /Pages. Todo lo demás cae provisionalmente en Other. El segundo pase entonces recorre el lado de referencia y sobrescribe: cada diccionario de página reasigna su /Contents a flujos de contenido, sus entradas /Annots a anotaciones, y su /Thumb a imágenes, mientras cada diccionario de fuente recorre su propia cadena de descriptor

// Shape of the second pass: the referrer names the object
Descriptor := DictOf(FontDict.FindValueByKeyName('FontDescriptor'));
if Assigned(Descriptor) then
begin
  MarkRef(FontDict.FindValueByKeyName('FontDescriptor'), catFontDicts);
  MarkRef(Descriptor.FindValueByKeyName('FontFile'),  catFontPrograms);
  MarkRef(Descriptor.FindValueByKeyName('FontFile2'), catFontPrograms);
  MarkRef(Descriptor.FindValueByKeyName('FontFile3'), catFontPrograms);
end;
// Type0 fonts keep the descriptor one level down
Descendants := FontDict.FindValueByKeyName('DescendantFonts', True);
if (Descendants is TPDFArray) and (TPDFArray(Descendants).Count > 0) then
  MarkFontProgramRefs(DictOf(TPDFArray(Descendants).Item[0]));

Leer el reporte y elegir el siguiente movimiento

Lee primero las participaciones, los conteos de objetos en segundo lugar, y trata cualquier brecha grande entre ellos como una señal. Un PDF moderno pone la mayoría de sus diccionarios pequeños dentro de flujos de objetos, así que Page tree y Structure tree rutinariamente muestran docenas de objetos contra casi cero bytes — su costo real se plegó en la línea de Object streams. Si Object streams es en sí grande, el archivo está denso con estructura tipo metadatos en vez de contenido, y la palanca es podar objetos, no comprimirlos. Los flujos de apariencia de anotación se comportan de manera similar: llevan /Subtype /Form, así que un documento muy sellado muestra su peso bajo Form XObjects mientras la línea Annotations se mantiene pequeña

function CategoryShare(Lib: TPDFlib; ListID: Integer;
  const Category: string): Double;
var
  I: Integer;
  Parts: TArray<string>;
  Inv: TFormatSettings;
begin
  Result := 0;
  Inv := FormatSettings;
  Inv.DecimalSeparator := '.';   // the report is locale-independent
  for I := 2 to Lib.GetStringListCount(ListID) do   // line 1 is Total
  begin
    Parts := string(Lib.GetStringListItem(ListID, I)).Split([',']);
    if (Length(Parts) = 4) and SameText(Parts[0], Category) then
      Exit(StrToFloatDef(Parts[3], 0, Inv));
  end;
end;

Dos hechos de formato importan si analizas los porcentajes en vez de solo mostrarlos. El separador decimal es siempre un punto literal sin importar la configuración regional de la máquina, así que analizarlo con el FormatSettings ambiental en una estación de trabajo alemana o francesa fallará o, peor, se leerá mal. Y los ceros finales se recortan, así que una categoría que contiene exactamente el 40% de los bytes se imprime como 40, no 40.0 — nunca asumas un número fijo de decimales. Con la participación en mano, el enrutamiento es mecánico: una participación dominante de Images apunta a DownsampleImages, una participación dominante de Font programs a SubsetEmbeddedFonts, y Content streams abultado a CompressContent

Lo que la auditoría deliberadamente no te dice

El total es una suma sobre objetos indirectos, y un archivo PDF es un poco más que sus objetos. El encabezado del archivo, el trailer, el espacio en blanco entre objetos y una tabla de referencia cruzada clásica no son objetos indirectos, así que esos bytes no se atribuyen a nada y el total de la auditoría queda un poco por debajo del tamaño en disco. Un flujo de referencia cruzada es distinto — es un objeto real con /Type /XRef, así que en un archivo moderno esos bytes sí aparecen, en la categoría Other. Ninguno de los dos comportamientos es un defecto, pero si estás reconciliando la auditoría contra un conteo de bytes del sistema de archivos, ahí es de donde viene la brecha

Dos límites más vale la pena mencionar con claridad. Primero, los números describen un archivo que fue cargado, no uno que se está redactando: para objetos construidos en memoria que aún no tienen una longitud almacenada, el tamaño recurre a la salida serializada con una asignación nominal para el diccionario de flujo, que es una estimación de la eventual escritura en vez de una medición. Audita después de guardar y recargar si quieres cifras exactas. Segundo, una línea Other abultada es un hallazgo, no un reporte de error — usualmente significa objetos huérfanos que ya nada referencia, que es trabajo para recolección de basura mark-and-sweep en vez de para cualquier pase de compresión

Usada de esta manera, la auditoría cambia la forma de la conversación. En vez de adivinar sobre el informe de 80 MB, lo abres, ejecutas una llamada, y lees que las imágenes son 8%, los programas de fuente son 61%, y el documento incrusta nueve programas de fuente completos para un estilo corporativo que usa tres tipos de letra. Esa es una respuesta corregible con un número adjunto. AuditDocumentSpace, junto con los pases de optimización hacia los que te apunta, viene incluido en losLab PDF Library para Delphi y C++Builder, donde las páginas de referencia documentan la lista completa de categorías y la API de lista de cadenas alrededor de ella