Files
libakgl/docs/16-text-and-fonts.md
Andrew Kesterson b938460127 Add the manual: nineteen chapters and a corrected README
docs/ is a narrative manual, not a second reference. Every header already
carries a substantial @file/@brief block and Doxyfile sets WARN_IF_UNDOCUMENTED
with WARN_AS_ERROR, so an undocumented symbol already fails CI. The gap was
navigation and worked examples. Chapters teach a task and link to the Doxygen
output; where a declaration or a constant table has to be in front of the
reader it arrives as a `c excerpt=` block, so the text *is* the header and
cannot diverge from it. That also preserves the hand-aligned bit-flag tables
scripts/reindent.el goes out of its way not to destroy.

The manual does not re-document its dependencies. libakerror owns the
ATTEMPT/CLEANUP/PROCESS/HANDLE/FINISH protocol, SDL3 owns renderers and events,
Tiled owns the map format, jansson owns json_t. A chapter that restated any of
them would be wrong the day upstream changed and nothing here would notice --
the same drift this work exists to fix, arriving from a different direction. So
each chapter says what libakgl adds or constrains and links out for the rest.

Chapter 4 is the exception and the reason for it: libakerror documents the
mechanism, but only libakgl can say which statuses its own functions raise and
what they mean here, and that was written down nowhere. It carries three tables
-- libakgl's five status codes, the libakerror statuses libakgl actually raises
with their meaning in this library, and the exit-status trap where
`exit(AKGL_ERR_SDL)` is a wait status of 0 because the band starts at 256.

Every chapter was written against src/ rather than against the header comments,
which is how 27 false claims in those comments came to light. Where a chapter
documents a known defect rather than a design decision it says so and points at
TODO.md.

README.md keeps the development process and hands the reader to docs/. Its
task-oriented FAQ is deleted rather than moved, because one source of truth per
topic is the whole point and that FAQ's examples did not compile.

Census: 39 compiled snippets, 89 verbatim header excerpts, 4 JSON documents run
through the real loaders, one linked-and-executed program with its output
compared byte for byte, one generated figure. 11 norun blocks, each justified.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 20:58:37 -04:00

10 KiB

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

#include <akerror.h>
#include <akstdlib.h>
#include <SDL3_ttf/SDL_ttf.h>
#include <akgl/game.h>
#include <akgl/registry.h>
#include <akgl/text.h>

/* 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 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.

#include <akerror.h>
#include <SDL3/SDL.h>
#include <SDL3_ttf/SDL_ttf.h>
#include <akgl/text.h>

/* 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:

#include <akerror.h>
#include <SDL3_ttf/SDL_ttf.h>
#include <akgl/text.h>

/* 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. Two names pointing at the same .ttf at the same size are two open fonts, not one shared one.