From 516d1a5f5aca7b7f0016c3afa744c65da26e7895 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 9 Sep 2026 12:58:46 -0400 Subject: [PATCH 1/3] Skip a codepoint the face does not draw glyph.build recorded every codepoint it was asked for. A codepoint the face does not map resolves to .notdef, whose advance is real, so the index gained an entry that draws a box. A face whose outlines stb_truetype cannot read produced entries of all zeros, which a host packs into its atlas and lays out as blank text. tg_glyph_present reads stbtt_FindGlyphIndex, and build skips a codepoint the face does not map along with one that yields neither ink nor an advance. The three prebaked sheets are byte identical, space included. --- src/sprite/glyph.zig | 18 ++++++++++++++++++ src/sprite/svgraster.c | 13 +++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/sprite/glyph.zig b/src/sprite/glyph.zig index d103cd2f..e97ae69a 100644 --- a/src/sprite/glyph.zig +++ b/src/sprite/glyph.zig @@ -9,6 +9,14 @@ const Allocator = std.mem.Allocator; // C glue (src/sprite/svgraster.c): stb_truetype SDF + the shared PNG encoder. extern fn tg_glyph_sdf(font: [*]const u8, font_len: c_int, cp: c_int, em_px: f32, pad: c_int, onedge: c_int, dist_scale: f32, w: *c_int, h: *c_int, xoff: *c_int, yoff: *c_int, advance: *f32) callconv(.c) ?[*]u8; extern fn tg_glyph_free(p: ?[*]u8) callconv(.c) void; +extern fn tg_glyph_present(font: [*]const u8, font_len: c_int, cp: c_int) callconv(.c) c_int; + +/// True when `font` has a glyph of its own for `cp`. A codepoint the face does +/// not map resolves to .notdef, which has an advance and usually a box, so the +/// metrics a rasterizer returns cannot answer this on their own. +pub fn present(font: []const u8, cp: u21) bool { + return tg_glyph_present(font.ptr, @intCast(font.len), @intCast(cp)) != 0; +} extern fn tg_png_encode(rgba: [*]const u8, w: c_int, h: c_int, out_len: *c_int) callconv(.c) ?[*]u8; extern fn tg_svg_free(p: ?*anyopaque) callconv(.c) void; @@ -90,6 +98,16 @@ pub fn build(a: Allocator, font: []const u8, cps: []const u21, em_px: f32, pad: .h = @as(f32, @floatFromInt(h)) / em_px, .advance = adv / em_px, }; + // A codepoint the face does not map resolves to .notdef, whose box + // would be baked as if it were the character. And a face whose + // outlines the rasterizer cannot read at all (Apple's variable UI + // faces carry `cidg` rather than glyf or CFF) yields neither ink nor + // an advance, which a host would pack into its atlas and lay out as + // nothing — a blank label that says the glyph arrived. Skip both: an + // absent codepoint is what "this face cannot draw it" means. A space + // has a glyph of its own and advances, so it survives. + if (!present(font, cp)) continue; + if (sdf == null and adv <= 0) continue; try cells.append(a, .{ .cp = cp, .w = @intCast(@max(w, 0)), .h = @intCast(@max(h, 0)), .sdf = sdf, .gi = gi }); } diff --git a/src/sprite/svgraster.c b/src/sprite/svgraster.c index 834ed31f..f56a64bf 100644 --- a/src/sprite/svgraster.c +++ b/src/sprite/svgraster.c @@ -47,6 +47,19 @@ unsigned char *tg_glyph_sdf(const unsigned char *font, int font_len, int cp, } void tg_glyph_free(unsigned char *p) { stbtt_FreeSDF(p, NULL); } +// Whether the face has a glyph of its own for `cp`. stbtt_FindGlyphIndex +// answers 0 for a codepoint the cmap does not map, which is .notdef — a real +// glyph with a real advance and, in most faces, a visible box. Metrics alone +// therefore cannot tell a character the face draws from one it does not, and a +// caller that trusted them would bake a row of tofu and record it as the text. +// 1 when the face has the glyph, 0 when it does not or cannot be read. +int tg_glyph_present(const unsigned char *font, int font_len, int cp) { + (void)font_len; + stbtt_fontinfo f; + if (!stbtt_InitFont(&f, font, stbtt_GetFontOffsetForIndex(font, 0))) return 0; + return stbtt_FindGlyphIndex(&f, cp) != 0; +} + // Rasterize a flattened SVG (viewBox normalized to "0 0 W H") at `scale` device // px per user unit. Forces even-odd winding on every shape — the S-101 danger // glyphs are compound paths whose inner subpath is a hole, and nonzero winding From 27818f8f4aff287907729caa2836ce2f61f6a8a4 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 9 Sep 2026 13:01:10 -0400 Subject: [PATCH 2/3] Bake an SDF sheet for named codepoints tile57_bake_glyph_sdf and _face rasterize printable ASCII and Latin-1 from the bundled Noto Sans. A chart that names its features in another script is labelled from glyphs outside that set, and CJK alone is around 20,000, more than one sheet holds. tile57_bake_glyph_sdf_codepoints reads font bytes the host supplies and the codepoints to rasterize, at the em size and spread the prebaked sheets use, so both merge into one atlas. It fails when the face draws none of them, so an empty sheet does not read as success. --- include/tile57.h | 19 +++++++++++++++++++ src/capi.zig | 44 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/include/tile57.h b/include/tile57.h index f842a865..b66158d3 100644 --- a/include/tile57.h +++ b/include/tile57.h @@ -1750,6 +1750,25 @@ tile57_status tile57_bake_glyph_sdf(tile57_assets *out, tile57_error *err); * place names and italic hydrography from the SDF text path. Free with * tile57_assets_free. */ tile57_status tile57_bake_glyph_sdf_face(tile57_assets *out, int32_t face, tile57_error *err); +/* tile57_bake_glyph_sdf for named codepoints, out of a font the HOST supplies: + * `font_bytes` is a TrueType file or a collection, of which the first face is + * read, and `codepoints` names the characters to rasterize. + * + * The bundled Noto Sans covers Latin, Greek and Cyrillic, and the prebaked + * sheets carry printable ASCII and Latin-1. A chart naming its features in + * another script needs glyphs no prebaked sheet can hold — CJK alone is some + * 20,000 — so a host bakes the characters its labels actually use, from + * whatever face its platform has for the script, and merges the sheet into the + * atlas it already has. + * + * Same output as tile57_bake_glyph_sdf: only sprite_* filled, at the same em + * size and spread, so the two sheets share one atlas. A codepoint the face has + * no glyph for is left out rather than failing the bake. Free with + * tile57_assets_free. */ +tile57_status tile57_bake_glyph_sdf_codepoints(tile57_assets *out, + const uint8_t *font_bytes, size_t font_len, + const uint32_t *codepoints, size_t count, + tile57_error *err); void tile57_assets_free(tile57_assets *out); /* ---- chart-style generation --------------------------------------------- diff --git a/src/capi.zig b/src/capi.zig index e668bbc5..12d2ac0b 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -2209,6 +2209,41 @@ export fn tile57_bake_glyph_sdf_face(out: ?*CAssets, face: i32, err: ?*CError) c return bakeGlyphSdf(out, face, err); } +/// An SDF sheet for named codepoints, out of a font the HOST supplies. See +/// tile57.h. +export fn tile57_bake_glyph_sdf_codepoints( + out: ?*CAssets, + font_bytes: ?[*]const u8, + font_len: usize, + codepoints: ?[*]const u32, + count: usize, + err: ?*CError, +) callconv(.c) c_int { + const o = out orelse return failWith(err, .badarg, "out must not be null"); + o.* = .{}; + const fb = font_bytes orelse return failWith(err, .badarg, "font_bytes must not be null"); + if (font_len == 0) return failWith(err, .badarg, "font_len must not be zero"); + const cps_c = codepoints orelse return failWith(err, .badarg, "codepoints must not be null"); + if (count == 0) return failWith(err, .badarg, "count must not be zero"); + + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const a = arena.allocator(); + const cps = a.alloc(u21, count) catch |e| return fail(err, e); + for (cps_c[0..count], cps) |src, *dst| { + if (src > 0x10FFFF) return failWith(err, .badarg, "codepoint above the Unicode range"); + dst.* = @intCast(src); + } + var atlas = glyph_sdf.build(a, fb[0..font_len], cps, 32.0, 6) catch |e| return fail(err, e); + // Every codepoint came back with neither ink nor an advance. Either the + // face draws none of them, or its outlines are in a format the rasterizer + // cannot read. Say so: a host that took an empty sheet as success would + // record the characters as served and never ask for them again. + if (atlas.glyphs.count() == 0) + return failWith(err, .unsupported, "the face draws none of those codepoints"); + return finishGlyphSdf(o, a, &atlas, err); +} + fn bakeGlyphSdf(out: ?*CAssets, face: i32, err: ?*CError) c_int { const o = out orelse return failWith(err, .badarg, "out must not be null"); o.* = .{}; @@ -2223,9 +2258,16 @@ fn bakeGlyphSdf(out: ?*CAssets, face: i32, err: ?*CError) c_int { }; const cps = glyph_sdf.defaultCodepoints(a) catch |e| return fail(err, e); var atlas = glyph_sdf.build(a, font, cps, 32.0, 6) catch |e| return fail(err, e); + return finishGlyphSdf(o, a, &atlas, err); +} + +/// Encode a built atlas into *out. `a` is the arena the atlas lives in; the two +/// buffers that leave are duped into gpa, which is what tile57_assets_free +/// releases. +fn finishGlyphSdf(o: *CAssets, a: std.mem.Allocator, atlas: *glyph_sdf.Atlas, err: ?*CError) c_int { const png = (atlas.encodePng(a) catch |e| return fail(err, e)) orelse return failWith(err, .internal, "glyph atlas PNG encode produced nothing"); - const json = glyphMetricsJson(a, &atlas) catch |e| return fail(err, e); + const json = glyphMetricsJson(a, atlas) catch |e| return fail(err, e); o.sprite_png = (gpa.dupe(u8, png) catch |e| { tile57_assets_free(o); return fail(err, e); From 2e44b1f4d1a1e1af0cebac6bc85fe3f6ac12f137 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 9 Sep 2026 13:02:01 -0400 Subject: [PATCH 3/3] Report whether a face draws a codepoint A host picking a face out of its platform's font store has two questions and the store answers one. Apple's UI faces cover CJK and are what CoreText returns for Chinese text, and their outlines are in Apple's cidg table rather than glyf or CFF, so no glyph comes out of them. tile57_font_covers rasterizes one codepoint from the given bytes and reports whether anything came out. tile57_label_font_covers asks the same of the bundled face, so a host can tell which of a chart's languages need a platform font. --- include/tile57.h | 16 ++++++++++++++++ src/capi.zig | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/include/tile57.h b/include/tile57.h index b66158d3..cef603cb 100644 --- a/include/tile57.h +++ b/include/tile57.h @@ -1769,6 +1769,22 @@ tile57_status tile57_bake_glyph_sdf_codepoints(tile57_assets *out, const uint8_t *font_bytes, size_t font_len, const uint32_t *codepoints, size_t count, tile57_error *err); +/* True when `font_bytes` can draw `codepoint`: the face has a glyph for it AND + * the rasterizer can read that glyph's outlines. + * + * A host picking a face out of its platform's font store has to ask, because + * the two are not the same question. Apple's UI faces cover CJK and are what + * CoreText hands back for Chinese text, but they carry Apple's `cidg` outlines + * rather than glyf or CFF, and no glyph comes out of them. A host that + * installed one would draw blank labels with nothing to say why. Ask with one + * character of the script, then install the first face that answers true. */ +tile57_status tile57_font_covers(const uint8_t *font_bytes, size_t font_len, + uint32_t codepoint, bool *out, tile57_error *err); +/* The same question about the BUNDLED label face, so a host can tell which + * scripts it needs a platform font for and which are already drawn. A chart + * stating four languages may need a face for one of them: asking first stops a + * host from mapping a font for Finnish and leaving Inuktitut blank. */ +tile57_status tile57_label_font_covers(uint32_t codepoint, bool *out, tile57_error *err); void tile57_assets_free(tile57_assets *out); /* ---- chart-style generation --------------------------------------------- diff --git a/src/capi.zig b/src/capi.zig index 12d2ac0b..6aae0ba6 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -2244,6 +2244,41 @@ export fn tile57_bake_glyph_sdf_codepoints( return finishGlyphSdf(o, a, &atlas, err); } +/// True when the BUNDLED label face can draw `codepoint`. See tile57.h. +export fn tile57_label_font_covers(codepoint: u32, out: ?*bool, err: ?*CError) callconv(.c) c_int { + const o = out orelse return failWith(err, .badarg, "out must not be null"); + o.* = false; + if (codepoint > 0x10FFFF) return failWith(err, .badarg, "codepoint above the Unicode range"); + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const one = [_]u21{@intCast(codepoint)}; + const ft = @import("render").font; + var atlas = glyph_sdf.build(arena.allocator(), ft.notosans, &one, 32.0, 6) catch |e| return fail(err, e); + o.* = atlas.glyphs.count() > 0; + return OK; +} + +/// True when `font_bytes` can draw `codepoint`. See tile57.h. +export fn tile57_font_covers( + font_bytes: ?[*]const u8, + font_len: usize, + codepoint: u32, + out: ?*bool, + err: ?*CError, +) callconv(.c) c_int { + const o = out orelse return failWith(err, .badarg, "out must not be null"); + o.* = false; + const fb = font_bytes orelse return failWith(err, .badarg, "font_bytes must not be null"); + if (font_len == 0) return failWith(err, .badarg, "font_len must not be zero"); + if (codepoint > 0x10FFFF) return failWith(err, .badarg, "codepoint above the Unicode range"); + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const one = [_]u21{@intCast(codepoint)}; + var atlas = glyph_sdf.build(arena.allocator(), fb[0..font_len], &one, 32.0, 6) catch |e| return fail(err, e); + o.* = atlas.glyphs.count() > 0; + return OK; +} + fn bakeGlyphSdf(out: ?*CAssets, face: i32, err: ?*CError) c_int { const o = out orelse return failWith(err, .badarg, "out must not be null"); o.* = .{};