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
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
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
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