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

224 lines
10 KiB
Markdown

# 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 <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](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 <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:
```c
#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](10-spritesheets-and-sprites.md). Two names pointing at the same `.ttf` at
the same size are two open fonts, not one shared one.