Kreslíme font

... Petr Blahoš, 19. 10. 2023 Font Python

Náš počítač dokáže vzít text, a vykreslit jej pomocí zvoleného fontu na obrazovku. Jak to ale dělá? Jaká jsou vlastně ve fontu data, která umožní převést tu informaci, že máme znak, např. A, a že ho máme vykreslit jako písmeno skupinu ploch? Vezmeme Python, najdeme si font ve formátu ttf a podíváme se na to.

Začněte tím, že si někde najdete font. Buď vezměte rozumný font z Vašeho počítače, nebo si třeba na stáhněte Roboto. Připomínám, že chceme ttf.

Dále si v Pythonu3 udělejte virtuální prostředí, a nainstaujte si do něj fonttools a wxPython.

python3 -m venv FONTPLAY
cd FONTPLAY
. ./bin/activate
pip install fonttools wxPython

Čteme font

Projekt fonttools, původně od Google, umí tak nějak všechno, co bychom si mohli přát. Pro začátek jej použijeme k načtení fontu a získání informací o glyphech, tedy znacích, které font obsahuje.

from fontTools.ttLib import TTFont

font = TTFont('Roboto-Regular.ttf')

print(font.getGlyphNames())
print('a' in font.getGlyphNames())
print('A' in font.getGlyphNames())
print('=' in font.getGlyphNames())
print('1' in font.getGlyphNames())

Ve výpisu jmen glyphů vidíte, že jména písmen tam nalezneme, ale číslice, nebo speciální znaky (např =) ne. Na druhou stranu, zkuste najít třeba glyph se jménem one. No nic, pro teď si vystačíme s písmeny.

glyph_set = font.getGlyphSet()
a = glyph_set["A"]
print(a.name, a.width, a.height, a.lsb, a.tsb)

Vlastnosti name, width, a height jsou asi poměrně intuitivní. LSB a TSB neboli Left/Top Side Bearing jsou hodnoty, které říkají, kde se začíná kresba znaku. Neboli o kolik je znak posunot zleva a shora. Zajímavé je, že u fontů často vídám, že Height a TSB je None.

A co tvar? Zajímavé je, že objekt a má metodu draw. Ta nás bude zajímat.

Kreslíme font

Metoda draw očekává jako parametr pen. To jako by naznačovalo, že když jí předhodíme správný pen, tak nám pomocí něj vykreslí znak. pen je typu fontTools.pens.basePen. Tak se , který je v modulu fontTools.pens.ttGlyphPen. A jakápak zajímavá pera tady máme:

  • WxPen
  • QtPen
  • SVGPathPen
  • ReportLabPen
  • CocoaPen
  • CairoPen

A spoustu dalších, u kterých ani není na první pohled vidět, k čemu jsou. My ale chceme kreslit, pro začátek na obrazovku, tak začneme s WxPen.

import wx
from fontTools.pens.wxPen import WxPen
from fontTools.ttLib import TTFont


class MyFrame(wx.Frame):
    def __init__(self, ttfont: TTFont):
        wx.Frame.__init__(self, None, -1, "Font painter")
        self.font = ttfont
        self.Bind(wx.EVT_PAINT, self.on_paint)

    def on_paint(self, evt):
        evt.Skip()
        dc = wx.PaintDC(self)
        gc = wx.GraphicsContext.Create(dc)
        glyph = self.font.getGlyphSet()['A']
        pen = WxPen(self.font.getGlyphSet())
        glyph.draw(pen)
        gc.SetBrush(wx.Brush('BLACK', wx.BRUSHSTYLE_SOLID))
        gc.FillPath(pen.path)


def show_ui(font):
    app = wx.App()
    frame = MyFrame(font)
    frame.Show()
    frame.Maximize(True)
    app.MainLoop()

if "__main__" == __name__:
    show_ui(TTFont("Roboto-Regular.ttf"))

Pro nás zajímavá část je ta, kde vytváříme pen a kde jej používáme.

        # Vytvoříme pen pro sadu glyphů
        pen = WxPen(self.font.getGlyphSet())
        # Vykreslíme pomocí toho pera námi vybraný glyph
        glyph.draw(pen)
        # Vykreslíme pomocí GraphicsContextu
        gc.SetBrush(wx.Brush('BLACK', wx.BRUSHSTYLE_SOLID))
        gc.FillPath(pen.path)

Jestli se ptáte: Proč dostává pen sadu glyphů? Tak to proto, že některé fonty mají tzv. composite glyphs, tedy takové, které jsou složené z více glyphů. Například znak Č je složený z C a háčku. A při vykreslování se potřebuje dostat na ty podglyphy.

Ale teď: Jestliže jste tohle úspěšně spustili, vidíte asi velké A, tak velké, že se nevejde na obrazovku, a navíc je vzhůru nohama. Vzhůru nohama je snadné opravit. Co s velikostí? Musíme si zjistit nějakou základní velikost ve fontu, ke které všechno vztáhneme.

Font má v sobě uloženou tzv. units per em, což je počet jednotek fontu (tedy to je jednička v rámci souřadnic jednotlivých křivek), na jedno EM, což je zase typografická jednotka. (Jedno EM se dá naivně popsat jako šířka písmene velké M. Není to přesně takhle, ale nám to bude stačit.)

Takže kresbu posuneme, převrátíme a zmenšíme, třeba tak, aby baseline byla uprostřed obrazovky.

        height = self.GetSize()[1]
        units_per_em = self.font['head'].unitsPerEm
        scale = height / 4 / units_per_em

        m = gc.CreateMatrix()
        # Posuneme se na střed obrazovky
        m.Translate(0, height // 2)
        # Převrátíme a zmenšíme
        m.Scale(scale, -scale)
        pen.path.Transform(m)

Což nám vygeneruje tohle:

Písmeno A

(Kompletní kód najdete zde.)

Více písmenek vedle sebe

No dobře. Vykreslili jsme si písmeno A. Ale co když vedle něj budeme chtít ještě písmeno I? No, zkusíme to.

    def draw_glyph(self, gc, glyph_name, m, brush):
        glyph = self.font.getGlyphSet()[glyph_name]
        pen = WxPen(self.font.getGlyphSet())
        glyph.draw(pen)
        pen.path.Transform(m)
        gc.SetBrush(brush)
        gc.FillPath(pen.path)
        return glyph

    def on_paint(self, evt):
        evt.Skip()

        dc = wx.PaintDC(self)
        gc = wx.GraphicsContext.Create(dc)

        height = self.GetSize()[1]
        units_per_em = self.font['head'].unitsPerEm
        scale = height / 4 / units_per_em

        m = gc.CreateMatrix()
        m.Translate(0, height // 2)
        m.Scale(scale, -scale)

        self.draw_glyph(gc, 'A', m, wx.BLACK_BRUSH)
        self.draw_glyph(gc, 'I', m, wx.RED_BRUSH)

Tímto jsme je ovšem nakreslili přes sebe. Takže se musíme posunout, vždy o šířku glyphu. Nějak takhle:

        glyph = self.draw_glyph(gc, 'A', m, wx.BLACK_BRUSH)
        m.Translate(glyph.width, 0)
        glyph = self.draw_glyph(gc, 'I', m, wx.RED_BRUSH)
        m.Translate(glyph.width, 0)

Nebo ještě lépe:

        for name in "AIai":
            glyph = self.draw_glyph(gc, name, m, wx.BLACK_BRUSH)
            m.Translate(glyph.width, 0)

Hledáme unicode

Mapování unicode kódu na jméno glyphu najdeme v cmap tabulce. Asi takhle:

cmap = self.font['cmap'].getBestCmap()
cmap.get(ord('A')) == 'A'
cmap.get(ord('1')) == 'one'

Takže když budeme chtít tisknout i něco jiného, něž písmena, můžeme třeba takto:

        cmap = self.font['cmap'].getBestCmap()
        for unicode in "AIai&32\u06CF87Š":
            glyph_name = cmap.get(ord(unicode))
            if not glyph_name:
                glyph_name = ".notdef"
            glyph = self.draw_glyph(gc, glyph_name, m, wx.BLACK_BRUSH)
            m.Translate(glyph.width, 0)

Všimněte si toho .notdef, které se použije, pokud ten unicode ve fontu nebyl nalezen. S fontem Roboto dostavu něco takového:

Více písmen

(Kompletní kód...)

Co dál

Určitě jste slyšeli o kerningu. Na něj se zkusíme podívat příště.


Komentáře byly zrušeny
V EU teď máme složitou situaci s Cookies. Na komentáře jsem používal jistou službu třetí strany. Ta však používá Cookies poměrně, ehm, benevolentně. Tak jsem se rozhodl komentáře zrušit. Pokud chcete, můžete mi napsat přímo