Glifos shaped renderizam como caixas .notdef quando um subsetter de fonte mantém só os glifos alcançáveis a partir dos code points emitidos. O HotPDF, o componente VCL nativo de PDF para Delphi e C++Builder, carregou exatamente esse defeito até a versão 2.435.0: a saída do GSUB do OpenType era registrada em um bitmap interno de uso que o subsetter declarava que iria respeitar e depois nunca de fato lia
Essa é uma falha diferente da descrita no bug do EndDoc que desativava silenciosamente o font subsetting. Aquele bug era sobre quando o subsetting rodava em relação à serialização, e ele desativava o subsetting por completo. Este é sobre o que o subset contém quando o subsetting roda perfeitamente no momento certo. O pipeline dispara na hora certa, o prefixo de seis letras do subset aparece em /BaseFont exatamente como a ISO 32000-1 §9.6.4 exige, o arquivo fica menor, toda página em Latim passa na prova limpa, e uma página em árabe sai como uma fileira de retângulos vazios. Bugs de ordenação são barulhentos assim que você olha. Bugs de closure ficam quietos para sempre, porque o subset é estruturalmente válido e só está errado sobre sua própria lista de membros
Por que glifos shaped renderizam como .notdef?
Porque o conjunto de code points que um documento emite não é o conjunto de glifos que o documento desenha, e um subsetter que confunde os dois descarta todo glifo produzido pelo shaping. Text shaping transforma uma sequência lógica de caracteres em uma sequência posicionada de glifos, e todo seu propósito é produzir glifos que nenhum caractere único de entrada mapeia: um heh medial árabe, uma ligadura fi, um conjunto devanágari, um alternate contextual selecionado pela feature rclt. Cada um desses é um glyph ID que uma lookup GSUB fabricou, não um que a tabela cmap te dá para qualquer caractere da sua string. Um subsetter guiado puramente pelo cmap está, portanto, percorrendo o índice errado. Ele mantém fielmente todo glifo que o texto poderia ter usado antes do shaping e descarta precisamente os glifos que o texto de fato usa depois do shaping. O renderizador então pede à fonte embutida o GID 1847, o subset zerou essa entrada em loca, e o glyph index 0 volta no lugar. O glyph index 0 é .notdef por definição do OpenType, e é por isso que a assinatura da falha é uma caixa vazia em vez de uma letra errada ou um crash. Nada no PDF está malformado; a fonte simplesmente não contém o glifo que o content stream pediu
Code points não são glifos: as três fontes de um subset
Uma closure de subset correta precisa unir três fontes independentes, cada uma com seu próprio acumulador. A primeira é o conjunto derivado de code points: o HotPDF acumula FUnicodeUsedCps conforme caracteres do BMP são emitidos e FUnicodeSmpUsed para caracteres do plano suplementar alcançados via pares substitutos, depois mapeia cada um através de FUnicodeCpToGid para um glyph ID. A segunda é o conjunto derivado de shaping, os glyph IDs que uma substituição GSUB produziu, registrados através de MarkUnicodeGlyphUsed e EnableShapingFeatureForSubset em FUnicodeExtraUsedGlyphs. A terceira é closure composta: um glifo cujo numberOfContours é -1 em glyf é montado a partir de glyph IDs de componentes, e manter o composto enquanto descarta seus componentes produz um contorno vazio em vez de um .notdef, o que é discutivelmente pior porque parece um bug de espaçamento
O HotPDF sempre tratou a primeira e a terceira. BuildAndApplyUnicodeFontSubset, o ponto de entrada do subsetting que o EndDoc chama antes da serialização, semeia o array de glifos usados com o GID 0, percorre os code points do BMP, percorre a lista de uso do SMP, e entrega o array a um construtor de subset que resolve componentes compostos internamente. A segunda fonte foi escrita mas nunca consumida, e como as três fontes falham em conteúdos diferentes, a lacuna pode ficar escondida por anos em uma base de código cujo corpus de regressão é majoritariamente em Latim
O array que foi escrito e nunca lido
O contrato foi documentado em três lugares e honrado em nenhum deles. A declaração de FUnicodeExtraUsedGlyphs afirmava que o subsetter do EndDoc a une com o uso derivado de code points; o comentário de cabeçalho em ApplyArabicGSUBRefinement prometia que todo GID substituto emitido é passado por MarkUnicodeGlyphUsed para que o subsetter puxe o glifo para dentro da fonte embutida; a mesma promessa aparece textualmente em ApplyArabicGSUBContextualRefinement para o caminho rclt. Ambos os chamadores cumpriram sua metade. Um grep sobre toda referência ao campo resolveu a outra metade em cerca de noventa segundos: uma declaração, uma alocação SetLength dentro de RegisterUnicodeTTF, e escritas nas duas rotinas de marcação. Nenhuma leitura sequer. Esse é o diagnóstico que vale a pena internalizar, porque generaliza bem para além de fontes. Quando um campo é escrito por vários call sites e lido por nenhum, a feature que ele representa não existe, por mais completamente que esteja comentada. O passo 1 do subsetter é pequeno o suficiente para ler em uma tela, e a lacuna é óbvia assim que você sabe procurar por ela
// Step 1: derive the used-glyph set (as it stood before 2.435.0)
SetLength(UsedGlyphs, FUnicodeNumGlyphs);
for I := 0 to FUnicodeNumGlyphs - 1 do
UsedGlyphs[I] := False;
UsedGlyphs[0] := True; // .notdef is always present
for Cp := 0 to $FFFF do // source 1a: BMP code points
if (Cp < Length(FUnicodeUsedCps)) and FUnicodeUsedCps[Cp]
and (Cp < Length(FUnicodeCpToGid)) then
begin
GID := FUnicodeCpToGid[Cp];
if (GID > 0) and (GID < FUnicodeNumGlyphs) then
UsedGlyphs[GID] := True;
end;
for I := 0 to High(FUnicodeSmpUsed) do // source 1b: SMP code points
begin
GID := FUnicodeSmpUsed[I].GID;
if (GID > 0) and (GID < FUnicodeNumGlyphs) then
UsedGlyphs[GID] := True;
end;
// source 2 was missing here: nothing ever consulted FUnicodeExtraUsedGlyphs
O conserto de um laço, e como marcar glifos você mesmo
O conserto é uma união, e seu argumento de segurança vem da direção da operação: ela só define bits, nunca os limpa, então nenhum glifo que costumava sobreviver ao subset pode começar a ser descartado
// v2.435.0: pull GSUB-derived extra glyphs into the subset.
// MarkUnicodeGlyphUsed / EnableShapingFeatureForSubset record GIDs that
// shaping produced but that no emitted code point maps to directly.
for I := 0 to FUnicodeNumGlyphs - 1 do
if (I < Length(FUnicodeExtraUsedGlyphs)) and FUnicodeExtraUsedGlyphs[I] then
UsedGlyphs[I] := True;
Três propriedades tornam isso uma mudança de baixo risco em vez de uma reescrita do motor de fontes. É monótona, como acima. É um no-op em fontes que nunca deram shape em nada, já que FUnicodeExtraUsedGlyphs permanece todo Falso e a saída em bytes para um documento só-em-Latim fica inalterada. E ela entra antes do passo 2, então ambos os construtores de subset a herdam: o construtor esparso que preserva a numeração de GID original, e o construtor compacto _BuildCompactSubsetTTF que o HotPDF seleciona sob PDF/A para renumerar glifos mantidos em um intervalo denso, encolher maxp.numGlyphs, e emitir o mapeamento antigo-para-novo como o stream /CIDToGIDMap exigido pela ISO 32000-1 §9.7.4.2. Ambos chamam _TTFWalkCompositeClosure internamente, então um glifo shaped que por acaso é composto agora também arrasta seus componentes junto. A closure composta nunca esteve quebrada; ela simplesmente nunca era alcançada para esses glyph IDs, porque os glyph IDs não estavam no conjunto que ela percorre. Se você conduz o motor GSUB diretamente em vez de confiar nas passagens de refinamento embutidas, a closure vira sua responsabilidade, e todo glyph ID substituto que você emitir precisa ser marcado antes que o EndDoc congele o conjunto de glifos usados
var
Pdf: THotPDF;
GIDs: array[0..1] of Word;
LigGID: Word;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.FileName := 'shaped.pdf';
Pdf.BeginDoc;
Pdf.RegisterUnicodeTTF('C:\Fonts\NotoNaskhArabic-Regular.ttf');
Pdf.ShapingFeatures := [sfArabicGSUB, sfStandardLigatures,
sfContextualAlternates];
GIDs[0] := Pdf.GetUnicodeGlyphForCodepoint($0644); // lam
GIDs[1] := Pdf.GetUnicodeGlyphForCodepoint($0627); // alef
if Pdf.ApplyLigatureSubstitution(GIDs, 0, 'liga', LigGID) then
Pdf.MarkUnicodeGlyphUsed(LigGID); // omit this and you get .notdef
Pdf.EnableShapingFeatureForSubset('rclt');
Pdf.CurrentPage.RtLTextOut(50, 700, 0, WideString(ArabicText));
Pdf.EndDoc;
finally
Pdf.Free;
end;
end;
EnableShapingFeatureForSubset é a contraparte em lote da chamada de GID único, e é deliberadamente conservadora. Ela percorre a lista de lookups do GSUB em busca das lookups ligadas a uma feature tag de quatro bytes sob o caminho de script e language atualmente selecionado, e marca os glyph IDs substitutos que essas lookups podem produzir. É um no-op defensivo quando a fonte não carrega tabela GSUB ou quando a feature está ausente daquele caminho, então chamá-la incondicionalmente é seguro. Também é uma superaproximação por design: ela pode manter glifos que um determinado documento nunca desenha. Para subsetting, superinclusão custa bytes e subinclusão custa correção, o que torna essa troca fácil de fazer. A estrutura dessas lookups, e as coverage tables que decidem quais glifos participam, é coberta no passo a passo sobre alternates estilísticos GSUB em Delphi puro
Como provar que o glifo está realmente no subset?
Lendo a fonte emitida, não olhando a página em um visualizador que pode estar substituindo uma fonte do sistema pelas suas costas. A checagem que pega toda essa classe de bug é mecânica: extraia o stream /FontFile2 do PDF de saída, analise loca, e confirme que o glyph ID esperado carrega uma entrada não vazia, ou seja, seus offsets de início e fim diferem. Uma entrada vazia é o subsetter tendo decidido que o glifo não é usado. Dois hábitos então tornam muito mais difícil enviar essa falha de novo. Mantenha uma página em script shaped no corpus de smoke test automatizado em vez de só no conjunto de prova manual, porque árabe, devanágari, e khmer exercitam caminhos de closure que nenhuma cobertura em Latim vai tocar. E sempre que existir um acumulador, verifique que algo o consome, já que um campo write-only é uma feature que compila, passa em teste no corpus errado, e não faz nada
Onde o conserto para
A closure de subset é necessária para um glifo shaped renderizar, e não é suficiente. O glifo também precisa ser endereçável a partir do content stream, o que é um problema separado com sua própria fronteira. As passagens embutidas de refinamento árabe do HotPDF só confirmam uma substituição quando todo glyph ID substituto é alcançável através de um code point de forma de apresentação Unicode via uma varredura reversa de cmap sobre aproximadamente 690 code points de U+FB50 a U+FDFF e U+FE70 a U+FEFF. Quando um substituto cai em um glyph ID fora desse intervalo, a janela de entrada passa inalterada em vez de emitir algo que o leitor não consegue endereçar; alternates específicos de fonte em glyph IDs arbitrários precisam de um code point de uso privado sintético alocado em U+E000 a U+F8FF para carregá-los através do caminho de emissão. Então o resumo honesto é que o conserto da 2.435.0 removeu um bloqueio duro em vez de completar a história. Antes dele, um glifo podia ser shaped corretamente, emitido corretamente, e ainda assim desaparecer no momento do subset, o que significava que o motor de shaping não podia ser confiado de ponta a ponta por mais boas que fossem suas lookups. O que resta é endereçabilidade, e essa restrição pelo menos falha visivelmente no ponto de emissão em vez de silenciosamente em um passo de build rodando depois de tudo que você estava observando. Para o lado de emissão do mesmo pipeline, veja o guia sobre shaping de texto árabe e RTL em PDFs Delphi
O font subsetting, o motor GSUB, e o shaping de scripts complexos descritos aqui vêm no HotPDF Component padrão para Delphi e C++Builder; a página de produto traz a referência completa de API para as chamadas de fonte Unicode e shaping citadas acima