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>
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 theTTF_Font *— a caller that fetched it earlier, ahud_draw_scorethat 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.xandyare the top-left corner of the text, not a centre. wraplengthgreater than 0 wraps on word boundaries at that pixel width. 0 or less draws a single line and breaks only on newlines intext.
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-127still documents the empty string as refused, andtext.h:120-122still warns that a failure after rasterizing leaks the surface and the texture. Both describe pre-0.5.0 behaviour.src/text.c:110-112returns success for"", andsrc/text.c:140-146destroys both objects in aCLEANUPblock that runs on every path.TODO.mditems 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.