Artigo Técnico

Auditoria de Tamanho de PDF em Delphi: Bytes por Categoria

Para descobrir para onde realmente vai o tamanho de um arquivo PDF, a losLab PDF Library expõe AuditDocumentSpace, que classifica cada objeto indireto em doze categorias — imagens, programas de fonte, dicionários de fonte, content streams, form XObjects, object streams, arquivos incorporados, metadados, árvore de estrutura, anotações, árvore de páginas, outros — e reporta a contagem de objetos, os bytes armazenados e a participação percentual de cada uma

A situação para a qual isso existe é familiar. Um relatório de 40 páginas sai do seu gerador com 80 MB, o cliente pergunta por quê, e tudo que você pode oferecer é um palpite. Provavelmente as imagens. Talvez as fontes. Então você liga o downsampling, entrega, e o arquivo cai para 74 MB porque o peso real estava em outro lugar completamente diferente. Nosso artigo complementar sobre subsetting de fontes e downsampling de imagens cobre como reduzir um PDF; este cobre o passo que deveria vir primeiro, que é medir o que você está prestes a reduzir

Por que medir antes de comprimir?

Porque os três passos de otimização padrão têm retornos radicalmente diferentes em qualquer arquivo dado, e nada no arquivo diz qual deles se aplica até você contar. Fazer subsetting de fontes em um documento cujas fontes já são 2% de seus bytes é uma tarde gasta movendo um erro de arredondamento. Fazer downsampling de imagens em um arquivo cujo volume é content streams não comprimidos produz a mesma decepção. O otimizador não é a parte difícil — toda biblioteca tem um. Saber qual otimizador apontar para este arquivo é a parte difícil, e essa é uma questão de contabilidade, não uma questão de compressão. Uma auditoria também captura os casos em que nenhum otimizador é a resposta: um arquivo que acaba sendo 60% anexos incorporados não precisa de melhor compressão, precisa de uma conversa sobre se esses anexos pertencem ao documento, e um arquivo que é 30% árvore de estrutura está pagando pela marcação de acessibilidade, o que geralmente é um custo deliberado que você não deveria remover silenciosamente. Uma vez que os bytes são atribuídos, você está tomando uma decisão de produto com números por trás dela em vez de recorrer ao interruptor mais próximo

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

AuditDocumentSpace retorna um handle de lista de strings em vez de um record, então o relatório sobrevive 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 em uma 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 de Delphi nesse laço vai morder você exatamente uma vez. GetStringListItem usa índices de item baseados em 1, correspondendo a GetStringListCount, e um índice fora do intervalo retorna uma string vazia em vez de disparar exceção. Escreva o laço como for I := 0 to Count - 1 por hábito e você obtém uma primeira linha em branco, uma última linha silenciosamente descartada, e nenhuma exceção em lugar nenhum para dizer que a indexação está errada. O relatório em si vai parecer quase certo, que é o pior modo de falha que uma ferramenta de diagnóstico pode ter

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

Porque o comprimento armazenado é ao mesmo tempo o número que você quer e o número que é barato de obter. Cada objeto indireto carrega TPDFIndObj.FLength, o comprimento bruto em bytes que o objeto ocupa no arquivo tal como foi analisado. Usá-lo significa que uma imagem DCTDecode de 900 KB é reportada como 900 KB — os bytes que ela custa a você em disco — em vez dos 40 MB de amostras RGB para os quais ela decodifica. Também significa que a auditoria nunca precisa decodificar nada: objetos carregados de forma preguiçosa permanecem preguiçosos, filtros permanecem não executados, e auditar um arquivo de 500 MB é uma passagem sobre 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, sua contagem de bytes é registrada como zero. Seu armazenamento já foi pago uma vez pelo stream contêiner, que a ISO 32000-1 §7.5.7 define como um stream /Type /ObjStm contendo muitos objetos em um único payload comprimido com Flate. Cobrar de cada membro sua própria parcela e depois cobrar o contêiner novamente inflaria o total além do tamanho real do arquivo. Isso tem uma consequência direta em como você lê a saída, coberta abaixo e com mais profundidade em nosso artigo sobre object streams e cross-reference streams

Por que um programa de fonte não consegue se classificar sozinho?

Porque um arquivo de fonte TrueType incorporado em um PDF não tem nenhum marcador dizendo isso. A ISO 32000-1 §9.8.1 define o programa de fonte incorporado como o valor de /FontFile, /FontFile2 ou /FontFile3 em um descritor de fonte, e o dicionário de stream na outra ponta dessa referência carrega /Length1 e chaves de filtro mas nenhum /Type e nenhum /Subtype que o identifique como fonte. Visto isoladamente, ele é um stream binário anônimo. Apenas o descritor que aponta para ele sabe o que ele é. A mesma assimetria aparece para anotações: a §12.5.2 torna /Type /Annot opcional em um dicionário de anotação, então o sinal confiável é a associação a um array /Annots de página, não o dicionário em si

Então a classificação roda duas vezes. A primeira passagem lê o /Type e /Subtype de cada objeto e pega 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 mais provisoriamente cai em Other. A segunda passagem então percorre o lado referenciador e sobrescreve: cada dicionário de página reatribui seu /Contents a content streams, suas entradas /Annots a anotações, e seu /Thumb a imagens, enquanto cada dicionário de fonte percorre 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]));

Lendo o relatório e escolhendo o próximo passo

Leia as participações primeiro, as contagens de objeto depois, e trate qualquer grande diferença entre elas como um sinal. Um PDF moderno coloca a maior parte de seus pequenos dicionários dentro de object streams, então Page tree e Structure tree rotineiramente mostram dezenas de objetos contra quase zero bytes — seu custo real foi absorvido pela linha de Object streams. Se Object streams em si é grande, o arquivo é denso com estrutura tipo metadado em vez de conteúdo, e a alavanca é podar objetos, não comprimi-los. Os streams de aparência de anotação se comportam de forma parecida: eles carregam /Subtype /Form, então um documento fortemente carimbado mostra seu peso em Form XObjects enquanto a linha de 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 fatos de formatação importam se você estiver fazendo parsing das porcentagens em vez de apenas exibi-las. O separador decimal é sempre um ponto literal independente da localidade da máquina, então fazer parsing com o FormatSettings ambiente em uma estação de trabalho alemã ou francesa vai falhar ou, pior, ler errado. E zeros à direita são removidos, então uma categoria contendo exatamente 40% dos bytes imprime como 40, não 40.0 — nunca assuma uma casa decimal fixa. Com a participação em mãos, o roteamento é mecânico: uma participação dominante de Images aponta para DownsampleImages, uma participação dominante de Font programs para SubsetEmbeddedFonts, e Content streams volumosos para CompressContent

O que a auditoria deliberadamente não diz a você

O total é uma soma sobre objetos indiretos, e um arquivo PDF é um pouco mais que seus objetos. O cabeçalho do arquivo, o trailer, o espaço em branco entre objetos e uma tabela de referência cruzada clássica não são objetos indiretos, então esses bytes não são atribuídos a nada e o total da auditoria fica um pouco abaixo do tamanho em disco. Um cross-reference stream é diferente — é um objeto de verdade com /Type /XRef, então em um arquivo moderno esses bytes aparecem, na categoria Other. Nenhum dos dois comportamentos é um defeito, mas se você está reconciliando a auditoria com uma contagem de bytes do sistema de arquivos, é daí que vem a diferença

Mais dois limites merecem ser ditos claramente. Primeiro, os números descrevem um arquivo que foi carregado, não um que está sendo autorado: para objetos construídos em memória que ainda não têm comprimento armazenado, o tamanho recorre à saída serializada com uma folga nominal para o dicionário de stream, que é uma estimativa da eventual gravação em vez de uma medição. Audite após salvar e recarregar se quiser números exatos. Segundo, uma linha Other gorda é um achado, não um relatório de bug — geralmente significa objetos órfãos que nada mais referencia, o que é trabalho para coleta de lixo mark-and-sweep em vez de qualquer passo de compressão

Usada dessa forma, a auditoria muda a forma da conversa. Em vez de adivinhar sobre o relatório de 80 MB, você o abre, executa uma chamada, e lê que imagens são 8%, programas de fonte são 61%, e o documento incorpora nove programas de fonte completos para um estilo de casa que usa três famílias. Essa é uma resposta corrigível com um número anexado a ela. AuditDocumentSpace, junto com os passos de otimização para os quais ela aponta, é fornecida 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 ao redor dela