Artigo Técnico

Embutir fontes variáveis CFF2 em PDF com HotPDF para Delphi

O HotPDF embute fontes variáveis OpenType com contornos CFF2 convertendo-as, nos valores de eixos selecionados, num programa de fonte CFF1 com chave CID que os leitores PDF já entendem. O texto nativo comum registado através do RegisterUnicodeTTF usa as tabelas cmap e hmtx da própria fonte, e o writer embute o programa convertido sob /FontFile3 com /Subtype /CIDFontType0C, mantendo cada CID igual ao glyph ID original

Pipeline do HotPDF para embutir uma fonte variável CFF2 em Delphi como CFF1 com chave CID sob FontFile3, com o desvio silencioso de substituição GDI que o caminho de bytes registados evita
o HotPDF congela a variação nos valores de eixos escolhidos e escreve um programa CFF1 com chave CID em que cada CID é igual ao glyph ID original, por isso nenhuma camada traduz entre numerações

O caminho óbvio vale a pena descrever, porque falha sem avisar ninguém. Analise as tabelas CFF2 e coloque-as em cache, depois deixe o caminho de texto simples resolver a fonte através do Windows GDI com um match só TrueType: o GDI não faz ideia do que fazer com contornos CFF2 e substitui silenciosamente por ArialUnicodeMS. Tudo o que estiver a jusante e leia a substituta recebe os contornos errados, as larguras erradas e a extração errada para caracteres do plano suplementar. O PDF abre bem. Só não está composto na fonte que pediu, e é por isso que o caminho de texto tem de trabalhar a partir dos bytes registados em vez de um lookup GDI

Porque é que um PDF não embute diretamente uma fonte CFF2?

O PDF não tem slot para um programa CFF2. O ISO 32000-2 §9.9 lista os tipos de programa de fonte embutida que um leitor tem de tratar: Type 1 sob /FontFile, TrueType sob /FontFile2, e sob /FontFile3 um programa CFF nu (Type1C ou CIDFontType0C) ou um wrapper OpenType em volta de contornos TrueType ou CFF. Todos os sabores CFF dessa lista significam CFF versão 1, o formato que os leitores analisam há duas décadas

Os três slots de fonte embutida do PDF, todos limitados a CFF versão 1, e o slot CFF2 em falta que leva o HotPDF a converter antes de embutir em Delphi
todos os sabores CFF que os leitores PDF têm de tratar são CFF versão 1, por isso uma fonte variável CFF2 não tem slot até ser convertida

O CFF2 é um formato diferente que partilha o nome por acaso. Larga o Name e o String INDEX, larga o charset, tira os avanços de glyph dos CharStrings e deixa-os ao hmtx, e acrescenta operadores blend para um único contorno descrever todo o espaço de desenho. Um parser CFF1 que encontre uma tabela CFF2 não tem base para adivinhar, e nada na especificação PDF o obriga a tentar. Envolvar a fonte CFF2 num stream OpenType e torcer é exatamente o tipo de ficheiro que renderiza numa máquina e falha na seguinte, por isso o HotPDF não o faz

A conversão também tem de ser estática. Como o artigo sobre instanciação estática de fontes variáveis explica, uma fonte PDF é um conjunto fixo de contornos com métricas fixas, e nenhum leitor é obrigado a aplicar coordenadas de eixos. O HotPDF por isso resolve primeiro a variação e só depois escreve CFF1

Escrever texto nativo numa fonte CFF2

Para o código da aplicação, uma fonte CFF2 comporta-se como qualquer outra fonte Unicode registada: selecione valores de eixos, registe o ficheiro, selecione a família pelo nome e desenhe. O RegisterUnicodeTTF mantém uma cópia separada da tabela de nomes original, por isso o SetFont casa com o nome de família, nome completo, nome PostScript ou nome de família tipográfica e seleciona os bytes da fonte estática congelada, sem instalação Windows ou lookup GDI envolvidos

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'brochure.pdf';
    Pdf.BeginDoc;
    // Os valores de eixos aplicam-se a fontes registadas após esta chamada
    Pdf.SetVariableFontAxis('wght', 650);
    Pdf.RegisterUnicodeTTF('C:\Fonts\AcmeSerifVF.otf');
    // Nome de família, completo, PostScript ou família tipográfica da tabela de nomes
    Pdf.CurrentPage.SetFont('Acme Serif VF', [], 12, DEFAULT_CHARSET);
    Pdf.CurrentPage.UnicodeTextOut(72, 100, 0, 'Variable weight 650, CFF2 outlines');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Os glyph IDs vêm do cmap registado e os avanços do hmtx registado. Um glyph cujo avanço é genuinamente zero fica zero em vez de recair numa medição GDI, e um par surrogate UTF-16 produz exatamente um avanço, não dois. Caracteres BMP, caracteres suplementares e portadores de shaping sintéticos mantêm mapeamentos Unicode por fonte, por isso mudar para outra fonte embutida ou iniciar uma página nova não deixa essa outra fonte atualizar o estado de subset desta fonte nem tomar emprestados os seus glyph IDs

A medição segue os mesmos bytes. O THPDFPage.GetWideTextWidth e o THPDFPage.GetWideCharAdvances leem as métricas da fonte selecionada respaldada em bytes, por isso a quebra de linhas e o alinhamento à direita concordam com o que é desenhado. Um detalhe de ordem apanha as pessoas: mudanças no SetVariableFontAxis feitas após o registo afetam a próxima fonte que registar, não a instância já congelada. Para obter um segundo peso, ponha o novo valor e registe outra vez

Como o HPDFConvertCFF2ToCFF constrói o programa com chave CID?

O HPDFConvertCFF2ToCFF produz um programa CFF1 cru com chave CID e um único Font DICT, em que o CID é igual ao glyph ID original em cada slot, incluindo os vazios e não usados. Essa identidade é todo o desenho. O nome da fonte, as larguras /W do PDF, os avanços CFF e a CMap ToUnicode referem-se todos à mesma numeração, por isso nenhuma camada tem de traduzir entre glyph IDs e CIDs, e nada pode dessincronizar

No lado PDF o descendente é uma fonte CIDFontType0 com encoding Identity-H ou Identity-V e o stream do programa assenta sob /FontFile3 com /Subtype /CIDFontType0C. Como o CID já é igual ao GID, o descendente não traz /CIDToGIDMap, e o stream é excluído do hook posterior de substituição de streams usado para fontes TrueType registadas. Ao contrário do wrapper /Subtype /OpenType, o stream convertido não sobe o requisito de versão PDF 1.6

As larguras precisaram de um pequeno truque. Um CharString Type 2 de CFF1 guarda o avanço como um primeiro operando opcional relativo ao nominalWidthX, e os operandos inteiros estão limitados ao intervalo signed de 16 bits. Um avanço OpenType é um valor unsigned de 16 bits, até 65535. O HotPDF põe o nominalWidthX em 32768 e o defaultWidthX em 0, e depois escreve cada avanço explicitamente como advance - 32768. Um avanço zero fica -32768 e um avanço de 65535 fica 32767, por isso todo o intervalo uint16 é representável sem clampear um único glyph

Como o HotPDF mapeia avanços unsigned de 16 bits em operandos CFF1 signed com nominalWidthX 32768, mantendo cada avanço de fonte na saída PDF Delphi sem clamping
o nominalWidthX 32768 desloca a janela de operandos para um avanço zero guardar como -32768 e 65535 guardar como 32767 sem clampear glyph nenhum

A geometria recebe o mesmo cuidado. A FontMatrix é [1/UnitsPerEm 0 0 1/UnitsPerEm 0 0], por isso uma fonte de 2048 unidades mantém as suas coordenadas nativas. Movimentos e linhas relativos demasiado grandes para um operando são divididos em passos representáveis, e as curvas cúbicas são subdivididas de forma a preservar a forma. Os registos originais de copyright e licença, a tabela de nomes original e as flags de embedding viajam na string Notice do CFF

Chamar o conversor fora de um documento

O conversor é uma função pública na unit HPDFOpenTypeAdvanced, escrita em Pascal, sem dependência de HarfBuzz e sem passo de instalação de fontes de sistema. Isso torna-o utilizável por si só, por exemplo para pré-converter uma fonte em build time ou para inspecionar o que o writer do PDF vai embutir

uses
  System.SysUtils, System.IOUtils, HPDFOpenTypeAdvanced;

procedure ConvertWeight650;
var
  Source, Cff: TBytes;
  Axes: THPDFVariableFontAxisValueArray;
begin
  Source := TFile.ReadAllBytes('C:\Fonts\AcmeSerifVF.otf');
  SetLength(Axes, 1);
  Axes[0].Tag := 'wght';
  Axes[0].Value := 650;
  Cff := HPDFConvertCFF2ToCFF(Source, Axes);
  if Length(Cff) = 0 then
    raise Exception.Create('Not convertible: not CFF2, embedding denied or bad axes');
  TFile.WriteAllBytes('AcmeSerif-650.cff', Cff);
end;

A entrada variável passa por instanciação estática só de geometria nas coordenadas dadas, cobrindo contornos e avanços. Uma fonte CFF2 estática aceita apenas uma seleção vazia; passar-lhe valores de eixos é tratado como erro em vez de ignorado. O resultado é um programa CFF1 nu, não um contentor sfnt, por isso não o entregue a código que espere um ficheiro OpenType

A falha é um TBytes vazio, não uma exceção, para os casos esperados: entrada que não é CFF2, uma seleção de eixos que o instancer não aceita, valores não finitos como NaN, e fontes cujo fsType do OS/2 define embedding de licença restrita (0x0002) ou só bitmap (0x0200). Teste sempre o comprimento. Dentro do EndDoc o writer fá-lo por si e levanta "StoreFont: CFF2 outline conversion failed", que é a decisão certa: um programa de fonte vazio num PDF distribuído é muito pior que um erro visível no seu log

Orçamentos e cancelamento para workloads de servidor

O terceiro parâmetro opcional, THPDFVariableFontControl, limita a memória e permite cancelar uma conversão longa. Sem ele o conversor usa os seus defaults: um limite de 128 MiB na entrada e saída de fonte, um orçamento de trabalho cumulativo de 1 GiB, e o limite CFF1 de 65535 bytes por CharString

type
  TFontJob = class
  private
    FCancelled: Boolean;
    procedure CheckCancel;
  public
    function Convert(const Source: TBytes): TBytes;
    property Cancelled: Boolean read FCancelled write FCancelled;
  end;

procedure TFontJob.CheckCancel;
begin
  if FCancelled then
    Abort;  // propaga para fora do HPDFConvertCFF2ToCFF
end;

function TFontJob.Convert(const Source: TBytes): TBytes;
var
  Control: THPDFVariableFontControl;
begin
  Control := THPDFVariableFontControl.Create(
    32 * 1024 * 1024,           // máximo de bytes de saída
    Int64(256) * 1024 * 1024,   // orçamento de trabalho cumulativo
    CheckCancel);
  try
    // seleção nil: instância por omissão de uma fonte variável, ou uma fonte CFF2 estática
    Result := HPDFConvertCFF2ToCFF(Source, nil, Control);
  finally
    Control.Free;
  end;
end;

Exceder orçamentos e cancelamento são reportados como exceções, deliberadamente distintos do caminho de resultado vazio. Um array vazio significa que a fonte não pode ser convertida; uma exceção significa que esta tentativa foi parada. Mantenha os dois separados no seu tratamento de erros, porque voltar a tentar uma fonte não conversível com um orçamento maior não ajuda

Quanto pesa uma fonte CFF2 embutida?

A principal coisa a planear é o tamanho do ficheiro: o HotPDF embute o conjunto completo de glyphs convertidos para fontes CFF2, esteja o EnableFontSubsetting ligado ou desligado. Uma fonte com vários milhares de glyphs acrescenta todos a cada documento que a usa, mesmo que a página mostre um título. Para um folheto é aceitável. Para um servidor de relatórios a escrever milhares de PDFs pequenos é um custo real que deve medir antes de se comprometer

As funcionalidades de tamanho relacionadas seguem a mesma regra. Fontes CFF2 convertidas são embutidas inteiras, por isso nem os subconjuntos compactos de fontes ativados pelo CompressDocument nem a cache persistente de subconjuntos de fontes lhes aplica. Fontes TrueType no mesmo documento continuam a sofrer subset e cache como habitual. A única vantagem de manter todos os glyphs é que fontes cuja licença proíbe subsetting são embutidas numa forma que o respeita

Os limites de recursos são limites de conversão finitos, e o próprio CFF1 capitula o que um único CharString pode conter. Um glyph cujo CharString convertido excede 65535 bytes, ou uma fonte que rebente com o orçamento de trabalho, falha visivelmente em vez de ser escrita com um contorno vazio. Esse é o trade-off pretendido: uma falha ruidosa numa fonte exótica ganha a um documento com glyphs silenciosamente em falta

O texto CFF2 nativo e o conversor CFF1 portável viajam com o HotPDF Delphi PDF component para Delphi e C++Builder, ao lado das funcionalidades de instanciação de fontes variáveis, glyphs de cor e subsetting de fontes cobertas noutros artigos deste blog