ttf_words.c: TTF-TEXT entry point, hmtx advance widths (item 4.3.7e)

Punch list §25 item 4.3.7e complete. New src/word_source/ttf_words.c
registers TTF-TEXT ( c-addr u x y size color -- ): lazily loads the v1
default font capsule + raster cache once, decodes UTF-8 (C
reimplementation mirroring capsules/fabric.4th's DECODE-UTF8 exactly),
looks up each glyph's cached bitmap, blits via fb_put_pixel, advances
the pen by the glyph's real hmtx advance width scaled to pixels.

Necessary plumbing: ttf_parse() now also locates hhea/hmtx, and
ttf_glyph_advance_width() reads a glyph's advance width -- required for
this item's own "proportional spacing correct" acceptance clause, no
advance-width data existed anywhere else in the parser. Verified in
tools/ttftest.c: A/a/0/space all read advance_width=600, correctly
uniform since JetBrainsMono-Regular.ttf is monospace.

(x,y) is raster pixel space (top-left origin, Y-down), deliberately not
the stroke font TEXT's Cartesian Y-up convention -- recorded explicitly
in ttf_words.h, not conflated.

Verified live, amd64, screendump: injected
S" Hi 4.3.7e!" 200 200 28 16777215 TTF-TEXT over a serial socket after
boot, no error, captured a screendump showing the string rendered
legibly with correct mixed-case/digit/punctuation glyphs and even
spacing. TTF-TEXT is this item's permanent deliverable, not a
throwaway probe. Compile-checked clean on all three architectures.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Robert Allan James
2026-08-10 23:11:40 -04:00
co-authored by Claude Sonnet 5
parent 2db4603d04
commit 715ebcc57e
8 changed files with 319 additions and 2 deletions
+39 -1
View File
@@ -4880,11 +4880,49 @@ document and committing that amendment as its own item.*
> nothing here is CANVAS-visual. Compile-checked clean (`-Wall -Werror -Wextra`) on all > nothing here is CANVAS-visual. Compile-checked clean (`-Wall -Werror -Wextra`) on all
> three architectures, with and without `-D__STARKERNEL__`. > three architectures, with and without `-D__STARKERNEL__`.
- [ ] **4.3.7e — `TTF-TEXT` entry point.** `TTF-TEXT ( c-addr u x y size color -- )`, - [x] **4.3.7e — `TTF-TEXT` entry point.** `TTF-TEXT ( c-addr u x y size color -- )`,
analogous to 4.3.6f's `TEXT` but TrueType-backed — becomes the primary text-rendering path analogous to 4.3.6f's `TEXT` but TrueType-backed — becomes the primary text-rendering path
per the resolved relationship to the stroke font (§27.7); `TEXT`/the stroke system remain per the resolved relationship to the stroke font (§27.7); `TEXT`/the stroke system remain
available, not deprecated. *Done when:* a UTF-8 string renders correctly via `TTF-TEXT` in available, not deprecated. *Done when:* a UTF-8 string renders correctly via `TTF-TEXT` in
one call, screendump-verified, proportional spacing correct. *Refs:* §27.7. one call, screendump-verified, proportional spacing correct. *Refs:* §27.7.
> **Done 2026-08-10.** New `src/word_source/ttf_words.c`/`ttf_words.h`, registered from
> `word_registry.c` as Module 30. `TTF-TEXT` lazily loads the v1 default font capsule
> (`fonts:JetBrainsMono-Regular.ttf`) and its raster cache once on first use, decodes the
> UTF-8 string byte-by-byte (a C reimplementation mirroring `capsules/fabric.4th`'s
> `DECODE-UTF8` exactly -- same lead-byte-length table, same U+FFFD fallback), looks up
> each codepoint's bitmap via `ttf_raster_cache_get()` (4.3.7d), blits it via
> `fb_put_pixel()`, and advances the pen by the glyph's real `hmtx` advance width (new --
> see below) scaled to pixels via the shared `q48_mul`/`q48_div` (safe here since advance
> widths and scale are always non-negative, unlike the rasterizer's signed cases).
>
> **Necessary plumbing added, not scope creep beyond this item's own "done when":**
> `ttf_parse()` now also locates `hhea` (for `numberOfHMetrics`) and `hmtx`, and
> `ttf_glyph_advance_width()` reads a glyph's advance width from it. Without this,
> "proportional spacing correct" (this item's own acceptance clause) would be unmet --
> there is no advance-width data anywhere else in the parser. Verified in
> `tools/ttftest.c`: `A`/`a`/`0`/space all read `advance_width=600`, correctly uniform
> since `JetBrainsMono-Regular.ttf` is monospace (a real structural property to check
> against, not an arbitrary assumption).
>
> **Coordinate convention, recorded explicitly, not conflated with the stroke font's:**
> `TTF-TEXT`'s `(x,y)` is raster pixel space (top-left origin, Y-down) -- the same space
> `PLOT`/`fb_put_pixel()` use -- NOT `capsules/fabric.4th`'s Cartesian Y-up space that the
> stroke font's `TEXT` (4.3.6f) uses via `CART-PLOT`. These are two deliberately different
> coordinate systems on two separate text paths; a caller mixing them up would get a
> vertically-flipped `y`. `(x,y)` is the first glyph's baseline-left origin.
>
> **Verified live, amd64, screendump.** Boot: same one-off script pattern as 4.3.7c
> (monitor socket + `socat` + HMP `screendump`, plus serial-socket command injection) --
> booted to `[Hera] ok>`, injected `S" Hi 4.3.7e!" 200 200 28 16777215 TTF-TEXT` over the
> serial socket, confirmed no error in the serial log (next prompt was a clean `ok>`), then
> captured a screendump. Result: `"Hi 4.3.7e!"` renders legibly at the expected position,
> mixed case + digits + punctuation all correct, glyphs evenly spaced left to right with no
> overlap -- `fb/amd64/ttf-text-hi437e-20260810-230926.png` (not committed; `fb/` is
> gitignored, matching every other screendump referenced in this document). Unlike
> 4.3.7c's `TTF-PROBE`, `TTF-TEXT` is the item's own permanent deliverable, not a
> throwaway -- nothing to revert.
>
> Compile-checked clean (`-Wall -Werror -Wextra`) on all three architectures.
- [ ] **4.3.7f — Checkpoint: TTF rendering, all three architectures.** Same posture as - [ ] **4.3.7f — Checkpoint: TTF rendering, all three architectures.** Same posture as
4.3.6g. *Done when:* a single screendump per architecture shows a representative sample 4.3.6g. *Done when:* a single screendump per architecture shows a representative sample
+1 -1
View File
@@ -1,5 +1,5 @@
# Capsule Block Manifest — Auto-generated # Capsule Block Manifest — Auto-generated
<!-- Generated by mkcapsule --manifest 2026-08-11T02:31:16Z --> <!-- Generated by mkcapsule --manifest 2026-08-11T03:10:50Z -->
<!-- DO NOT EDIT — re-run mkcapsule --manifest to refresh. --> <!-- DO NOT EDIT — re-run mkcapsule --manifest to refresh. -->
<!-- Hand-written justifications and immutability notes live --> <!-- Hand-written justifications and immutability notes live -->
<!-- in MANIFEST.md alongside this auto-generated index. --> <!-- in MANIFEST.md alongside this auto-generated index. -->
+19
View File
@@ -93,6 +93,12 @@ typedef struct {
/* Selected cmap subtable (format 4 only, see file header comment). */ /* Selected cmap subtable (format 4 only, see file header comment). */
uint32_t cmap_subtable_off; uint32_t cmap_subtable_off;
int has_cmap; int has_cmap;
/* hhea/hmtx, for ttf_glyph_advance_width() -- proportional spacing
* (FABRIC.md item 4.3.7e). Mandatory tables per the TrueType spec,
* so their absence fails ttf_parse() same as head/maxp/loca/glyf. */
uint32_t hmtx_off;
uint16_t num_h_metrics;
} ttf_font_t; } ttf_font_t;
/** Raw glyf record header, per §27.6/4.3.7's "done when" clause. */ /** Raw glyf record header, per §27.6/4.3.7's "done when" clause. */
@@ -137,6 +143,19 @@ uint32_t ttf_codepoint_to_glyph(const ttf_font_t *font, uint32_t codepoint);
int ttf_glyph_header(const ttf_font_t *font, uint32_t glyph_index, int ttf_glyph_header(const ttf_font_t *font, uint32_t glyph_index,
ttf_glyph_header_t *out); ttf_glyph_header_t *out);
/**
* ttf_glyph_advance_width - Horizontal advance width via hmtx, in raw
* font design units (NOT scaled by unitsPerEm -- caller's job, e.g.
* `q48_mul(q48_from_u64(width), scale)`; always non-negative, so the
* shared q48_mul is fine here unlike the signed cases documented in
* ttf.c's rasterizer). Glyphs past hmtx's numberOfHMetrics entries share
* the last entry's width, per spec.
*
* @param glyph_index As returned by ttf_codepoint_to_glyph()
* @return advance width, or 0 if glyph_index is out of range
*/
uint16_t ttf_glyph_advance_width(const ttf_font_t *font, uint32_t glyph_index);
/** One outline point, in raw font design units (NOT scaled by unitsPerEm — /** One outline point, in raw font design units (NOT scaled by unitsPerEm —
* that's the caller's job, same convention as ttf_glyph_header_t's bbox), * that's the caller's job, same convention as ttf_glyph_header_t's bbox),
* expressed in Q48.16. Two's-complement negative values are expected and * expressed in Q48.16. Two's-complement negative values are expected and
+28
View File
@@ -131,6 +131,24 @@ int ttf_parse(const uint8_t *data, uint32_t size, ttf_font_t *out) {
if (rc != TTF_OK) return rc; if (rc != TTF_OK) return rc;
if (!in_bounds(out, out->glyf_off, out->glyf_len)) return TTF_ERR_OUT_OF_BOUNDS; if (!in_bounds(out, out->glyf_off, out->glyf_len)) return TTF_ERR_OUT_OF_BOUNDS;
/* hhea (for numberOfHMetrics) + hmtx (advance widths) -- mandatory,
* needed for ttf_glyph_advance_width() (4.3.7e's proportional
* spacing). numberOfHMetrics is hhea's 18th field, a fixed 36-byte
* table per spec. */
{
uint32_t hhea_off, hhea_len_unused, hmtx_len;
rc = find_table(data, size, num_tables, TAG('h', 'h', 'e', 'a'), &hhea_off, &hhea_len_unused);
if (rc != TTF_OK) return rc;
if (!in_bounds(out, hhea_off, 36)) return TTF_ERR_BAD_TABLE;
out->num_h_metrics = rd_u16(data, hhea_off + 34);
rc = find_table(data, size, num_tables, TAG('h', 'm', 't', 'x'), &out->hmtx_off, &hmtx_len);
if (rc != TTF_OK) return rc;
if (out->num_h_metrics == 0
|| !in_bounds(out, out->hmtx_off, (uint32_t) out->num_h_metrics * 4))
return TTF_ERR_BAD_TABLE;
}
/* cmap is optional to overall parse success; codepoint lookup just /* cmap is optional to overall parse success; codepoint lookup just
* fails cleanly (TTF_GLYPH_MISSING) if it's absent or unrecognized. */ * fails cleanly (TTF_GLYPH_MISSING) if it's absent or unrecognized. */
{ {
@@ -262,6 +280,16 @@ int ttf_glyph_header(const ttf_font_t *font, uint32_t glyph_index, ttf_glyph_hea
return TTF_OK; return TTF_OK;
} }
uint16_t ttf_glyph_advance_width(const ttf_font_t *font, uint32_t glyph_index) {
uint32_t idx, off;
if (!font || font->num_h_metrics == 0) return 0;
idx = glyph_index < font->num_h_metrics ? glyph_index : (uint32_t) font->num_h_metrics - 1;
off = font->hmtx_off + idx * 4;
if (!in_bounds(font, off, 2)) return 0;
return rd_u16(font->data, off);
}
/* =========================================================================== /* ===========================================================================
* 4.3.7a — glyph outline extraction * 4.3.7a — glyph outline extraction
* =========================================================================== * ===========================================================================
+2
View File
@@ -75,6 +75,7 @@
#include "word_source/include/defer_words.h" #include "word_source/include/defer_words.h"
#include "word_source/include/framebuffer_words.h" #include "word_source/include/framebuffer_words.h"
#include "word_source/include/keyboard_words.h" #include "word_source/include/keyboard_words.h"
#include "word_source/include/ttf_words.h"
/** /**
* @brief Registers a single FORTH word in the virtual machine * @brief Registers a single FORTH word in the virtual machine
@@ -132,6 +133,7 @@ void register_forth79_words(VM *vm) {
register_defer_words(vm); /* Module 27: DEFER / IS late binding */ register_defer_words(vm); /* Module 27: DEFER / IS late binding */
register_framebuffer_words(vm); /* Module 28: Console fabric -- raw framebuffer primitives */ register_framebuffer_words(vm); /* Module 28: Console fabric -- raw framebuffer primitives */
register_keyboard_words(vm); /* Module 29: Console fabric -- raw keyboard scancode diagnostic */ register_keyboard_words(vm); /* Module 29: Console fabric -- raw keyboard scancode diagnostic */
register_ttf_words(vm); /* Module 30: TrueType text entry point */
log_message(LOG_INFO, "FORTH-79 Standard word set registration complete"); log_message(LOG_INFO, "FORTH-79 Standard word set registration complete");
} }
+38
View File
@@ -0,0 +1,38 @@
/*
StarForth Steady-State Virtual Machine Runtime
Copyright (c) 20232025 Robert A. James
All rights reserved.
Licensed under the StarForth License, Version 1.0
*/
#ifndef TTF_WORDS_H
#define TTF_WORDS_H
#include "vm.h"
/**
* @defgroup ttf_words TrueType Text Words
* @{
*
* @brief `TTF-TEXT`, the TrueType-backed text entry point (FABRIC.md item
* 4.3.7e). Kernel-only; no-op on hosted builds (no framebuffer/capsule
* system exists there).
*
* @par TTF-TEXT ( c-addr u x y size color -- )
* Render a UTF-8 string via the v1 default font capsule
* (`fonts:JetBrainsMono-Regular.ttf`, loaded and cached lazily on first
* use). (x,y) is raster pixel space (top-left origin, Y-down) -- NOT
* capsules/fabric.4th's Cartesian Y-up convention used by the stroke
* font's `TEXT` (4.3.6f); the two are deliberately different coordinate
* systems, not interchangeable. (x,y) is the first glyph's baseline-left
* origin. `size` is capped at `TTF_CACHE_MAX_SIZE_PX` (see ttf.h).
* Advances proportionally per glyph via the font's real hmtx advance
* width, scaled to pixels.
* @}
*/
void register_ttf_words(VM *vm);
#endif /* TTF_WORDS_H */
+163
View File
@@ -0,0 +1,163 @@
/*
StarForth Steady-State Virtual Machine Runtime
Copyright (c) 20232025 Robert A. James
All rights reserved.
Licensed under the StarForth License, Version 1.0
*/
/* ttf_words.c — TrueType text entry point (FABRIC.md item 4.3.7e).
* Kernel-only; no-op on hosted builds. */
#include "include/ttf_words.h"
#include "../../include/log.h"
#include "../../include/word_registry.h"
#ifdef __STARKERNEL__
#include "starkernel/framebuffer.h"
#include "starkernel/ttf.h"
#include "starkernel/capsule_generated.h"
#define TTF_WORDS_CACHE_SLOTS 32
#define TTF_WORDS_DEFAULT_FONT "fonts:JetBrainsMono-Regular.ttf"
static ttf_font_t g_ttf_font;
static int g_ttf_font_ready = 0;
static ttf_raster_cache_slot_t g_ttf_cache_slots[TTF_WORDS_CACHE_SLOTS];
static ttf_raster_cache_t g_ttf_cache;
static int ttf_words_ensure_font(void) {
if (g_ttf_font_ready) return 1;
if (ttf_load_from_capsule(
capsule_get_directory(), capsule_get_descriptors(),
capsule_get_names(), capsule_get_arena(),
TTF_WORDS_DEFAULT_FONT, &g_ttf_font) != TTF_OK)
return 0;
ttf_raster_cache_init(&g_ttf_cache, g_ttf_cache_slots, TTF_WORDS_CACHE_SLOTS);
g_ttf_font_ready = 1;
return 1;
}
/* Decode one UTF-8 codepoint from VM memory at *a (VM-space address),
* with *len bytes known remaining. Mirrors capsules/fabric.4th's
* DECODE-UTF8 exactly: same lead-byte-length table, same U+FFFD
* fallback (advancing exactly one byte) on an invalid or truncated
* sequence. Advances *a and *len past the consumed bytes. */
static uint32_t ttf_words_decode_utf8(VM *vm, vaddr_t *a, cell_t *len) {
uint8_t lead, b[4];
uint32_t seqlen, i, cp;
if (*len <= 0 || !vm_addr_ok(vm, *a, 1)) {
*len = 0;
return 0xFFFD;
}
lead = vm_load_u8(vm, *a);
if (lead < 0x80) seqlen = 1;
else if ((lead & 0xE0) == 0xC0) seqlen = 2;
else if ((lead & 0xF0) == 0xE0) seqlen = 3;
else if ((lead & 0xF8) == 0xF0) seqlen = 4;
else seqlen = 0;
if (seqlen == 0 || (cell_t) seqlen > *len || !vm_addr_ok(vm, *a, seqlen)) {
*a += 1;
*len -= 1;
return 0xFFFD;
}
for (i = 0; i < seqlen; i++) b[i] = vm_load_u8(vm, *a + i);
switch (seqlen) {
case 1:
cp = b[0];
break;
case 2:
cp = ((uint32_t) (b[0] & 0x1F) << 6) | (b[1] & 0x3F);
break;
case 3:
cp = ((uint32_t) (b[0] & 0x0F) << 12) | ((uint32_t) (b[1] & 0x3F) << 6) | (b[2] & 0x3F);
break;
default:
cp = ((uint32_t) (b[0] & 0x07) << 18) | ((uint32_t) (b[1] & 0x3F) << 12)
| ((uint32_t) (b[2] & 0x3F) << 6) | (b[3] & 0x3F);
break;
}
*a += seqlen;
*len -= (cell_t) seqlen;
return cp;
}
/* TTF-TEXT ( c-addr u x y size color -- ) */
static void ttf_word_text(VM *vm) {
cell_t color, size, y, x, u, addr_cell;
vaddr_t a;
cell_t remaining;
uint32_t pen_x;
q48_16_t scale;
if (vm->dsp < 5) {
log_message(LOG_ERROR, "TTF-TEXT: Stack underflow");
vm->error = 1;
return;
}
color = vm_pop(vm);
size = vm_pop(vm);
y = vm_pop(vm);
x = vm_pop(vm);
u = vm_pop(vm);
addr_cell = vm_pop(vm);
if (!ttf_words_ensure_font()) {
log_message(LOG_ERROR, "TTF-TEXT: font capsule load failed");
vm->error = 1;
return;
}
if (size <= 0 || size > TTF_CACHE_MAX_SIZE_PX) {
log_message(LOG_ERROR, "TTF-TEXT: size out of range");
vm->error = 1;
return;
}
a = VM_ADDR(addr_cell);
remaining = u;
pen_x = (uint32_t) x;
scale = q48_div(q48_from_u64((uint32_t) size), q48_from_u64(g_ttf_font.units_per_em));
while (remaining > 0) {
uint32_t cp = ttf_words_decode_utf8(vm, &a, &remaining);
ttf_bitmap_t bmp;
int rc, was_hit;
uint32_t gid, adv_units, px, py;
q48_16_t adv_q;
rc = ttf_raster_cache_get(&g_ttf_cache, &g_ttf_font, cp, (uint32_t) size, &bmp, &was_hit);
if (rc == TTF_OK) {
for (py = 0; py < bmp.height; py++) {
for (px = 0; px < bmp.width; px++) {
if (bmp.pixels[py * bmp.width + px]) {
fb_put_pixel(pen_x + px - TTF_CACHE_MARGIN,
(uint32_t) y + py - (uint32_t) size - TTF_CACHE_MARGIN,
(uint32_t) color);
}
}
}
}
gid = ttf_codepoint_to_glyph(&g_ttf_font, cp);
adv_units = ttf_glyph_advance_width(&g_ttf_font, gid);
adv_q = q48_mul(q48_from_u64(adv_units), scale);
pen_x += (uint32_t) q48_to_u64(adv_q);
}
}
#endif /* __STARKERNEL__ */
void register_ttf_words(VM *vm)
{
#ifdef __STARKERNEL__
register_word(vm, "TTF-TEXT", ttf_word_text);
#else
(void) vm;
#endif
}
+29
View File
@@ -270,6 +270,34 @@ static void test_raster_cache(const ttf_font_t *font) {
} }
} }
/* Advance width (hmtx, needed for 4.3.7e's proportional spacing): no
* independent reference for this specific font's values, but
* JetBrainsMono is monospace, so every printable glyph's advance width
* should be identical and nonzero -- a real structural property, not an
* arbitrary assumption. */
static void test_advance_width(const ttf_font_t *font) {
static const uint32_t cps[] = {0x0041, 0x0061, 0x0030, 0x0020};
uint16_t first = 0;
size_t i;
for (i = 0; i < sizeof(cps) / sizeof(cps[0]); i++) {
uint32_t gid = ttf_codepoint_to_glyph(font, cps[i]);
uint16_t adv = ttf_glyph_advance_width(font, gid);
printf("U+%04X advance_width=%u\n", cps[i], adv);
if (i == 0) {
first = adv;
if (adv == 0) {
printf(" FAIL advance_width: U+%04X is 0\n", cps[i]);
failures++;
}
} else if (adv != first) {
printf(" FAIL advance_width: U+%04X=%u != U+%04X=%u (JetBrainsMono is monospace)\n",
cps[i], adv, cps[0], first);
failures++;
}
}
}
int main(int argc, char **argv) { int main(int argc, char **argv) {
FILE *fp; FILE *fp;
long size; long size;
@@ -337,6 +365,7 @@ int main(int argc, char **argv) {
test_rasterize(&font, 0x0061, "'a'"); test_rasterize(&font, 0x0061, "'a'");
test_raster_cache(&font); test_raster_cache(&font);
test_advance_width(&font);
free(buf); free(buf);