From 715ebcc57edbda3b61fe7cedb439a264df44de8a Mon Sep 17 00:00:00 2001 From: Robert Allan James Date: Mon, 10 Aug 2026 23:11:40 -0400 Subject: [PATCH] ttf_words.c: TTF-TEXT entry point, hmtx advance widths (item 4.3.7e) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- FABRIC.md | 40 ++++++- capsules/BLOCK_MAP.md | 2 +- include/starkernel/ttf.h | 19 ++++ src/starkernel/hal/ttf.c | 28 +++++ src/word_registry.c | 2 + src/word_source/include/ttf_words.h | 38 +++++++ src/word_source/ttf_words.c | 163 ++++++++++++++++++++++++++++ tools/ttftest.c | 29 +++++ 8 files changed, 319 insertions(+), 2 deletions(-) create mode 100644 src/word_source/include/ttf_words.h create mode 100644 src/word_source/ttf_words.c diff --git a/FABRIC.md b/FABRIC.md index e7c0d42..c352691 100644 --- a/FABRIC.md +++ b/FABRIC.md @@ -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 > 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 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 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.6g. *Done when:* a single screendump per architecture shows a representative sample diff --git a/capsules/BLOCK_MAP.md b/capsules/BLOCK_MAP.md index eb591fa..375ef3b 100644 --- a/capsules/BLOCK_MAP.md +++ b/capsules/BLOCK_MAP.md @@ -1,5 +1,5 @@ # Capsule Block Manifest — Auto-generated - + diff --git a/include/starkernel/ttf.h b/include/starkernel/ttf.h index 185a41e..98938db 100644 --- a/include/starkernel/ttf.h +++ b/include/starkernel/ttf.h @@ -93,6 +93,12 @@ typedef struct { /* Selected cmap subtable (format 4 only, see file header comment). */ uint32_t cmap_subtable_off; 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; /** 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, 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 — * 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 diff --git a/src/starkernel/hal/ttf.c b/src/starkernel/hal/ttf.c index f1aba30..0380640 100644 --- a/src/starkernel/hal/ttf.c +++ b/src/starkernel/hal/ttf.c @@ -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 (!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 * 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; } +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 * =========================================================================== diff --git a/src/word_registry.c b/src/word_registry.c index 2b530c4..eab10f1 100644 --- a/src/word_registry.c +++ b/src/word_registry.c @@ -75,6 +75,7 @@ #include "word_source/include/defer_words.h" #include "word_source/include/framebuffer_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 @@ -132,6 +133,7 @@ void register_forth79_words(VM *vm) { register_defer_words(vm); /* Module 27: DEFER / IS late binding */ register_framebuffer_words(vm); /* Module 28: Console fabric -- raw framebuffer primitives */ 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"); } \ No newline at end of file diff --git a/src/word_source/include/ttf_words.h b/src/word_source/include/ttf_words.h new file mode 100644 index 0000000..29f1a25 --- /dev/null +++ b/src/word_source/include/ttf_words.h @@ -0,0 +1,38 @@ +/* + StarForth — Steady-State Virtual Machine Runtime + + Copyright (c) 2023–2025 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 */ diff --git a/src/word_source/ttf_words.c b/src/word_source/ttf_words.c new file mode 100644 index 0000000..9fe857d --- /dev/null +++ b/src/word_source/ttf_words.c @@ -0,0 +1,163 @@ +/* + StarForth — Steady-State Virtual Machine Runtime + + Copyright (c) 2023–2025 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 +} diff --git a/tools/ttftest.c b/tools/ttftest.c index 35b1bef..511a8ed 100644 --- a/tools/ttftest.c +++ b/tools/ttftest.c @@ -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) { FILE *fp; long size; @@ -337,6 +365,7 @@ int main(int argc, char **argv) { test_rasterize(&font, 0x0061, "'a'"); test_raster_cache(&font); + test_advance_width(&font); free(buf);