Artigo Técnico

Auditoria de Tamanho de PDF em Delphi: Discriminação por Categoria

Para descobrir onde vai realmente o tamanho de um ficheiro PDF, a losLab PDF Library disponibiliza AuditDocumentSpace, que classifica cada objeto indireto em doze categorias — imagens, programas de tipo de letra, dicionários de tipo de letra, streams de conteúdo, form XObjects, object streams, ficheiros incorporados, metadados, árvore de estrutura, anotações, árvore de páginas, outros — e reporta a contagem de objetos, os bytes armazenados e a percentagem de cada categoria

A situação para a qual isto existe é familiar. Um relatório de 40 páginas sai do seu gerador com 80 MB, o cliente pergunta porquê, e tudo o que consegue oferecer é um palpite. Provavelmente as imagens. Talvez os tipos de letra. Então ativa a subamostragem, envia, e o ficheiro fica com 74 MB porque o peso real estava algures completamente diferente. O nosso artigo complementar sobre subconjuntos de tipos de letra e subamostragem de imagens cobre como reduzir um PDF; este cobre o passo que deveria vir primeiro, que é medir aquilo que está prestes a reduzir

Por que medir antes de comprimir?

Porque as três passagens de otimização standard têm ganhos radicalmente diferentes em qualquer ficheiro dado, e nada no ficheiro lhe diz qual se aplica até contar. Criar subconjuntos de tipos de letra num documento cujos tipos de letra já são 2% dos seus bytes é uma tarde gasta a mover um erro de arredondamento. Subamostrar imagens num ficheiro cujo volume são streams de conteúdo não comprimidos produz a mesma desilusão. O otimizador não é a parte difícil — todas as bibliotecas têm um. Saber para que otimizador apontar este ficheiro é a parte difícil, e essa é uma questão de contabilidade, não de compressão. Uma auditoria também apanha os casos em que nenhum otimizador é a resposta: um ficheiro que se revela ser 60% anexos incorporados não precisa de melhor compressão, precisa de uma conversa sobre se esses anexos pertencem ao documento, e um ficheiro que é 30% árvore de estrutura está a pagar por marcação de acessibilidade, o que é normalmente um custo deliberado que não deve retirar silenciosamente. Uma vez atribuídos os bytes, está a tomar uma decisão de produto com números por detrás em vez de recorrer ao interruptor mais próximo

O que contém o relatório de doze categorias

AuditDocumentSpace devolve um handle de lista de strings em vez de um registo, para que o relatório sobreviva intacto às fachadas planas de DLL e COM. A lista contém uma linha de resumo Total,Objects,Bytes,100.0 seguida de exatamente doze linhas Category,Objects,Bytes,Percent numa ordem fixa que faz parte do contrato: Images, Font programs, Font dictionaries, Content streams, Form XObjects, Object streams, Embedded files, Metadata, Structure tree, Annotations, Page tree, Other. Treze linhas, sempre, mesmo quando uma categoria está vazia

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;

Um detalhe do Delphi naquele ciclo vai morder-lhe exatamente uma vez. GetStringListItem usa índices de item baseados em 1, coincidindo com GetStringListCount, e um índice fora do intervalo devolve uma string vazia em vez de lançar uma exceção. Escreva o ciclo como for I := 0 to Count - 1 por hábito e obtém uma primeira linha em branco, uma última linha silenciosamente perdida, e nenhuma exceção em lado nenhum a dizer-lhe que a indexação está errada. O próprio relatório vai parecer quase correto, que é o pior modo de falha que uma ferramenta de diagnóstico pode ter

Por que usa a auditoria o comprimento armazenado em vez do tamanho descodificado?

Porque o comprimento armazenado é ao mesmo tempo o número que se quer e o número que é barato de obter. Cada objeto indireto transporta TPDFIndObj.FLength, o comprimento em bytes em bruto que o objeto ocupa no ficheiro conforme analisado. Usá-lo significa que uma imagem DCTDecode de 900 KB é reportada como 900 KB — os bytes que lhe custa em disco — em vez dos 40 MB de amostras RGB para que descodifica. Também significa que a auditoria nunca tem de descodificar nada: os objetos carregados de forma preguiçosa permanecem preguiçosos, os filtros permanecem por executar, e auditar um ficheiro de 500 MB é uma passagem pelos cabeçalhos de objeto em vez de um ciclo completo de descompressão

A segunda regra é uma defesa contra contagem dupla. Quando um objeto vive dentro de um object stream comprimido, indicado por um FObjStrNum diferente de zero, a sua contagem de bytes é registada como zero. O seu armazenamento já foi pago uma vez pelo stream contentor, que a ISO 32000-1 §7.5.7 define como um stream /Type /ObjStm que contém muitos objetos numa única carga comprimida em Flate. Cobrar a cada membro a sua própria parte e depois cobrar de novo o contentor inflacionaria o total para além do tamanho real do ficheiro. Isto tem uma consequência direta na forma como lê o output, coberta abaixo e com mais profundidade no nosso artigo sobre object streams e cross-reference streams

Por que não consegue um programa de tipo de letra classificar-se a si próprio?

Porque um ficheiro de tipo de letra TrueType incorporado num PDF não tem qualquer marca a dizê-lo. A ISO 32000-1 §9.8.1 define o programa de tipo de letra incorporado como o valor de /FontFile, /FontFile2 ou /FontFile3 num descritor de tipo de letra, e o dicionário de stream do outro lado dessa referência transporta as chaves /Length1 e de filtro mas nenhum /Type e nenhum /Subtype que o identifique como um tipo de letra. Visto isoladamente, é um stream binário anónimo. Só o descritor que aponta para ele sabe o que é. A mesma assimetria aparece para as anotações: a §12.5.2 torna /Type /Annot opcional num dicionário de anotação, pelo que o sinal fiável é a pertença a um array /Annots de página, não o próprio dicionário

Assim, a classificação corre duas vezes. A primeira passagem lê o próprio /Type e /Subtype de cada objeto e apanha os ganhos fáceis: /ObjStm, /Subtype /Image, /Subtype /Form, /Type /Font e /Type /FontDescriptor, /Metadata, /EmbeddedFile e /Filespec, /StructTreeRoot e /StructElem, /Annot, /Page e /Pages. Tudo o resto cai provisoriamente em Other. A segunda passagem percorre depois o lado referenciador e sobrepõe: cada dicionário de página reatribui o seu /Contents a streams de conteúdo, as suas entradas /Annots a anotações, e o seu /Thumb a imagens, enquanto cada dicionário de tipo de letra percorre a sua própria cadeia de descritor

// 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]));

Ler o relatório e escolher o próximo passo

Leia primeiro as percentagens, depois as contagens de objetos, e trate qualquer grande diferença entre as duas como um sinal. Um PDF moderno coloca a maioria dos seus pequenos dicionários dentro de object streams, pelo que Page tree e Structure tree mostram rotineiramente dezenas de objetos contra bytes quase nulos — o seu custo real foi absorvido pela linha Object streams. Se Object streams for por si só grande, o ficheiro está denso de estrutura tipo metadados em vez de conteúdo, e a alavanca é a poda de objetos, não a compressão. Os streams de aparência de anotação comportam-se de forma semelhante: transportam /Subtype /Form, pelo que um documento fortemente carimbado mostra o seu peso em Form XObjects enquanto a linha Annotations permanece pequena

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;

Dois factos de formatação importam se estiver a analisar as percentagens em vez de as apresentar. O separador decimal é sempre um ponto literal independentemente da localidade da máquina, pelo que analisar com as FormatSettings ambientes numa estação de trabalho alemã ou francesa vai falhar ou, pior, ler mal. E os zeros à direita são cortados, pelo que uma categoria que detém exatamente 40% dos bytes é impressa como 40, não 40.0 — nunca assuma uma casa decimal fixa. Com a percentagem em mãos, o encaminhamento é mecânico: uma percentagem de Images dominante aponta para DownsampleImages, uma percentagem de Font programs dominante para SubsetEmbeddedFonts, e Content streams volumosos para CompressContent

O que a auditoria deliberadamente não lhe diz

O total é uma soma sobre objetos indiretos, e um ficheiro PDF é ligeiramente mais do que os seus objetos. O cabeçalho do ficheiro, o trailer, os espaços em branco entre objetos e uma tabela de referências cruzadas clássica não são objetos indiretos, pelo que esses bytes não são atribuídos a nada e o total da auditoria fica ligeiramente abaixo do tamanho em disco. Um cross-reference stream é diferente — é um objeto real com /Type /XRef, pelo que num ficheiro moderno esses bytes aparecem, na categoria Other. Nenhum dos dois comportamentos é um defeito, mas se estiver a reconciliar a auditoria com uma contagem de bytes do sistema de ficheiros, é daí que vem a diferença

Mais duas fronteiras valem a pena ser ditas com clareza. Primeiro, os números descrevem um ficheiro que foi carregado, não um que está a ser autorado: para objetos construídos em memória que ainda não têm comprimento armazenado, o tamanho recorre ao output serializado com uma tolerância nominal para o dicionário de stream, o que é uma estimativa da eventual gravação em vez de uma medição. Faça a auditoria depois de gravar e recarregar se quiser números exatos. Segundo, uma linha Other volumosa é um achado, não um relatório de erro — normalmente significa objetos órfãos que já ninguém referencia, o que é um trabalho para recolha de lixo mark-and-sweep em vez de qualquer passagem de compressão

Usada desta forma, a auditoria muda a forma da conversa. Em vez de adivinhar sobre o relatório de 80 MB, abre-o, executa uma chamada, e lê que as imagens são 8%, os programas de tipo de letra são 61%, e o documento incorpora nove programas de tipo de letra completos para um estilo corporativo que usa três famílias. Essa é uma resposta corrigível com um número associado. AuditDocumentSpace, juntamente com as passagens de otimização para que aponta, é disponibilizado na losLab PDF Library para Delphi e C++Builder, onde as páginas de referência documentam a lista completa de categorias e a API de lista de strings à sua volta