# 16. Text and fonts **SDL3_ttf owns fonts.** It opens the file, rasterizes the glyphs, and computes the metrics; its API and its behaviour are documented in the [SDL3_ttf wiki](https://wiki.libsdl.org/SDL3_ttf/). This chapter covers the three things libakgl adds on top: a name-keyed font registry, a one-call rasterize-and-blit, and two measure functions that need no window. There is no akgl font type. A font is a `TTF_Font *`, and what libakgl gives you is a place to keep it. ## Fonts live in a registry, keyed by a name you choose `akgl_text_loadfont(name, filepath, size)` opens the file and publishes the handle in `AKGL_REGISTRY_FONT` under `name`. The name is yours — it is not derived from the file — and `filepath` is used verbatim, not resolved against `SDL_GetBasePath()`. **The size is baked into the handle, so one file at two sizes is two fonts under two names.** That is SDL_ttf's model, not libakgl's, and there is no way around it: ```c #include #include #include #include #include #include /* Two sizes of one file are two fonts under two names. */ akerr_ErrorContext *hud_load_fonts(char *path) { PREPARE_ERROR(errctx); PASS(errctx, akgl_text_loadfont("hud", path, 14)); PASS(errctx, akgl_text_loadfont("title", path, 32)); SUCCEED_RETURN(errctx); } akerr_ErrorContext *hud_draw_score(int score) { TTF_Font *font = NULL; SDL_Color white = { 255, 255, 255, 255 }; char line[32]; int count = 0; PREPARE_ERROR(errctx); /* The registry hands back a raw TTF_Font *. It is not reference counted: nothing here takes a reference and nothing gives one back. */ font = (TTF_Font *)SDL_GetPointerProperty(AKGL_REGISTRY_FONT, "hud", NULL); FAIL_ZERO_RETURN(errctx, font, AKERR_KEY, "No font registered as \"hud\""); PASS(errctx, aksl_snprintf(&count, line, sizeof(line), "SCORE %d", score)); PASS(errctx, akgl_text_rendertextat(font, line, white, 0, 8, 8)); SUCCEED_RETURN(errctx); } ``` **Fonts are not reference counted.** The four asset pools in [Chapter 5](05-the-heap.md) all count references; the font registry does not. It holds a bare pointer. Two consequences: - `akgl_text_unloadfont(name)` closes the font whether or not anything is still using it. Anything holding the `TTF_Font *` — a caller that fetched it earlier, a `hud_draw_score` that fetched it last frame and cached it — is left with a dangling pointer. - Loading over an existing name replaces the entry and closes the font it displaced. The new font is opened **first**, so a failed load leaves you with the font you already had. `akgl_text_unloadfont` on a name that is not registered raises `AKERR_KEY`, which makes a double unload an error rather than a double close. `akgl_text_loadfont` raises `AKERR_KEY` when it cannot write the registry — in practice, because `akgl_registry_init_font()` has not run. ## The teardown ordering trap **`akgl_text_unloadallfonts()` must run before `TTF_Quit()` and `SDL_Quit()`.** This is the one ordering constraint in the subsystem and it is easy to get wrong, because nothing fails loudly when you do. ```c #include #include #include #include /* Shutdown order. Both of the other two orderings are wrong. */ void shutdown_text(void) { IGNORE(akgl_text_unloadallfonts()); TTF_Quit(); SDL_Quit(); } ``` A font is reachable only by name, through `AKGL_REGISTRY_FONT`, which is an SDL property set. So: | Order | What happens | |---|---| | `unloadallfonts`, `TTF_Quit`, `SDL_Quit` | Correct. Every font closed, then the registry destroyed, then SDL | | `SDL_Quit` first | **SDL_Quit destroys the property registry, taking the last reference to every font with it.** They are never closed and are now unreachable — a leak bounded by how many fonts you loaded, with no way to do anything about it | | `TTF_Quit` first | The `TTF_Font` handles are already invalid; `unloadallfonts` then closes freed pointers | `akgl_text_unloadallfonts` enumerates the registry, closes every font, destroys the property set and sets `AKGL_REGISTRY_FONT` to 0. It has no failure path: an uninitialized registry is success rather than an error, because shutdown paths run after partial startups. **Afterwards the registry is gone.** `akgl_text_loadfont` needs `akgl_registry_init_font()` again before it can register anything, so this is a shutdown call rather than a between-scenes one. To swap fonts mid-game, use `akgl_text_unloadfont` per name. ## Drawing is rasterize, upload, blit, destroy — every call `akgl_text_rendertextat(font, text, color, wraplength, x, y)` does the whole job in one call: renders blended through SDL_ttf, uploads the surface as a texture, draws it through `akgl_renderer->draw_texture`, and destroys both the texture and the surface before it returns. **There is no cache.** Every call pays the full cost. `PERFORMANCE.md` puts numbers on it, on a 640x480 software-rasterized frame: | Operation | Cost | |---|---:| | `akgl_text_rendertextat`, 15 characters | 12,601.7 ns | | `akgl_text_measure`, 15 characters | 37.3 ns | | Six lines of HUD text | 0.076 ms — 0.5% of a 16.67 ms frame | Measuring is 340 times cheaper than drawing, which tells you the whole cost is the rasterize and the upload. **Six HUD readouts is 76 µs a frame. That is fine at 60 fps on a laptop and it is 4% of a 2 ms GPU frame** — and it is being paid every frame for a score that changes once a second. A one-line cache keyed on (font, string, colour) would take it to nothing; `PERFORMANCE.md` calls it the single clearest optimisation in the library, and `TODO.md` carries it as a target. So: **fine for a HUD line, wrong for a static body of text redrawn every frame.** If you are drawing a page of dialogue that does not change, rasterize it yourself once with SDL3_ttf, keep the texture, and blit it through `akgl_renderer->draw_texture`. Two more properties of the draw: - **Coordinates are screen coordinates, not world ones.** This does not go through `akgl_camera`, so a HUD stays put while the world scrolls under it. `x` and `y` are the top-left corner of the text, not a centre. - **`wraplength` greater than 0 wraps on word boundaries at that pixel width.** 0 or less draws a single line and breaks only on newlines in `text`. The renderer guards are worth knowing because of what they catch. `akgl_text_rendertextat` raises `AKERR_NULLPOINTER` if `akgl_renderer`, its `sdl_renderer`, or its `draw_texture` is `NULL` — and that last one is the state a backend is in between being allocated and being run through `akgl_render_2d_bind()`. Reusing `AKERR_NULLPOINTER` for a failed rasterize or a failed texture upload is the same status doing double duty; the message carries `SDL_GetError()` and tells you which. ## Measuring needs no renderer and no window `akgl_text_measure` and `akgl_text_measure_wrapped` touch nothing but the font. They work before `akgl_render_2d_init`, and in a program that never creates a window at all. A caller building a character grid measures one cell and derives the rest from it: ```c #include #include #include /* Measuring touches no renderer and no window, so a grid can be derived before anything is on screen. */ akerr_ErrorContext *cell_size(TTF_Font *font, int *cellw, int *cellh) { PREPARE_ERROR(errctx); PASS(errctx, akgl_text_measure(font, "M", cellw, cellh)); SUCCEED_RETURN(errctx); } /* A negative wrap length is refused rather than passed through: SDL_ttf reads it as a very large unsigned width and silently stops wrapping. */ akerr_ErrorContext *box_size(TTF_Font *font, char *body, int width, int *w, int *h) { PREPARE_ERROR(errctx); PASS(errctx, akgl_text_measure_wrapped(font, body, width, w, h)); SUCCEED_RETURN(errctx); } ``` The two differ in exactly the way `akgl_text_rendertextat`'s `wraplength` argument suggests: | Function | Height reported | Width reported | |---|---|---| | `akgl_text_measure` | One line, whatever the text contains | The whole string on one line | | `akgl_text_measure_wrapped` | Every line the text wraps onto | The longest line, not `wraplength` | **A negative `wraplength` is refused with `AKERR_OUTOFBOUNDS`**, and that is a deliberate libakgl decision rather than a passthrough. SDL_ttf takes the wrap width as an `int` and reads a negative one as a very large *unsigned* width, which silently disables wrapping — returning a measurement that is wrong rather than an error. A `wraplength` of 0 is legal and wraps on newlines only. ## The empty string Both halves accept it. `akgl_text_measure("")` returns 0 wide by one line high, so a cursor sitting on an empty line has somewhere to be, and `akgl_text_rendertextat` with `""` returns success without rasterizing anything. That is worth stating because it was not always true and the header still says otherwise. SDL_ttf refuses the empty string from both rasterizers with "Text has zero width", and until 0.5.0 `akgl_text_rendertextat` passed that on as `AKERR_NULLPOINTER` — so the two halves of one header disagreed about one string, and a caller drawing a line that might be empty had to check for it. It is a `SUCCEED_RETURN` now, placed *after* the font, text and backend guards, so drawing nothing still refuses everything drawing something refuses. > **Stale header prose.** `include/akgl/text.h:123-127` still documents the empty string as > refused, and `text.h:120-122` still warns that a failure after rasterizing leaks the > surface and the texture. Both describe pre-0.5.0 behaviour. `src/text.c:110-112` returns > success for `""`, and `src/text.c:140-146` destroys both objects in a `CLEANUP` block that > runs on every path. `TODO.md` items 27 and 23 record both as fixed. The header comments > want correcting in their own commit. ## Fonts, the pools, and what is not shared Nothing about fonts goes through the akgl heap. A `TTF_Font` is about ten kilobytes once FreeType's own structures are counted, it is allocated by SDL_ttf, and it is freed by `TTF_CloseFont`. The registry stores a pointer and nothing else. That is different from spritesheets, which *are* pooled and *are* shared by resolved path — see [Chapter 10](10-spritesheets-and-sprites.md). Two names pointing at the same `.ttf` at the same size are two open fonts, not one shared one.