Artigo Técnico

Faça subset de fontes já embutidas em um PDF no Delphi

O PDFium Component faz subset de fontes que já estão embutidas em um PDF existente por meio do SubsetPdfDocumentFonts na unit FPdfDocumentFontSubset. A função substitui um programa TrueType /FontFile2 embutido por um menor apenas quando os próprios recursos da fonte provam todo glifo que o documento pode referenciar; uma fonte cujo domínio de glifos não pode ser provado é deixada exatamente como estava

O motivo habitual é um acervo que você não produziu. Faturas, extratos e relatórios de geradores mais antigos frequentemente carregam uma Arial completa ou uma face CJK inteira em cada arquivo, e algumas centenas de kilobytes de fonte por documento de duas páginas somam rápido ao longo de alguns milhões de documentos. Renderizar de novo a partir da origem raramente é uma opção. O que você tem é o PDF, e a única pergunta segura é quanto de cada fonte embutida pode ir sem que uma única página perca um caractere

Por que fazer subset de um PDF existente arrisca perder glifos?

Fazer subset de um PDF existente arrisca perder glifos porque o pós-processador não sabe que glifos o writer original usou. O produtor que embutiu a fonte conhecia o próprio texto; uma ferramenta rodando depois precisa reconstruir esse conhecimento, e qualquer glifo que perder vira uma caixa vazia ou uma forma .notdef em uma página que ninguém olha até um cliente olhar

A abordagem óbvia é interpretar todo content stream, todo Form XObject, toda aparência de anotação e toda aparência padrão de campo, coletar os códigos mostrados com cada fonte, e manter esses glifos. Funciona até encontrar um recurso de fonte compartilhado entre páginas e forms, um campo de formulário cuja aparência um viewer regenera do /DA, ou um content stream com uma sequência de operadores que o scanner não modela. O SubsetPdfDocumentFonts deliberadamente não varre content streams. Ele deriva o domínio de glifos dos metadados que o PDF precisa carregar sobre cada fonte de qualquer forma: o /CIDToGIDMap, os arrays de largura /W e /W2, o bitmap /CIDSet, o CMap /ToUnicode, o array /Widths de fonte simples e o /Encoding. Quando esses metadados não limitam o domínio, a fonte fica intocada

Como o PDFium Component prova o domínio de glifos ao fazer subset de fontes TrueType embutidas a partir do Delphi: /CIDToGIDMap, larguras /W e /W2, /CIDSet, /ToUnicode, /Widths e /Encoding limitam o domínio sem varrer content streams, e uma fonte impossível de provar é preservada intocada
O SubsetPdfDocumentFonts transforma metadados de fonte em um domínio de glifos provado antes de cortar qualquer coisa, e uma fonte cujos recursos não conseguem limitar o domínio é reportada e deixada intocada

Mantendo glyph IDs originais para os content streams ficarem intocados

O backend de subset por trás do SubsetPdfDocumentFonts, o TPdfTrueTypeSubsetBackend, mantém todo slot de glyph ID original, então nenhum content stream, CID ou /CIDToGIDMap jamais precisa ser regravado. Ele reconstrói as tabelas glyf e loca, dá a todo glifo não usado uma faixa de loca de comprimento zero e deixa o maxp.numGlyphs inalterado. O glifo 4711 continua sendo o glifo 4711 depois do subset; ele só tem contornos se o documento consegue alcançá-lo

Essa escolha remove a parte mais arriscada de fazer subset de um documento pronto. Renumerar glifos significaria regravar toda string em todo content stream que usa a fonte, mais o stream de mapeamento, mais qualquer coisa que cacheie glyph IDs. IDs retidos custam um pouco de tamanho, porque a loca mantém uma entrada por glifo original, mas o volume de um programa TrueType são dados de contorno, e é isso que vai embora. Glifos compostos são seguidos até seus componentes, então uma letra acentuada mantém a letra base e o acento de que é construída; referências compostas cíclicas ou fundas demais reprovam a fonte em vez de produzir um fechamento quebrado

Glyph IDs retidos depois de fazer subset de fontes TrueType embutidas com o PDFium Component em Delphi: o glifo 4711 mantém seu número e contornos, glifos não usados recebem faixas loca de comprimento zero enquanto maxp.numGlyphs fica inalterado, então content streams, CIDs e /CIDToGIDMap jamais são regravados
Como os glyph IDs sobrevivem ao subset, content streams, CIDs e o /CIDToGIDMap jamais precisam de regravação - o ganho de tamanho vem dos contornos descartados, não da renumeração

A fonte renomeada também segue a ISO 32000-1 §9.6.4, que exige que o nome de uma fonte subset comece com seis letras maiúsculas e um sinal de mais. O componente deriva as seis letras de um digest SHA-256 do novo programa de fonte, remove primeiro qualquer prefixo ABCDEF+ existente, resolve colisões entre fontes no mesmo documento, e grava o novo nome tanto no /FontName do descritor quanto no /BaseFont do dicionário de fonte, incluindo o pai Type0 de um CIDFont

uses
  System.SysUtils, System.Classes, FPdfDocumentFontSubset;

procedure SubsetEmbeddedFonts(const SourceName, TargetName: string);
var
  Source, Target: TFileStream;
  Options: TPdfDocumentFontSubsetOptions;
  Report: TPdfDocumentFontSubsetReport;
  Output: TBytes;
begin
  Options := TPdfDocumentFontSubsetOptions.Default;
  Source := TFileStream.Create(SourceName, fmOpenRead or fmShareDenyWrite);
  try
    if not SubsetPdfDocumentFonts(Source, Options, Output, Report) then
      raise Exception.Create('Font subsetting failed: ' + Report.ErrorMessage);
  finally
    Source.Free;
  end;
  // A saída é sempre um PDF completo: o arquivo reconstruído quando Report.Applied
  // é True, caso contrário uma cópia byte a byte da origem
  Target := TFileStream.Create(TargetName, fmCreate);
  try
    if Length(Output) > 0 then
      Target.WriteBuffer(Output[0], Length(Output));
  finally
    Target.Free;
  end;
end;

Como o SubsetPdfDocumentFonts prova o domínio de glifos de um CIDFont?

O SubsetPdfDocumentFonts prova o domínio de glifos de uma fonte CIDFontType2 a partir de seu mapeamento CID para glifo, e o mapeamento decide quanta evidência extra é necessária. Um stream /CIDToGIDMap explícito (ISO 32000-1 §9.7.4.2) já é uma lista de todo glifo que a fonte pode endereçar, então todo GID no stream entra no domínio. Um stream de mapeamento maior que 65536 entradas, ou um com contagem de bytes ímpar, reprova no perfil limitado e a fonte é preservada

O /CIDToGIDMap /Identity é o caso difícil, e também o mais comum em arquivos modernos. Identity diz que CID iguala GID, o que não diz nada sobre quais CIDs o documento usa. O componente portanto exige evidência primária: um array de largura /W ou /W2 explícito (§9.7.4.3), cujas entradas de CID marcam glifos para os quais o produtor declarou métricas, ou um stream /CIDSet no descritor de fonte (§9.8.3), um bitmap com um bit por CID, bit mais significativo primeiro. Uma fonte Identity bare sem nenhum dos dois é preservada com a razão Identity CIDToGIDMap requires an explicit W array or CIDSet stream

A armadilha em usar o /W como evidência é o /DW. Um produtor pode deixar de fora todo CID cuja largura iguala a largura padrão, e um writer bem otimizado faz exatamente isso para as larguras de glifo mais comuns. Se o domínio viesse do /W sozinho, esses glifos desapareceriam silenciosamente do subset. Para fechar esse buraco, o componente percorre toda fonte Type0 cujo /DescendantFonts aponta para o CIDFont, exige que seu /Encoding seja /Identity-H ou /Identity-V para um código de 2 bytes ser o CID, e mescla os códigos de origem de toda entrada beginbfchar e beginbfrange de seu CMap /ToUnicode (§9.10.3) no domínio. Qualquer outra codificação, ou um stream /ToUnicode que não possa ser decodificado dentro do orçamento, preserva a fonte

Decisão de evidência para um CIDToGIDMap Identity no subset de fontes do PDFium Component para Delphi: um stream de mapeamento explícito já lista todo glifo, Identity exige um array de largura /W ou /W2 ou um bitmap /CIDSet, e os códigos de origem do /ToUnicode fecham o ponto cego da largura padrão /DW
Um CIDToGIDMap Identity não diz nada sobre uso por si só, então o subsetter exige evidência de /W ou /CIDSet e mescla códigos do /ToUnicode para cobrir os glifos que o /W omite no /DW

Fontes TrueType simples: de Widths a glyph IDs

Fontes /Subtype /TrueType de byte único são provadas por sua faixa de códigos e sua codificação, e então resolvidas a glyph IDs pelo cmap do próprio programa de fonte. O componente exige 0 <= FirstChar <= LastChar <= 255 e um array /Widths cujo comprimento é exatamente LastChar - FirstChar + 1; um array curto, longo ou não numérico significa que o domínio de códigos não está provado, e a fonte fica como está

Cada código nessa faixa, mais qualquer código nomeado no /Differences ou coberto pelo /ToUnicode, é então mapeado do jeito que a ISO 32000-1 §9.6.6 descreve. Códigos sob /WinAnsiEncoding, /MacRomanEncoding ou /StandardEncoding passam pela tabela correspondente para Unicode. Nomes de glifo do /Differences são resolvidos pela Adobe Glyph List, nomes uniXXXX e uXXXX, os 258 nomes de glifo padrão Macintosh e a tabela de nomes post formato 2.0 da própria fonte. Os valores resultantes são buscados em subtables cmap de formato 0, 4, 6 e 12, incluindo Unicode (3,1), (3,10) e (0,*), Mac Roman (1,0), e Windows Symbol (3,0) em F000 mais o código

Quando uma fonte não tem codificação base de forma alguma, o componente marca a união de toda interpretação plausível em vez de chutar uma. Isso mantém alguns glifos que provavelmente não são usados, que é a direção certa para errar. O caso oposto é estrito: um nome de /Differences que mapeia para nenhum glyph ID provado reprova a fonte inteira, porque um nome não resolvido significa que o domínio está incompleto

procedure ReportFontSubset(Source: TStream; Log: TStrings);
var
  Options: TPdfDocumentFontSubsetOptions;
  Report: TPdfDocumentFontSubsetReport;
  Output: TBytes;
  I: Integer;
begin
  Options := TPdfDocumentFontSubsetOptions.Default;
  Options.SubsetOptions.MinimumSavingPercent := 10;  // por fonte
  Options.MinimumDocumentSavingPercent := 5;         // arquivo inteiro
  if not SubsetPdfDocumentFonts(Source, Options, Output, Report) then
  begin
    Log.Add('Rejected: ' + Report.ErrorMessage);
    Exit;
  end;
  Log.Add(Format('Fonts %d, subset %d, preserved %d, glyphs %d',
    [Report.FontCount, Report.SubsetFontCount, Report.PreservedFontCount,
     Report.GlyphCount]));
  Log.Add(Format('Font bytes %d -> %d, file bytes %d -> %d',
    [Report.OriginalFontBytes, Report.SubsetFontBytes,
     Report.SourceByteCount, Report.OutputByteCount]));
  for I := 0 to High(Report.Issues) do
    Log.Add(Format('  object %d kept: %s',
      [Report.Issues[I].ObjectNumber, Report.Issues[I].Reason]));
end;

O que o SubsetPdfDocumentFonts deixa em paz?

O SubsetPdfDocumentFonts deixa em paz toda fonte que não consegue provar, e reporta cada uma em Report.Issues com o número de objeto e uma razão em vez de reprovar o documento. O Report.Complete é True apenas quando nenhuma fonte foi preservada. Os casos preservados valem conhecer antes de prometer uma redução de tamanho:

  • Programas Type 1 /FontFile e CFF ou OpenType /FontFile3, reportados como Only TrueType FontFile2 programs are supported
  • CIDFonts Identity sem /W, /W2 ou /CIDSet, e CIDFonts sem stream de mapeamento nem /Identity
  • Programas de fonte referenciados de algum lugar que não seus descritores de fonte, e descritores compartilhados com um consumidor que não é CIDFontType2 nem TrueType simples
  • Fontes cujo subset perde menos que SubsetOptions.MinimumSavingPercent, e toda fonte quando o arquivo reconstruído perde menos que o MinimumDocumentSavingPercent; nesse caso o Report.Applied é False e a saída são os bytes originais

Duas entradas falham de vez em vez de serem preservadas. Documentos assinados e documentos com campos de assinatura são rejeitados, porque substituir objetos de fonte invalidaria a faixa de bytes de toda assinatura, e arquivos criptografados precisam primeiro de uma origem decifrada. Uma rodada bem-sucedida grava os objetos de substituição como uma atualização esparsa e então reconstrói o arquivo pela mesma passada de alcançabilidade descrita no guia de podar objetos órfãos e deduplicar recursos, então os programas de fonte antigos não sobrevivem como objetos inalcançáveis e o resultado é uma única revisão limpa

Fazendo subset de texto novo ao salvar com saSubsetNewFonts

Fontes que o PDFium Component embute durante a sessão atual são um caso separado, tratado pela opção de salvar saSubsetNewFonts em vez de pelo SubsetPdfDocumentFonts. O saSubsetNewFonts mapeia para a flag FPDF_SUBSET_NEW_FONTS do PDFium, que roda o subsetter nativo HarfBuzz sobre fontes que chamadas nativas como o TPdf.AddText carregaram do sistema, incluindo faces OpenType CFF. O PDFium upstream ainda rotula a flag como experimental. Ela não toca em fontes que já estavam no arquivo

var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.CreateDocument;
    Pdf.AddPage(0, 595, 842);
    Pdf.AddText('Quarterly statement', 'Arial', 14, 72, 770, clBlack, $FF, 0.0);
    if not Pdf.SaveAs('statement.pdf', saSubsetNewFonts) then
      raise Exception.Create('Save failed');
  finally
    Pdf.Free;
  end;
end;

Na sonda do componente, um documento com texto Arial caiu de 590 KB para 22 KB sob saSubsetNewFonts, e a extração de texto do arquivo salvo ainda casava exatamente. Uma fonte colorida COLR v0, Segoe UI Emoji de 7.0 MB, passou pelo mesmo caminho para 10.5 KB, renderizada com seus contornos base já que o PDF não tem noção de camadas COLR. Fontes variáveis são a exceção, e o bloqueio senta na frente do subsetter: o GetFontData da GDI devolveu um stream substituto de 18.3 MB para o arquivo Segoe UI Variable de 1.85 MB, e a criação do objeto de texto falhou antes de qualquer subset começar. O passo a passo de embutir fontes CID Type 2 a partir do Delphi cobre o caminho de embutimento por que essas fontes passam primeiro

Onde a prova para

A prova dentro do SubsetPdfDocumentFonts é exatamente tão forte quanto os metadados de fonte que o produtor gravou. Um CIDFont Identity cuja evidência é o /W sozinho ainda tem um ponto cego: um glifo cuja largura iguala o /DW, que não tem entrada no /ToUnicode, e que o descritor não lista em um /CIDSet. Tal glifo não pode ser provado a partir dos recursos, e o componente não pode saber que ele existe sem a varredura de content streams que evita de propósito. Produtores que omitem tanto o /ToUnicode quanto o /CIDSet enquanto comprimem o /W são raros, mas existem, então para um acervo de um gerador desconhecido, renderize uma amostra antes e depois e compare pixels e texto extraído

Antes de rodar um lote, também ajuda saber com o que você está lidando. A abordagem em ler propriedades de fontes PDF com o PDFium Component diz quais fontes estão embutidas de forma alguma, e o Report.Issues de uma rodada seca diz quais delas serão preservadas e por quê. As opções padrão também limitam o trabalho: 1024 fontes, cerca de um milhão de glifos no total, 128 MB de origem e um orçamento de trabalho fixo, com um CancellationToken opcional para jobs em lote longos

FAQ

O SubsetPdfDocumentFonts muda o texto ou o layout do PDF?

Não. Glyph IDs, CIDs, larguras e content streams originais são mantidos, então extração de texto e renderização usam os mesmos glifos de antes; apenas contornos inalcançáveis são removidos e os nomes das fontes recebem um prefixo de subset novo

Por que uma fonte embutida não recebeu subset?

Leia o Report.Issues. Cada fonte preservada carrega uma razão como um /W ou /CIDSet ausente em um CIDFont Identity, um programa não TrueType, um array /Widths malformado ou uma economia abaixo do limiar

O saSubsetNewFonts é o mesmo que SubsetPdfDocumentFonts?

Não. O saSubsetNewFonts faz subset de fontes embutidas na sessão atual quando você salva, enquanto o SubsetPdfDocumentFonts trabalha sobre um stream PDF existente e substitui fontes que já haviam sido embutidas por outro produtor

Subset de fontes de documento, subset de fontes ao salvar e a passada de poda de órfãos por trás deles vêm todos com o PDFium Component for Delphi and C++Builder, que embrulha o PDFium para projetos Delphi, C++Builder e Lazarus