Artigo Técnico

Subset de fontes já embebidas num PDF a partir do Delphi

O PDFium Component faz subset de fontes já embebidas num PDF existente através do SubsetPdfDocumentFonts na unidade FPdfDocumentFontSubset. A função substitui um programa /FontFile2 TrueType embebido por um mais pequeno só quando os próprios recursos da fonte provam todos os glifos que o documento pode referenciar; uma fonte cujo domínio de glifos não pode ser provado fica exatamente como estava

A razão habitual para querer isto é um arquivo que não produziu. Faturas, extratos e relatórios de geradores antigos costumam transportar uma Arial completa ou uma fonte CJK inteira em cada ficheiro, e algumas centenas de kilobytes de fonte por documento de duas páginas somam depressa em alguns milhões de documentos. Renderizar de novo a partir da origem raramente é uma opção. O que se tem é o PDF, e a única pergunta segura é quanto de cada fonte embebida pode sair sem uma única página perder um carácter

Por que é 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 escritor original usou. O produtor que embebeu a fonte conhecia o seu próprio texto; uma ferramenta que corre depois tem de reconstruir esse conhecimento, e qualquer glifo que perca transforma-se numa caixa vazia ou numa forma .notdef numa página que ninguém olha até um cliente olhar

A abordagem óbvia é analisar todos os fluxos de conteúdo, todos os Form XObjects, todas as aparências de anotações e todas as aparências predefinidas de campos, recolher os códigos mostrados com cada fonte, e manter esses glifos. Funciona até encontrar um recurso de fonte partilhado entre páginas e formulários, um campo de formulário cuja aparência um visualizador regenera a partir do /DA, ou um fluxo de conteúdo com uma sequência de operadores que o scanner não modela. O SubsetPdfDocumentFonts deliberadamente não analisa fluxos de conteúdo. Deriva o domínio de glifos dos metadados que o PDF tem de transportar sobre cada fonte de qualquer forma: o /CIDToGIDMap, as matrizes de larguras /W e /W2, o bitmap /CIDSet, a CMap /ToUnicode, a matriz /Widths de fonte simples e o /Encoding. Quando esses metadados não delimitam o domínio, a fonte fica intocada

Como o PDFium Component prova o domínio de glifos ao fazer subset de fontes TrueType embebidas a partir do Delphi: /CIDToGIDMap, larguras /W e /W2, /CIDSet, /ToUnicode, /Widths e /Encoding delimitam o domínio sem analisar fluxos de conteúdo, e uma fonte não provável é preservada intocada
O SubsetPdfDocumentFonts transforma metadados de fonte num domínio de glifos provado antes de cortar qualquer coisa, e uma fonte cujos recursos não conseguem delimitar o domínio é reportada e deixada intocada

Manter os IDs de glifo originais para que os fluxos de conteúdo fiquem intocados

O backend de subset por trás do SubsetPdfDocumentFonts, o TPdfTrueTypeSubsetBackend, mantém todos os espaços de ID de glifo originais, pelo que nenhum fluxo de conteúdo, CID ou /CIDToGIDMap tem de ser reescrito. Reconstrói as tabelas glyf e loca, dá a cada glifo não usado um intervalo loca de comprimento zero e deixa o maxp.numGlyphs inalterado. O glifo 4711 continua a ser o glifo 4711 depois do subset; só tem contornos se o documento o conseguir alcançar

Essa escolha remove a parte mais arriscada de fazer subset de um documento acabado. Renumerar glifos significaria reescrever todas as strings em todos os fluxos de conteúdo que usem a fonte, mais o fluxo de mapeamento, mais tudo o que guarde IDs de glifo em cache. IDs retidos custam um pouco de tamanho, porque o loca mantém uma entrada por glifo original, mas o grosso de um programa TrueType são dados de contorno, e é isso que sai. Glifos compostos são seguidos até aos seus componentes, pelo que uma letra acentuada mantém a letra base e o acento de que é construída; referências compostas cíclicas ou demasiado profundas fazem a fonte falhar em vez de produzir um fecho partido

IDs de glifo retidos após subset de fontes TrueType embebidas com o PDFium Component no Delphi: o glifo 4711 mantém o seu número e contornos, glifos não usados recebem intervalos loca de comprimento zero enquanto maxp.numGlyphs fica inalterado, pelo que fluxos de conteúdo, CIDs e /CIDToGIDMap nunca são reescritos
Como os IDs de glifo sobrevivem ao subset, os fluxos de conteúdo, CIDs e o /CIDToGIDMap nunca precisam de reescrita - o ganho de tamanho vem dos contornos largados, 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 por 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, retira primeiro qualquer prefixo ABCDEF+ existente, resolve colisões entre fontes no mesmo documento, e escreve o novo nome tanto no /FontName do descritor como 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 ficheiro 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 prova o SubsetPdfDocumentFonts o domínio de glifos de um CIDFont?

O SubsetPdfDocumentFonts prova o domínio de glifos de uma fonte CIDFontType2 a partir do seu mapeamento CID-para-glifo, e o mapeamento decide quanta evidência extra é necessária. Um fluxo /CIDToGIDMap explícito (ISO 32000-1 §9.7.4.2) já é uma lista de todos os glifos que a fonte consegue endereçar, pelo que todos os GIDs no fluxo entram no domínio. Um fluxo de mapeamento maior do que 65536 entradas, ou com contagem de bytes ímpar, falha o perfil delimitado e a fonte é preservada

O /CIDToGIDMap /Identity é o caso difícil, e é também o mais comum nos ficheiros modernos. Identity diz que CID iguala GID, o que não lhe diz nada sobre que CIDs o documento usa. O componente portanto exige evidência primária: uma matriz de larguras /W ou /W2 explícita (§9.7.4.3), cujas entradas CID marcam glifos para os quais o produtor declarou métricas, ou um fluxo /CIDSet no descritor da fonte (§9.8.3), um bitmap com um bit por CID, bit mais significativo primeiro. Uma fonte Identity simples 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 tem o direito de deixar de fora todos os CIDs cuja largura iguala a largura predefinida, e um escritor bem otimizado faz exatamente isso para as larguras de glifo mais comuns. Se o domínio viesse só do /W, esses glifos desapareceriam discretamente do subset. Para fechar esse fosso o componente percorre todas as fontes Type0 cujo /DescendantFonts aponta para o CIDFont, exige que o seu /Encoding seja /Identity-H ou /Identity-V para que um código de 2 bytes seja o CID, e funde os códigos de origem de cada entrada beginbfchar e beginbfrange da sua CMap /ToUnicode (§9.10.3) no domínio. Qualquer outra codificação, ou um fluxo /ToUnicode que não possa ser descodificado 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 fluxo de mapeamento explícito já lista todos os glifos, Identity exige uma matriz de larguras /W ou /W2 ou um bitmap /CIDSet, e os códigos de origem /ToUnicode fecham o ponto cego da largura predefinida /DW
Um CIDToGIDMap Identity não diz nada sobre uso por si, pelo que o subsetter exige evidência /W ou /CIDSet e funde códigos /ToUnicode para cobrir os glifos que o /W omite no /DW

Fontes TrueType simples: das Widths aos IDs de glifo

Fontes /Subtype /TrueType de byte único são provadas através do seu intervalo de códigos e da sua codificação, e depois resolvidas para IDs de glifo através do cmap do próprio programa de fonte. O componente exige 0 <= FirstChar <= LastChar <= 255 e uma matriz /Widths cujo comprimento é exatamente LastChar - FirstChar + 1; uma matriz curta, comprida ou não numérica significa que o domínio de códigos não está provado, e a fonte fica como está

Cada código nesse intervalo, mais qualquer código nomeado em /Differences ou coberto pelo /ToUnicode, é depois mapeado à maneira que a ISO 32000-1 §9.6.6 descreve. Códigos sob /WinAnsiEncoding, /MacRomanEncoding ou /StandardEncoding passam pela tabela correspondente para Unicode. Nomes de glifos em /Differences são resolvidos através da 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 procurados 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 todo, o componente marca a união de toda a interpretação plausível em vez de adivinhar 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 /Differences que mapeie para nenhum ID de glifo provado rejeita 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;         // ficheiro 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 quieto?

O SubsetPdfDocumentFonts deixa quietas todas as fontes 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 falhar o documento. O Report.Complete é True só quando nenhuma fonte foi preservada. Os casos preservados valem a pena 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 nem /CIDSet, e CIDFonts sem fluxo de mapeamento nem /Identity
  • Programas de fonte referenciados de algures que não os seus descritores de fonte, e descritores partilhados com um consumidor que não seja CIDFontType2 ou TrueType simples
  • Fontes cujo subset não alcança SubsetOptions.MinimumSavingPercent, e todas as fontes quando o ficheiro reconstruído não alcança o MinimumDocumentSavingPercent; nesse caso o Report.Applied é False e a saída são os bytes originais

Duas entradas falham de saída em vez de serem preservadas. Documentos assinados e documentos com campos de assinatura são rejeitados, porque substituir objetos de fonte invalidaria o intervalo de bytes de cada assinatura, e ficheiros encriptados precisam primeiro de uma origem desencriptada. Uma execução bem-sucedida escreve os objetos de substituição como uma atualização esparsa e depois reconstrói o ficheiro através da mesma passagem de alcançabilidade descrita no guia de podar objetos órfãos e deduplicar recursos, pelo que os programas de fonte antigos não sobrevivem como objetos inalcançáveis e o resultado é uma única revisão limpa

Fazer subset de texto novo na gravação com saSubsetNewFonts

Fontes que o PDFium Component embebe durante a sessão atual são um caso à parte, tratado pela opção de gravação saSubsetNewFonts em vez de pelo SubsetPdfDocumentFonts. O saSubsetNewFonts mapeia para a flag FPDF_SUBSET_NEW_FONTS do PDFium, que corre 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 ficheiro

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 ficheiro gravado ainda coincidiu exatamente. Uma fonte de cor COLR v0, Segoe UI Emoji com 7.0 MB, passou pelo mesmo caminho para 10.5 KB, renderizada como os 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-se à frente do subsetter: o GetFontData do GDI devolveu um fluxo substituto de 18.3 MB para o ficheiro Segoe UI Variable de 1.85 MB, e a criação de objetos de texto falhou antes de qualquer subset começar. A caminhada pela incorporação de fontes CID Type 2 a partir do Delphi cobre o caminho de incorporação por que essas fontes passam primeiro

Onde a prova para

A prova dentro do SubsetPdfDocumentFonts é exatamente tão forte como os metadados de fonte que o produtor escreveu. Um CIDFont Identity cuja evidência é só o /W ainda tem um ponto cego: um glifo cuja largura iguala o /DW, que não tem entrada /ToUnicode, e que o descritor não lista num /CIDSet. Tal glifo não é provável a partir dos recursos, e o componente não pode saber que existe sem a análise de fluxos de conteúdo que evita de propósito. Produtores que omitem ambos /ToUnicode e /CIDSet enquanto comprimem o /W são raros, mas existem, por isso para um arquivo de um gerador desconhecido, renderize uma amostra antes e depois e compare píxeis e texto extraído

Antes de correr um lote, também ajuda saber com o que está a lidar. A abordagem em ler propriedades de fontes PDF com o PDFium Component diz-lhe que fontes estão embebidas de todo, e o Report.Issues de uma execução a seco diz-lhe quais dessas serão preservadas e porquê. As opções predefinidas também delimitam 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 trabalhos em lote longos

FAQ

O SubsetPdfDocumentFonts muda o texto ou o layout do PDF?

Não. IDs de glifo originais, CIDs, larguras e fluxos de conteúdo são mantidos, pelo que a extração de texto e a renderização usam os mesmos glifos que antes; só os contornos inalcançáveis são removidos e os nomes das fontes recebem um novo prefixo de subset

Por que uma fonte embebida não foi submetida a subset?

Leia o Report.Issues. Cada fonte preservada transporta uma razão como um /W ou /CIDSet em falta num CIDFont Identity, um programa não TrueType, uma matriz /Widths malformada ou uma poupança abaixo do limiar

O saSubsetNewFonts é o mesmo que o SubsetPdfDocumentFonts?

Não. O saSubsetNewFonts faz subset de fontes embebidas na sessão atual quando grava, enquanto o SubsetPdfDocumentFonts trabalha sobre um fluxo PDF existente e substitui fontes que já tinham sido embebidas por outro produtor

Subset de fontes de documento, subset de fontes na gravação e a passagem de poda de órfãos por trás deles viajam todos com o PDFium Component for Delphi and C++Builder, que embrulha o PDFium para projetos Delphi, C++Builder e Lazarus