#!/usr/bin/env python3 """Bake the embedded mono font's anti-aliased atlas, at authoring time. Reads a TrueType/OpenType file and writes a C++ translation unit holding a greyscale coverage atlas, one fixed cell per glyph, plus the per-glyph rect, bearing and advance table the text pipeline stages from. tools/fonts/gen_atlas.py [--font PATH] [--em N] [--supersample N] [--out PATH] [--check] This is an **authoring-time** tool. `plot_core` and every public header under `include/plot/` stay third-party-free: nothing here runs at build time, the atlas is committed as data, and `fontTools` is never needed to compile or run the library. Regenerating is a deliberate act followed by a commit. Rasterisation is ours, not a rasteriser's: the glyph outlines are flattened with `fontTools.pens`, scanline-filled at `supersample` times the em, and box-filtered down to one coverage byte per texel. Nothing shells out. Determinism ----------- Same font file plus the same em size and supersample factor must produce a byte-identical translation unit -- `--check` depends on it, and so does a reviewable diff. Everything emitted is derived from integers (cell geometry, the font's own advance units) or from a fixed, sorted walk of the coverage list; no timestamp, no set/dict iteration order, no float formatting, no host path beyond the source font's file name. Blank ink is a hard failure --------------------------- Every requested codepoint must rasterise to at least one inked texel. A silent blank cell is exactly the failure that makes a font swap look like it worked, so the generator exits non-zero instead. The single documented exception is U+0020, which is blank *by definition* -- an intentional blank with a full advance. Provenance ---------- The default face is Adwaita Mono, `AdwaitaMono-Regular.ttf`, released under the SIL Open Font License 1.1 (a copy is committed at `licences/OFL-1.1.txt`). The backend is named neutrally (`EmbeddedMonoFont`) because OFL reserves the font name for modified redistributions. The generated file records the source file name, the font's own name/version strings, the em size, the supersample factor and the exact coverage set. """ from __future__ import annotations import argparse import math import os import sys from pathlib import Path DEFAULT_FONT = "/usr/share/fonts/Adwaita/AdwaitaMono-Regular.ttf" DEFAULT_EM = 13 DEFAULT_SUPERSAMPLE = 4 DEFAULT_OUT = "src/render/embedded_mono_font.cpp" SCRIPT = "tools/fonts/gen_atlas.py" # The fallback box, in font units with the origin at the pen on the baseline, y up # like the outlines it stands in for. Its two contours wind oppositely, so the # non-zero fill leaves a hollow box: an outer rectangle and an inner hole, each # stroke 100 units (1.3 px at 13 px em). It is authored here, not taken from the # font -- it is what an uncovered codepoint draws. FALLBACK_OUTER = ((60.0, -100.0), (580.0, -100.0), (580.0, 700.0), (60.0, 700.0)) FALLBACK_INNER = ((160.0, 0.0), (160.0, 600.0), (480.0, 600.0), (480.0, 0.0)) # The codepoint side of the coverage set: printable ASCII, the three codepoints # `plot::format_si` emits, and the two dashes a plotting label uses. The fallback # slot is appended after them. ASCII = list(range(0x20, 0x7F)) SPECIALS = [0xB0, 0xB5, 0x03A9, 0x2013, 0x2014] FALLBACK_CODEPOINT = 0xFFFFFFFF # not a Unicode scalar; cannot collide # Sub-curve flattening steps. Fixed, not tolerance-adaptive: at a 13 px em they # are far below one supersampled texel even at 4x, and a constant keeps the # output independent of anything but the font file. QUAD_STEPS = 12 CUBIC_STEPS = 16 EXIT_OK = 0 EXIT_MISMATCH = 1 EXIT_BAD_FONT = 2 EXIT_NO_FONTTOOLS = 3 def _fail(code: int, message: str) -> int: print(f"{SCRIPT}: {message}", file=sys.stderr) return code def _require_fonttools(): try: from fontTools.pens.basePen import BasePen # noqa: F401 from fontTools.ttLib import TTFont # noqa: F401 except ImportError: raise SystemExit( _fail( EXIT_NO_FONTTOOLS, "fontTools is not installed; this is an authoring-time tool. " "Install it (pip install fonttools) to regenerate the atlas.", ) ) def _contour_pen_class(): from fontTools.pens.basePen import BasePen class ContourPen(BasePen): """Flattens one glyph to contour point lists in cell space. `xf` maps a font-unit point (y up, origin at the pen on the baseline) to supersampled cell coordinates (y down, origin at the cell's top-left), so every geometric decision below is made once, here. """ def __init__(self, glyph_set, xf): super().__init__(glyph_set) self._xf = xf self.contours = [] self._current = [] def _map(self, point): return self._xf(point[0], point[1]) def _moveTo(self, pt): self._current = [self._map(pt)] def _lineTo(self, pt): self._current.append(self._map(pt)) def _qCurveToOne(self, control, point): p0 = self._current[-1] p1 = self._map(control) p2 = self._map(point) for step in range(1, QUAD_STEPS + 1): t = step / QUAD_STEPS u = 1.0 - t self._current.append( ( u * u * p0[0] + 2.0 * u * t * p1[0] + t * t * p2[0], u * u * p0[1] + 2.0 * u * t * p1[1] + t * t * p2[1], ) ) def _curveToOne(self, c1, c2, point): p0 = self._current[-1] p1 = self._map(c1) p2 = self._map(c2) p3 = self._map(point) for step in range(1, CUBIC_STEPS + 1): t = step / CUBIC_STEPS u = 1.0 - t b0 = u * u * u b1 = 3.0 * u * u * t b2 = 3.0 * u * t * t b3 = t * t * t self._current.append( ( b0 * p0[0] + b1 * p1[0] + b2 * p2[0] + b3 * p3[0], b0 * p0[1] + b1 * p1[1] + b2 * p2[1] + b3 * p3[1], ) ) def _closePath(self): if len(self._current) >= 3: self.contours.append(self._current) self._current = [] def _endPath(self): self._closePath() return ContourPen def _scanline_mask(contours, width, height): """Non-zero-winding scanline fill of `contours` into a `height` x `width` 0/1 grid. The fill rule is non-zero rather than even-odd because TrueType orients a hole opposite its outer contour and a font is entitled to overlapping contours; even-odd would punch a hole out of any overlap. """ mask = [[0] * width for _ in range(height)] edges = [] for contour in contours: count = len(contour) for i in range(count): x0, y0 = contour[i] x1, y1 = contour[(i + 1) % count] if y0 != y1: edges.append((x0, y0, x1, y1)) for row in range(height): yc = row + 0.5 crossings = [] for x0, y0, x1, y1 in edges: if (y0 <= yc) != (y1 <= yc): t = (yc - y0) / (y1 - y0) crossings.append((x0 + t * (x1 - x0), 1 if y1 > y0 else -1)) if not crossings: continue crossings.sort() winding = 0 span_start = 0.0 for x, direction in crossings: previous = winding winding += direction if previous == 0 and winding != 0: span_start = x elif previous != 0 and winding == 0: first = max(0, int(math.ceil(span_start - 0.5))) last = min(width, int(math.floor(x - 0.5)) + 1) target = mask[row] for column in range(first, last): target[column] = 1 return mask def _coverage_cell(contours, cell_w, cell_h, supersample): """Box-filter `contours` into `cell_h` rows of `cell_w` coverage bytes. `contours` are in supersampled cell space, so each output texel averages the `supersample` x `supersample` sub-samples that land on it -- a plain coverage count, which is what makes the result greyscale. """ area = supersample * supersample mask = _scanline_mask(contours, cell_w * supersample, cell_h * supersample) rows = [] for ty in range(cell_h): row = [] for tx in range(cell_w): covered = 0 for sy in range(supersample): sub = mask[ty * supersample + sy] base = tx * supersample for sx in range(supersample): covered += sub[base + sx] # Round-half-up in integers: deterministic, and independent of the # host's floating-point formatting. row.append((covered * 255 + area // 2) // area) rows.append(row) return rows def _cell_transform(em, units_per_em, ascent_px, supersample): scale = em / units_per_em def xf(x_units, y_units): return (x_units * scale * supersample, (ascent_px - y_units * scale) * supersample) return xf def _read_font(path): from fontTools.ttLib import TTFont if not os.path.isfile(path): raise SystemExit(_fail(EXIT_BAD_FONT, f"no such font file: {path}")) try: return TTFont(path, recalcBBoxes=False, recalcTimestamp=False) except Exception as exc: # fontTools raises a spread of parser errors raise SystemExit(_fail(EXIT_BAD_FONT, f"cannot parse {path}: {exc}")) def build(font_path, em, supersample): """Rasterise the coverage set at `em` px; returns the geometry and cells.""" font = _read_font(font_path) head = font["head"] os2 = font["OS/2"] units_per_em = head.unitsPerEm glyph_set = font.getGlyphSet() cmap = font.getBestCmap() hmtx = font["hmtx"] name_record = font["name"] def name(identifier, fallback): value = name_record.getDebugName(identifier) return value if value else fallback scale = em / units_per_em ascent_units = os2.sTypoAscender descent_units = -os2.sTypoDescender ascent_px = ascent_units * scale contours_class = _contour_pen_class() from fontTools.pens.boundsPen import BoundsPen codepoints = list(ASCII) + list(SPECIALS) # The cell: wide enough for the widest advance (the pen must never leave the # cell) and for the widest ink; tall enough for the face's ascent-to-descent # extent. Never trimmed to any one glyph's ink -- a fixed-cell grid is what # lets the pipeline carry one rect size per draw. widest_advance = 0 widest_ink = 0.0 for codepoint in codepoints: glyph_name = cmap.get(codepoint) if glyph_name is None: raise SystemExit( _fail(EXIT_BAD_FONT, f"{font_path} has no glyph for U+{codepoint:04X}") ) advance, _bearing = hmtx[glyph_name] widest_advance = max(widest_advance, advance) bounds = BoundsPen(glyph_set) glyph_set[glyph_name].draw(bounds) if bounds.bounds is not None: widest_ink = max(widest_ink, bounds.bounds[2]) cell_w = int(math.ceil(max(widest_advance * scale, widest_ink * scale) - 1e-9)) cell_h = int(math.ceil(ascent_px + descent_units * scale - 1e-9)) if cell_w <= 0 or cell_h <= 0: raise SystemExit(_fail(EXIT_BAD_FONT, "the computed cell is empty")) xf = _cell_transform(em, units_per_em, ascent_px, supersample) cells = [] advances = [] for codepoint in codepoints: glyph_name = cmap[codepoint] pen = contours_class(glyph_set, xf) glyph_set[glyph_name].draw(pen) rows = _coverage_cell(pen.contours, cell_w, cell_h, supersample) ink = sum(sum(row) for row in rows) if codepoint != 0x20 and ink == 0: # A silent blank cell is the failure mode this whole exercise exists # to remove: refuse to ship it. raise SystemExit( _fail( EXIT_BAD_FONT, f"U+{codepoint:04X} ({glyph_name}) rasterised to no ink at " f"{em} px; the cell is too small or the glyph is missing", ) ) cells.append(rows) advances.append(hmtx[glyph_name][0]) fallback_pen = contours_class(glyph_set, xf) fallback_pen.contours = [ [fallback_pen._xf(x, y) for x, y in FALLBACK_OUTER], [fallback_pen._xf(x, y) for x, y in FALLBACK_INNER], ] fallback_rows = _coverage_cell(fallback_pen.contours, cell_w, cell_h, supersample) if sum(sum(row) for row in fallback_rows) == 0: raise SystemExit(_fail(EXIT_BAD_FONT, "the fallback box rasterised to no ink")) cells.append(fallback_rows) advances.append(widest_advance) geometry = { "em": em, "supersample": supersample, "units_per_em": units_per_em, "scale": scale, "ascent_units": ascent_units, "descent_units": descent_units, "ascent_px": ascent_px, "cell_w": cell_w, "cell_h": cell_h, "glyph_count": len(codepoints) + 1, "fallback_slot": len(codepoints), "codepoints": codepoints, "advances": advances, "cells": cells, } provenance = { "font_file": os.path.basename(font_path), "font_name": name(4, "unknown"), "font_version": name(5, "unknown"), } font.close() return geometry, provenance def _codepoint_label(codepoint): if codepoint == FALLBACK_CODEPOINT: return "fallback box" return f"U+{codepoint:04X}" def emit(geometry, provenance): """Render one `build()` result as the generated translation unit.""" em = geometry["em"] supersample = geometry["supersample"] units_per_em = geometry["units_per_em"] cell_w = geometry["cell_w"] cell_h = geometry["cell_h"] glyph_count = geometry["glyph_count"] fallback_slot = geometry["fallback_slot"] atlas_w = cell_w * glyph_count atlas_h = cell_h codepoints = geometry["codepoints"] advances = geometry["advances"] slot_codepoints = list(codepoints) + [FALLBACK_CODEPOINT] ascii_lo = ASCII[0] ascii_hi = ASCII[-1] lines = [] add = lines.append add("// GENERATED FILE -- do not edit by hand.") add("//") add(f"// Regenerate with: {SCRIPT}") add(f"// The generator takes the source font, the em size and the supersample factor;") add(f"// its defaults bake the face this file holds. `{SCRIPT} --check`") add("// regenerates into memory and fails if this file has drifted from its input.") add("//") add("// Provenance") add("// source font : " + provenance["font_file"]) add("// font name : " + provenance["font_name"]) add("// font version : " + provenance["font_version"]) add("// licence : SIL Open Font License 1.1 (licences/OFL-1.1.txt)") add(f"// units per em : {units_per_em}") add(f"// em size : {em} px") add(f"// supersample : {supersample}x ({supersample * supersample} samples per texel)") add(f"// coverage : U+{ascii_lo:04X}..U+{ascii_hi:04X}, " + ", ".join(f"U+{cp:04X}" for cp in SPECIALS) + ", plus a fallback box") add(f"// cell : {cell_w} x {cell_h} texels, one row of {glyph_count}") add(f"// atlas : {atlas_w} x {atlas_h} texels") add("//") add("// The cell is the face's maximum advance wide and its ascent-to-descent extent") add("// tall; every glyph is drawn into that cell with its bearing preserved, never") add("// trimmed to its ink, because the pipeline stages one rect *origin* per glyph and") add("// carries one rect *size* for the whole draw (see render::TextBackend::cell_uv).") add("// Coverage is a plain supersampled coverage count, so a stroke's edges are grey") add("// and not a 1-bit threshold.") add("") add("#include ") add("") add("#include ") add("#include ") add("") add("namespace plot::render {") add("namespace {") add("") add("// Geometry, all of it derived from the bake above by the generator.") add(f"constexpr u32 kEmPx = {em}u;") add(f"constexpr u32 kSupersample = {supersample}u;") add(f"constexpr u32 kUnitsPerEm = {units_per_em}u;") add(f"constexpr u32 kCellW = {cell_w}u;") add(f"constexpr u32 kCellH = {cell_h}u;") add(f"constexpr u32 kGlyphCount = {glyph_count}u;") add(f"constexpr u32 kFallbackSlot = {fallback_slot}u;") add("constexpr u32 kAtlasW = kCellW * kGlyphCount;") add("constexpr u32 kAtlasH = kCellH;") add("constexpr u32 kAsciiFirst = " + f"{ascii_lo}u;") add("constexpr u32 kAsciiGlyphs = " + f"{len(ASCII)}u;") add("constexpr u32 kFallbackCodepoint = " + f"0x{FALLBACK_CODEPOINT:08X}u;") add("") add(f"constexpr u32 kAscentUnits = {geometry['ascent_units']}u;") add("// Font pixels per font unit. The one non-integer factor, and the only place a") add("// float enters: everything else here is an integer.") add("constexpr f32 kScale = static_cast(kEmPx) / static_cast(kUnitsPerEm);") add("constexpr f32 kAscentPx = static_cast(kAscentUnits) * kScale;") add("") add("// The public header repeats the em size, the supersample factor and the") add("// fallback marker so a caller can read them without opening this table.") add("// These asserts are what make that a pin and not a copy: re-baking at another") add("// em or supersample factor, or moving the fallback marker, fails to compile") add("// until the header agrees, so the two can never silently disagree.") add("static_assert(kEmPx == EmbeddedMonoFont::kEmPx,") add(" \"embedded_mono_font.hpp kEmPx disagrees with the bake\");") add("static_assert(kSupersample == EmbeddedMonoFont::kSupersample,") add(" \"embedded_mono_font.hpp kSupersample disagrees with the bake\");") add("static_assert(kFallbackCodepoint == EmbeddedMonoFont::kFallbackCodepoint,") add(" \"embedded_mono_font.hpp kFallbackCodepoint disagrees with the bake\");") add("") add("/// The codepoint each slot was baked for, in slot order; the fallback slot") add("/// carries `kFallbackCodepoint`, which is not a Unicode scalar.") add(f"constexpr u32 kCodepoints[kGlyphCount] = {{") for slot, codepoint in enumerate(codepoints): add(f" 0x{codepoint:04X}u, // [{slot:3d}] {_codepoint_label(codepoint)}") add(f" 0x{FALLBACK_CODEPOINT:08X}u, // [{fallback_slot:3d}] {_codepoint_label(FALLBACK_CODEPOINT)}") add("};") add("") add("/// The advance each slot contributes to the pen, in font units. The face is") add("/// monospaced, so these are all equal -- but the table is per glyph, because the") add("/// advance is the font's own `hmtx` value and not an assumption about the face.") add(f"constexpr u32 kAdvanceUnits[kGlyphCount] = {{") for slot, advance in enumerate(advances): codepoint = codepoints[slot] if slot < len(codepoints) else FALLBACK_CODEPOINT add(f" {advance}u, // [{slot:3d}] {_codepoint_label(codepoint)}") add("};") add("") add("/// The packed atlas: one row of cells, top row first, straight coverage, 0 for") add("/// background and 255 for full ink. GL_R8-ready, no dilation, no gamma.") add(f"constexpr u8 kAtlas[kAtlasW * kAtlasH] = {{") for row in range(atlas_h): for slot in range(glyph_count): values = geometry["cells"][slot][row] body = ", ".join(f"0x{value:02X}" for value in values) label = _codepoint_label(slot_codepoints[slot]) add(f" {body}, // [{slot:3d}] {label} row {row:2d}") add("};") add("") add("/// One slot's glyph: the cell it occupies in the atlas, drawn from the cell's") add("/// top-left (the pen) with no extra bearing -- the bearing is baked into the") add("/// cell's ink -- and the advance the pen moves by.") add("constexpr Glyph make_glyph(u32 slot) noexcept {") add(" const f32 u0 = static_cast(slot * kCellW) / static_cast(kAtlasW);") add(" const f32 du = static_cast(kCellW) / static_cast(kAtlasW);") add(" const f32 dv = static_cast(kCellH) / static_cast(kAtlasH);") add(" return Glyph{u0, 0.0f, u0 + du, dv, 0.0f, 0.0f,") add(" static_cast(kAdvanceUnits[slot]) * kScale};") add("}") add("") add("template ") add("constexpr std::array make_glyphs(") add(" std::integer_sequence) noexcept {") add(" return {make_glyph(Slots)...};") add("}") add("") add("constexpr std::array kGlyphs =") add(" make_glyphs(std::make_integer_sequence{});") add("") add("} // namespace") add("") add("u32 EmbeddedMonoFont::glyph_index(u32 codepoint) noexcept {") add(" if (codepoint >= kAsciiFirst && codepoint < kAsciiFirst + kAsciiGlyphs) {") add(" return codepoint - kAsciiFirst;") add(" }") add(" switch (codepoint) {") for offset, codepoint in enumerate(SPECIALS): add(f" case 0x{codepoint:04X}u:") add(f" return kAsciiGlyphs + {offset}u;") add(" default:") add(" return kFallbackSlot;") add(" }") add("}") add("") add("f32 EmbeddedMonoFont::advance_codepoint(u32 codepoint) noexcept {") add(" return static_cast(kAdvanceUnits[glyph_index(codepoint)]) * kScale;") add("}") add("") add("f32 EmbeddedMonoFont::measure(const char* utf8) const {") add(" if (utf8 == nullptr) {") add(" return 0.0f;") add(" }") add(" f32 width = 0.0f;") add(" for (const char* p = utf8; *p != '\\0';) {") add(" width += advance_codepoint(next_codepoint(p));") add(" }") add(" return width;") add("}") add("") add("f32 EmbeddedMonoFont::height() const { return static_cast(kCellH); }") add("") add("f32 EmbeddedMonoFont::ascent() const { return kAscentPx; }") add("") add("f32 EmbeddedMonoFont::em_px() const { return static_cast(kEmPx); }") add("") add("const Glyph* EmbeddedMonoFont::glyph(u32 codepoint) const {") add(" return &kGlyphs[glyph_index(codepoint)];") add("}") add("") add("Vec2 EmbeddedMonoFont::cell_uv() const {") add(" return Vec2{static_cast(kCellW) / static_cast(kAtlasW),") add(" static_cast(kCellH) / static_cast(kAtlasH)};") add("}") add("") add("const u8* EmbeddedMonoFont::atlas_pixels() const { return kAtlas; }") add("") add("u32 EmbeddedMonoFont::atlas_width() const { return kAtlasW; }") add("") add("u32 EmbeddedMonoFont::atlas_height() const { return kAtlasH; }") add("") add("u32 EmbeddedMonoFont::revision() const { return 0u; }") add("") add("u32 EmbeddedMonoFont::glyph_count() noexcept { return kGlyphCount; }") add("") add("u32 EmbeddedMonoFont::codepoint_of_slot(u32 slot) noexcept {") add(" return slot < kGlyphCount ? kCodepoints[slot] : kFallbackCodepoint;") add("}") add("") add("u32 EmbeddedMonoFont::cell_width() noexcept { return kCellW; }") add("") add("u32 EmbeddedMonoFont::cell_height() noexcept { return kCellH; }") add("") add("Vec2 EmbeddedMonoFont::atlas_origin(u32 slot) noexcept {") add(" const u32 s = slot < kGlyphCount ? slot : kFallbackSlot;") add(" return Vec2{static_cast(s * kCellW), 0.0f};") add("}") add("") add("const u8* EmbeddedMonoFont::atlas_image() noexcept { return kAtlas; }") add("") add("} // namespace plot::render") add("") return "\n".join(lines) def generate(font_path, em, supersample): geometry, provenance = build(font_path, em, supersample) return emit(geometry, provenance), geometry def main(argv=None): parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--font", default=DEFAULT_FONT, help="source TrueType/OpenType file") parser.add_argument("--em", type=int, default=DEFAULT_EM, help="em size in pixels") parser.add_argument( "--supersample", type=int, default=DEFAULT_SUPERSAMPLE, help="coverage samples per texel" ) parser.add_argument("--out", default=DEFAULT_OUT, help="translation unit to write") parser.add_argument( "--check", action="store_true", help="regenerate and fail if the committed file differs, instead of writing", ) args = parser.parse_args(argv) if args.em <= 0 or args.supersample <= 0: return _fail(EXIT_BAD_FONT, "em size and supersample factor must be positive") _require_fonttools() if args.check: out = Path(args.out) committed = out.read_bytes() if out.is_file() else None # A missing committed file is drift whatever else is wrong with the # machine, so it is reported before the font is read: otherwise a checkout # with no atlas *and* no installed face would look like an environment # gap (EXIT_BAD_FONT) instead of the failure it is. if committed is None: return _fail(EXIT_MISMATCH, f"{out} does not exist; run {SCRIPT} to generate it") fresh, _ = generate(args.font, args.em, args.supersample) if committed != fresh.encode(): return _fail( EXIT_MISMATCH, f"{out} differs from a fresh bake; regenerate with {SCRIPT} and commit the result", ) print(f"{SCRIPT}: {out} matches a fresh bake", file=sys.stderr) return EXIT_OK text, geometry = generate(args.font, args.em, args.supersample) Path(args.out).write_bytes(text.encode()) ink = sum(sum(row) for cell in geometry["cells"] for row in cell) print( f"{SCRIPT}: {args.out}: {geometry['glyph_count']} glyphs, " f"cell {geometry['cell_w']}x{geometry['cell_h']}, " f"atlas {geometry['cell_w'] * geometry['glyph_count']}x{geometry['cell_h']}, " f"{args.em} px em, {args.supersample}x, {ink} ink units", file=sys.stderr, ) return EXIT_OK if __name__ == "__main__": raise SystemExit(main())