feat(render): anti-aliased embedded Adwaita Mono atlas, generated and committed

This commit is contained in:
Martino Ferrari committed 2026-09-28 10:40:23 +02:00
1 parent 3ff7b9c978
commit 5ca3587325
17 files changed
+3417 -622

No files matched your search

+1 -1
View File
@@ -16,7 +16,7 @@ add_library(plot_core
src/render/shaders.cpp
src/render/text.cpp
src/render/text_atlas.cpp
src/render/bitmap_font.cpp
src/render/embedded_mono_font.cpp
src/render/primitives.cpp
src/render/buffer.cpp
src/render/vertex_array.cpp
+1 -1
View File
@@ -35,7 +35,7 @@
#include <plot/data/series.hpp>
#include <plot/data/stream_series.hpp>
#include <plot/render/bitmap_font.hpp>
#include <plot/render/embedded_mono_font.hpp>
#include <plot/render/buffer.hpp>
#include <plot/render/primitives.hpp>
#include <plot/render/program.hpp>
+1 -1
View File
@@ -23,7 +23,7 @@ enum class TickFormat {
/// Writes an SI-prefixed rendering of `v` to `buf`, always NUL-terminated and
/// never writing past `n` bytes (a too-small buffer truncates, it does not
/// overflow). ASCII plus the UTF-8 micro sign U+00B5 (0xC2 0xB5) for 1e-6, so
/// `plot::render::BitmapFont` renders it instead of mojibake.
/// `plot::render::EmbeddedMonoFont` renders it instead of mojibake.
///
/// `format_si(0.0)` is exactly `"0"`. Values use three significant digits, so
/// `1234.0` renders as `1.23k`. The sign of a negative value is kept. A value
-120
View File
@@ -1,120 +0,0 @@
#pragma once
#include <plot/core/types.hpp>
#include <plot/math/vec2.hpp>
#include <plot/render/text.hpp>
namespace plot::render {
/// 8x8 bitmap font, the text backend of the renderer.
///
/// The glyphs are 1-bit, one byte per row, bit 0 the leftmost pixel; the table
/// and its provenance are in `src/render/bitmap_font.cpp`. Nothing here needs a
/// font file, FreeType or stb_truetype: `plot_core` stays dependency-free.
///
/// Metrics are proportional, not fixed width: an advance is the glyph's ink
/// width plus one pixel of spacing, floored at two pixels, derived from the
/// same bitmaps the atlas uploads, so a second width table cannot drift from the
/// artwork. Whitespace is a blank glyph with a nonzero advance, and every
/// codepoint without a glyph draws a visible box, so a missing glyph is obvious
/// rather than silently blank.
///
/// Metrics are in unscaled font pixels, one per glyph texel, and the atlas is a
/// grid of `kGlyphPx` cells: every glyph's rect is its own cell, so a run drawn
/// at `s` pixels per font pixel measures `measure(utf8) * s` and sits in a cell
/// of `s * kGlyphPx` pixels. `TextBatch::add_text` takes that factor and applies
/// it to the pen it advances.
class BitmapFont final : public TextBackend {
public:
// Atlas geometry: a grid of `kGlyphPx` cells whose width is a whole number of
// them. A glyph's rect is its own cell, normalised by these sizes, so the grid
// is all the text pipeline has to know about the atlas.
static constexpr u32 kGlyphPx = 8; ///< glyph cell edge, texels
static constexpr u32 kFirstChar = 32; ///< U+0020
static constexpr u32 kAsciiGlyphs = 95; ///< U+0020..U+007E
static constexpr u32 kDegreeSlot = kAsciiGlyphs; ///< U+00B0 DEGREE SIGN
static constexpr u32 kMicroSlot = kAsciiGlyphs + 1u; ///< U+00B5 MICRO SIGN
static constexpr u32 kOhmSlot = kAsciiGlyphs + 2u; ///< U+03A9 OHM
static constexpr u32 kFallbackSlot = kAsciiGlyphs + 3u; ///< missing-glyph box
static constexpr u32 kGlyphCount = kAsciiGlyphs + 4u;
static constexpr u32 kAtlasCols = 16;
static constexpr u32 kAtlasRows = 7;
static constexpr u32 kAtlasWidth = kAtlasCols * kGlyphPx; ///< 128 texels
static constexpr u32 kAtlasHeight = kAtlasRows * kGlyphPx; ///< 56 texels
/// The atlas cell, normalised: one `kGlyphPx` square of the grid. Every glyph's
/// rect is this, and `cell_uv()` reports it -- one number, derived from the same
/// two constants `pack_atlas()` packs, so the grid the pipeline draws and the
/// grid the ink was written into cannot disagree.
static constexpr Vec2 kCellUV{static_cast<f32>(kGlyphPx) / static_cast<f32>(kAtlasWidth),
static_cast<f32>(kGlyphPx) / static_cast<f32>(kAtlasHeight)};
static_assert(kAtlasCols * kAtlasRows >= kGlyphCount, "atlas too small for the glyph set");
BitmapFont() noexcept = default;
/// Width of `utf8`, the sum of the glyph advances: zero for the empty string
/// and for null. The ASCII path allocates nothing.
f32 measure(const char* utf8) const override;
/// Cell height in pixels (one glyph row of text occupies `height()` pixels).
f32 height() const override;
/// Top of the cell to the baseline, in pixels.
f32 ascent() const override;
/// Advance of one ASCII character in pixels. A byte that is not printable
/// ASCII reports the missing-glyph box, matching what `measure` gives that
/// byte on its own.
f32 advance(char c) const noexcept;
/// Advance of a decoded Unicode codepoint, in pixels.
f32 advance_codepoint(u32 codepoint) const noexcept;
/// Glyph slot for a codepoint: printable ASCII is its own slot, the three SI
/// codepoints `format_si` emits map to real glyphs, and anything else maps to
/// the visible missing-glyph box.
static u32 glyph_index(u32 codepoint) noexcept;
/// The glyph for `codepoint`: its atlas cell as a normalised rect (see
/// `Glyph`), a zero bearing and its ink-derived advance. Never null -- a
/// codepoint with no artwork maps to the visible missing-glyph box, which is
/// what a label outside the coverage set should show.
const Glyph* glyph(u32 codepoint) const override;
/// `kCellUV`: the 8x8 cell every glyph occupies.
Vec2 cell_uv() const override { return kCellUV; }
/// The packed atlas, as `TextBackend` sees it: the bytes `atlas_image()`
/// returns, `kAtlasWidth` by `kAtlasHeight`, GL_R8-ready. Never null.
const u8* atlas_pixels() const override;
u32 atlas_width() const override;
u32 atlas_height() const override;
/// Always zero: the atlas is a compile-time constant, so it never changes and
/// the renderer uploads it once.
u32 revision() const override;
/// Rows in a glyph bitmap, always `kGlyphPx`.
u32 glyph_rows(char c) const noexcept;
/// The `kGlyphPx` rows of one ASCII character's bitmap, top row first.
const u8* glyph_bitmap(char c) const noexcept;
/// The `kGlyphPx` rows of a glyph slot.
static const u8* glyph_bitmap_of(u32 slot) noexcept;
/// The packed atlas image, ready for `plot::gl::Texture::upload_r8`: rows of
/// `kAtlasWidth` bytes, top row first, 255 for ink and 0 for background
/// (straight alpha, no dilation or gamma applied).
static const u8* atlas_image() noexcept;
/// Texel origin of a glyph slot in the atlas, in texels; `glyph()` gives the
/// same rectangle normalised, which is what the text pipeline stages.
static Vec2 atlas_origin(u32 slot) noexcept;
private:
/// Every slot's rect, bearing and advance, derived from the bitmaps above at
/// compile time. This replaced a per-instance `advances_` array holding the
/// same numbers, so the advances the pen uses and the ones `glyph()` publishes
/// cannot drift apart.
static const Glyph* glyph_table() noexcept;
};
} // namespace plot::render
+107
View File
@@ -0,0 +1,107 @@
#pragma once
#include <plot/core/types.hpp>
#include <plot/math/vec2.hpp>
#include <plot/render/text.hpp>
namespace plot::render {
/// The renderer's built-in text backend: an anti-aliased greyscale atlas of
/// Adwaita Mono, baked at authoring time and committed as data.
///
/// There is no font file, FreeType or stb_truetype behind this: the coverage
/// bytes and the glyph table are generated by `tools/fonts/gen_atlas.py` into
/// `src/render/embedded_mono_font.cpp`, so `plot_core` stays dependency-free and
/// nothing rasterises at build or run time. That file carries the provenance --
/// source face, its version, the em size, the supersample factor and the
/// coverage set -- and `gen_atlas.py --check` fails if it has drifted from its
/// input. Adwaita Mono is SIL OFL 1.1 (a copy is committed at
/// `licences/OFL-1.1.txt`); the class is named neutrally because OFL reserves
/// the font name for modified redistributions.
///
/// **One cell, every glyph.** The atlas is a fixed-cell grid -- `cell_uv()` is
/// the contract, and the staging path asserts it per glyph. Each cell is the
/// face's maximum advance wide and its ascent-to-descent extent tall, and every
/// glyph is drawn into its cell with its bearing preserved, never trimmed to its
/// ink. Because the face is monospaced the pen advances by the same amount for
/// every glyph, which is the face's own `hmtx` advance and not an assumption.
///
/// A codepoint outside the coverage set draws the baked fallback box, so a label
/// the atlas cannot spell is visible rather than silently blank.
class EmbeddedMonoFont final : public TextBackend {
public:
/// The size the atlas was baked at, in font pixels, and the coverage samples
/// per texel. Repeated here so a caller can reason about the artwork without
/// opening the generated table; the generated translation unit is the
/// authority and pins the same values.
static constexpr u32 kEmPx = 13;
static constexpr u32 kSupersample = 4;
/// The codepoint value `codepoint_of_slot()` reports for the fallback slot. It
/// is deliberately not a Unicode scalar, so it can never collide with a
/// codepoint the atlas really holds.
static constexpr u32 kFallbackCodepoint = 0xFFFFFFFFu;
EmbeddedMonoFont() noexcept = default;
/// Width of `utf8` in font pixels: the sum of the glyphs' advances, zero for
/// the empty string and for null. Allocates nothing.
f32 measure(const char* utf8) const override;
/// Cell height in font pixels: one line of text occupies this many pixels.
f32 height() const override;
/// Top of the cell to the baseline, in font pixels.
f32 ascent() const override;
/// `kEmPx`: the size the face was baked at. The renderer scales a run by
/// `Style::axis_font_px / em_px()`, so the same backend draws the size the
/// style asked for whatever it was baked at.
f32 em_px() const override;
/// The glyph for `codepoint`: its atlas cell as a normalised rect, no extra
/// bearing -- the bearing is inside the cell's ink -- and its advance. Never
/// null: anything outside the coverage set maps to the fallback box.
const Glyph* glyph(u32 codepoint) const override;
/// `(kCellW / atlas_width(), kCellH / atlas_height())`: the cell every glyph's
/// rect is.
Vec2 cell_uv() const override;
/// The packed atlas, GL_R8-ready, rows of `atlas_width()` bytes, top row
/// first, one coverage byte per texel. Never null.
const u8* atlas_pixels() const override;
u32 atlas_width() const override;
u32 atlas_height() const override;
/// Always zero: the atlas is a compile-time constant, so the renderer uploads
/// it once and never re-uploads.
u32 revision() const override;
/// Advance of a decoded codepoint, in font pixels. A codepoint outside the
/// coverage set reports the fallback box's advance.
static f32 advance_codepoint(u32 codepoint) noexcept;
/// Slot a codepoint maps to: printable ASCII is a direct index, the bakes SI
/// and dash codepoints are a short switch, and everything else is the fallback
/// box -- so a missing glyph is always visible, never blank.
static u32 glyph_index(u32 codepoint) noexcept;
/// Number of cells in the atlas, the fallback included.
static u32 glyph_count() noexcept;
/// The codepoint a slot was baked for, or `kFallbackCodepoint` for the
/// fallback slot. Out-of-range slots report the fallback too.
static u32 codepoint_of_slot(u32 slot) noexcept;
/// The fixed cell size, in atlas texels. Every glyph's rect is exactly this.
static u32 cell_width() noexcept;
static u32 cell_height() noexcept;
/// Texel origin of a slot's cell in the atlas; `glyph()` gives the same
/// rectangle normalised, which is what the text pipeline stages.
static Vec2 atlas_origin(u32 slot) noexcept;
/// The packed atlas image: the bytes `atlas_pixels()` returns, as a
/// compile-time table. Never null.
static const u8* atlas_image() noexcept;
};
} // namespace plot::render
+24 -17
View File
@@ -8,7 +8,7 @@
#include <plot/core/types.hpp>
#include <plot/math/vec2.hpp>
#include <plot/plot/plot.hpp>
#include <plot/render/bitmap_font.hpp>
#include <plot/render/embedded_mono_font.hpp>
#include <plot/render/buffer.hpp>
#include <plot/render/primitives.hpp>
#include <plot/render/program.hpp>
@@ -155,9 +155,11 @@ class Renderer {
bool using_persistent_upload() const noexcept {
return line_vao_.persistent() && text_vao_.persistent();
}
/// The axis text size the renderer actually draws with: `style().axis_font_px`
/// rounded to the nearest whole multiple of `render::BitmapFont::kGlyphPx`,
/// because a 1-bit glyph only scales cleanly by whole factors.
/// The axis text size the renderer actually draws with: the on-screen cell
/// height of the backend it draws with. `Style::axis_font_px` is an em size, so
/// with an ascent-to-descent cell it is this that a host reads back as the line
/// height it got -- and it is the backend's own `height()` scaled, so it always
/// agrees with the metrics the plot laid the labels out with.
f32 axis_font_px() const noexcept { return font_backend_.cell_px(); }
/// Writes the framebuffer's viewport region as a binary PPM (P6, 8 bits per
@@ -174,23 +176,25 @@ class Renderer {
bool dump_frame_ppm(const char* path, Status* st = nullptr) const;
private:
/// The bitmap font as the plot layer sees it, at the size the text pipeline
/// draws it: `measure`/`height`/`ascent` scale with the glyph cell, so a label
/// can never be laid out for one size and drawn at another. The 8x8 font scales
/// by whole multiples of its cell only -- anything else lands a 1-bit glyph's
/// strokes unevenly -- so `reset()` picks the multiple nearest the requested
/// size.
/// The backend the plot layer sees, at the size the text pipeline draws it:
/// `measure`/`height`/`ascent` scale with the run, so a label can never be laid
/// out for one size and drawn at another.
///
/// It is also the backend the renderer uploads the atlas from, so it forwards
/// the whole interface: the metrics and the glyph bearings and advances are
/// scaled, while the artwork itself -- the atlas bytes, its size and its
/// revision -- is the font's own and passes through untouched.
/// It wraps a `TextBackend` and nothing more: the scale is
/// `axis_font_px / em_px()`, a plain ratio, so an anti-aliased face is drawn at
/// the size the style asked for rather than at a whole multiple of its cell,
/// and any backend -- the embedded font, a host's TrueType face -- is drawn the
/// same way. It forwards the whole interface: the metrics and the glyph
/// bearings and advances are scaled, while the artwork itself -- the atlas
/// bytes, its size and its revision -- is the backend's own and passes through
/// untouched.
class AxisFont final : public render::TextBackend {
public:
void reset(const render::BitmapFont& font, f32 requested_px) noexcept;
void reset(const render::TextBackend& font, f32 requested_px) noexcept;
f32 measure(const char* utf8) const override;
f32 height() const override;
f32 ascent() const override;
f32 em_px() const override;
/// The font's glyph at this wrapper's scale: the atlas rect is unchanged (it
/// is scale-free) and the bearing and advance are multiplied by it, so one
/// `Glyph` describes the glyph this backend draws. The result lives in the
@@ -213,7 +217,7 @@ class Renderer {
f32 scale() const noexcept { return scale_; }
private:
const render::BitmapFont* font_ = nullptr;
const render::TextBackend* font_ = nullptr;
f32 cell_px_ = 1.0f;
f32 scale_ = 1.0f;
/// `glyph()`'s scaled result, returned by pointer because the interface is
@@ -280,7 +284,10 @@ class Renderer {
void draw_measures(const Plot& plot, const PlotLayout& layout);
void draw_annotations(const Plot& plot);
render::BitmapFont font_;
/// The built-in backend the renderer owns. Every use of it goes through the
/// `TextBackend` interface -- `self`-typed nowhere -- so swapping it for
/// another face is a change to this member alone.
render::EmbeddedMonoFont default_font_;
AxisFont font_backend_;
render::Style style_;
+12
View File
@@ -108,6 +108,18 @@ class TextBackend {
/// text bottoms instead of cell bottoms.
virtual f32 ascent() const = 0;
/// The face's em size in font pixels: the size the atlas was baked at, in the
/// same units as `height()` and `ascent()`.
///
/// It exists so a run can be drawn at the size a caller asked for whatever the
/// backend was baked at: the renderer scales by `requested_px / em_px()`, so a
/// backend re-baked at another size -- or swapped for a different face -- keeps
/// drawing `Style::axis_font_px`, and the renderer never has to know which font
/// it holds. A backend with no meaningful em (one whose "font pixels" are an
/// arbitrary unit) returns something proportional, `height()` being the
/// natural choice.
virtual f32 em_px() const = 0;
/// The glyph for `codepoint`, or null when this backend has none -- the caller
/// then draws nothing for it (a backend that wants a visible missing-glyph box
/// publishes one as the glyph for the codepoints it cannot render). The
+96
View File
@@ -0,0 +1,96 @@
Copyright (c) 2016, The Inter Project Authors (https://github.com/rsms/inter)
Copyright (c) 2015-2025, Renzhi Li (aka. Belleve Invis, belleve@typeof.net)
Copyright (c) 2025, Jamie Gravendeel (https://jamie.garden)
Copyright (c) 2025, Florian Müllner
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
-264
View File
@@ -1,264 +0,0 @@
#include <plot/render/bitmap_font.hpp>
#include <array>
namespace plot::render {
namespace {
// Glyph data and its provenance
// -----------------------------
// The 95 printable ASCII glyphs (slots 0..94, U+0020..U+007E) are the
// `font8x8_basic` table of the font8x8 project by Daniel Hepper, released into
// the public domain. It is itself based on Marcel Sondaar's public-domain 8x8
// VGA font (via the IBM public-domain VGA fonts). Fetched on 2026-09-27 from
// https://raw.githubusercontent.com/dhepper/font8x8/master/font8x8_basic.h
// The glyphs the plot layer's SI labels need come from the same project:
// U+00B0 DEGREE SIGN and U+00B5 MICRO SIGN from font8x8_ext_latin.h (which
// covers U+00A0..U+00FF), and U+03A9 GREEK CAPITAL LETTER OMEGA from
// font8x8_greek.h (which covers U+0390..U+03C9).
// The missing-glyph box in the last slot is authored here: a hollow 8x8 square.
// Nothing else in this table is hand-drawn, and none of it is a library: it is
// public-domain data, so `plot_core` still links nothing third-party.
//
// Row 0 is the top row and bit 0 is the leftmost pixel, one byte per row.
constexpr u8 kGlyphBits[BitmapFont::kGlyphCount][BitmapFont::kGlyphPx] = {
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // [ 0] U+0020 space
{0x18, 0x3C, 0x3C, 0x18, 0x18, 0x00, 0x18, 0x00}, // [ 1] U+0021 '!'
{0x36, 0x36, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // [ 2] U+0022 '"'
{0x36, 0x36, 0x7F, 0x36, 0x7F, 0x36, 0x36, 0x00}, // [ 3] U+0023 '#'
{0x0C, 0x3E, 0x03, 0x1E, 0x30, 0x1F, 0x0C, 0x00}, // [ 4] U+0024 '$'
{0x00, 0x63, 0x33, 0x18, 0x0C, 0x66, 0x63, 0x00}, // [ 5] U+0025 '%'
{0x1C, 0x36, 0x1C, 0x6E, 0x3B, 0x33, 0x6E, 0x00}, // [ 6] U+0026 '&'
{0x06, 0x06, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00}, // [ 7] U+0027 '''
{0x18, 0x0C, 0x06, 0x06, 0x06, 0x0C, 0x18, 0x00}, // [ 8] U+0028 '('
{0x06, 0x0C, 0x18, 0x18, 0x18, 0x0C, 0x06, 0x00}, // [ 9] U+0029 ')'
{0x00, 0x66, 0x3C, 0xFF, 0x3C, 0x66, 0x00, 0x00}, // [10] U+002A '*'
{0x00, 0x0C, 0x0C, 0x3F, 0x0C, 0x0C, 0x00, 0x00}, // [11] U+002B '+'
{0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C, 0x06}, // [12] U+002C ','
{0x00, 0x00, 0x00, 0x3F, 0x00, 0x00, 0x00, 0x00}, // [13] U+002D '-'
{0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C, 0x00}, // [14] U+002E '.'
{0x60, 0x30, 0x18, 0x0C, 0x06, 0x03, 0x01, 0x00}, // [15] U+002F '/'
{0x3E, 0x63, 0x73, 0x7B, 0x6F, 0x67, 0x3E, 0x00}, // [16] U+0030 '0'
{0x0C, 0x0E, 0x0C, 0x0C, 0x0C, 0x0C, 0x3F, 0x00}, // [17] U+0031 '1'
{0x1E, 0x33, 0x30, 0x1C, 0x06, 0x33, 0x3F, 0x00}, // [18] U+0032 '2'
{0x1E, 0x33, 0x30, 0x1C, 0x30, 0x33, 0x1E, 0x00}, // [19] U+0033 '3'
{0x38, 0x3C, 0x36, 0x33, 0x7F, 0x30, 0x78, 0x00}, // [20] U+0034 '4'
{0x3F, 0x03, 0x1F, 0x30, 0x30, 0x33, 0x1E, 0x00}, // [21] U+0035 '5'
{0x1C, 0x06, 0x03, 0x1F, 0x33, 0x33, 0x1E, 0x00}, // [22] U+0036 '6'
{0x3F, 0x33, 0x30, 0x18, 0x0C, 0x0C, 0x0C, 0x00}, // [23] U+0037 '7'
{0x1E, 0x33, 0x33, 0x1E, 0x33, 0x33, 0x1E, 0x00}, // [24] U+0038 '8'
{0x1E, 0x33, 0x33, 0x3E, 0x30, 0x18, 0x0E, 0x00}, // [25] U+0039 '9'
{0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x00}, // [26] U+003A ':'
{0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x06}, // [27] U+003B ';'
{0x18, 0x0C, 0x06, 0x03, 0x06, 0x0C, 0x18, 0x00}, // [28] U+003C '<'
{0x00, 0x00, 0x3F, 0x00, 0x00, 0x3F, 0x00, 0x00}, // [29] U+003D '='
{0x06, 0x0C, 0x18, 0x30, 0x18, 0x0C, 0x06, 0x00}, // [30] U+003E '>'
{0x1E, 0x33, 0x30, 0x18, 0x0C, 0x00, 0x0C, 0x00}, // [31] U+003F '?'
{0x3E, 0x63, 0x7B, 0x7B, 0x7B, 0x03, 0x1E, 0x00}, // [32] U+0040 '@'
{0x0C, 0x1E, 0x33, 0x33, 0x3F, 0x33, 0x33, 0x00}, // [33] U+0041 'A'
{0x3F, 0x66, 0x66, 0x3E, 0x66, 0x66, 0x3F, 0x00}, // [34] U+0042 'B'
{0x3C, 0x66, 0x03, 0x03, 0x03, 0x66, 0x3C, 0x00}, // [35] U+0043 'C'
{0x1F, 0x36, 0x66, 0x66, 0x66, 0x36, 0x1F, 0x00}, // [36] U+0044 'D'
{0x7F, 0x46, 0x16, 0x1E, 0x16, 0x46, 0x7F, 0x00}, // [37] U+0045 'E'
{0x7F, 0x46, 0x16, 0x1E, 0x16, 0x06, 0x0F, 0x00}, // [38] U+0046 'F'
{0x3C, 0x66, 0x03, 0x03, 0x73, 0x66, 0x7C, 0x00}, // [39] U+0047 'G'
{0x33, 0x33, 0x33, 0x3F, 0x33, 0x33, 0x33, 0x00}, // [40] U+0048 'H'
{0x1E, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // [41] U+0049 'I'
{0x78, 0x30, 0x30, 0x30, 0x33, 0x33, 0x1E, 0x00}, // [42] U+004A 'J'
{0x67, 0x66, 0x36, 0x1E, 0x36, 0x66, 0x67, 0x00}, // [43] U+004B 'K'
{0x0F, 0x06, 0x06, 0x06, 0x46, 0x66, 0x7F, 0x00}, // [44] U+004C 'L'
{0x63, 0x77, 0x7F, 0x7F, 0x6B, 0x63, 0x63, 0x00}, // [45] U+004D 'M'
{0x63, 0x67, 0x6F, 0x7B, 0x73, 0x63, 0x63, 0x00}, // [46] U+004E 'N'
{0x1C, 0x36, 0x63, 0x63, 0x63, 0x36, 0x1C, 0x00}, // [47] U+004F 'O'
{0x3F, 0x66, 0x66, 0x3E, 0x06, 0x06, 0x0F, 0x00}, // [48] U+0050 'P'
{0x1E, 0x33, 0x33, 0x33, 0x3B, 0x1E, 0x38, 0x00}, // [49] U+0051 'Q'
{0x3F, 0x66, 0x66, 0x3E, 0x36, 0x66, 0x67, 0x00}, // [50] U+0052 'R'
{0x1E, 0x33, 0x07, 0x0E, 0x38, 0x33, 0x1E, 0x00}, // [51] U+0053 'S'
{0x3F, 0x2D, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // [52] U+0054 'T'
{0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x3F, 0x00}, // [53] U+0055 'U'
{0x33, 0x33, 0x33, 0x33, 0x33, 0x1E, 0x0C, 0x00}, // [54] U+0056 'V'
{0x63, 0x63, 0x63, 0x6B, 0x7F, 0x77, 0x63, 0x00}, // [55] U+0057 'W'
{0x63, 0x63, 0x36, 0x1C, 0x1C, 0x36, 0x63, 0x00}, // [56] U+0058 'X'
{0x33, 0x33, 0x33, 0x1E, 0x0C, 0x0C, 0x1E, 0x00}, // [57] U+0059 'Y'
{0x7F, 0x63, 0x31, 0x18, 0x4C, 0x66, 0x7F, 0x00}, // [58] U+005A 'Z'
{0x1E, 0x06, 0x06, 0x06, 0x06, 0x06, 0x1E, 0x00}, // [59] U+005B '['
{0x03, 0x06, 0x0C, 0x18, 0x30, 0x60, 0x40, 0x00}, // [60] U+005C '\'
{0x1E, 0x18, 0x18, 0x18, 0x18, 0x18, 0x1E, 0x00}, // [61] U+005D ']'
{0x08, 0x1C, 0x36, 0x63, 0x00, 0x00, 0x00, 0x00}, // [62] U+005E '^'
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF}, // [63] U+005F '_'
{0x0C, 0x0C, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00}, // [64] U+0060 '`'
{0x00, 0x00, 0x1E, 0x30, 0x3E, 0x33, 0x6E, 0x00}, // [65] U+0061 'a'
{0x07, 0x06, 0x06, 0x3E, 0x66, 0x66, 0x3B, 0x00}, // [66] U+0062 'b'
{0x00, 0x00, 0x1E, 0x33, 0x03, 0x33, 0x1E, 0x00}, // [67] U+0063 'c'
{0x38, 0x30, 0x30, 0x3E, 0x33, 0x33, 0x6E, 0x00}, // [68] U+0064 'd'
{0x00, 0x00, 0x1E, 0x33, 0x3F, 0x03, 0x1E, 0x00}, // [69] U+0065 'e'
{0x1C, 0x36, 0x06, 0x0F, 0x06, 0x06, 0x0F, 0x00}, // [70] U+0066 'f'
{0x00, 0x00, 0x6E, 0x33, 0x33, 0x3E, 0x30, 0x1F}, // [71] U+0067 'g'
{0x07, 0x06, 0x36, 0x6E, 0x66, 0x66, 0x67, 0x00}, // [72] U+0068 'h'
{0x0C, 0x00, 0x0E, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // [73] U+0069 'i'
{0x30, 0x00, 0x30, 0x30, 0x30, 0x33, 0x33, 0x1E}, // [74] U+006A 'j'
{0x07, 0x06, 0x66, 0x36, 0x1E, 0x36, 0x67, 0x00}, // [75] U+006B 'k'
{0x0E, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // [76] U+006C 'l'
{0x00, 0x00, 0x33, 0x7F, 0x7F, 0x6B, 0x63, 0x00}, // [77] U+006D 'm'
{0x00, 0x00, 0x1F, 0x33, 0x33, 0x33, 0x33, 0x00}, // [78] U+006E 'n'
{0x00, 0x00, 0x1E, 0x33, 0x33, 0x33, 0x1E, 0x00}, // [79] U+006F 'o'
{0x00, 0x00, 0x3B, 0x66, 0x66, 0x3E, 0x06, 0x0F}, // [80] U+0070 'p'
{0x00, 0x00, 0x6E, 0x33, 0x33, 0x3E, 0x30, 0x78}, // [81] U+0071 'q'
{0x00, 0x00, 0x3B, 0x6E, 0x66, 0x06, 0x0F, 0x00}, // [82] U+0072 'r'
{0x00, 0x00, 0x3E, 0x03, 0x1E, 0x30, 0x1F, 0x00}, // [83] U+0073 's'
{0x08, 0x0C, 0x3E, 0x0C, 0x0C, 0x2C, 0x18, 0x00}, // [84] U+0074 't'
{0x00, 0x00, 0x33, 0x33, 0x33, 0x33, 0x6E, 0x00}, // [85] U+0075 'u'
{0x00, 0x00, 0x33, 0x33, 0x33, 0x1E, 0x0C, 0x00}, // [86] U+0076 'v'
{0x00, 0x00, 0x63, 0x6B, 0x7F, 0x7F, 0x36, 0x00}, // [87] U+0077 'w'
{0x00, 0x00, 0x63, 0x36, 0x1C, 0x36, 0x63, 0x00}, // [88] U+0078 'x'
{0x00, 0x00, 0x33, 0x33, 0x33, 0x3E, 0x30, 0x1F}, // [89] U+0079 'y'
{0x00, 0x00, 0x3F, 0x19, 0x0C, 0x26, 0x3F, 0x00}, // [90] U+007A 'z'
{0x38, 0x0C, 0x0C, 0x07, 0x0C, 0x0C, 0x38, 0x00}, // [91] U+007B '{'
{0x18, 0x18, 0x18, 0x00, 0x18, 0x18, 0x18, 0x00}, // [92] U+007C '|'
{0x07, 0x0C, 0x0C, 0x38, 0x0C, 0x0C, 0x07, 0x00}, // [93] U+007D '}'
{0x6E, 0x3B, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // [94] U+007E '~'
{0x1C, 0x36, 0x36, 0x1C, 0x00, 0x00, 0x00, 0x00}, // [95] U+00B0 degree
{0x00, 0x00, 0x66, 0x66, 0x66, 0x3E, 0x06, 0x03}, // [96] U+00B5 micro
{0x3E, 0x63, 0x63, 0x63, 0x36, 0x36, 0x77, 0x00}, // [97] U+03A9 ohm
{0x7E, 0x42, 0x42, 0x42, 0x42, 0x42, 0x7E, 0x00}, // [98] missing-glyph box, authored here
};
/// Repacks the glyph-major table into the atlas image the text pipeline
/// uploads: one 8x8 cell per slot, 255 for ink, straight alpha, no dilation.
constexpr std::array<u8, BitmapFont::kAtlasWidth * BitmapFont::kAtlasHeight> pack_atlas() noexcept {
std::array<u8, BitmapFont::kAtlasWidth * BitmapFont::kAtlasHeight> image{};
for (u32 slot = 0; slot < BitmapFont::kGlyphCount; ++slot) {
const u32 cell_x = (slot % BitmapFont::kAtlasCols) * BitmapFont::kGlyphPx;
const u32 cell_y = (slot / BitmapFont::kAtlasCols) * BitmapFont::kGlyphPx;
for (u32 y = 0; y < BitmapFont::kGlyphPx; ++y) {
const u8 bits = kGlyphBits[slot][y];
for (u32 x = 0; x < BitmapFont::kGlyphPx; ++x) {
image[(cell_y + y) * BitmapFont::kAtlasWidth + cell_x + x] =
static_cast<u8>(((bits >> x) & 1u) != 0u ? 0xFFu : 0x00u);
}
}
}
return image;
}
constexpr std::array<u8, BitmapFont::kAtlasWidth * BitmapFont::kAtlasHeight> kAtlasImage =
pack_atlas();
/// Span of set bits in a glyph's rows: 0 for a blank glyph such as space.
constexpr u32 ink_width(const u8* rows) noexcept {
u32 lo = BitmapFont::kGlyphPx;
u32 hi = 0;
bool any = false;
for (u32 y = 0; y < BitmapFont::kGlyphPx; ++y) {
for (u32 x = 0; x < BitmapFont::kGlyphPx; ++x) {
if (((rows[y] >> x) & 1u) != 0u) {
if (x < lo) {
lo = x;
}
if (x > hi) {
hi = x;
}
any = true;
}
}
}
return any ? hi - lo + 1u : 0u;
}
/// The glyph table the pipeline stages from: one entry per slot, its rect at the
/// slot's place in the grid and one cell big -- the cell `cell_uv()` declares,
/// so a glyph's rect and the size the draw is told cannot disagree -- a zero
/// bearing (the ink sits at the cell's top-left) and the ink-derived advance.
/// Built from the same tables `pack_atlas()` packs.
constexpr std::array<Glyph, BitmapFont::kGlyphCount> build_glyphs() noexcept {
std::array<Glyph, BitmapFont::kGlyphCount> glyphs{};
for (u32 slot = 0; slot < BitmapFont::kGlyphCount; ++slot) {
const f32 u0 = static_cast<f32>(slot % BitmapFont::kAtlasCols) * BitmapFont::kCellUV.x;
const f32 v0 = static_cast<f32>(slot / BitmapFont::kAtlasCols) * BitmapFont::kCellUV.y;
const u32 ink = ink_width(kGlyphBits[slot]);
const u32 spaced = ink + 1u; // one pixel of side bearing, floored at two
glyphs[slot] = Glyph{u0, v0, u0 + BitmapFont::kCellUV.x, v0 + BitmapFont::kCellUV.y, 0.0f,
0.0f, static_cast<f32>(spaced < 2u ? 2u : spaced)};
}
return glyphs;
}
constexpr std::array<Glyph, BitmapFont::kGlyphCount> kGlyphs = build_glyphs();
} // namespace
const Glyph* BitmapFont::glyph_table() noexcept { return kGlyphs.data(); }
f32 BitmapFont::measure(const char* utf8) const {
if (utf8 == nullptr) {
return 0.0f;
}
f32 width = 0.0f;
for (const char* p = utf8; *p != '\0';) {
width += advance_codepoint(next_codepoint(p));
}
return width;
}
const Glyph* BitmapFont::glyph(u32 codepoint) const {
// Every codepoint has a glyph: `glyph_index` maps whatever the font cannot
// draw -- including `kInvalidCodepoint` -- to the visible box.
return &glyph_table()[glyph_index(codepoint)];
}
const u8* BitmapFont::atlas_pixels() const { return atlas_image(); }
u32 BitmapFont::atlas_width() const { return kAtlasWidth; }
u32 BitmapFont::atlas_height() const { return kAtlasHeight; }
u32 BitmapFont::revision() const { return 0u; }
f32 BitmapFont::height() const { return static_cast<f32>(kGlyphPx); }
f32 BitmapFont::ascent() const { return static_cast<f32>(kGlyphPx - 1u); }
f32 BitmapFont::advance(char c) const noexcept {
return glyph_table()[glyph_index(static_cast<u32>(static_cast<unsigned char>(c)))].advance;
}
f32 BitmapFont::advance_codepoint(u32 codepoint) const noexcept {
return glyph_table()[glyph_index(codepoint)].advance;
}
u32 BitmapFont::glyph_index(u32 codepoint) noexcept {
if (codepoint >= kFirstChar && codepoint < kFirstChar + kAsciiGlyphs) {
return codepoint - kFirstChar;
}
switch (codepoint) {
case 0x00B0u:
return kDegreeSlot;
case 0x00B5u:
return kMicroSlot;
case 0x03A9u:
return kOhmSlot;
default:
return kFallbackSlot;
}
}
u32 BitmapFont::glyph_rows(char) const noexcept { return kGlyphPx; }
const u8* BitmapFont::glyph_bitmap(char c) const noexcept {
return glyph_bitmap_of(glyph_index(static_cast<u32>(static_cast<unsigned char>(c))));
}
const u8* BitmapFont::glyph_bitmap_of(u32 slot) noexcept {
return kGlyphBits[slot < kGlyphCount ? slot : kFallbackSlot];
}
const u8* BitmapFont::atlas_image() noexcept { return kAtlasImage.data(); }
Vec2 BitmapFont::atlas_origin(u32 slot) noexcept {
const u32 s = slot < kGlyphCount ? slot : kFallbackSlot;
return Vec2{static_cast<f32>((s % kAtlasCols) * kGlyphPx),
static_cast<f32>((s / kAtlasCols) * kGlyphPx)};
}
} // namespace plot::render
File diff suppressed because it is too large. Load diff
+24 -16
View File
@@ -88,23 +88,25 @@ f32 walk_text_clipped(Vec2 pen, const char* utf8, f32 budget_px, const render::T
void warn_replaced_text_backend(const render::TextBackend* previous) noexcept {
std::fprintf(stderr,
"plot::Renderer::draw: replacing the Plot's TextBackend (%p) with the renderer's "
"own bitmap font; a plot drawn by a Renderer measures the labels it draws.\n",
"own embedded font; a plot drawn by a Renderer measures the labels it draws.\n",
static_cast<const void*>(previous));
}
#endif
} // namespace
void Renderer::AxisFont::reset(const render::BitmapFont& font, f32 requested_px) noexcept {
void Renderer::AxisFont::reset(const render::TextBackend& font, f32 requested_px) noexcept {
font_ = &font;
const f32 native = static_cast<f32>(render::BitmapFont::kGlyphPx);
// Whole multiples of the native cell only: a 1-bit glyph drawn at 1.5x lands
// its strokes on uneven pixel counts, and the layout has to agree with what the
// pipeline draws, so the requested size picks the nearest multiple.
f32 steps = std::round(requested_px / native);
if (!(steps >= 1.0f)) steps = 1.0f; // also catches NaN
cell_px_ = steps * native;
scale_ = cell_px_ / native;
// The requested size is an em size, exactly as `em_px()` reports the size the
// atlas was baked at, so the ratio between them is all the scaling there is:
// an anti-aliased face is legible at any ratio, and a style asking for 12 px
// from a 13 px bake gets 12/13 rather than a snapped whole multiple. A backend
// that cannot say (`em_px()` at or below zero, or NaN) keeps a 1:1 scale rather
// than dividing by it.
const f32 baked = font.em_px();
const f32 ratio = requested_px / baked;
scale_ = (baked > 0.0f && ratio > 0.0f) ? ratio : 1.0f;
cell_px_ = font.height() * scale_;
}
f32 Renderer::AxisFont::measure(const char* utf8) const {
@@ -119,6 +121,12 @@ f32 Renderer::AxisFont::ascent() const {
return font_ != nullptr ? font_->ascent() * scale_ : 0.0f;
}
f32 Renderer::AxisFont::em_px() const {
// The em is the one metric that does not scale: it is the face's own unit, and
// it is what the scale above was computed from.
return font_ != nullptr ? font_->em_px() : 0.0f;
}
const render::Glyph* Renderer::AxisFont::glyph(u32 codepoint) const {
if (font_ == nullptr) {
return nullptr; // nothing to draw with, so nothing to describe
@@ -169,7 +177,7 @@ bool Renderer::init(Status* st) noexcept {
const render::UploadMode mode =
persistent ? render::UploadMode::PersistentMapped : render::UploadMode::Orphan;
font_backend_.reset(font_, style_.axis_font_px);
font_backend_.reset(default_font_, style_.axis_font_px);
// The atlas is the backend's: `sync()` uploads whatever it exposes, at whatever
// size it says, and restores the host's unpack alignment around the upload
@@ -263,7 +271,7 @@ void Renderer::draw(Plot& plot) {
// The plot reserves its label strips from TextBackend metrics, so it must
// measure with the size the text pipeline draws with, and re-read the style
// each frame so a changed axis_font_px takes effect on the next one.
font_backend_.reset(font_, style_.axis_font_px);
font_backend_.reset(default_font_, style_.axis_font_px);
// The cell every glyph staged this frame is drawn in, declared from the backend
// rather than left to whichever glyph is staged first: the instances carry rect
// origins, and the draw carries this size (see TextBackend::cell_uv).
@@ -280,7 +288,7 @@ void Renderer::draw(Plot& plot) {
}
#ifndef NDEBUG
// The renderer owns the text of the frames it draws: every label goes through
// the text pipeline in `font_`, so the plot is handed this font's metrics
// the text pipeline in `default_font_`, so the plot is handed this backend's metrics
// whatever a host installed. A host backend is therefore replaced on the first
// draw -- said out loud in a debug build, since its disappearance is otherwise
// silent -- and a Plot nobody draws keeps it (see Plot::set_text_backend()).
@@ -456,17 +464,17 @@ void Renderer::stage_xy(const f32* xy, u32 n, bool log_x, bool log_y) {
Vec2 Renderer::stage_text(Vec2 pos_px, const char* utf8) {
// Text is staged in viewport pixels: the text program has no offset uniform.
return glyphs_.add_text(utf8, to_viewport(pos_px), font_backend_.scale(), font_);
return glyphs_.add_text(utf8, to_viewport(pos_px), font_backend_.scale(), default_font_);
}
f32 Renderer::clipped_text_width(const char* utf8, f32 budget_px) const {
return walk_text_clipped(Vec2{}, utf8, budget_px, font_, font_backend_.scale(),
return walk_text_clipped(Vec2{}, utf8, budget_px, default_font_, font_backend_.scale(),
[](Vec2, const render::Glyph&) {});
}
f32 Renderer::stage_text_clipped(Vec2 pos_px, const char* utf8, f32 budget_px) {
const f32 scale = font_backend_.scale();
return walk_text_clipped(to_viewport(pos_px), utf8, budget_px, font_, scale,
return walk_text_clipped(to_viewport(pos_px), utf8, budget_px, default_font_, scale,
[this, scale](Vec2 pen, const render::Glyph& glyph) {
glyphs_.add_glyph(pen, glyph, scale);
});
+6 -1
View File
@@ -70,7 +70,12 @@ target_include_directories(plot_tests PRIVATE "${CMAKE_SOURCE_DIR}/adapters")
target_compile_definitions(plot_tests PRIVATE PLOT_PYRAMID_COUNT_COVERS
PLOT_PYRAMID_COUNT_MERGES
PLOT_RING_STATS
PLOT_COUNT_MEASURE_RECOMPUTES)
PLOT_COUNT_MEASURE_RECOMPUTES
# The committed source root, so the
# atlas-generator case can run
# tools/fonts/gen_atlas.py --check
# against the committed atlas.
PLOT_SOURCE_DIR="${CMAKE_SOURCE_DIR}")
plot_apply_warnings(plot_tests)
add_test(NAME plot_tests COMMAND plot_tests)
+36 -20
View File
@@ -6,13 +6,14 @@
#include <plot/gl/caps.hpp>
#include <plot/gl/handle.hpp>
#include <plot/gl/loader.hpp>
#include <plot/render/bitmap_font.hpp>
#include <plot/render/embedded_mono_font.hpp>
#include <plot/render/primitives.hpp>
#include <plot/render/program.hpp>
#include <plot/render/shaders.hpp>
#include <plot/render/text_atlas.hpp>
#include <plot/render/vertex_array.hpp>
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <cstring>
@@ -341,10 +342,10 @@ PLOT_TEST(line_pipeline_draws_an_aa_strip) {
// The text pipeline end to end: the R8 atlas upload, the per-instance glyph UV
// rect, the NEAREST texel-centre sampling, the tint and the same single y flip.
// Every one of the cell's 64 pixels is checked against the glyph bitmap, so a
// wrong atlas cell, a wrong UV or a transposed axis cannot pass.
// Every texel of the cell is checked against the backend's own coverage byte, so
// a wrong atlas cell, a wrong UV or a transposed axis cannot pass.
PLOT_TEST(text_pipeline_draws_a_glyph) {
using plot::render::BitmapFont;
using plot::render::EmbeddedMonoFont;
plottest::HeadlessContext ctx(256, 256);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
@@ -352,12 +353,12 @@ PLOT_TEST(text_pipeline_draws_a_glyph) {
// The backend's atlas, through the owner the renderer uses: single channel,
// GL_NEAREST, straight alpha, uploaded from the backend's own bytes.
BitmapFont font;
EmbeddedMonoFont font;
plot::render::TextAtlas atlas;
PLOT_CHECK(atlas.sync(font, &st));
PLOT_CHECK(!atlas.empty());
PLOT_CHECK_NEAR(atlas.texel_size().x, static_cast<plot::f32>(BitmapFont::kAtlasWidth), 1e-6);
PLOT_CHECK_NEAR(atlas.texel_size().y, static_cast<plot::f32>(BitmapFont::kAtlasHeight), 1e-6);
PLOT_CHECK_NEAR(atlas.texel_size().x, static_cast<plot::f32>(font.atlas_width()), 1e-6);
PLOT_CHECK_NEAR(atlas.texel_size().y, static_cast<plot::f32>(font.atlas_height()), 1e-6);
plot::render::TextProgram program;
PLOT_CHECK(program.create(&st));
@@ -383,29 +384,44 @@ PLOT_TEST(text_pipeline_draws_a_glyph) {
program.bind();
program.set_viewport({static_cast<f32>(ctx.width), static_cast<f32>(ctx.height)});
program.set_px_range({static_cast<f32>(BitmapFont::kGlyphPx), static_cast<f32>(BitmapFont::kGlyphPx)});
// The backend's own cell, on screen at 1:1: the draw's one rect size.
const plot::f32 cell_w = static_cast<plot::f32>(EmbeddedMonoFont::cell_width());
const plot::f32 cell_h = static_cast<plot::f32>(EmbeddedMonoFont::cell_height());
program.set_px_range({cell_w, cell_h});
program.set_glyph_uv(font.cell_uv());
program.set_texture_size(atlas.texel_size());
program.set_color(plot::Color::hex(0xFF00FF));
// White, so the blended pixel value *is* the coverage byte: the shader emits
// `vec4(rgb, a * coverage)`, and a white tint over the black clear lands at
// `1.0 * coverage` in every channel.
program.set_color(plot::Color::hex(0xFFFFFF));
atlas.bind();
glyphs.bind();
plot::gl::glDrawArraysInstanced(plot::gl::GL_TRIANGLE_STRIP, 0, 4, 1);
// One cell is 8x8 texels and u_px_range is 8, so texel (gx, gy) lands on one
// target pixel: Rect x = 64 + gx, and Rect y = 100 + gy is GL row 155 - gy.
const plot::u8* rows = font.glyph_bitmap('A');
// The cell is cell_w x cell_h texels and u_px_range is the same, so texel
// (gx, gy) lands on exactly one target pixel: Rect x = 64 + gx, and Rect y =
// 100 + gy is GL row 155 - gy. A transposed axis, a wrong cell or a wrong UV
// all move ink away from where the coverage byte says it is.
const plot::u32 slot = EmbeddedMonoFont::glyph_index(static_cast<plot::u32>('A'));
const plot::Vec2 origin = EmbeddedMonoFont::atlas_origin(slot);
const plot::u8* atlas_bytes = font.atlas_pixels();
plot::u32 antialiased = 0;
plot::u8 px[4] = {};
for (plot::u32 gy = 0; gy < BitmapFont::kGlyphPx; ++gy) {
for (plot::u32 gx = 0; gx < BitmapFont::kGlyphPx; ++gx) {
const bool ink = ((rows[gy] >> gx) & 1u) != 0u;
for (plot::u32 gy = 0; gy < EmbeddedMonoFont::cell_height(); ++gy) {
for (plot::u32 gx = 0; gx < EmbeddedMonoFont::cell_width(); ++gx) {
const plot::u32 ax = static_cast<plot::u32>(origin.x) + gx;
const plot::u32 ay = static_cast<plot::u32>(origin.y) + gy;
const plot::u8 want = atlas_bytes[static_cast<std::size_t>(ay) * font.atlas_width() + ax];
PLOT_CHECK(ctx.read_pixel(64u + gx, 155u - gy, px));
if (ink) {
PLOT_CHECK(px[0] > 200 && px[1] < 60 && px[2] > 200);
} else {
PLOT_CHECK(px[0] < 40 && px[2] < 40);
}
PLOT_CHECK_NEAR(px[0], static_cast<plot::f32>(want), 2.0);
PLOT_CHECK_NEAR(px[1], static_cast<plot::f32>(want), 2.0);
PLOT_CHECK_NEAR(px[2], static_cast<plot::f32>(want), 2.0);
if (want > 0u && want < 255u) ++antialiased;
}
}
// 'A' has grey edge texels and they reached the framebuffer: a 1-bit atlas
// could only ever have produced 0 or 255 here.
PLOT_CHECK(antialiased > 0u);
PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR);
ctx.destroy();
}
+4 -4
View File
@@ -3,7 +3,7 @@
#include <plot/plot/detail/measure_count.hpp>
#include <plot/data/array_series.hpp>
#include <plot/data/stream_series.hpp>
#include <plot/render/bitmap_font.hpp>
#include <plot/render/embedded_mono_font.hpp>
#include <cmath>
#include <vector>
@@ -73,7 +73,7 @@ PLOT_TEST(layout_without_text_backend_uses_default_label_areas) {
}
PLOT_TEST(layout_derives_label_areas_from_font_metrics) {
plot::render::BitmapFont font;
plot::render::EmbeddedMonoFont font;
plot::Plot p;
p.set_text_backend(&font);
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
@@ -103,7 +103,7 @@ PLOT_TEST(layout_derives_label_areas_from_font_metrics) {
/// Every titled axis of a direction gets its own text line inside its strip;
/// without that, the second titled axis of an edge is drawn on top of the first.
PLOT_TEST(layout_stacks_one_title_line_per_titled_axis) {
plot::render::BitmapFont font;
plot::render::EmbeddedMonoFont font;
plot::Plot p;
p.set_text_backend(&font);
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
@@ -127,7 +127,7 @@ PLOT_TEST(layout_stacks_one_title_line_per_titled_axis) {
/// An axis with no title reserves nothing: the strips exist so that a titled axis
/// has somewhere outside `axes` to draw in, not as a fixed cost per plot.
PLOT_TEST(layout_reserves_no_title_strip_without_a_title) {
plot::render::BitmapFont font;
plot::render::EmbeddedMonoFont font;
plot::Plot p;
p.set_text_backend(&font);
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
+280 -129
View File
@@ -1,12 +1,17 @@
// CPU-side contract of the render layer: the staging batches that feed the
// instanced line and text pipelines, and the bitmap font's metrics and atlas.
// instanced line and text pipelines, and the embedded font's metrics and atlas.
// Nothing here touches GL, so it runs on any machine that can build the tests.
#include "harness.hpp"
#include <plot/render/bitmap_font.hpp>
#include <plot/render/embedded_mono_font.hpp>
#include <plot/render/primitives.hpp>
#include <cstddef>
#include <cstdio>
#include <cstdlib>
#include <string>
#include <sys/wait.h>
#include <vector>
PLOT_TEST(line_batch_emits_two_verts_per_segment) {
plot::render::LineBatch b;
@@ -99,152 +104,293 @@ PLOT_TEST(line_batch_reuses_capacity_across_frames) {
PLOT_CHECK(b.data().data() == store);
}
PLOT_TEST(text_batch_measures_ascii) {
plot::render::BitmapFont f;
const f32 w = f.measure("12.5 ms");
PLOT_CHECK(w > 0.0f);
namespace {
using plot::render::EmbeddedMonoFont;
/// The coverage byte of one texel of `slot`'s cell: 0 background, 255 full ink.
plot::u8 cell_texel(const EmbeddedMonoFont& font, plot::u32 slot, plot::u32 col, plot::u32 row) {
const plot::Vec2 origin = EmbeddedMonoFont::atlas_origin(slot);
const plot::u32 x = static_cast<plot::u32>(origin.x) + col;
const plot::u32 y = static_cast<plot::u32>(origin.y) + row;
const std::size_t index = static_cast<std::size_t>(y) * font.atlas_width() + x;
return font.atlas_image()[index];
}
/// Total coverage of one cell: zero means the cell rasterised blank.
plot::u32 cell_ink_units(const EmbeddedMonoFont& font, plot::u32 slot) {
plot::u32 total = 0u;
for (plot::u32 y = 0; y < EmbeddedMonoFont::cell_height(); ++y) {
for (plot::u32 x = 0; x < EmbeddedMonoFont::cell_width(); ++x) {
total += cell_texel(font, slot, x, y);
}
}
return total;
}
/// The fallback slot, found by the marker codepoint rather than assumed.
plot::u32 fallback_slot() {
for (plot::u32 slot = 0; slot < EmbeddedMonoFont::glyph_count(); ++slot) {
if (EmbeddedMonoFont::codepoint_of_slot(slot) == EmbeddedMonoFont::kFallbackCodepoint) {
return slot;
}
}
return EmbeddedMonoFont::glyph_count();
}
/// The plot library's own source root, so the generator test can run the committed
/// script against the committed output. Injected by tests/CMakeLists.txt.
constexpr const char* kSourceDir = PLOT_SOURCE_DIR;
} // namespace
// The embedded atlas' metrics: a line taller than its ascent (descenders need the
// room), a non-empty advance, and -- the point of the face -- a fixed advance, so
// a label's width is its length and nothing else. A text backend with variable
// advances is the tile the plot library deliberately does not lay out (see the
// spec's non-goals), so `i` and `W` measure the same and a caller cannot be
// surprised by a label's reserved strip.
PLOT_TEST(embedded_font_metrics_are_sane) {
const EmbeddedMonoFont f;
PLOT_CHECK(f.height() > f.ascent());
PLOT_CHECK(f.ascent() > 0.0f);
PLOT_CHECK_NEAR(f.measure(""), 0.0, 1e-6);
PLOT_CHECK(f.measure("ab") < f.measure("abc")); // monotonic in length
PLOT_CHECK(f.measure("iiii") < f.measure("WWWW")); // proportional-ish, not fixed-width-blank
PLOT_CHECK_NEAR(f.measure(nullptr), 0.0, 1e-6);
// One advance per codepoint, whatever the glyph: this is `i` and `W` alike.
PLOT_CHECK_NEAR(f.measure("i"), f.measure("W"), 1e-6);
PLOT_CHECK_NEAR(f.measure("i"), EmbeddedMonoFont::advance_codepoint('i'), 1e-6);
// Monotone in length, and exactly the sum of the advances it stages.
PLOT_CHECK(f.measure("ab") < f.measure("abc"));
PLOT_CHECK(f.measure("abc") < f.measure("abcd"));
PLOT_CHECK_NEAR(f.measure("12.5 ms"),
(f.glyph('1')->advance + f.glyph('2')->advance + f.glyph('.')->advance +
f.glyph('5')->advance + f.glyph(' ')->advance + f.glyph('m')->advance +
f.glyph('s')->advance),
1e-6);
// The em is the size the table was baked at, and the scale the renderer draws
// with is a ratio of it -- so it must be a positive number and not `height()`.
PLOT_CHECK_NEAR(f.em_px(), static_cast<plot::f32>(EmbeddedMonoFont::kEmPx), 1e-6);
PLOT_CHECK(f.em_px() > 0.0f);
}
PLOT_TEST(bitmap_font_advance_follows_ink_extent) {
plot::render::BitmapFont f;
PLOT_CHECK(f.advance('i') < f.advance('W'));
// Whitespace is a blank glyph with a nonzero advance, never zero.
PLOT_CHECK(f.advance(' ') >= 2.0f);
// Advance is ink width + 1px of spacing, floored at 2px: derived here from
// the same bitmaps the atlas uploads, so a second width table cannot drift.
auto ink_width = [&f](char c) {
const plot::u8* rows = f.glyph_bitmap(c);
int lo = 8;
int hi = -1;
for (plot::u32 y = 0; y < f.glyph_rows(c); ++y) {
for (int x = 0; x < 8; ++x) {
if ((rows[y] >> x) & 1u) {
if (x < lo) lo = x;
if (x > hi) hi = x;
// Every codepoint the atlas claims to cover has its own cell, the cell is inside
// the atlas and is the declared cell, and its ink is non-empty -- the generator
// refuses to emit a blank cell for a covered codepoint, and this pins that the
// committed atlas is the generator's output and not a hand edit. U+0020 is the
// one documented blank: a space has no ink by definition and still advances.
PLOT_TEST(embedded_atlas_covers_every_expected_codepoint) {
const EmbeddedMonoFont f;
const plot::Vec2 cell = f.cell_uv();
PLOT_CHECK_NEAR(cell.x, static_cast<plot::f32>(EmbeddedMonoFont::cell_width()) /
static_cast<plot::f32>(f.atlas_width()),
1e-9);
PLOT_CHECK_NEAR(cell.y, static_cast<plot::f32>(EmbeddedMonoFont::cell_height()) /
static_cast<plot::f32>(f.atlas_height()),
1e-9);
PLOT_CHECK(f.atlas_pixels() != nullptr);
std::vector<plot::u32> expected;
for (plot::u32 cp = 0x20u; cp <= 0x7Eu; ++cp) expected.push_back(cp);
for (plot::u32 cp : {0x00B0u, 0x00B5u, 0x03A9u, 0x2013u, 0x2014u}) expected.push_back(cp);
for (plot::u32 cp : expected) {
const plot::u32 slot = EmbeddedMonoFont::glyph_index(cp);
PLOT_CHECK(slot < EmbeddedMonoFont::glyph_count());
PLOT_CHECK(EmbeddedMonoFont::codepoint_of_slot(slot) == cp);
const plot::render::Glyph* g = f.glyph(cp);
PLOT_CHECK(g != nullptr);
// The rect is the atlas cell, and it lies inside the atlas.
PLOT_CHECK(plot::render::glyph_is_cell(*g, cell));
PLOT_CHECK(g->u0 >= 0.0f && g->u1 <= 1.0f && g->v0 >= 0.0f && g->v1 <= 1.0f);
if (cp == 0x20u) {
PLOT_CHECK(cell_ink_units(f, slot) == 0u); // a space is blank ...
PLOT_CHECK(g->advance > 0.0f); // ... and still advances
} else {
PLOT_CHECK(cell_ink_units(f, slot) > 0u); // a covered glyph has ink
}
}
// The fallback box is a real cell with real ink, at a fixed slot, and every
// codepoint outside the coverage set draws it -- so a label the atlas cannot
// spell is visible rather than silently blank.
const plot::u32 fallback = fallback_slot();
PLOT_CHECK(fallback < EmbeddedMonoFont::glyph_count());
PLOT_CHECK(cell_ink_units(f, fallback) > 0u);
PLOT_CHECK(EmbeddedMonoFont::glyph_index(0x4E00u) == fallback);
PLOT_CHECK(EmbeddedMonoFont::glyph_index(0xFFFFu) == fallback);
PLOT_CHECK(EmbeddedMonoFont::glyph_index(plot::render::kInvalidCodepoint) == fallback);
PLOT_CHECK(f.glyph(0x4E00u)->advance > 0.0f);
}
// The atlas carries more than one coverage value: an anti-aliased raster has grey
// edges, and a 1-bit table could only ever be 0 or 255. This is the data-level
// half of the anti-aliasing evidence (the frame-level half is in the render
// smoke test): a table that passed through a threshold would fail it.
PLOT_TEST(embedded_atlas_holds_antialiased_coverage) {
const EmbeddedMonoFont f;
const plot::u8* atlas = f.atlas_pixels();
const std::size_t size = static_cast<std::size_t>(f.atlas_width()) * f.atlas_height();
plot::u32 partial = 0u;
plot::u32 full = 0u;
plot::u32 blank = 0u;
for (std::size_t i = 0; i < size; ++i) {
if (atlas[i] == 0u) ++blank;
else if (atlas[i] == 255u) ++full;
else ++partial;
}
return hi < 0 ? 0 : hi - lo + 1;
};
const char* samples = "iW.1_m";
PLOT_CHECK(full > 0u);
PLOT_CHECK(blank > 0u);
// Every glyph's stroke edge contributes: the partial population is a large
// fraction of the inked texels, not a handful of rounding artefacts.
PLOT_CHECK(partial > full / 2u);
}
// The face is monospaced, so every codepoint's advance is the same -- but it is
// the font's own `hmtx` advance carried through the bake, not a constant invented
// here: `advance_codepoint` and the per-glyph `advance` agree, and the whitespace
// glyphs that must never be zero are not.
PLOT_TEST(embedded_font_advance_matches_the_font_units) {
const EmbeddedMonoFont f;
const plot::f32 advance = EmbeddedMonoFont::advance_codepoint('A');
PLOT_CHECK(advance > 0.0f);
PLOT_CHECK(EmbeddedMonoFont::advance_codepoint(' ') > 0.0f);
const char* samples = "iW.1_m ";
for (const char* c = samples; *c != '\0'; ++c) {
const int ink = ink_width(*c);
const f32 want = ink + 1 < 2 ? 2.0f : static_cast<f32>(ink + 1);
PLOT_CHECK_NEAR(f.advance(*c), want, 1e-6);
PLOT_CHECK_NEAR(f.glyph(static_cast<plot::u32>(*c))->advance, advance, 1e-6);
}
// `advance_codepoint` is a lookup, not a walk: it agrees with `measure` for a
// single decoded codepoint, and disagrees with nothing in the table.
PLOT_CHECK_NEAR(f.measure("W"), EmbeddedMonoFont::advance_codepoint('W'), 1e-6);
for (plot::u32 cp = 0x20u; cp <= 0x7Eu; ++cp) {
PLOT_CHECK_NEAR(f.glyph(cp)->advance, EmbeddedMonoFont::advance_codepoint(cp), 1e-6);
}
// measure() is exactly the sum of the glyph advances it stages.
f32 sum = 0.0f;
for (const char* c = "12.5 ms"; *c != '\0'; ++c) sum += f.advance(*c);
PLOT_CHECK_NEAR(f.measure("12.5 ms"), sum, 1e-6);
}
PLOT_TEST(bitmap_font_decodes_si_units_and_falls_back_visibly) {
plot::render::BitmapFont f;
// plot::format_si emits U+00B5 MICRO SIGN, U+00B0 DEGREE SIGN and U+03A9 OHM.
// Decoding and the fallback, on the codepoints `plot::format_si` and a plotting
// label actually emit: the three SI symbols are real glyphs, everything else is
// the visible fallback box, and a malformed byte is not measured as zero.
PLOT_TEST(embedded_font_decodes_si_units_and_falls_back_visibly) {
const EmbeddedMonoFont f;
const plot::u32 fallback = fallback_slot();
const f32 micro = f.measure("\xC2\xB5s");
const f32 degree = f.measure("\xC2\xB0");
const f32 ohm = f.measure("\xCE\xA9");
PLOT_CHECK(micro > 0.0f);
PLOT_CHECK(degree > 0.0f);
PLOT_CHECK(ohm > 0.0f);
PLOT_CHECK_NEAR(micro, f.advance_codepoint(0x00B5u) + f.advance('s'), 1e-6);
PLOT_CHECK_NEAR(degree, f.advance_codepoint(0x00B0u), 1e-6);
PLOT_CHECK_NEAR(ohm, f.advance_codepoint(0x03A9u), 1e-6);
// Those three map to their own renderable glyph, not to the missing-glyph box.
const plot::u32 fallback = f.glyph_index(0x4E00u); // CJK, deliberately absent
PLOT_CHECK(f.glyph_index(0x00B5u) != fallback);
PLOT_CHECK(f.glyph_index(0x00B0u) != fallback);
PLOT_CHECK(f.glyph_index(0x03A9u) != fallback);
PLOT_CHECK(f.glyph_index(0x00B5u) != f.glyph_index(0x00B0u));
PLOT_CHECK(f.glyph_index(0x00B0u) != f.glyph_index(0x03A9u));
// Anything else non-ASCII is a visible box with a nonzero advance, and a
// malformed UTF-8 byte is treated the same way rather than measured as zero.
PLOT_CHECK_NEAR(f.measure("\xE4\xB8\x80"), f.advance_codepoint(0x4E00u), 1e-6);
PLOT_CHECK(f.advance_codepoint(0x4E00u) > 0.0f);
PLOT_CHECK(f.advance(static_cast<char>(0x80)) > 0.0f);
PLOT_CHECK_NEAR(f.advance(static_cast<char>(0x80)), f.advance_codepoint(0x4E00u), 1e-6);
// The fallback box glyph has ink; a blank bitmap would hide the problem.
bool any_ink = false;
const plot::u8* box = plot::render::BitmapFont::glyph_bitmap_of(fallback);
for (plot::u32 y = 0; y < plot::render::BitmapFont::kGlyphPx; ++y) {
any_ink = any_ink || box[y] != 0;
}
PLOT_CHECK(any_ink);
PLOT_CHECK_NEAR(micro, EmbeddedMonoFont::advance_codepoint(0x00B5u) + f.glyph('s')->advance, 1e-6);
// Each maps to its own cell, not to the box.
PLOT_CHECK(EmbeddedMonoFont::glyph_index(0x00B5u) != fallback);
PLOT_CHECK(EmbeddedMonoFont::glyph_index(0x00B0u) != fallback);
PLOT_CHECK(EmbeddedMonoFont::glyph_index(0x03A9u) != fallback);
PLOT_CHECK(EmbeddedMonoFont::glyph_index(0x00B5u) != EmbeddedMonoFont::glyph_index(0x00B0u));
PLOT_CHECK(EmbeddedMonoFont::glyph_index(0x00B0u) != EmbeddedMonoFont::glyph_index(0x03A9u));
// The two dashes the coverage adds are real cells too, not the box.
PLOT_CHECK(EmbeddedMonoFont::glyph_index(0x2013u) != fallback);
PLOT_CHECK(EmbeddedMonoFont::glyph_index(0x2014u) != fallback);
// A CJK codepoint and a bare malformed byte both draw the box, and both
// advance: a missing glyph is visible and never a zero-width hole.
PLOT_CHECK_NEAR(f.measure("\xE4\xB8\x80"), EmbeddedMonoFont::advance_codepoint(0x4E00u), 1e-6);
PLOT_CHECK_NEAR(f.measure("\x80"), EmbeddedMonoFont::advance_codepoint(0x4E00u), 1e-6);
}
// The packed atlas must be left-to-right in absolute terms: `(row >> x) & 1` is
// also how every other expectation here is derived, so a table transcribed with
// the opposite in-row bit order would keep those tests green while drawing
// mirrored text. 'A' cannot catch that (it is
// symmetric about its vertical axis); 'L', 'b' and '/' can.
PLOT_TEST(bitmap_font_bit_order_runs_left_to_right) {
using plot::render::BitmapFont;
const plot::u8* atlas = BitmapFont::atlas_image();
auto ink = [atlas](char c, plot::u32 col, plot::u32 row) {
const plot::u32 slot = static_cast<plot::u32>(c) - BitmapFont::kFirstChar;
const plot::Vec2 origin = BitmapFont::atlas_origin(slot);
const auto x = static_cast<std::size_t>(origin.x) + col;
const auto y = static_cast<std::size_t>(origin.y) + row;
return atlas[y * BitmapFont::kAtlasWidth + x] != 0u;
// The atlas must be left-to-right in absolute terms. The coverage bytes are what
// the GPU samples, so a generator that rasterised with the opposite x sense -- or
// an atlas packed with the cells reversed -- would keep every rect check green
// while drawing mirrored text. 'A' cannot catch that (it is symmetric about its
// vertical axis); 'L', 'b' and '/' can, and they are asserted against absolute
// texel coordinates rather than against the table itself.
PLOT_TEST(embedded_atlas_orientation_runs_left_to_right) {
const EmbeddedMonoFont f;
using plot::render::EmbeddedMonoFont;
auto ink = [&f](char c, plot::u32 col, plot::u32 row) {
const plot::u32 slot = EmbeddedMonoFont::glyph_index(static_cast<plot::u32>(c));
return cell_texel(f, slot, col, row) != 0u;
};
// 'L': stem and top serif on the left, the foot running right along the
// bottom row and stopping one texel short of the right edge.
PLOT_CHECK(ink('L', 0u, 0u));
PLOT_CHECK(!ink('L', 7u, 0u));
PLOT_CHECK(ink('L', 1u, 3u));
PLOT_CHECK(!ink('L', 6u, 3u));
PLOT_CHECK(ink('L', 6u, 6u));
PLOT_CHECK(!ink('L', 7u, 6u));
// 'b': the stem is the leftmost column of the upper rows.
PLOT_CHECK(ink('b', 0u, 0u));
PLOT_CHECK(!ink('b', 7u, 0u));
// '/': starts at the right of the top row and ends at the left of the last
// one — the opposite of '\'.
PLOT_CHECK(ink('/', 6u, 0u));
PLOT_CHECK(!ink('/', 0u, 0u));
PLOT_CHECK(ink('/', 0u, 6u));
PLOT_CHECK(!ink('/', 6u, 6u));
// 'L': the stem runs down the left, and the foot runs right along the bottom --
// so at cap height there is ink on the left and none on the right, and the
// bottom row has ink to the right of where the stem stopped.
PLOT_CHECK(ink('L', 1u, 6u));
PLOT_CHECK(!ink('L', 6u, 6u));
PLOT_CHECK(ink('L', 5u, 11u));
PLOT_CHECK(!ink('L', 5u, 3u));
// 'b': the stem is the left column of the upper rows, the bowl the right.
PLOT_CHECK(ink('b', 1u, 4u));
PLOT_CHECK(!ink('b', 6u, 4u));
PLOT_CHECK(ink('b', 6u, 8u));
// '/': starts at the top right and ends at the bottom left -- the opposite of
// '\'. This is the pair that pins the diagonal's direction.
PLOT_CHECK(ink('/', 5u, 2u));
PLOT_CHECK(!ink('/', 2u, 2u));
PLOT_CHECK(ink('/', 2u, 12u));
PLOT_CHECK(!ink('/', 5u, 12u));
}
PLOT_TEST(bitmap_font_atlas_is_uploadable) {
using plot::render::BitmapFont;
PLOT_CHECK(BitmapFont::kAtlasWidth == BitmapFont::kAtlasCols * BitmapFont::kGlyphPx);
PLOT_CHECK(BitmapFont::kAtlasHeight % BitmapFont::kGlyphPx == 0);
PLOT_CHECK(BitmapFont::kAtlasCols * BitmapFont::kAtlasRows >= BitmapFont::kGlyphCount);
PLOT_CHECK(BitmapFont::kAsciiGlyphs == 95);
// The atlas is a fixed-cell grid of one texel format: a whole number of cells
// across, the cell dividing the height, and every glyph's rect the declared cell
// -- so the pipeline's single per-draw rect size is the right one for every glyph.
PLOT_TEST(embedded_atlas_is_uploadable) {
using plot::render::EmbeddedMonoFont;
PLOT_CHECK(EmbeddedMonoFont::cell_width() > 0u);
PLOT_CHECK(EmbeddedMonoFont::cell_height() > 0u);
PLOT_CHECK(EmbeddedMonoFont::glyph_count() == 95u + 5u + 1u); // ASCII, SI/dashes, box
plot::render::BitmapFont f;
PLOT_CHECK(f.glyph_rows('A') == 8);
const EmbeddedMonoFont f;
PLOT_CHECK(f.atlas_width() == EmbeddedMonoFont::cell_width() * EmbeddedMonoFont::glyph_count());
PLOT_CHECK(f.atlas_height() == EmbeddedMonoFont::cell_height());
PLOT_CHECK(f.atlas_pixels() != nullptr);
PLOT_CHECK(f.height() > 0.0f);
PLOT_CHECK(f.ascent() > 0.0f);
PLOT_CHECK(f.ascent() <= f.height());
PLOT_CHECK(f.ascent() < f.height());
// Each glyph occupies its own atlas cell, and that cell is the bitmap byte
// for byte: 255 where a row bit is set, 0 elsewhere (straight alpha).
const plot::u8* atlas = BitmapFont::atlas_image();
const plot::u32 slots[] = {'A' - 32, 'g' - 32, 'i' - 32, 'W' - 32, 0, '.' - 32,
BitmapFont::kFallbackSlot, BitmapFont::kMicroSlot};
for (const plot::u32 slot : slots) {
const plot::Vec2 o = BitmapFont::atlas_origin(slot);
const plot::u8* rows = BitmapFont::glyph_bitmap_of(slot);
for (plot::u32 y = 0; y < BitmapFont::kGlyphPx; ++y) {
for (plot::u32 x = 0; x < BitmapFont::kGlyphPx; ++x) {
const plot::u8 want = ((rows[y] >> x) & 1u) != 0u ? 0xFFu : 0x00u;
const auto row = static_cast<std::size_t>(o.y) + y;
const auto col = static_cast<std::size_t>(o.x) + x;
PLOT_CHECK(atlas[row * BitmapFont::kAtlasWidth + col] == want);
// Every cell, the fallback included, is the declared cell and holds at least
// one non-zero byte (bar the space), and the ASCII codepoints are a direct
// index into the table.
for (plot::u32 slot = 0; slot < EmbeddedMonoFont::glyph_count(); ++slot) {
const plot::u32 cp = EmbeddedMonoFont::codepoint_of_slot(slot);
if (cp != EmbeddedMonoFont::kFallbackCodepoint) {
PLOT_CHECK(EmbeddedMonoFont::glyph_index(cp) == slot);
}
const plot::render::Glyph* g = f.glyph(cp);
PLOT_CHECK(plot::render::glyph_is_cell(*g, f.cell_uv()));
}
for (plot::u32 cp = 0x20u; cp <= 0x7Eu; ++cp) {
PLOT_CHECK(EmbeddedMonoFont::glyph_index(cp) == cp - 0x20u);
}
// The atlas is a whole number of cells and no glyph leaves its cell.
PLOT_CHECK(BitmapFont::kAtlasWidth == 16u * BitmapFont::kGlyphPx);
// A single-row atlas: the cell spans the whole height, so v is 0..1.
PLOT_CHECK_NEAR(f.cell_uv().y, 1.0, 1e-9);
PLOT_CHECK_NEAR(EmbeddedMonoFont::atlas_origin(0u).x, 0.0, 1e-9);
PLOT_CHECK_NEAR(EmbeddedMonoFont::atlas_origin(1u).x,
static_cast<plot::f32>(EmbeddedMonoFont::cell_width()), 1e-9);
}
// The committed atlas is the generator's output, not a hand edit, and the
// generator is deterministic: the same source font, em size and supersample
// factor produce it byte for byte. The check runs the committed script against
// the committed file, so a table touched by hand, a stale bake after a coverage
// change, or a non-deterministic generator all fail here.
//
// `fontTools` is an authoring-time tool and the test machine is not required to
// have it. Its absence is *reported*, not silently passed: the generator exits
// with a distinct code for it, and this case says so on stdout.
PLOT_TEST(generator_is_deterministic_and_current) {
const std::string command = std::string("python3 ") + kSourceDir +
"/tools/fonts/gen_atlas.py --check --out " + kSourceDir +
"/src/render/embedded_mono_font.cpp";
const int status = std::system(command.c_str());
PLOT_CHECK(status != -1);
PLOT_CHECK(WIFEXITED(status));
const int code = WEXITSTATUS(status);
if (code == 3) { // `gen_atlas.py`'s "fontTools is not installed" code
std::printf(
"SKIPPED: generator_is_deterministic_and_current -- fontTools is not "
"installed, so the atlas could not be re-baked\n");
return;
}
PLOT_CHECK(code == 0);
}
PLOT_TEST(text_batch_stages_one_quad_per_glyph) {
plot::render::BitmapFont f;
plot::render::EmbeddedMonoFont f;
plot::render::TextBatch b;
b.begin();
const plot::Vec2 end = b.add_text("12.5 ms", {0.0f, 0.0f}, 1.0f, f);
@@ -254,7 +400,7 @@ PLOT_TEST(text_batch_stages_one_quad_per_glyph) {
PLOT_CHECK_NEAR(end.y, 0.0, 1e-6);
PLOT_CHECK_NEAR(b.data()[0], 0.0, 1e-6); // first cell at the pen
PLOT_CHECK_NEAR(b.data()[1], 0.0, 1e-6);
PLOT_CHECK_NEAR(b.data()[6], f.advance('1'), 1e-6); // pen advanced per glyph
PLOT_CHECK_NEAR(b.data()[6], f.glyph('1')->advance, 1e-6); // pen advanced per glyph
// The staged cell's UV is the glyph's own atlas rect (see `Glyph`), and the
// basis of an unrotated run is the screen's +x. '.' is the string's third
// glyph, so its instance is the third: floats 12..17.
@@ -284,7 +430,7 @@ PLOT_TEST(text_batch_stages_one_quad_per_glyph) {
PLOT_CHECK(b.glyph_count() == 2);
PLOT_CHECK_NEAR(scaled.x, 10.0f + 2.0f * f.measure("AW"), 1e-6);
PLOT_CHECK_NEAR(scaled.y, 4.0, 1e-6);
PLOT_CHECK_NEAR(b.data()[6], 10.0f + 2.0f * f.advance('A'), 1e-6); // second cell's pen
PLOT_CHECK_NEAR(b.data()[6], 10.0f + 2.0f * f.glyph('A')->advance, 1e-6); // second cell's pen
}
// The pen follows the basis, not the screen axes. A run staged with `axis` has to
@@ -292,7 +438,7 @@ PLOT_TEST(text_batch_stages_one_quad_per_glyph) {
// what reserves the room for a rotated y label -- and its cells have to march along
// the same direction, or the ink lands beside the pen instead of up from it.
PLOT_TEST(text_batch_advances_along_its_basis) {
plot::render::BitmapFont f;
plot::render::EmbeddedMonoFont f;
plot::render::TextBatch b;
const plot::Vec2 pen{100.0f, 40.0f};
const f32 width = f.measure("12.5 ms");
@@ -318,7 +464,7 @@ PLOT_TEST(text_batch_advances_along_its_basis) {
// The second cell is one advance further up than the pen, which is the same rule
// the unrotated case follows in x.
PLOT_CHECK_NEAR(b.data()[6], pen.x, 1e-6);
PLOT_CHECK_NEAR(b.data()[7], pen.y - f.advance('1'), 1e-6);
PLOT_CHECK_NEAR(b.data()[7], pen.y - f.glyph('1')->advance, 1e-6);
// A diagonal direction is normalised, so a caller can hand over any direction
// and the basis the shader receives is still unit.
@@ -340,6 +486,7 @@ class TrimmedCellBackend final : public plot::render::TextBackend {
plot::f32 measure(const char*) const override { return 8.0f; }
plot::f32 height() const override { return 8.0f; }
plot::f32 ascent() const override { return 8.0f; }
plot::f32 em_px() const override { return 8.0f; }
plot::Vec2 cell_uv() const override { return plot::Vec2{0.5f, 0.25f}; }
plot::u32 revision() const override { return 1u; } // baked once, never re-baked
const plot::render::Glyph* glyph(plot::u32 codepoint) const override {
@@ -396,17 +543,19 @@ PLOT_TEST(a_trimmed_glyph_is_not_the_atlas_cell) {
PLOT_CHECK(plot::render::glyph_is_cell(rounded, cell));
// A real font's grid holds for every glyph it publishes, not just the first.
plot::render::BitmapFont font;
plot::render::EmbeddedMonoFont font;
for (plot::u32 codepoint = 32u; codepoint < 127u; ++codepoint) {
const plot::render::Glyph* g = font.glyph(codepoint);
PLOT_CHECK(g != nullptr);
PLOT_CHECK(plot::render::glyph_is_cell(*g, font.cell_uv()));
}
PLOT_CHECK_NEAR(font.cell_uv().x, static_cast<plot::f32>(plot::render::BitmapFont::kGlyphPx) /
static_cast<plot::f32>(plot::render::BitmapFont::kAtlasWidth),
PLOT_CHECK_NEAR(font.cell_uv().x,
static_cast<plot::f32>(plot::render::EmbeddedMonoFont::cell_width()) /
static_cast<plot::f32>(font.atlas_width()),
1e-9);
PLOT_CHECK_NEAR(font.cell_uv().y, static_cast<plot::f32>(plot::render::BitmapFont::kGlyphPx) /
static_cast<plot::f32>(plot::render::BitmapFont::kAtlasHeight),
PLOT_CHECK_NEAR(font.cell_uv().y,
static_cast<plot::f32>(plot::render::EmbeddedMonoFont::cell_height()) /
static_cast<plot::f32>(font.atlas_height()),
1e-9);
}
@@ -418,6 +567,7 @@ class HalfCellBackend final : public plot::render::TextBackend {
plot::f32 measure(const char*) const override { return 4.0f; }
plot::f32 height() const override { return 8.0f; }
plot::f32 ascent() const override { return 6.0f; }
plot::f32 em_px() const override { return 8.0f; }
plot::Vec2 cell_uv() const override { return plot::Vec2{0.5f, 0.25f}; }
plot::u32 revision() const override { return 1u; }
const plot::render::Glyph* glyph(plot::u32) const override { return &cell_; }
@@ -471,7 +621,7 @@ PLOT_TEST(text_batch_takes_its_cell_from_the_backend) {
// And the real font's grid, staged the same way, reports the font's cell -- the
// two never mix silently, and the batch's value tracks the backend it was given.
plot::render::BitmapFont font;
plot::render::EmbeddedMonoFont font;
b.begin();
b.add_text("A", {0.0f, 0.0f}, 1.0f, font);
PLOT_CHECK_NEAR(b.glyph_cell_uv().x, font.cell_uv().x, 1e-9);
@@ -503,6 +653,7 @@ class HoleInCoverageBackend final : public plot::render::TextBackend {
}
plot::f32 height() const override { return 8.0f; }
plot::f32 ascent() const override { return 6.0f; }
plot::f32 em_px() const override { return 8.0f; }
plot::Vec2 cell_uv() const override { return plot::Vec2{0.5f, 1.0f}; }
plot::u32 revision() const override { return 1u; }
const plot::render::Glyph* glyph(plot::u32 codepoint) const override {
@@ -523,7 +674,7 @@ class HoleInCoverageBackend final : public plot::render::TextBackend {
// it and does not advance the pen, so a run stays exactly as long as the same
// backend measured it and the glyphs it *can* draw keep their places. A backend
// that would rather show something publishes a fallback box as that codepoint's
// glyph (which is what `BitmapFont` does); this is the other legal answer, and the
// glyph (which is what `EmbeddedMonoFont` does); this is the other legal answer, and the
// staging path has to honour it rather than draw whatever was staged last or
// advance by a width nobody published.
PLOT_TEST(add_text_skips_a_codepoint_the_backend_cannot_draw) {
@@ -568,7 +719,7 @@ PLOT_TEST(add_text_skips_a_codepoint_the_backend_cannot_draw) {
// A real font never hits this path (every codepoint maps to its box), so the
// same string through it stages one glyph per codepoint: the two answers to
// "no glyph" are the backend's choice, not the pipeline's.
plot::render::BitmapFont font;
plot::render::EmbeddedMonoFont font;
batch.begin();
batch.add_text("A?B", pen, 1.0f, font);
PLOT_CHECK(batch.glyph_count() == 3);
+81 -48
View File
@@ -119,6 +119,24 @@ bool pixel_is(const plot::u8 px[4], plot::Color c) {
return near(px[0], c.r) && near(px[1], c.g) && near(px[2], c.b);
}
/// True when `px` holds `c` at some coverage -- the anti-aliased case `pixel_is`
/// cannot see. The embedded atlas is greyscale and the renderer draws it at
/// `Style::axis_font_px` from a 13 px bake, so at the default size a glyph stroke
/// is barely a pixel wide and *no* pixel is ever fully covered: demanding the
/// exact colour finds no text at all.
///
/// A partially covered pixel is `c` scaled toward the frame's dark chrome, so this
/// asks for every channel to be at least a third of `c`'s: bright enough to be
/// glyph ink, dark enough to exclude a background, and deliberately blind to which
/// of two chalk-toned chrome colours a pixel is -- which is why every caller scans
/// a region only one element draws in.
bool text_ink(const plot::u8 px[4], plot::Color c) {
const auto lifted = [](plot::u8 got, plot::f32 want) {
return static_cast<plot::f32>(got) >= want * 255.0f * 0.35f;
};
return lifted(px[0], c.r) && lifted(px[1], c.g) && lifted(px[2], c.b);
}
/// No column held ink.
constexpr plot::u32 kNoColumn = 0xFFFFFFFFu;
@@ -132,6 +150,7 @@ class OnePxPerStringBackend final : public plot::render::TextBackend {
plot::f32 measure(const char*) const override { return 1.0f; }
plot::f32 height() const override { return 1.0f; }
plot::f32 ascent() const override { return 1.0f; }
plot::f32 em_px() const override { return 1.0f; }
const plot::render::Glyph* glyph(plot::u32) const override { return nullptr; }
plot::Vec2 cell_uv() const override { return plot::Vec2{}; } // no atlas, no cell
const plot::u8* atlas_pixels() const override { return nullptr; }
@@ -190,6 +209,8 @@ class BakeableBackend final : public plot::render::TextBackend {
plot::f32 measure(const char*) const override { return 8.0f; }
plot::f32 height() const override { return static_cast<plot::f32>(size_); }
plot::f32 ascent() const override { return static_cast<plot::f32>(size_); }
/// Its cell is the whole atlas, so its em is the bake size: one unit is one pixel.
plot::f32 em_px() const override { return static_cast<plot::f32>(size_); }
const plot::render::Glyph* glyph(plot::u32) const override { return &glyph_; }
plot::Vec2 cell_uv() const override { return pixels_.empty() ? plot::Vec2{} : plot::Vec2{1.0f, 1.0f}; }
const plot::u8* atlas_pixels() const override { return pixels_.empty() ? nullptr : pixels_.data(); }
@@ -217,14 +238,16 @@ struct InkBox {
plot::u32 height() const noexcept { return any() ? y1 - y0 + 1u : 0u; }
};
/// Scans the whole framebuffer for fully covered `text`-coloured pixels.
/// Scans the whole framebuffer for `text`-coloured ink, anti-aliased fringe and
/// all (see `text_ink`). These cases draw nothing but the run, so everything lit
/// in the target's colour is the run.
InkBox scan_ink_box(plottest::HeadlessContext& ctx, plot::Color text) {
InkBox box;
for (plot::u32 y = 0; y < ctx.height; ++y) {
for (plot::u32 x = 0; x < ctx.width; ++x) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - y, px));
if (!pixel_is(px, text)) continue;
if (!text_ink(px, text)) continue;
if (!box.any()) {
box.x0 = x;
box.y0 = y;
@@ -290,8 +313,8 @@ struct TextInk {
plot::u32 widest_row_run = 0;
};
/// Scans the top-down pixels of [x0, x1) x [y, y + rows) for fully covered text
/// pixels in `text`.
/// Scans the top-down pixels of [x0, x1) x [y, y + rows) for `text`-coloured ink,
/// anti-aliased fringe and all (see `text_ink`).
TextInk scan_text_ink(plottest::HeadlessContext& ctx, plot::u32 x0, plot::u32 x1, plot::u32 y,
plot::u32 rows, plot::Color text) {
TextInk ink;
@@ -301,7 +324,7 @@ TextInk scan_text_ink(plottest::HeadlessContext& ctx, plot::u32 x0, plot::u32 x1
for (plot::u32 x = x0; x < x1; ++x) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - (y + r), px));
if (!pixel_is(px, text)) continue;
if (!text_ink(px, text)) continue;
if (first == kNoColumn) first = x;
last = x;
if (ink.leftmost == kNoColumn) ink.leftmost = x;
@@ -661,8 +684,12 @@ PLOT_TEST(renderer_measures_the_labels_it_draws) {
const plot::u32 y = static_cast<plot::u32>(labels.y);
const plot::u32 rows = static_cast<plot::u32>(labels.h);
const plot::u32 strip_x = static_cast<plot::u32>(labels.x);
// Exactly the strip, and no overhang: the axes' own border line runs down its
// right edge through these same rows, and a scanner that reached past the strip
// would read that line as label ink -- it is the same chalk-toned colour family
// as the text (see `text_ink`).
const TextInk ink =
scan_text_ink(ctx, strip_x, strip_x + static_cast<plot::u32>(labels.w) + 16u, y, rows, text);
scan_text_ink(ctx, strip_x, strip_x + static_cast<plot::u32>(labels.w), y, rows, text);
PLOT_CHECK(ink.leftmost != kNoColumn); // labels were drawn
PLOT_CHECK(ink.leftmost >= strip_x); // inside their strip
PLOT_CHECK(static_cast<plot::f32>(ink.widest_row_run) >= 0.6f * widest); // full width drawn
@@ -1017,7 +1044,7 @@ PLOT_TEST(titled_axis_pairs_get_their_own_rows) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(static_cast<plot::u32>(strip.x) + column,
ctx.height - 1u - (static_cast<plot::u32>(strip.y) + row), px));
has = pixel_is(px, title);
has = text_ink(px, title);
}
if (has && !previous) ++bands;
if (has) ++rows;
@@ -1261,11 +1288,13 @@ PLOT_TEST(rotated_text_advances_along_its_basis) {
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::render::BitmapFont font;
plot::render::EmbeddedMonoFont font;
plot::render::TextAtlas atlas;
const plot::Vec2 pen{300.0f, 180.0f};
const plot::f32 px = 2.0f; // 16 px cells: six glyphs is over a hundred pixels
const plot::u32 cell = static_cast<plot::u32>(8.0f * px);
const plot::f32 px = 2.0f; // one cell is 16 x 34 screen pixels at this scale
const plot::u32 cell = plot::render::EmbeddedMonoFont::cell_height() * 2u;
const plot::u32 advance_px =
static_cast<plot::u32>(font.glyph(static_cast<plot::u32>('M'))->advance * px);
const InkBox flat = draw_text_run(ctx, atlas, font, "MWMWMW", pen, px, {1.0f, 0.0f});
PLOT_CHECK(flat.any());
@@ -1273,6 +1302,7 @@ PLOT_TEST(rotated_text_advances_along_its_basis) {
PLOT_CHECK(flat.x0 >= static_cast<plot::u32>(pen.x));
PLOT_CHECK(flat.y0 >= static_cast<plot::u32>(pen.y));
PLOT_CHECK(flat.height() <= cell);
PLOT_CHECK(flat.width() > 4u * advance_px); // six monospaced glyphs, and then some
const InkBox up = draw_text_run(ctx, atlas, font, "MWMWMW", pen, px, {0.0f, -1.0f});
PLOT_CHECK(up.any());
@@ -1281,42 +1311,45 @@ PLOT_TEST(rotated_text_advances_along_its_basis) {
PLOT_CHECK(up.width() <= cell); // one cell wide, so nothing marched right
PLOT_CHECK(up.x0 >= static_cast<plot::u32>(pen.x));
PLOT_CHECK(up.x1 < static_cast<plot::u32>(pen.x) + cell);
// Ink above the pen: the first cell starts at the pen and every later one is a
// cell further up, so the run's rows run from well above the pen down to the
// Ink above the pen: the first cell starts at the pen and every later one is an
// advance further up, so the run's rows run from well above the pen down to the
// pen's own cell -- and the pen's own cell is the lowest row of ink.
PLOT_CHECK(up.y0 + 4u * cell < static_cast<plot::u32>(pen.y));
PLOT_CHECK(up.y0 + 4u * advance_px < static_cast<plot::u32>(pen.y));
PLOT_CHECK(up.y1 < static_cast<plot::u32>(pen.y) + cell);
ctx.destroy();
}
// The orientation rule, stated rather than hardcoded: a glyph's texel `(col, row)`
// lands at the pen plus `col` steps along the cell's +v axis and `row` steps along
// its +x axis. That is the mapping kTextVS implements, so with `axis = (0, -1)` the
// 'L' -- whose ink is a stem down its left column and a foot along its bottom row --
// has to come out as a top bar with the foot running up the right-hand side.
// lands at the pen plus `col` steps along the cell's +x axis (`ux` here) and `row`
// steps along its +v axis (the perpendicular). That is the mapping kTextVS
// implements, so with `axis = (0, -1)` the 'L' -- whose ink is a stem down its left
// columns and a foot along its bottom rows -- has to come out as a bar standing up
// from the pen with the foot running right.
//
// The check is two-sided: the basis-derived corner holds ink and the corner a
// mirrored perpendicular would put it in does not, so a glyph flipped about its own
// diagonal fails even though it is the same size and shape.
// The check is two-sided: the texels the basis says hold ink do, and the texels a
// mirrored perpendicular would put that ink in hold none -- so a glyph flipped
// about its own diagonal fails even though it is the same size and shape. The
// coverage is anti-aliased, so "held ink" is a coverage threshold rather than an
// exact white: a texel of the stem is fully covered and a texel of the foot is
// half, and both are ink.
PLOT_TEST(rotated_glyph_orientation_is_not_mirrored) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::render::BitmapFont font;
plot::render::EmbeddedMonoFont font;
plot::render::TextAtlas atlas;
const plot::Vec2 pen{300.0f, 200.0f};
const plot::f32 px = 4.0f; // an 8x8 glyph at 32 px: one texel is a 4x4 pixel block
const plot::f32 px = 4.0f; // one texel is a 4x4 pixel block
const plot::Vec2 ux{0.0f, -1.0f}; // a 90 degree counter-clockwise label
const plot::Vec2 uy{-ux.y, ux.x}; // (1, 0): the cell's own +v axis
// Where the centre of the glyph's texel (col, row) is drawn. Every cell is
// `kGlyphPx` texels across, the cell's top-left is the pen because the bitmap
// font's bearings are zero, and the rule is the shader's own: the atlas' u runs
// along the cell's +x axis (`ux` here, `a_axis` there) and its v along the
// perpendicular (`v_axis`), which is what `v_uv = a_uv + corner * u_glyph_uv`
// maps corner by corner.
// Where the centre of the glyph's texel (col, row) is drawn. The cell's top-left
// is the pen (the embedded font bakes its bearings into the cell), and the rule
// is the shader's own: the atlas' u runs along the cell's +x axis (`ux` here,
// `a_axis` there) and its v along the perpendicular (`v_axis`), which is what
// `v_uv = a_uv + corner * u_glyph_uv` maps corner by corner.
auto texel_centre = [&](plot::f32 col, plot::f32 row, plot::Vec2 v_axis) {
const plot::f32 du = (col + 0.5f) * px;
const plot::f32 dv = (row + 0.5f) * px;
@@ -1326,28 +1359,28 @@ PLOT_TEST(rotated_glyph_orientation_is_not_mirrored) {
plot::u8 pxv[4] = {};
PLOT_CHECK(ctx.read_pixel(static_cast<plot::u32>(at.x),
ctx.height - 1u - static_cast<plot::u32>(at.y), pxv));
return pixel_is(pxv, plot::Color::hex(0xFFFFFF));
return pxv[0] >= 96u; // white tint over black: the pixel *is* the coverage
};
PLOT_CHECK(draw_text_run(ctx, atlas, font, "L", pen, px, ux).any());
// 'L' is inked at texel (0, 0) -- the stem's top -- and at (6, 6), the far end of
// its foot. Both corners are where the basis puts them ...
PLOT_CHECK(inked(texel_centre(0.0f, 0.0f, uy)));
PLOT_CHECK(inked(texel_centre(6.0f, 6.0f, uy)));
// ... and the two *off-diagonal* texels of its ink state the rule the diagonal
// pair cannot: the stem meets the foot at (0, 6), which is where the cell's
// first texel row and last texel column meet, while (6, 0) is blank. A shader
// that swapped the cell's two axes would trade those two, and both of these
// would fail -- the diagonal probes would not.
PLOT_CHECK(inked(texel_centre(0.0f, 6.0f, uy)));
PLOT_CHECK(!inked(texel_centre(6.0f, 0.0f, uy)));
// ... and the mirrored basis -- the other perpendicular a shader could build --
// would put the stem's top on the opposite side of the pen, where this frame has
// no ink at all.
// The stem (cell columns 1-2, rows 3-12) and the foot (columns 2-6, rows 11-12)
// put ink at these texels ...
PLOT_CHECK(inked(texel_centre(2.0f, 4.0f, uy))); // stem, fully covered
PLOT_CHECK(inked(texel_centre(2.0f, 11.0f, uy))); // stem meets the foot
PLOT_CHECK(inked(texel_centre(5.0f, 11.0f, uy))); // the foot's far end
// ... and none at these, which are the cell's blank margin: a shader that
// swapped the cell's two axes would put the ink in the wrong half and fail both
// sides.
PLOT_CHECK(!inked(texel_centre(2.0f, 0.0f, uy))); // above the first row of ink
PLOT_CHECK(!inked(texel_centre(6.0f, 4.0f, uy))); // right of the stem at cap height
PLOT_CHECK(!inked(texel_centre(0.0f, 4.0f, uy))); // the cell's empty first column
// The mirrored perpendicular -- the other basis a shader could build -- would
// draw the whole cell on the opposite side of the pen, where this frame has no
// ink at all.
const plot::Vec2 mirrored{-uy.x, -uy.y};
PLOT_CHECK(!inked(texel_centre(0.0f, 0.0f, mirrored)));
PLOT_CHECK(!inked(texel_centre(6.0f, 6.0f, mirrored)));
PLOT_CHECK(!inked(texel_centre(2.0f, 4.0f, mirrored)));
PLOT_CHECK(!inked(texel_centre(5.0f, 11.0f, mirrored)));
ctx.destroy();
}
@@ -1505,7 +1538,7 @@ PLOT_TEST(text_atlas_reports_a_status_when_the_entry_points_are_missing) {
PLOT_CHECK(plot::gl::load(nullptr));
PLOT_CHECK(plot::gl::available());
plot::render::BitmapFont font;
plot::render::EmbeddedMonoFont font;
plot::render::TextAtlas atlas;
{
RefuseGetIntegerv refused;
@@ -1522,8 +1555,8 @@ PLOT_TEST(text_atlas_reports_a_status_when_the_entry_points_are_missing) {
// the Status it got.
PLOT_CHECK(atlas.sync(font, &st));
PLOT_CHECK(!atlas.empty());
PLOT_CHECK_NEAR(atlas.texel_size().x, static_cast<plot::f32>(plot::render::BitmapFont::kAtlasWidth),
1e-6);
PLOT_CHECK_NEAR(atlas.texel_size().x, static_cast<plot::f32>(font.atlas_width()), 1e-6);
PLOT_CHECK_NEAR(atlas.texel_size().y, static_cast<plot::f32>(font.atlas_height()), 1e-6);
PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR);
ctx.destroy();
}
+645
View File
@@ -0,0 +1,645 @@
#!/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 <plot/render/embedded_mono_font.hpp>")
add("")
add("#include <array>")
add("#include <utility>")
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<f32>(kEmPx) / static_cast<f32>(kUnitsPerEm);")
add("constexpr f32 kAscentPx = static_cast<f32>(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<f32>(slot * kCellW) / static_cast<f32>(kAtlasW);")
add(" const f32 du = static_cast<f32>(kCellW) / static_cast<f32>(kAtlasW);")
add(" const f32 dv = static_cast<f32>(kCellH) / static_cast<f32>(kAtlasH);")
add(" return Glyph{u0, 0.0f, u0 + du, dv, 0.0f, 0.0f,")
add(" static_cast<f32>(kAdvanceUnits[slot]) * kScale};")
add("}")
add("")
add("template <u32... Slots>")
add("constexpr std::array<Glyph, sizeof...(Slots)> make_glyphs(")
add(" std::integer_sequence<u32, Slots...>) noexcept {")
add(" return {make_glyph(Slots)...};")
add("}")
add("")
add("constexpr std::array<Glyph, kGlyphCount> kGlyphs =")
add(" make_glyphs(std::make_integer_sequence<u32, kGlyphCount>{});")
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<f32>(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<f32>(kCellH); }")
add("")
add("f32 EmbeddedMonoFont::ascent() const { return kAscentPx; }")
add("")
add("f32 EmbeddedMonoFont::em_px() const { return static_cast<f32>(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<f32>(kCellW) / static_cast<f32>(kAtlasW),")
add(" static_cast<f32>(kCellH) / static_cast<f32>(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<f32>(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
fresh, _ = generate(args.font, args.em, args.supersample)
if committed is None:
return _fail(EXIT_MISMATCH, f"{out} does not exist; run {SCRIPT} to generate it")
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())