feat(text): optional vendored stb_truetype backend for runtime external fonts
This commit is contained in:
1 parent
daff60e3f0
commit
14d664a246
12 files changed
+6511
-19
No files matched your search
@@ -8,11 +8,11 @@ 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
|
||||
/// There is no font file, FreeType or third-party rasteriser 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
|
||||
@@ -27,6 +27,12 @@ namespace plot::render {
|
||||
///
|
||||
/// A codepoint outside the coverage set draws the baked fallback box, so a label
|
||||
/// the atlas cannot spell is visible rather than silently blank.
|
||||
///
|
||||
/// This is the only backend the core library ships. The one that bakes a font
|
||||
/// file at run time belongs to the optional target -- `plot/text/true_type_font.hpp`,
|
||||
/// deliberately not part of `include/plot/plot.hpp` -- and no public header
|
||||
/// outside `plot/text/` names what rasterises it, which is the isolation that
|
||||
/// target exists to keep.
|
||||
class EmbeddedMonoFont final : public TextBackend {
|
||||
public:
|
||||
/// The size the atlas was baked at, in font pixels, and the coverage samples
|
||||
|
||||
@@ -77,6 +77,32 @@ class Renderer {
|
||||
/// `Unsupported`; anything the driver refuses is `GLFailure` plus its log.
|
||||
bool init(Status* st = nullptr) noexcept;
|
||||
|
||||
/// The face every frame this renderer draws uses: the embedded one until a host
|
||||
/// installs another here.
|
||||
|
||||
/// `init()` binds `render::EmbeddedMonoFont`, and `draw()` installs the binding
|
||||
/// on the plot it is given (replacing whatever a host set with
|
||||
/// `Plot::set_text_backend()`, which is how a plot with no renderer keeps its
|
||||
/// own), so this is the way to draw with a face of your own -- a
|
||||
/// `plot::text::TrueTypeFont` from the optional target, say, or any other
|
||||
/// `TextBackend`. Passing null restores the embedded face. The swap takes
|
||||
/// effect on the next frame, which re-reads the backend's metrics at
|
||||
/// `style().device(axis_font_px)` and re-uploads its atlas -- so nothing here
|
||||
/// has to know whether the replacement is a different face, the same face
|
||||
/// re-baked at another size, or a backend whose pixels changed: the revision
|
||||
/// comparison in `render::TextAtlas` decides, and a replacement whose atlas is
|
||||
/// already on the GPU under the same revision is uploaded anyway (see the
|
||||
/// implementation for why that is the safe direction).
|
||||
///
|
||||
/// The backend is borrowed, not owned: it must outlive the frames drawn with it,
|
||||
/// which is the same lifetime rule `Plot::set_text_backend()` states.
|
||||
void set_text_backend(const render::TextBackend* font) noexcept;
|
||||
|
||||
/// The backend the last frame was drawn with, or the embedded face when the
|
||||
/// host installed none. The renderer's own wrapper while a frame is in flight,
|
||||
/// so a caller reads this after `draw()` to see what the plot was handed.
|
||||
const render::TextBackend* text_backend() const noexcept { return font_; }
|
||||
|
||||
/// Starts a frame: records `fb_w` x `fb_h` for the projection, flips
|
||||
/// `viewport_px` (top-left origin, like every `Rect` in this library) into GL's
|
||||
/// bottom-left origin once, clears that region to `style().background` and
|
||||
@@ -387,10 +413,14 @@ class Renderer {
|
||||
/// numbers.
|
||||
void draw_legend(const Plot& plot, const PlotLayout& layout);
|
||||
|
||||
/// 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.
|
||||
/// The built-in backend the renderer owns, and the face the frames draw with:
|
||||
/// `set_text_backend()` writes `font_`, which starts here and comes back here
|
||||
/// when a host passes null. Every use of the face goes through the
|
||||
/// `TextBackend` interface -- `self`-typed nowhere -- so a host's backend and
|
||||
/// this one are interchangeable, and the only thing that has to happen on a swap
|
||||
/// is the atlas re-upload `set_text_backend()` arranges.
|
||||
render::EmbeddedMonoFont default_font_;
|
||||
const render::TextBackend* font_ = &default_font_;
|
||||
AxisFont font_backend_;
|
||||
render::Style style_;
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
#pragma once
|
||||
#include <memory>
|
||||
#include <span>
|
||||
|
||||
#include <plot/core/status.hpp>
|
||||
#include <plot/core/types.hpp>
|
||||
#include <plot/math/vec2.hpp>
|
||||
#include <plot/render/text.hpp>
|
||||
|
||||
namespace plot::text {
|
||||
|
||||
/// A TrueType/OpenType file rasterised into a `render::TextBackend`, at one pixel
|
||||
/// size, at run time: the half of the font story that is not committed as data.
|
||||
///
|
||||
/// `render::EmbeddedMonoFont` is the default and needs nothing: its atlas is
|
||||
/// generated at authoring time and compiled in, so a host gets a font by just
|
||||
/// rendering. This is the other half of the requirement -- a label drawn in the
|
||||
/// face the *host* chooses, loaded from a file the host has -- and it is
|
||||
/// deliberately not part of `plot_core`:
|
||||
///
|
||||
/// **Where the rasteriser comes from.** This is the only class in the project
|
||||
/// behind which a third-party rasteriser sits: `third_party/stb_truetype.h`,
|
||||
/// stb_truetype v1.26, public domain, authored by Sean Barrett / RAD Game Tools,
|
||||
/// from https://github.com/nothings/stb/blob/master/stb_truetype.h (retrieved
|
||||
/// 2026-09-28; the vendored copy is sha256
|
||||
/// ecd30b05e0dd4fea3a13c26810dd9e1992dc379049482c393d5a19e6b5090aab). It is
|
||||
/// compiled into the optional `plot_font_ttf` target alone, which is why this
|
||||
/// header mentions no type of it: the parser and the baked atlas live behind
|
||||
/// `Impl`, and only `src/text/true_type_font.cpp` and the implementation unit
|
||||
/// next to it ever include the vendored header. `plot_core` and every public
|
||||
/// header outside this directory stay third-party-free, and a build with
|
||||
/// `PLOT_BUILD_TTF_FONT=OFF` neither compiles nor links any of it -- this header
|
||||
/// compiles on its own regardless, because all it needs is `plot::render`.
|
||||
///
|
||||
/// **One cell, every glyph, like the embedded face.** The atlas is a fixed-cell
|
||||
/// grid -- `render::TextBackend::cell_uv()` is where the size is declared and the
|
||||
/// staging path asserts it per glyph -- so this is *not* the naive TrueType
|
||||
/// packing that trims every glyph to its ink: each codepoint's rasterised bitmap
|
||||
/// is blitted into a cell of the one size, at the offset its bitmap box reports
|
||||
/// from the pen, exactly as `tools/fonts/gen_atlas.py` bakes the embedded face.
|
||||
/// A proportional face therefore works, and pays for it in atlas area (the cell
|
||||
/// is as wide as the widest advance, so a narrow `i` has room for a `W`'s worth
|
||||
/// of ink next to it). The pen never enters the next cell early: `advance` is the
|
||||
/// face's own, so a proportional run advances per glyph while every rect stays
|
||||
/// one size.
|
||||
///
|
||||
/// **What is rejected, and how.** `from_file()` parses and bakes first and
|
||||
/// installs the result only on success, so a failure leaves the object exactly as
|
||||
/// it was -- a host can try a file, keep the font it already had, and read why
|
||||
/// from `*st`. A missing or unreadable path, a directory, a file that is not a
|
||||
/// font, a non-positive `px`, and a bake that produced no ink are all failures
|
||||
/// with a `Status`, and none of them throws across this boundary: the convention
|
||||
/// is `bool` plus out-params, the same one every other fallible `plot` call uses,
|
||||
/// and no `Result<T>` is introduced for a font.
|
||||
///
|
||||
/// **Trust.** stb_truetype's own header says it does no range checking of the
|
||||
/// offsets in a font file, so a file crafted to be hostile can read memory. This
|
||||
/// class parses the host's fonts, not untrusted input: a *random* file is
|
||||
/// rejected, a *malicious* one is out of scope, and the same is true of
|
||||
/// everything else in `third_party/`.
|
||||
///
|
||||
/// **Where it lives.** `plot_core` does not provide this class: it is built by
|
||||
/// the optional `plot_font_ttf` target, and this header is deliberately absent
|
||||
/// from `include/plot/plot.hpp` for that reason (see
|
||||
/// `cmake/plot_umbrella_check.cmake`, which exempts it by name).
|
||||
class TrueTypeFont final : public render::TextBackend {
|
||||
public:
|
||||
/// No face: no atlas, no cell, `glyph()` null for everything. A legal backend
|
||||
/// to hand a renderer -- it means "no text", the same way a null atlas does --
|
||||
/// and the state `from_file()` leaves behind when it reports a failure.
|
||||
TrueTypeFont() noexcept;
|
||||
/// Out of line: `Impl` is incomplete here on purpose, so that no declaration of
|
||||
/// stb_truetype's parser reaches this header or anything that includes it.
|
||||
~TrueTypeFont() override;
|
||||
|
||||
/// The owner of the parsed face and its atlas, and the glyph pointers handed
|
||||
/// out of `glyph()`. A copy would have to duplicate the parse and leave one of
|
||||
/// the two pointing at the other's bytes; a move would leave glyph pointers
|
||||
/// naming an object that no longer exists. Neither is needed: `from_file()`
|
||||
/// fills an object in place, and a backend is installed by pointer anyway (see
|
||||
/// `render::Renderer::set_text_backend()`).
|
||||
TrueTypeFont(const TrueTypeFont&) = delete;
|
||||
TrueTypeFont& operator=(const TrueTypeFont&) = delete;
|
||||
TrueTypeFont(TrueTypeFont&&) = delete;
|
||||
TrueTypeFont& operator=(TrueTypeFont&&) = delete;
|
||||
|
||||
/// Reads the font at `utf8_path` whole and bakes an atlas at `px` pixels per em
|
||||
/// with the *current* coverage -- the default coverage for a fresh object, and
|
||||
/// whatever `set_coverage()` last set for one that is being re-pointed at
|
||||
/// another file.
|
||||
///
|
||||
/// False with `*st` filled in (and `*out` untouched) for a path that is not a
|
||||
/// readable regular file, a file the parser cannot accept, a non-positive or
|
||||
/// non-finite `px`, and a bake in which no requested codepoint produced ink --
|
||||
/// the last is the `tools/fonts/gen_atlas.py` rule, where a blank cell that
|
||||
/// nobody notices is exactly the failure this feature exists to remove.
|
||||
/// `*out` must not be null. It allocates, so a failure to allocate is reported
|
||||
/// as `ErrorCode::OutOfMemory` rather than thrown.
|
||||
static bool from_file(const char* utf8_path, f32 px, TrueTypeFont* out, Status* st = nullptr);
|
||||
|
||||
/// Which codepoints to bake, re-baking the loaded face at the `px` it was
|
||||
/// loaded at and bumping `revision()` so the renderer re-uploads. A codepoint
|
||||
/// the face has no glyph for is dropped from the bake rather than given a blank
|
||||
/// cell: the draw then falls back to the box, which is visible, where a blank
|
||||
/// cell would silently render nothing.
|
||||
///
|
||||
/// A re-bake that produces no ink leaves the previous atlas in place -- there
|
||||
/// is no return value to report through, and losing a working font to a bad
|
||||
/// argument is worse than ignoring it. On a backend with no face this does
|
||||
/// nothing at all.
|
||||
void set_coverage(std::span<const u32> codepoints);
|
||||
|
||||
/// The coverage a bake uses when nobody has called `set_coverage()`: the
|
||||
/// embedded face's set, so swapping one backend for the other spells the same
|
||||
/// labels -- printable ASCII, the three codepoints `plot::format_si()` emits
|
||||
/// (U+00B0 degree, U+00B5 micro, U+03A9 ohm) and the two dashes a plotting
|
||||
/// label uses. The fallback box is not a codepoint and is not in this list.
|
||||
static std::span<const u32> default_coverage() noexcept;
|
||||
|
||||
/// Width of `utf8` in font pixels: the sum of the glyphs' advances, zero for
|
||||
/// the empty string and for null. A codepoint outside the coverage counts as
|
||||
/// the fallback box's advance, so a label the atlas cannot spell still gets a
|
||||
/// width. Allocates nothing.
|
||||
f32 measure(const char* utf8) const override;
|
||||
|
||||
/// The cell's height in font pixels. The baseline sits `ascent()` below the
|
||||
/// cell's top, so text aligns on it whatever the face's own descent is.
|
||||
f32 height() const override;
|
||||
/// Cell top to baseline, in font pixels: the face's ascent where it fits.
|
||||
f32 ascent() const override;
|
||||
/// The size the atlas was baked at, in font pixels -- `px` as passed to
|
||||
/// `from_file()`. The renderer scales a run by `requested_px / em_px()`, which
|
||||
/// is what lets a face be baked at any size and still be drawn at the size the
|
||||
/// style asked for.
|
||||
f32 em_px() const override;
|
||||
|
||||
/// The glyph for `codepoint`: its cell as a normalised rect (the same rect for
|
||||
/// every glyph -- the fixed-cell contract), its bearing into the cell -- zero,
|
||||
/// because the pen starts at the cell's left edge and the ink is placed inside
|
||||
/// it -- and the face's advance. Never null once a face is loaded: a codepoint
|
||||
/// outside the coverage maps to the fallback box, so a missing glyph is visible
|
||||
/// rather than blank. Null for a backend with no face.
|
||||
const render::Glyph* glyph(u32 codepoint) const override;
|
||||
|
||||
/// `(cell_width() / atlas_width(), 1)` -- the atlas is a single row of cells.
|
||||
Vec2 cell_uv() const override;
|
||||
|
||||
/// The baked atlas, GL_R8-ready: one row of `atlas_width()` bytes, top row
|
||||
/// first, one coverage byte per texel. Null while no face is loaded.
|
||||
const u8* atlas_pixels() const override;
|
||||
/// Cells wide by one cell tall, so `atlas_width() == cell_width() * slots`.
|
||||
u32 atlas_width() const override;
|
||||
u32 atlas_height() const override;
|
||||
|
||||
/// Bumped by every bake this object performs: `from_file()` counts one, each
|
||||
/// `set_coverage()` one more, and a re-point at another file continues the
|
||||
/// count from where the object was rather than restarting it -- so a face
|
||||
/// installed over one the renderer has already uploaded is never mistaken for
|
||||
/// it, whatever the two backends would have numbered their own first bakes.
|
||||
u32 revision() const override;
|
||||
|
||||
/// Cells in the atlas: the codepoints the last bake covered, plus the fallback
|
||||
/// slot. Zero with no face.
|
||||
u32 glyph_count() const noexcept;
|
||||
|
||||
/// The fixed cell size, in atlas texels. Every glyph's rect is exactly this,
|
||||
/// which is the contract `cell_uv()` declares and the staging path asserts.
|
||||
u32 cell_width() const noexcept;
|
||||
u32 cell_height() const noexcept;
|
||||
|
||||
private:
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
|
||||
} // namespace plot::text
|
||||
Reference in new issue
Block a user