Artigo Técnico

CompressDocument do HotPDF: subconjuntos de fontes compactos

O THotPDF.CompressDocument do HotPDF é um único interruptor que faz o BeginDoc produzir o PDF lossless mais pequeno que o componente consegue escrever: FlateDecode ao nível máximo, um cross-reference stream com object streams, font subsetting e subconjuntos de fontes compactos que renumeram os glifos mantidos atrás de um /CIDToGIDMap explícito. O EndDoc depois repõe as suas próprias definições. Um documento de teste de três páginas com Arial e SimSun caiu de 10.2 MB para 20 KB com renderização idêntica

O que é que o CompressDocument liga afinal?

O CompressDocument sobrepõe seis definições do escritor, mais o limite de object streams, durante um documento e repõe todas depois. No BeginDoc, antes de a versão PDF assentar, o HotPDF guarda os seus valores e põe o Compression em cmFlateDecode, o CompressionLevel em clMaximum, liga o EnableFontSubsetting e o CompactFontSubsetting, e ativa o UseXRefStream mais o UseObjectStreams (ISO 32000-1 §7.5.7 e §7.5.8). Os object streams precisam de PDF 1.5, por isso um Version mais antigo é elevado a 1.5 quando não está trancado. O PDF/A-1 proíbe ambas as estruturas, por isso um documento PDF/A-1 mantém a sua tabela de cross-reference clássica e recebe apenas o trabalho de Flate e de fontes. As imagens ficam exatamente como as incorporou

Diagrama do ciclo de vida do CompressDocument do HotPDF no Delphi: o BeginDoc guarda os valores próprios do escritor, sobrepõe seis definições incluindo Compression e UseObjectStreams durante um documento, e o EndDoc repõe cada valor emprestado no seu finally mais exterior enquanto a propriedade CompressDocument em si continua True
Seis definições do escritor e o limite de object streams são emprestados por exatamente um documento e devolvidos quando o EndDoc corre, por isso um relatório que falhe nunca deixa o componente preso na compressão máxima
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'invoice-2026-1042.pdf';
    Pdf.CompressDocument := True;    // aplicado pelo BeginDoc, desfeito pelo EndDoc
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.TextOut(40, 40, 0, 'Invoice 2026-1042');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

A reposição acontece no finally mais exterior do EndDoc, por isso uma exceção a meio de um relatório não deixa um componente de vida longa preso na compressão máxima para o trabalho seguinte. A propriedade CompressDocument em si fica True; só as seis definições que ela pediu emprestadas voltam. A versão é tratada com mais cuidado. O HotPDF desfaz a sua própria elevação a 1.5 só se o documento ainda acabar em 1.5, por isso quando outra funcionalidade empurrou o ficheiro para 1.6 durante a corrida (uma fonte OpenType incorporada, digamos), a versão mais alta fica, exatamente como estaria sem compressão

Porque é que os subconjuntos de fontes continuam grandes sem compactação?

Um subconjunto TrueType clássico deixa cair os contornos que nunca desenha mas mantém cada glyph ID onde estava, e é essa numeração que o mantém pesado. O content stream mostra CIDs iguais aos GIDs originais, por isso o subconjunto tem de manter um offset loca e uma entrada hmtx para cada slot até ao glifo mais alto que retém, vazio ou não. Numa fonte latina esse peso é ruído. Numa fonte CJK como a SimSun, cujos ideogramas se sentam no fundo de uma tabela de glifos muito grande, dois caracteres chineses arrastam consigo tabelas dimensionadas para a fonte inteira. As regras de fecho de subconjunto de fontes para glifos moldados decidem que glifos sobrevivem; a compactação trata de quanto custam os sobreviventes

O CompactFontSubsetting renumera os glifos mantidos para um intervalo denso a começar em zero e escreve um stream /CIDToGIDMap no CIDFont, que a ISO 32000-1 §9.7.4.2 define como uma tabela de GIDs de dois bytes indexada por CID. Essa tabela é todo o truque. Os content streams, a matriz de larguras /W e o CMap ToUnicode mantêm todos os CIDs originais, por isso nada do que já foi escrito tem de mudar; só a procura de CID para glifo se move para o mapa. No teste que motivou a funcionalidade, a SimSun com dois caracteres passou de 24.8 KB de dados de fonte para 3.1 KB

Comparação entre um subconjunto de fontes do HotPDF esparso, que retém entradas loca e hmtx para cada glyph ID original até ao GID mais alto retido, e o output do CompactFontSubsetting, que renumera os glifos mantidos densamente a partir de zero e mapeia os CIDs através de um stream CIDToGIDMap enquanto os content streams, o /W e o ToUnicode ficam inalterados
A renumeração move o custo para fora do programa da fonte e para um pequeno stream de mapa — dois caracteres SimSun caíram de 24.8 KB para 3.1 KB sem tocar num byte do conteúdo já escrito

A compactação tem limites firmes, e degrada em silêncio em vez de falhar. O HotPDF constrói subconjuntos compactos só para faces Type 0 TrueType, tanto as definidas através do SetFont com subsetting ligado como a face registada através do RegisterUnicodeTTF. Uma fonte TrueType simples encontra os seus glifos através do cmap dentro do programa da fonte, o que a renumeração partiria, por isso mantém o subconjunto esparso. As faces OpenType-CFF também não têm caminho compacto. Uma construção compacta que falhe recua para o subconjunto esparso em vez de levantar. A propriedade vem desligada por predefinição, por isso o output existente mantém-se byte a byte idêntico, enquanto sob PDF/A a face Unicode registada recebe sempre um subconjunto compacto

Pdf.EnableFontSubsetting := True;
Pdf.CompactFontSubsetting := True;   // utilizável sem CompressDocument
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('SimSun', [], 12);
Pdf.CurrentPage.TextOut(40, 40, 0, WideString('Total: '#$4E2D#$6587));
Pdf.EndDoc;

Como é que o escritor compactado aperta a estrutura do ficheiro?

Mal as fontes e os streams ficam pequenos, os dicionários e os dados de cross-reference tornam-se o maior custo restante, por isso o escritor de object streams por trás do CompressDocument apara também isso. O guia de object streams e atualizações incrementais cobre o formato do contentor em si; o caminho de compressão acrescenta quatro refinamentos por cima:

  • Sintaxe compacta conforme a ISO 32000-1 §7.2.2: um espaço só é escrito entre dois tokens que de outra forma correriam juntos como caracteres regulares, por isso /Type /Page torna-se /Type/Page
  • Os campos do cross-reference stream tomam qualquer largura que a §7.5.8.2 permita, por isso um ficheiro abaixo de 16 MB guarda cada offset em 3 bytes em vez de 4
  • Até 250 objetos entram em cada object stream em vez dos habituais 100, a menos que defina o seu próprio limite através do ConfigureAdaptiveObjectStreamPacking
  • Quando o ficheiro não está encriptado, o Catalog e o dicionário Info também são empacotados em object streams; o output encriptado mantém-nos ao nível de topo

A sintaxe compacta veio com uma armadilha que convém conhecer se estender o escritor. A assinatura preenche a assinatura depois de o ficheiro estar escrito procurando nos bytes os placeholders literais /ByteRange ( e /Contents <, e a grafia compacta transformá-los-ia em /ByteRange( e /Contents<, que a procura nunca encontra. Os dicionários de assinatura (Type Sig ou DocTimeStamp, FT Sig) e o dicionário de encriptação mantêm por isso o layout com espaços. Um defeito relacionado afetava builds antes da v2.766.41: cada gravação com object streams, o CompressDocument incluído, começava com duas linhas de cabeçalho %PDF-, por isso faça upgrade se um validador estrito marcar o seu output

Dá para comprimir um PDF já carregado?

Sim, através do overload de opções CompressLoadedDocument(Options, Info), que corre os mesmos passos lossless num ficheiro existente. Com THPDFLoadedDocumentCompressionOptions.Default ele remove recursos de página não usados, funde fontes e formulários idênticos, faz subsetting das fontes incorporadas com subconjuntos compactos ligados, recomprime streams sem filtro, Flate, LZW, ASCII e RunLength com Flate quando o resultado é menor, e faz a próxima gravação usar object streams. O HighRatioFlate vem desligado por predefinição, e os object streams são saltados para PDF/A-1 e gravações incrementais. O overload CompressLoadedDocument sem parâmetros é a chamada mais antiga e mais estreita que só comprime streams não comprimidos com Flate

Fluxo do CompressLoadedDocument do HotPDF no Delphi: a chamada remove recursos de página não usados, funde fontes e formulários idênticos, faz subsetting das fontes incorporadas com subconjuntos compactos, recomprime streams com Flate só quando o resultado é menor, e liga object streams para a próxima gravação, enquanto campos de assinatura disparam RefusedBySignaturePolicy e deixam o ficheiro intocado
Cada passo reescreve bytes que uma assinatura cobre, por isso o documento inteiro é recusado a menos que permita explicitamente a invalidação — o Info.BytesSaved então soma só o trabalho de recursos, fontes e streams
var
  Doc: THotPDF;
  Options: THPDFLoadedDocumentCompressionOptions;
  Info: THPDFLoadedDocumentCompressionInfo;
begin
  Doc := THotPDF.Create(nil);
  try
    Doc.AutoLaunch := False;
    Doc.LoadFromFile('quarterly-report.pdf');
    Options := THPDFLoadedDocumentCompressionOptions.Default;
    Doc.CompressLoadedDocument(Options, Info);
    if Info.RefusedBySignaturePolicy then
      Writeln(Format('Left untouched: %d signature fields', [Info.SignatureCount]))
    else
    begin
      Writeln(Format('Compact fonts: %d, stream bytes saved: %d',
        [Info.Fonts.CompactSubsetFontCount, Info.BytesSaved]));
      Doc.SaveLoadedDocument('quarterly-report-compact.pdf');
    end;
  finally
    Doc.Free;
  end;
end;

Duas fronteiras interessa no caminho carregado. Cada passo reescreve bytes que uma assinatura cobre, por isso um documento com campos de assinatura é recusado como um todo: a chamada devolve 0, põe RefusedBySignaturePolicy e não muda nada, a menos que defina AllowSignatureInvalidation, depois do que o Info.SignaturesInvalidated diz-lhe o que abriu mão. A compactação também é mais conservadora aqui do que no caminho de criação. O HotPDF compacta só programas de fontes usados exclusivamente por fontes CIDFontType2 com um /CIDToGIDMap Identity, onde CID é igual a GID, e salta programas com um stream de mapa existente, um /CIDSet, ou tabelas de glifos coloridos como COLR, sbix, CBDT ou SVG, porque a reconstrução compacta deixaria cair as camadas de cor. Note também que o Info.BytesSaved soma só os passos de recursos, fontes e streams; o ganho de object streams aparece quando o ficheiro é escrito

Que resultados esperar na prática?

Os ganhos acompanham a proporção do ficheiro que é estrutura não comprimida e dados de fonte inchados, e não o número de páginas. A amostra de três páginas com Arial e SimSun encolheu de 10.2 MB para 20 KB quando gerada com CompressDocument, e de 10.2 MB para 19.8 KB quando o original não comprimido foi carregado e passado pelo CompressLoadedDocument, com renderização idêntica nas duas vias. Um PDF já compacto mal se mexe: no conjunto de regressão, esses ficheiros pouparam entre -0.07% e +0.06% do tamanho original. Ficheiros pesados em fotos ganham pouco, porque nenhum dos caminhos toca nos dados de imagem

Se gera os mesmos relatórios CJK todas as noites, emparelhe os subconjuntos compactos com a cache persistente de subconjuntos de fontes em disco para o trabalho de subsetting não se repetir a cada corrida, e faça diff dos outputs comprimidos pelo conteúdo dos objetos e não pelos bytes, já que um campo alterado re-Flate um object stream inteiro. As referências completas de propriedades e records estão na página de produto do componente PDF Delphi HotPDF