Usando emojis no matplotlib

Traduções: English (en)
Data de publicação: 20/07/2022
Categorias: matplotlib, emoji

Mês passado, enquanto eu escrevia o artigo com todas as estatísticas para o aniversário de dois anos do blog, o artigo "Estatísticas do blog após dois anos", eu decidi que eu queria um gráfico com emojis. Assim que eu pensei nisso eu sabia que não iria funcionar de primeira e que eu estava me condenando a algumas horas investigando erros.

Na primeira tentativa de gerar um gráfico com emojis, foi isso que saiu:

{image}/emojis-traced.png

Esses emojis só tem o contorno e alguns estão até faltando. Eu queria que o matplotlib usasse uma fonte própria para emoji, como a NotoColorEmoji ou twemoji, que são coloridas e bonitas. As duas estavam instaladas no meu sistema mas não estavam sendo detectadas automaticamente pelo matplotlib.

Depois de pesquisar um pouco eu descobri como adicionar uma fonte explicitamente ao matplotlib:

matplotlib.font_manager.fontManager.addfont("/usr/share/fonts/noto/NotoColorEmoji.ttf")

E eu também adicionei fontname="noto color emoji" à chamada do matplotlib que deveria renderizar usando essa fonte (no meu caso xticks()).

Forçando o matplotlib a usar a fonte de emoji que eu queria desse jeito realmente fez os emojis só com contorno pararem de aparecer, mas também fez a imagem inteira parar de aparecer 😝, agora a única coisa que eu tinha era traceback:

 Traceback (most recent call last):
   File "/home/nfraprado/emoji.py", line 3, in <module>
     matplotlib.font_manager.fontManager.addfont('/usr/share/fonts/noto/NotoColorEmoji.ttf')
   File "/usr/lib/python3.10/site-packages/matplotlib/font_manager.py", line 1092, in addfont
     font = ft2font.FT2Font(path)
 RuntimeError: In FT2Font: Could not set the fontsize (error code 0x17)

Com código de erro até, me convidando a entrar mais fundo 😆. Como essa área de fontes não me era familiar, a primeira coisa a fazer era descobrir é o que é o FT2Font. Navegando no fonte descobri que ele é o código do matplotlib responsável por lidar com o FreeType, a biblioteca que renderiza as fontes.

A mensagem de erro do traceback estava vindo dessa linha, o que significa que FT_Set_Char_Size(), que é uma função do freetype, estava falhando com o código de erro 0x17. Uma checada na referência de códigos de erro do freetype mostrou que ele significava "invalid pixel size".

Enquanto eu tentava descobrir o que diferia as fontes de emoji de fontes normais, eu reparei em algo que parecia ser fundamental: Rodar fc-scan nas fontes de emoji revelava que elas tinham uma propriedade pixelsize que não estava presente em fontes normais. Além disso, rodar ftview (do pacote freetype2-demos) nas fontes de emoji passando qualquer tamanho resultava nos emojis sendo desenhados sempre do mesmo tamanho, enquanto fontes normais eram redimensionadas corretamente.

A essa altura eu precisava realmente entrar no código, então eu clonei o fonte do matplotlib e do freetype, compilei, e configurei o ambiente para que eu conseguisse usá-los: instalei o matplotlib em um ambiente virtual com pip install -e . e apontei a variável de ambiente LD_LIBRARY_PATH para a pasta contendo os arquivos resultantes da compilação do freetype.

Depois de habilitar os logs de debug no freetype (com FT2_DEBUG=any:5) e adicionar alguns logs meus, eu percebi que a diferença no código que roda para as fontes de emoji é por conta de FT_HAS_FIXED_SIZES ser verdadeiro, cujo significado pode ser visto aqui.

Então resumindo o problema (até onde eu entendo): as fontes de emoji são compostas por imagens embutidas, que possuem um tamanho específico. Isso é indicado com FT_HAS_FIXED_SIZES sendo verdadeiro, e o tamanho dessas imagens aparece como o atributo pixelsize. Quando o matplotlib vai desenhar a fonte, ele especifica o tamanho em que ele quer desenhar a fonte, mas já que a fonte de emoji tem um tamanho fixo, o código do freetype espera que o tamanho passado seja o mesmo tamanho da fonte. Isso porque você pode ter múltiplas versões das imagens, com diferentes tamanhos, dentro de uma mesma fonte, então o freetype iria escolher aquela que tem o tamanho que foi pedido.

Com isso em mente, eu fiz essa modificação:

diff --git a/src/base/ftobjs.c b/src/base/ftobjs.c
index f66273f3d3cb..a59a61119702 100644
--- a/src/base/ftobjs.c
+++ b/src/base/ftobjs.c
@@ -3074,6 +3074,7 @@
     FT_Int   i;
     FT_Long  w, h;

+    ignore_width = 1;

     if ( !FT_HAS_FIXED_SIZES( face ) )
       return FT_THROW( Invalid_Face_Handle );
@@ -3101,8 +3102,6 @@
       FT_Bitmap_Size*  bsize = face->available_sizes + i;


-      if ( h != FT_PIX_ROUND( bsize->y_ppem ) )
-        continue;

       if ( w == FT_PIX_ROUND( bsize->x_ppem ) || ignore_width )
       {

O que essa mudança faz é forçar o freetype a retornar a primeira opção de imagem dentro da fonte, mesmo que ela não tenha o tamanho que foi pedido.

Com isso feito, um outro erro passou a ocorrer, agora no matplotlib:

Traceback (most recent call last):
  File "/home/nfraprado/emoji.py", line 3, in <module>
    matplotlib.font_manager.fontManager.addfont('/usr/share/fonts/noto/NotoColorEmoji.ttf')
  File "/home/nfraprado/matplotlib/lib/matplotlib/font_manager.py", line 1134, in addfont
    prop = ttfFontProperty(font)
  File "/home/nfraprado/matplotlib/lib/matplotlib/font_manager.py", line 545, in ttfFontProperty
    raise NotImplementedError("Non-scalable fonts are not supported")
NotImplementedError: Non-scalable fonts are not supported

Então só agora o matplotlib estava reclamando que a fonte não é redimensionável, o que deveria ter sido o erro desde o início... Enfim, eu estava curioso para ver se isso tudo iria funcionar no fim, então eu simplesmente removi essa checagem:

diff --git a/lib/matplotlib/font_manager.py b/lib/matplotlib/font_manager.py
index f57fc9c051b0..08d8c28752cb 100644
--- a/lib/matplotlib/font_manager.py
+++ b/lib/matplotlib/font_manager.py
@@ -541,8 +541,6 @@ def ttfFontProperty(font):
     #  Length value is an absolute font size, e.g., 12pt
     #  Percentage values are in 'em's.  Most robust specification.

-    if not font.scalable:
-        raise NotImplementedError("Non-scalable fonts are not supported")
     size = 'scalable'

     return FontEntry(font.fname, name, style, variant, weight, stretch, size)

E finalmente obtive alguma saída:

{image}/emojis-bw-unscaled.png

Os emojis estão em preto e branco, e não estão do tamanho certo, mas é um avanço. Nessa hora, minha saída estava parecida com a que eu tinha visto em um artigo de blog e issue no Github linkada enquanto estava pesquisando. O problema nesse caso era o formato TTC, então não era a mesma coisa, mas a alternativa de usar um outro backend que tivesse um suporte melhor para emojis, mplcairo, parecia promissora.

Então eu instalei o mplcairo com o pip e fiz o script usar ele com

matplotlib.use("module://mplcairo.gtk")

E essa foi a saída:

{image}/emojis-colored-scaled.png

Então os emojis realmente renderizaram coloridos e com o tamanho certo com esse backend! O único problema era que o texto no eixo Y estava rotacionado, mas uma busca rápida mostrou que isso já estava consertado no repositório, então eu reinstalei o mplcairo do git e tudo funcionou perfeitamente!

Quando eu comecei a investigar esse problema eu pensei que fosse acabar enviando alguma modificação consertando a fonte, o freetype ou o matplotlib, mas com o meu entendimento atual, eu não tenho certeza qual seria a forma correta de consertar o problema. Eu precisaria investigar um pouco mais para saber, mas dado que eu consegui fazer funcionar para o meu uso, apesar de com algumas gambiarras, eu provavelmente não vou mais investigar esse problema por enquanto.

Dito isso, o meu entendimento é que o mplcairo faz o redimensionamento da fonte por conta própria para fontes de tamanho fixo (emojis), então fazer o mplcairo checar os tamanhos disponíveis na fonte e passar para o freetype aquele que for mais próximo do tamanho que vai ser desenhado, poderia ser uma forma de se livrar da gambiarra no freetype. Já quanto à gambiarra no matplotlib, talvez fontes de tamanho fixo poderiam ser permitidas quando o o backend mplcairo estivesse sendo usado, já que ele claramente sabe lidar com elas. Mas, de novo, mais investigação necessária para concluir de fato.

De qualquer forma, com essas duas gambiarras mostradas aqui e o backend mplcairo, eu consegui gerar o gráfico com os emojis mais frequentemente usados que está no final do artigo "Estatísticas do blog após dois anos". O script completo que gerou esse gráfico pode ser visto aqui.

Por fim, para garantir a reprodutibilidade, vale mencionar que tudo isso foi testado com os pacotes nos seguintes commits:

  • mplcairo: 74c27c3dbd54 ("Tweak path search in build-windows-wheel.")
  • matplotlib: 60ae76b6b5b0 ("Merge pull request #23243 from timhoffm/take-over-22839")
  • freetype: e7482ff4c2a3 ("* src/lzw/ftzopen.c (ft_lzwstate_stack_grow): Cosmetic macro change.")