feat(text): optional vendored stb_truetype backend for runtime external fonts

This commit is contained in:
Martino Ferrari committed 2026-09-28 16:07:27 +02:00
1 parent daff60e3f0
commit 14d664a246
12 files changed
+6511 -19

No files matched your search

+36 -1
View File
@@ -43,6 +43,33 @@ target_compile_features(plot_core PUBLIC cxx_std_20)
target_link_libraries(plot_core PRIVATE ${CMAKE_DL_LIBS})
plot_apply_warnings(plot_core)
# The optional runtime-font backend: plot::text::TrueTypeFont, which bakes a real
# TrueType/OpenType file into a fixed-cell atlas at run time (see
# include/plot/text/true_type_font.hpp). It is the one target that compiles the
# vendored third_party/stb_truetype.h, so it is opt-in and separable -- the core
# library, its public headers and its tests build without it -- and its default
# follows the vendored header's presence: a tree that has the file wants the
# feature, and one that does not cannot have it. Asking for it explicitly without
# the header is an error rather than a silent downgrade, because the option is
# then the only thing that would have said so.
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/third_party/stb_truetype.h")
set(PLOT_VENDORED_STB_TRUETYPE TRUE)
else()
set(PLOT_VENDORED_STB_TRUETYPE FALSE)
endif()
option(PLOT_BUILD_TTF_FONT
"Build the optional plot::text::TrueTypeFont backend (vendored stb_truetype)"
${PLOT_VENDORED_STB_TRUETYPE})
if(PLOT_BUILD_TTF_FONT AND NOT PLOT_VENDORED_STB_TRUETYPE)
message(FATAL_ERROR
"plot: PLOT_BUILD_TTF_FONT=ON needs third_party/stb_truetype.h, which this tree does "
"not have. Restore it -- include/plot/text/true_type_font.hpp records the version and "
"the source URL it came from -- or configure with -DPLOT_BUILD_TTF_FONT=OFF.")
endif()
if(PLOT_BUILD_TTF_FONT)
add_subdirectory(src/text)
endif()
# Adapters own the surface and translate their host's events; examples link the
# adapter they demo. Both are opt-in, so the core library and its tests still build
# on a bare toolchain.
@@ -120,7 +147,15 @@ if(PLOT_INSTALL)
ARCHIVE DESTINATION lib
LIBRARY DESTINATION lib
RUNTIME DESTINATION bin)
install(DIRECTORY include/plot DESTINATION include)
# The optional backend's header goes in only when that backend was built: an
# installed header with no library behind it is a promise the prefix cannot keep,
# and which configuration a prefix came from is not something a consumer can see.
if(PLOT_BUILD_TTF_FONT)
install(DIRECTORY include/plot DESTINATION include)
else()
install(DIRECTORY include/plot DESTINATION include
PATTERN "true_type_font.hpp" EXCLUDE)
endif()
install(EXPORT plotTargets
NAMESPACE plot::
+41
View File
@@ -6,6 +6,18 @@
# */detail/ instrumentation, which is documented as not part of the surface and
# deliberately stays out of the umbrella.
#
# One more class is exempt: a header owned by an *optional* target. Such a target
# can be switched off (PLOT_BUILD_TTF_FONT), so its header cannot be named by an
# umbrella every consumer compiles -- that would make plot.hpp unbuildable in a
# configuration where the target does not exist. The list below holds those
# headers by exact path, one per target, because an exemption is a hole in this
# check and a hole should be a list of files rather than a wildcard or a
# directory prefix: a header that is merely inconvenient cannot slip in behind
# one. The run names what it exempted, so the hole is visible in the configure
# log, and the header is still held to standalone compilation like any other
# (tests/CMakeLists.txt generates a self-containment TU for every globbed header
# regardless of which targets are on).
#
# Runs twice: tests/CMakeLists.txt includes it at configure time (so a header
# added without its umbrella line stops the configure rather than the run), and
# ctest runs it again as `plot_umbrella_covers_public_headers` via `cmake -P`.
@@ -15,6 +27,20 @@
# Inputs (set by the including scope, or passed with -D in script mode):
# PLOT_INCLUDE_DIR the installed-header root, e.g. <source>/include
# PLOT_UMBRELLA the umbrella header, e.g. <PLOT_INCLUDE_DIR>/plot/plot.hpp
# PLOT_OPTIONAL_HEADERS optional-target headers, relative to PLOT_INCLUDE_DIR,
# that the umbrella deliberately does not name. Defaults
# to the list below; a caller may override it, and
# emptying it is how a test proves the exemption is what
# keeps a header out of the "missing" report.
if(NOT DEFINED PLOT_OPTIONAL_HEADERS)
set(PLOT_OPTIONAL_HEADERS
# plot::text::TrueTypeFont, built only by the PLOT_BUILD_TTF_FONT target
# because it is the one thing in the project that reaches the vendored
# third_party/stb_truetype.h. That target is off wherever the vendored
# header is absent, so the umbrella must not require this one.
"plot/text/true_type_font.hpp")
endif()
if(NOT DEFINED PLOT_INCLUDE_DIR)
message(FATAL_ERROR "plot: PLOT_INCLUDE_DIR is not set")
@@ -34,10 +60,15 @@ file(RELATIVE_PATH plot_umbrella_relative "${PLOT_INCLUDE_DIR}" "${PLOT_UMBRELLA
# Every other public header is named by the umbrella.
set(plot_umbrella_missing "")
set(plot_umbrella_exempt "")
foreach(header IN LISTS plot_umbrella_headers)
if(header MATCHES "/detail/" OR header STREQUAL plot_umbrella_relative)
continue()
endif()
if(header IN_LIST PLOT_OPTIONAL_HEADERS)
list(APPEND plot_umbrella_exempt "${header}")
continue()
endif()
string(FIND "${plot_umbrella_text}" "#include <${header}>" plot_umbrella_pos)
if(plot_umbrella_pos EQUAL -1)
list(APPEND plot_umbrella_missing "${header}")
@@ -53,6 +84,16 @@ if(plot_umbrella_missing)
"if it is implementation-only and not part of the public surface.")
endif()
# Said out loud rather than kept quiet: this is the one place the coverage rule
# is not applied, so a configure log that does not list it would hide the fact
# that a header is unchecked against the umbrella.
if(plot_umbrella_exempt)
string(REPLACE ";" "\n " plot_umbrella_exempt_text "${plot_umbrella_exempt}")
message(STATUS
"plot: umbrella check exempts optional-target header(s) the umbrella does "
"not name by design:\n ${plot_umbrella_exempt_text}")
endif()
# And every header the umbrella names is a file that exists (a rename or a
# deleted header cannot leave a dangling include behind).
string(REGEX MATCHALL "#include <plot/[^>]+>" plot_umbrella_includes "${plot_umbrella_text}")
+11 -5
View File
@@ -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
+33 -3
View File
@@ -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_;
+176
View File
@@ -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
+30 -10
View File
@@ -113,7 +113,8 @@ 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 embedded font; a plot drawn by a Renderer measures the labels it draws.\n",
"own font; a plot drawn by a Renderer measures the labels it draws -- install the "
"face with Renderer::set_text_backend() instead.\n",
static_cast<const void*>(previous));
}
#endif
@@ -185,6 +186,23 @@ u32 Renderer::AxisFont::revision() const { return font_ != nullptr ? font_->revi
// ------------------------------------------------------------------ init
void Renderer::set_text_backend(const render::TextBackend* font) noexcept {
const render::TextBackend* next = font != nullptr ? font : &default_font_;
if (next == font_) {
return; // the same face: no upload, and nothing changed
}
// A replacement is a different *source* of atlas bytes, which `revision()`
// cannot express on its own: two faces may legitimately number their first bake
// the same. So the cache is dropped rather than trusted. Assigning a fresh
// atlas releases the texture the last frame sampled and forgets the revision
// that was uploaded, which makes the next `begin_frame_text()` upload whatever
// the new backend exposes, at whatever size it says. That is one upload on the
// frame a host deliberately swapped the font -- and it is the direction that
// cannot draw the old face's glyphs under the new face's rects.
atlas_ = render::TextAtlas{};
font_ = next;
}
bool Renderer::init(Status* st) noexcept {
if (!gl::available()) {
return fail(st, ErrorCode::Unsupported,
@@ -202,7 +220,7 @@ bool Renderer::init(Status* st) noexcept {
const render::UploadMode mode =
persistent ? render::UploadMode::PersistentMapped : render::UploadMode::Orphan;
font_backend_.reset(default_font_, style_.device(style_.axis_font_px));
font_backend_.reset(*font_, style_.device(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
@@ -292,7 +310,7 @@ bool Renderer::begin_frame_text() noexcept {
// 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(default_font_, style_.device(style_.axis_font_px));
font_backend_.reset(*font_, style_.device(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).
@@ -310,10 +328,12 @@ bool Renderer::begin_frame_text() noexcept {
void Renderer::prepare_plot(Plot& plot) {
#ifndef NDEBUG
// The renderer owns the text of the frames it draws: every label goes through
// 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()).
// the text pipeline in `font_` -- the embedded face, or whatever a host put
// there with Renderer::set_text_backend() -- so the plot is handed this
// backend's metrics whatever it was given. A backend installed on the *Plot* 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()).
if (const render::TextBackend* previous = plot.text_backend();
previous != nullptr && previous != &font_backend_) {
warn_replaced_text_backend(previous);
@@ -573,17 +593,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(), default_font_);
return glyphs_.add_text(utf8, to_viewport(pos_px), font_backend_.scale(), *font_);
}
f32 Renderer::clipped_text_width(const char* utf8, f32 budget_px, Vec2 axis) const {
return walk_text_clipped(Vec2{}, utf8, budget_px, default_font_, font_backend_.scale(), axis,
return walk_text_clipped(Vec2{}, utf8, budget_px, *font_, font_backend_.scale(), axis,
[](Vec2, const render::Glyph&) {});
}
f32 Renderer::stage_text_clipped(Vec2 pos_px, const char* utf8, f32 budget_px, Vec2 axis) {
const f32 scale = font_backend_.scale();
return walk_text_clipped(to_viewport(pos_px), utf8, budget_px, default_font_, scale, axis,
return walk_text_clipped(to_viewport(pos_px), utf8, budget_px, *font_, scale, axis,
[this, scale, axis](Vec2 pen, const render::Glyph& glyph) {
glyphs_.add_glyph(pen, glyph, scale, axis);
});
+58
View File
@@ -0,0 +1,58 @@
# The optional runtime-font target: plot::text::TrueTypeFont, which bakes a real
# TrueType/OpenType file into a fixed-cell coverage atlas at run time. It is the
# only target in the project that reaches the vendored third_party/stb_truetype.h,
# and the top-level CMakeLists.txt adds this directory only when PLOT_BUILD_TTF_FONT
# is on -- so plot_core, its public headers and every other target stay
# third-party-free, and a build with the option off neither compiles nor links any
# of it.
#
# Two targets, not one, and for the reason the whole task exists: stb_truetype is
# third-party source, so it is compiled in its own object library to which
# plot_apply_warnings() is *not* applied. The project's warning set stays on our
# own code (and stays clean under it) without a single per-file suppression to
# quiet someone else's header -- see cmake/plot_options.cmake, whose warning
# policy is "never suppressed per file".
add_library(plot_stb_truetype OBJECT stb_truetype_impl.cpp)
# The vendored header's directory is PRIVATE and on this object library alone:
# nothing else in the build can reach stb_truetype.h even by accident, and a
# consumer of the installed target needs none of it (the parser is compiled into
# the archive, so only our own header is on the interface).
target_include_directories(plot_stb_truetype PRIVATE "${CMAKE_SOURCE_DIR}/third_party")
target_compile_features(plot_stb_truetype PRIVATE cxx_std_20)
# The backend itself. Its objects come from the object library above, so the
# archive is self-contained: a consumer links plot::font_ttf and nothing else, and
# no non-exported target leaks into the install set's link interface.
add_library(plot_font_ttf STATIC
true_type_font.cpp
$<TARGET_OBJECTS:plot_stb_truetype>)
add_library(plot::font_ttf ALIAS plot_font_ttf)
# The installed tree names it plot::font_ttf, like the build tree (see
# plot_core's EXPORT_NAME).
set_target_properties(plot_font_ttf PROPERTIES EXPORT_NAME font_ttf)
# The public include root is the project's own `include/`, because that is where
# the target's header is reached from: <plot/text/true_type_font.hpp>. The build
# and install interfaces are the same pair plot_core declares, spelled here
# because this target is the one that owns the header.
target_include_directories(plot_font_ttf PUBLIC
$<BUILD_INTERFACE:${CMAKE_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:include>)
# PRIVATE, like the object library's: this target's own source includes the
# vendored header for its declarations (the implementation is the object library
# above), and nothing that links plot::font_ttf inherits the path -- a consumer
# needs none of it, because stb_truetype is compiled into the archive.
target_include_directories(plot_font_ttf PRIVATE "${CMAKE_SOURCE_DIR}/third_party")
target_compile_features(plot_font_ttf PUBLIC cxx_std_20)
target_link_libraries(plot_font_ttf PUBLIC plot::core)
plot_apply_warnings(plot_font_ttf)
# It joins the install and export set: it has no dependency a consumer would have
# to re-find (the vendored header is compiled in, and third_party/ is not
# installed at all), so find_package(plot) + target_link_libraries(consumer
# PRIVATE plot::font_ttf) works from an installed prefix whenever the option was
# on -- and plotConfig.cmake then needs no find_dependency() for it. The reason
# string is what the configure log prints.
plot_record_adapter_export(plot_font_ttf TRUE
"its only third-party dependency is compiled in from third_party/" "")
+16
View File
@@ -0,0 +1,16 @@
// The vendored rasteriser's single implementation unit: the one translation unit
// in this project that defines STB_TRUETYPE_IMPLEMENTATION, so the ~5000-line
// stb_truetype.h is compiled exactly once, into the optional plot_font_ttf
// target, and never into plot_core. It is a separate translation unit -- and its
// own object-library target in src/text/CMakeLists.txt -- so that the project's
// warning set (-Wall -Wextra -Wpedantic -Wconversion -Wshadow) covers our own
// sources without covering third-party code, and so that no warning is ever
// suppressed per file to accommodate it (see plot_apply_warnings in
// cmake/plot_options.cmake).
//
// stb_truetype v1.26 -- public domain, Sean Barrett / RAD Game Tools. The
// vendored copy at third_party/stb_truetype.h is pinned by the sha256 recorded in
// the wrapper header include/plot/text/true_type_font.hpp, which is also where
// the source URL and the retrieval date live.
#define STB_TRUETYPE_IMPLEMENTATION
#include <stb_truetype.h>
+529
View File
@@ -0,0 +1,529 @@
// The optional TrueType backend: the one place in the project that parses a font
// file at run time, and the only consumer of the vendored third_party/stb_truetype.h
// (see src/text/stb_truetype_impl.cpp for its single implementation unit, and
// include/plot/text/true_type_font.hpp for its provenance). Built only by the
// PLOT_BUILD_TTF_FONT target, so nothing here is compiled or linked into
// plot_core.
//
// The bake follows tools/fonts/gen_atlas.py's cell scheme, deliberately: a
// proportional face is drawn into a uniform cell rather than trimmed to its ink,
// because the text pipeline stages one rect origin per glyph and carries the
// rect's size once per draw (see render::TextBackend::cell_uv). The cell is as
// wide as the widest advance *or* the widest ink's right edge -- the pen must
// never leave the cell, and neither may a glyph's ink -- and as tall as the
// face's ascent-to-descent extent, extended when a baked glyph's own bitmap box
// needs more room than the face declares. The pen starts at the cell's left edge
// and the baseline sits `ascent()` below the cell's top, so the ink is blitted at
// the offset its bitmap box reports from the pen and every rect stays one size.
#include <plot/text/true_type_font.hpp>
#include <stb_truetype.h>
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <filesystem>
#include <fstream>
#include <new>
#include <system_error>
#include <vector>
namespace plot::text {
namespace {
/// The fallback box, in font units relative to the baseline: the same outline
/// `tools/fonts/gen_atlas.py` bakes for the embedded face (its FALLBACK_OUTER and
/// FALLBACK_INNER), so a codepoint neither backend can spell shows the same mark.
/// It is drawn from geometry rather than from the face, because the face may have
/// no usable `.notdef` and a fallback that depends on one is not a fallback.
constexpr f32 kFallbackOuterX0 = 60.0f;
constexpr f32 kFallbackOuterY0 = -100.0f;
constexpr f32 kFallbackOuterX1 = 580.0f;
constexpr f32 kFallbackOuterY1 = 700.0f;
constexpr f32 kFallbackInnerX0 = 160.0f;
constexpr f32 kFallbackInnerY0 = 0.0f;
constexpr f32 kFallbackInnerX1 = 480.0f;
constexpr f32 kFallbackInnerY1 = 600.0f;
/// A codepoint the face can draw, with the advance the pen moves by: pass one of
/// a bake, whose only job is to size the cell every one of them is drawn into.
struct Drawable {
u32 codepoint = 0;
f32 advance = 0.0f;
};
/// One bake's complete output, so `from_file()` and `set_coverage()` can build a
/// new one and swap it in only when it succeeded: a failed bake leaves the
/// previous atlas -- and every glyph pointer a caller may be holding -- exactly
/// where it was.
struct Bake {
u32 cell_w = 0;
u32 cell_h = 0;
i32 baseline = 0;
std::vector<u32> codepoints;
std::vector<render::Glyph> glyphs;
render::Glyph fallback{};
std::vector<u8> atlas;
u32 atlas_w = 0;
u32 atlas_h = 0;
};
bool fail(Status* st, ErrorCode code, const char* message) noexcept {
if (st != nullptr) {
*st = Status{code, message};
}
return false;
}
u32 read_be16(std::span<const u8> bytes, std::size_t at) noexcept {
return (static_cast<u32>(bytes[at]) << 8) | static_cast<u32>(bytes[at + 1]);
}
u32 read_be32(std::span<const u8> bytes, std::size_t at) noexcept {
return (static_cast<u32>(bytes[at]) << 24) | (static_cast<u32>(bytes[at + 1]) << 16) |
(static_cast<u32>(bytes[at + 2]) << 8) | static_cast<u32>(bytes[at + 3]);
}
/// True when `bytes` opens with a font signature *and* the table directory that
/// follows fits inside it.
///
/// stb_truetype does no range checking of a font file's offsets -- its own header
/// says so, in capitals -- and its table scan reads `numTables` 16-byte records
/// from offset 12 whatever the file's length is. Handing it a text file would
/// therefore read past the buffer: an immediate error under the address
/// sanitizer, and a plain out-of-bounds read everywhere else. So the directory the
/// scan walks is validated here, before the parser sees a byte. What is inside
/// those tables stays stb's business: a *random* file is rejected here, and a file
/// *crafted* to be hostile remains out of scope, as the vendored header warns.
bool has_sfnt_directory(std::span<const u8> bytes) noexcept {
if (bytes.size() < 12u) {
return false;
}
const u32 version = read_be32(bytes, 0u);
constexpr u32 kTrueType = 0x00010000u;
constexpr u32 kAppleTrue = 0x74727565u; // 'true'
constexpr u32 kOpenTypeCff = 0x4F54544Fu; // 'OTTO'
constexpr u32 kTypeOne = 0x74797031u; // 'typ1'
constexpr u32 kCollection = 0x74746366u; // 'ttcf'
if (version == kCollection) {
// stb reads a collection's first font offset at byte 12, so the header has to
// be that long before it is asked for one.
return bytes.size() >= 16u;
}
if (version != kTrueType && version != kAppleTrue && version != kOpenTypeCff &&
version != kTypeOne) {
return false;
}
const u64 directory_end = 12u + 16u * static_cast<u64>(read_be16(bytes, 4u));
return directory_end <= static_cast<u64>(bytes.size());
}
/// Blits `bitmap` into `slot`'s cell, `x`/`y` being the ink's top-left in cell
/// coordinates. Ink outside the cell is dropped rather than written into the
/// neighbouring cell: a face whose ink overhangs its advance would otherwise
/// corrupt whichever glyph owns the next column, and clipping is what the
/// embedded generator does at the same boundary. Returns the texels written.
u32 blit_cell(std::vector<u8>& atlas, const Bake& bake, u32 slot, const u8* bitmap, i32 width,
i32 height, i32 x, i32 y) noexcept {
u32 written = 0;
for (i32 row = 0; row < height; ++row) {
const i32 cell_y = y + row;
if (cell_y < 0 || cell_y >= static_cast<i32>(bake.cell_h)) {
continue;
}
for (i32 column = 0; column < width; ++column) {
const u8 value = bitmap[static_cast<std::size_t>(row) * static_cast<std::size_t>(width) +
static_cast<std::size_t>(column)];
if (value == 0u) {
continue;
}
const i32 cell_x = x + column;
if (cell_x < 0 || cell_x >= static_cast<i32>(bake.cell_w)) {
continue;
}
atlas[static_cast<std::size_t>(cell_y) * bake.atlas_w +
static_cast<std::size_t>(slot) * bake.cell_w + static_cast<std::size_t>(cell_x)] = value;
++written;
}
}
return written;
}
/// Draws the fallback box into `slot`'s cell: the outline between the outer and
/// inner rectangles, roughly one texel thick at the bake's scale. It is geometry,
/// not a rasterised glyph, so the box is there for a face with no usable
/// `.notdef` too.
void blit_fallback_box(std::vector<u8>& atlas, const Bake& bake, u32 slot, f32 scale) noexcept {
const auto to_x = [scale](f32 units) { return static_cast<i32>(std::lround(units * scale)); };
const auto to_y = [&bake, scale](f32 units) {
return bake.baseline - static_cast<i32>(std::lround(units * scale));
};
const i32 outer_left = to_x(kFallbackOuterX0);
const i32 outer_right = to_x(kFallbackOuterX1);
const i32 outer_top = to_y(kFallbackOuterY1);
const i32 outer_bottom = to_y(kFallbackOuterY0);
const i32 inner_left = to_x(kFallbackInnerX0);
const i32 inner_right = to_x(kFallbackInnerX1);
const i32 inner_top = to_y(kFallbackInnerY1);
const i32 inner_bottom = to_y(kFallbackInnerY0);
for (i32 y = outer_top; y < outer_bottom; ++y) {
if (y < 0 || y >= static_cast<i32>(bake.cell_h)) {
continue;
}
for (i32 x = outer_left; x < outer_right; ++x) {
if (x < 0 || x >= static_cast<i32>(bake.cell_w)) {
continue;
}
if (x >= inner_left && x < inner_right && y >= inner_top && y < inner_bottom) {
continue; // the hole the box is drawn around
}
atlas[static_cast<std::size_t>(y) * bake.atlas_w +
static_cast<std::size_t>(slot) * bake.cell_w + static_cast<std::size_t>(x)] = 255u;
}
}
}
/// Bakes `coverage` at `scale` into `out`: the cell, the glyph table and the
/// single-row atlas, in two passes over the face. False, with `*st` filled in,
/// when no requested codepoint produced ink -- an atlas whose only mark is the
/// fallback box is the "everything the caller asked for came out blank" failure
/// this backend exists to refuse (see `TrueTypeFont::from_file`). `out` is only
/// meaningful when this returns true.
bool bake_face(const stbtt_fontinfo& info, f32 scale, std::span<const u32> coverage, Bake* out,
Status* st) {
i32 ascent_units = 0;
i32 descent_units = 0;
i32 line_gap = 0;
stbtt_GetFontVMetrics(&info, &ascent_units, &descent_units, &line_gap);
const f32 ascent_f = static_cast<f32>(ascent_units) * scale;
const f32 descent_f = -static_cast<f32>(descent_units) * scale;
std::vector<u32> requested(coverage.begin(), coverage.end());
std::sort(requested.begin(), requested.end());
requested.erase(std::unique(requested.begin(), requested.end()), requested.end());
// Pass one: what every drawable codepoint would need. A codepoint the face has
// no glyph for is dropped here rather than given a blank cell -- the draw then
// falls back to the box, which is visible, where a blank cell renders nothing
// and says so to nobody.
std::vector<Drawable> drawable;
drawable.reserve(requested.size());
f32 widest_advance = 0.0f;
i32 highest = 0;
i32 lowest = 0;
i32 rightmost = 0;
for (const u32 codepoint : requested) {
if (codepoint > 0x10FFFFu) {
continue; // kInvalidCodepoint, and anything else that is not a scalar
}
if (stbtt_FindGlyphIndex(&info, static_cast<i32>(codepoint)) == 0) {
continue;
}
i32 x0 = 0;
i32 y0 = 0;
i32 x1 = 0;
i32 y1 = 0;
stbtt_GetCodepointBitmapBox(&info, static_cast<i32>(codepoint), scale, scale, &x0, &y0, &x1,
&y1);
i32 advance_units = 0;
i32 bearing_units = 0;
stbtt_GetCodepointHMetrics(&info, static_cast<i32>(codepoint), &advance_units, &bearing_units);
const f32 advance = static_cast<f32>(advance_units) * scale;
widest_advance = std::max(widest_advance, advance);
highest = std::min(highest, y0);
lowest = std::max(lowest, y1);
rightmost = std::max(rightmost, x1);
drawable.push_back(Drawable{codepoint, advance});
}
// The cell: wide enough for the widest advance *and* the widest ink (the
// generator's rule -- an overhanging glyph stays inside its own cell instead of
// reaching into the next one), as tall as the face's ascent-to-descent extent,
// and never so short that a baked bitmap box hangs out of it.
const u32 cell_w = std::max<u32>(
1u,
static_cast<u32>(std::ceil(std::max(widest_advance, static_cast<f32>(rightmost)) - 1e-4f)));
i32 baseline = static_cast<i32>(std::floor(ascent_f));
baseline = std::max(baseline, -highest); // room for the tallest ink above the pen
baseline = std::max(baseline, 0);
const u32 extent = static_cast<u32>(std::ceil(ascent_f + descent_f - 1e-4f));
const u32 below = static_cast<u32>(std::max(0, baseline + lowest));
const u32 cell_h = std::max<u32>(1u, std::max(extent, below));
out->cell_w = cell_w;
out->cell_h = cell_h;
out->baseline = baseline;
const u32 slots = static_cast<u32>(drawable.size()) + 1u; // the fallback box is the last slot
out->atlas_w = cell_w * slots;
out->atlas_h = cell_h;
out->atlas.assign(static_cast<std::size_t>(out->atlas_w) * out->atlas_h, 0u);
out->codepoints.clear();
out->codepoints.reserve(drawable.size());
out->glyphs.clear();
out->glyphs.reserve(slots);
// Every glyph's rect is the same one cell, so the rect is built once per slot
// and only its `u0` moves. The v range is the whole atlas height, which is one
// cell: the atlas is a single row, so `cell_uv()` is `(cell_w / atlas_w, 1)`.
const f32 du = static_cast<f32>(cell_w) / static_cast<f32>(out->atlas_w);
const f32 dv = static_cast<f32>(cell_h) / static_cast<f32>(out->atlas_h);
const auto glyph_for_slot = [&](u32 slot, f32 advance) {
const f32 u0 = static_cast<f32>(slot * cell_w) / static_cast<f32>(out->atlas_w);
return render::Glyph{u0, 0.0f, u0 + du, dv, 0.0f, 0.0f, advance};
};
// Pass two: rasterise and blit. The offset `stbtt_GetCodepointBitmap` reports is
// the box pass one measured -- stb derives both from the same glyph box -- which
// is why placing the ink at it is what keeps the bearing.
u32 inked = 0;
for (std::size_t i = 0; i < drawable.size(); ++i) {
const Drawable& glyph = drawable[i];
i32 width = 0;
i32 height = 0;
i32 x = 0;
i32 y = 0;
u8* bitmap = stbtt_GetCodepointBitmap(&info, scale, scale, static_cast<i32>(glyph.codepoint),
&width, &height, &x, &y);
if (bitmap != nullptr) {
inked += blit_cell(out->atlas, *out, static_cast<u32>(i), bitmap, width, height, x,
baseline + y);
stbtt_FreeBitmap(bitmap, nullptr);
}
out->codepoints.push_back(glyph.codepoint);
out->glyphs.push_back(glyph_for_slot(static_cast<u32>(i), glyph.advance));
}
if (inked == 0u) {
return fail(st, ErrorCode::InvalidArgument,
"the bake produced no ink: none of the requested codepoints drew anything at this "
"em size");
}
// The fallback slot, last: the embedded face's box, with the widest advance so a
// run of missing glyphs takes the room of a real one.
const u32 fallback_slot = static_cast<u32>(drawable.size());
blit_fallback_box(out->atlas, *out, fallback_slot, scale);
out->fallback = glyph_for_slot(fallback_slot, widest_advance);
return true;
}
} // namespace
struct TrueTypeFont::Impl {
/// The whole file, because `stbtt_fontinfo` holds pointers into it and a font
/// is not readable piecemeal: the parser walks tables wherever they are.
std::vector<u8> file;
stbtt_fontinfo info{};
f32 px = 0.0f;
f32 scale = 0.0f;
/// The coverage a re-point at another file inherits, so a host's choice of
/// codepoints survives a `from_file()`.
std::vector<u32> coverage;
Bake bake;
/// Counts the bakes this object has performed, so a face installed over one the
/// renderer has already uploaded never carries the number it uploaded.
u32 revision = 0;
};
TrueTypeFont::TrueTypeFont() noexcept = default;
TrueTypeFont::~TrueTypeFont() = default;
std::span<const u32> TrueTypeFont::default_coverage() noexcept {
// The embedded font's coverage: printable ASCII, the codepoints
// `plot::format_si()` emits, and the two dashes a plotting label uses. The
// fallback box is not a codepoint, so it is not in the list -- it is what every
// codepoint *outside* the list draws.
static const std::vector<u32> kCoverage = [] {
std::vector<u32> codepoints;
codepoints.reserve(100u);
for (u32 codepoint = 0x20u; codepoint <= 0x7Eu; ++codepoint) {
codepoints.push_back(codepoint);
}
codepoints.push_back(0x00B0u); // degree
codepoints.push_back(0x00B5u); // micro
codepoints.push_back(0x03A9u); // ohm
codepoints.push_back(0x2013u); // en dash
codepoints.push_back(0x2014u); // em dash
return codepoints;
}();
return kCoverage;
}
bool TrueTypeFont::from_file(const char* utf8_path, f32 px, TrueTypeFont* out, Status* st) {
if (out == nullptr) {
return fail(st, ErrorCode::InvalidArgument,
"TrueTypeFont::from_file() needs somewhere to put the font");
}
if (utf8_path == nullptr || utf8_path[0] == '\0') {
return fail(st, ErrorCode::InvalidArgument, "TrueTypeFont::from_file() needs a font file path");
}
if (!std::isfinite(px) || !(px > 0.0f)) {
return fail(st, ErrorCode::InvalidArgument,
"a font em size must be a positive, finite number of pixels");
}
std::error_code ec;
const std::filesystem::path path{utf8_path};
const std::filesystem::file_status path_status = std::filesystem::status(path, ec);
if (ec || !std::filesystem::exists(path_status)) {
return fail(st, ErrorCode::InvalidArgument, "no such font file");
}
if (!std::filesystem::is_regular_file(path_status)) {
return fail(st, ErrorCode::InvalidArgument, "the font path is not a regular file");
}
const std::uintmax_t size = std::filesystem::file_size(path, ec);
if (ec || size == 0u) {
return fail(st, ErrorCode::InvalidArgument, "the font file is empty or its size is unreadable");
}
try {
// Everything is built in a candidate and swapped in at the end, so a failure
// at any step leaves `*out` exactly as it was -- including the atlas and the
// glyph pointers a caller may still be drawing from.
Impl candidate;
candidate.px = px;
if (out->impl_ != nullptr) {
candidate.coverage = out->impl_->coverage;
candidate.revision = out->impl_->revision;
} else {
candidate.coverage.assign(default_coverage().begin(), default_coverage().end());
}
candidate.file.resize(static_cast<std::size_t>(size));
std::ifstream in(path, std::ios::binary);
if (!in) {
return fail(st, ErrorCode::InvalidArgument, "the font file cannot be opened");
}
in.read(reinterpret_cast<char*>(candidate.file.data()),
static_cast<std::streamsize>(candidate.file.size()));
if (!in) {
return fail(st, ErrorCode::InvalidArgument, "the font file cannot be read");
}
const std::span<const u8> bytes{candidate.file};
// The directory is validated before stb sees the file at all -- and again at
// the font the collection header points at, if this is a collection, because
// that offset comes out of the file too (see has_sfnt_directory).
int offset = -1;
if (has_sfnt_directory(bytes)) {
offset = stbtt_GetFontOffsetForIndex(candidate.file.data(), 0);
}
if (offset < 0 || static_cast<std::uintmax_t>(offset) >= size ||
!has_sfnt_directory(bytes.subspan(static_cast<std::size_t>(offset)))) {
return fail(st, ErrorCode::InvalidArgument, "the file is not a font");
}
if (stbtt_InitFont(&candidate.info, candidate.file.data(), offset) == 0) {
return fail(st, ErrorCode::InvalidArgument,
"the font cannot be parsed: a TrueType outline needs a table this file lacks");
}
candidate.scale = stbtt_ScaleForPixelHeight(&candidate.info, px);
if (!(candidate.scale > 0.0f)) {
return fail(st, ErrorCode::InvalidArgument, "the face declares no size to scale from");
}
if (!bake_face(candidate.info, candidate.scale, candidate.coverage, &candidate.bake, st)) {
return false;
}
candidate.revision += 1u;
out->impl_ = std::make_unique<Impl>(std::move(candidate));
} catch (const std::bad_alloc&) {
return fail(st, ErrorCode::OutOfMemory, "the font atlas could not be allocated");
}
return true;
}
void TrueTypeFont::set_coverage(std::span<const u32> codepoints) {
Impl* impl = impl_.get();
if (impl == nullptr) {
return; // no face: nothing to bake, and nothing to re-bake
}
std::vector<u32> coverage(codepoints.begin(), codepoints.end());
try {
Bake candidate;
if (!bake_face(impl->info, impl->scale, coverage, &candidate, nullptr)) {
// A re-bake that produced no ink cannot be reported through this interface
// -- there is no Status to fill in -- so the previous atlas stays. Losing a
// working font to an argument this backend cannot answer for is worse than
// ignoring the request.
return;
}
impl->coverage = std::move(coverage);
impl->bake = std::move(candidate);
impl->revision += 1u;
} catch (const std::bad_alloc&) {
// Same reasoning: the atlas that is already there is a font that works.
}
}
f32 TrueTypeFont::measure(const char* utf8) const {
if (utf8 == nullptr || impl_ == nullptr) {
return 0.0f;
}
const Bake& bake = impl_->bake;
f32 width = 0.0f;
for (const char* p = utf8; *p != '\0';) {
const u32 codepoint = render::next_codepoint(p);
const auto found = std::lower_bound(bake.codepoints.begin(), bake.codepoints.end(), codepoint);
width += (found != bake.codepoints.end() && *found == codepoint)
? bake.glyphs[static_cast<std::size_t>(found - bake.codepoints.begin())].advance
: bake.fallback.advance;
}
return width;
}
f32 TrueTypeFont::height() const {
return impl_ != nullptr ? static_cast<f32>(impl_->bake.cell_h) : 0.0f;
}
f32 TrueTypeFont::ascent() const {
return impl_ != nullptr ? static_cast<f32>(impl_->bake.baseline) : 0.0f;
}
f32 TrueTypeFont::em_px() const { return impl_ != nullptr ? impl_->px : 0.0f; }
const render::Glyph* TrueTypeFont::glyph(u32 codepoint) const {
if (impl_ == nullptr) {
return nullptr; // no face: the legal "no text" backend, drawn as nothing
}
const Bake& bake = impl_->bake;
const auto found = std::lower_bound(bake.codepoints.begin(), bake.codepoints.end(), codepoint);
if (found != bake.codepoints.end() && *found == codepoint) {
return &bake.glyphs[static_cast<std::size_t>(found - bake.codepoints.begin())];
}
return &bake.fallback;
}
Vec2 TrueTypeFont::cell_uv() const {
if (impl_ == nullptr || impl_->bake.atlas_w == 0u) {
return Vec2{};
}
return Vec2{static_cast<f32>(impl_->bake.cell_w) / static_cast<f32>(impl_->bake.atlas_w),
static_cast<f32>(impl_->bake.cell_h) / static_cast<f32>(impl_->bake.atlas_h)};
}
const u8* TrueTypeFont::atlas_pixels() const {
if (impl_ == nullptr || impl_->bake.atlas.empty()) {
return nullptr;
}
return impl_->bake.atlas.data();
}
u32 TrueTypeFont::atlas_width() const { return impl_ != nullptr ? impl_->bake.atlas_w : 0u; }
u32 TrueTypeFont::atlas_height() const { return impl_ != nullptr ? impl_->bake.atlas_h : 0u; }
u32 TrueTypeFont::revision() const { return impl_ != nullptr ? impl_->revision : 0u; }
u32 TrueTypeFont::glyph_count() const noexcept {
return impl_ != nullptr ? static_cast<u32>(impl_->bake.glyphs.size()) + 1u : 0u;
}
u32 TrueTypeFont::cell_width() const noexcept {
return impl_ != nullptr ? impl_->bake.cell_w : 0u;
}
u32 TrueTypeFont::cell_height() const noexcept {
return impl_ != nullptr ? impl_->bake.cell_h : 0u;
}
} // namespace plot::text
+18
View File
@@ -75,6 +75,24 @@ target_sources(plot_tests PRIVATE test_qt_pointer.cpp)
target_sources(plot_tests PRIVATE test_gtk_input.cpp)
target_include_directories(plot_tests PRIVATE "${CMAKE_SOURCE_DIR}/adapters")
# The optional runtime-font backend brings its own cases, and they exist only when
# its target does: the header, the class and the archive are all absent otherwise,
# so there would be nothing to compile them against (see PLOT_BUILD_TTF_FONT in the
# top-level CMakeLists.txt). Guarded on the target rather than on the option, like
# the adapter cases above, so a target that exists for any reason gets its cases.
#
# Two paths are handed over as definitions because a case needs them and should
# not guess: the core archive the "no stb_truetype in plot_core" case inspects
# (a generator expression, so it is this build's own archive) and the cmake the
# umbrella check is re-run with.
if(TARGET plot::font_ttf)
target_sources(plot_tests PRIVATE test_true_type_font.cpp)
target_link_libraries(plot_tests PRIVATE plot::font_ttf)
target_compile_definitions(plot_tests PRIVATE
PLOT_CORE_ARCHIVE="$<TARGET_FILE:plot_core>"
PLOT_CMAKE_COMMAND="${CMAKE_COMMAND}")
endif()
# Same switches the library is built with: the cost tests read the counters
# behind them (see include/plot/data/detail/{cover,merge}_count.hpp).
target_compile_definitions(plot_tests PRIVATE PLOT_PYRAMID_COUNT_COVERS
+484
View File
@@ -0,0 +1,484 @@
// The optional runtime-font backend, on the real driver and on the real archive:
// what a host gets when it points `plot::text::TrueTypeFont` at a font file.
//
// It is compiled only when the plot_font_ttf target exists (see the guard in
// tests/CMakeLists.txt), because the class and the header are what that target
// provides -- there is nothing here that could pass without them. The cases that
// need a font file read the system Adwaita Mono, the face the embedded atlas was
// generated from, and say so rather than failing on a machine that lacks it.
//
// What is asserted here and nowhere else: that a bake on *real* data keeps the
// fixed-cell contract per glyph (the staging path's debug assertion is the
// enforcement, this is the audit), that the failure paths report a `Status` and
// leave the object alone, that a renderer draws with an installed backend and
// re-uploads its atlas when the backend re-bakes, and that none of this reached
// plot_core -- neither its archive nor any public header outside text/.
#include "harness.hpp"
#include "egl_headless.hpp"
#include <plot/core/status.hpp>
#include <plot/gl/api.hpp>
#include <plot/gl/loader.hpp>
#include <plot/plot/plot.hpp>
#include <plot/render/embedded_mono_font.hpp>
#include <plot/render/renderer.hpp>
#include <plot/text/true_type_font.hpp>
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iterator>
#include <string>
#include <system_error>
#include <vector>
#include <sys/wait.h>
#include <unistd.h>
namespace {
/// The face the cases bake: the system Adwaita Mono, which is the same face the
/// embedded atlas was generated from, so a case can compare the two backends on
/// equal terms.
constexpr const char* kAdwaitaMono = "/usr/share/fonts/Adwaita/AdwaitaMono-Regular.ttf";
/// The viewport the renderer case draws into. Smaller than the smoke suite's 640
/// x 360 because every assertion there scans pixels one `glReadPixels` at a time.
constexpr plot::u32 kW = 360;
constexpr plot::u32 kH = 240;
constexpr plot::u32 kSpace = 0x20u;
/// A codepoint no face and no default coverage has: what "the fallback box" is
/// for, and the one codepoint a case can count on being outside the coverage.
constexpr plot::u32 kUncovered = 0x10FFFFu;
bool have_adwaita_mono() {
std::error_code ec;
return std::filesystem::is_regular_file(kAdwaitaMono, ec);
}
/// The suite has no skip protocol, and a case that returns silently passes without
/// having tested anything -- so a case that cannot run says which one it was and
/// why, loud enough to be read in the ctest log.
void skip(const char* name, const char* why) { std::printf("[skip] %s: %s\n", name, why); }
/// The texels of `slot`'s cell, summed: zero means that cell holds no ink at all.
plot::u32 cell_ink(const plot::text::TrueTypeFont& font, plot::u32 slot) {
const plot::u8* atlas = font.atlas_pixels();
const plot::u32 cell_w = font.cell_width();
const plot::u32 cell_h = font.cell_height();
const plot::u32 atlas_w = font.atlas_width();
PLOT_CHECK(atlas != nullptr);
PLOT_CHECK((slot + 1u) * cell_w <= atlas_w);
plot::u32 total = 0;
for (plot::u32 y = 0; y < cell_h; ++y) {
for (plot::u32 x = 0; x < cell_w; ++x) {
total += atlas[static_cast<std::size_t>(y) * atlas_w +
static_cast<std::size_t>(slot) * cell_w + x];
}
}
return total;
}
/// The slot a glyph's rect names: its normalised `u0` back in cells. It is read
/// out of the glyph rather than tracked, so a bake whose rect and atlas disagree
/// cannot pass by the test repeating the bake's own arithmetic.
plot::u32 slot_of(const plot::text::TrueTypeFont& font, const plot::render::Glyph& glyph) {
const double cells = static_cast<double>(glyph.u0) * static_cast<double>(font.atlas_width()) /
static_cast<double>(font.cell_width());
return static_cast<plot::u32>(std::lround(cells));
}
/// A cheap order-sensitive signature of the ink in `area`: two frames that drew
/// the same thing hash the same, and a frame whose glyphs came from another atlas
/// -- or from another cell of the same atlas -- does not. The position is folded
/// in, so a glyph that moved cannot hash equal to one that did not.
plot::u64 ink_signature(plottest::HeadlessContext& ctx, plot::Rect area) {
constexpr plot::u64 kOffset = 1469598103934665603ull;
constexpr plot::u64 kPrime = 1099511628211ull;
plot::u64 hash = kOffset;
const plot::u32 x_end = static_cast<plot::u32>(area.right());
const plot::u32 y_end = static_cast<plot::u32>(area.bottom());
for (plot::u32 y = static_cast<plot::u32>(area.y); y < y_end; ++y) {
for (plot::u32 x = static_cast<plot::u32>(area.x); x < x_end; ++x) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - y, px));
for (const plot::u8 byte : px) {
hash ^= static_cast<plot::u64>(byte);
hash *= kPrime;
}
}
}
return hash;
}
/// One command's whole output and its exit status, run through the shell so a
/// case can drive `nm` and `cmake` the way the build does.
struct Command {
bool ran = false;
int exit_status = -1;
std::string output;
};
Command run_command(const std::string& command) {
Command result;
std::FILE* pipe = ::popen(command.c_str(), "r");
if (pipe == nullptr) {
return result;
}
char buffer[4096];
while (std::fgets(buffer, sizeof buffer, pipe) != nullptr) {
result.output += buffer;
}
const int status = ::pclose(pipe);
result.ran = true;
result.exit_status =
(status != -1 && WIFEXITED(status)) ? WEXITSTATUS(status) : -1;
return result;
}
std::string read_text_file(const std::filesystem::path& path) {
std::ifstream in(path, std::ios::binary);
if (!in) {
return {};
}
return std::string(std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>());
}
void write_text_file(const std::filesystem::path& path, const std::string& text) {
std::ofstream out(path, std::ios::binary | std::ios::trunc);
PLOT_CHECK(out.good());
out << text;
PLOT_CHECK(out.good());
}
/// One frame of `plot` with whatever backend `renderer` currently draws with, as
/// the ink signature of the whole viewport. The frame must be clean: a driver
/// error would make "the ink differs" mean nothing.
plot::u64 frame_signature(plottest::HeadlessContext& ctx, plot::Renderer& renderer,
plot::Plot& plot) {
renderer.begin_frame(kW, kH, {0.0f, 0.0f, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
renderer.draw(plot);
renderer.end_frame();
PLOT_CHECK(!renderer.last_frame_had_gl_error());
return ink_signature(ctx, {0.0f, 0.0f, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
}
/// A plot whose frame carries plenty of text: two titled axes (so the face is
/// asked for words) and a range whose tick labels are digits, dashes and a
/// decimal point -- every kind of run the coverage exists for.
plot::Plot text_plot() {
plot::Plot plot;
plot.set_viewport({0.0f, 0.0f, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
plot.add_x_axis({"TTT", {-1000.0, 1000.0}, false, plot::AxisPlace::Bottom, false});
plot.add_y_axis({"VVV", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
return plot;
}
} // namespace
// A bake of real coverage on a real face: every requested codepoint is in the
// grid, every rect is the declared cell, every glyph has ink and an advance the
// pen can move by, and the cell the backend declares is the cell the atlas
// actually holds. The fixed-cell contract is what the staging path asserts in a
// debug build; this is the same claim, on the data.
PLOT_TEST(ttf_backend_bakes_the_requested_coverage) {
if (!have_adwaita_mono()) {
skip("ttf_backend_bakes_the_requested_coverage", "no system Adwaita Mono to bake");
return;
}
const plot::f32 px = 20.0f;
plot::text::TrueTypeFont font;
plot::Status st;
PLOT_CHECK(plot::text::TrueTypeFont::from_file(kAdwaitaMono, px, &font, &st));
PLOT_CHECK(static_cast<bool>(st));
PLOT_CHECK_NEAR(font.em_px(), static_cast<double>(px), 1e-6);
const plot::u32 cell_w = font.cell_width();
const plot::u32 cell_h = font.cell_height();
PLOT_CHECK(cell_w > 0u);
PLOT_CHECK(cell_h > 0u);
PLOT_CHECK_NEAR(font.height(), static_cast<double>(cell_h), 1e-6);
PLOT_CHECK(font.ascent() > 0.0f && font.ascent() < font.height());
PLOT_CHECK(font.revision() == 1u); // the first bake of a fresh object
// The atlas is a single row of one-cell-wide slots, and the declared cell is
// exactly that: what `cell_uv()` says and what the rect of every glyph is.
const auto coverage = plot::text::TrueTypeFont::default_coverage();
PLOT_CHECK(coverage.size() == 100u); // 95 printable ASCII + the 5 specials
PLOT_CHECK(font.glyph_count() == coverage.size() + 1u); // ... and the fallback box
PLOT_CHECK(font.atlas_width() == cell_w * font.glyph_count());
PLOT_CHECK(font.atlas_height() == cell_h);
const plot::Vec2 cell = font.cell_uv();
PLOT_CHECK_NEAR(cell.x, static_cast<double>(cell_w) / font.atlas_width(), 1e-6);
PLOT_CHECK_NEAR(cell.y, 1.0, 1e-6);
PLOT_CHECK(font.atlas_pixels() != nullptr);
std::vector<bool> seen(font.glyph_count(), false);
for (const plot::u32 codepoint : coverage) {
const plot::render::Glyph* glyph = font.glyph(codepoint);
PLOT_CHECK(glyph != nullptr);
// The contract every backend keeps and the staging path asserts: the rect is
// the cell, not the glyph's ink box.
PLOT_CHECK(plot::render::glyph_is_cell(*glyph, cell));
const plot::u32 slot = slot_of(font, *glyph);
PLOT_CHECK((slot + 1u) * cell_w <= font.atlas_width());
PLOT_CHECK(!seen[slot]); // one cell per codepoint, no two sharing a rect
seen[slot] = true;
// The advance is the pen's step into the next cell, so it is positive and it
// never reaches past the one cell the rect declares.
PLOT_CHECK(glyph->advance > 0.0f);
PLOT_CHECK(glyph->advance <= static_cast<plot::f32>(cell_w) + 1e-4f);
// Ink, except for the space, which has an advance and nothing to draw -- the
// same exception the embedded generator makes when it walks its coverage.
if (codepoint == kSpace) {
PLOT_CHECK(cell_ink(font, slot) == 0u);
PLOT_CHECK_NEAR(font.measure(" "), glyph->advance, 1e-4);
} else {
PLOT_CHECK(cell_ink(font, slot) > 0u);
}
}
// A codepoint outside the coverage is not blank: it draws the fallback box, and
// it draws it as the same cell as every other glyph.
const plot::render::Glyph* fallback = font.glyph(kUncovered);
PLOT_CHECK(fallback != nullptr);
PLOT_CHECK(fallback == font.glyph(plot::render::kInvalidCodepoint));
PLOT_CHECK(plot::render::glyph_is_cell(*fallback, cell));
PLOT_CHECK(cell_ink(font, slot_of(font, *fallback)) > 0u);
PLOT_CHECK(!seen[slot_of(font, *fallback)]);
// `measure()` walks the same coverage `glyph()` answers from, including the
// fallback's advance for a codepoint it cannot spell.
PLOT_CHECK_NEAR(font.measure(""), 0.0, 1e-9);
PLOT_CHECK_NEAR(font.measure(nullptr), 0.0, 1e-9);
PLOT_CHECK_NEAR(font.measure("WWW"), 3.0 * font.glyph('W')->advance, 1e-3);
PLOT_CHECK_NEAR(font.measure("\xF0\x9F\x99\x82"), font.glyph(kUncovered)->advance, 1e-3);
}
// Every way a font can fail to arrive: a path that is not there, one that is a
// directory, a file that is not a font, an em size that is not positive, and a
// bake whose coverage came out blank. Each reports through a `Status`, each
// leaves the object it was handed exactly as it was -- the atlas, the cell, the
// revision and every glyph pointer -- and none of them takes the process with it.
PLOT_TEST(ttf_failure_paths_report_status) {
if (!have_adwaita_mono()) {
skip("ttf_failure_paths_report_status", "no system Adwaita Mono to bake");
return;
}
plot::text::TrueTypeFont font;
plot::Status st;
PLOT_CHECK(plot::text::TrueTypeFont::from_file(kAdwaitaMono, 20.0f, &font, &st));
const plot::u32 revision = font.revision();
const plot::u32 atlas_w = font.atlas_width();
const plot::u32 cell_w = font.cell_width();
const plot::u8* atlas = font.atlas_pixels();
const plot::render::Glyph* glyph = font.glyph('A');
const auto unchanged = [&] {
return font.revision() == revision && font.atlas_width() == atlas_w &&
font.atlas_pixels() == atlas && font.cell_width() == cell_w &&
font.glyph('A') == glyph;
};
const auto rejects = [&](const char* what, const char* path, plot::f32 px) {
plot::Status failure;
PLOT_CHECK(!plot::text::TrueTypeFont::from_file(path, px, &font, &failure));
PLOT_CHECK(!static_cast<bool>(failure));
PLOT_CHECK(failure.code != plot::ErrorCode::Ok);
PLOT_CHECK(failure.message != nullptr && failure.message[0] != '\0');
PLOT_CHECK(unchanged());
std::printf("[fail-%s] %s\n", what, failure.message);
};
rejects("missing", "/nonexistent/plot/no-such-font.ttf", 20.0f);
rejects("directory", "/usr/share/fonts", 20.0f);
// A real text file from this repository: it exists, it reads, and it is not a
// font -- the case for a file the parser cannot accept.
rejects("not-a-font", PLOT_SOURCE_DIR "/CMakeLists.txt", 20.0f);
rejects("zero-px", kAdwaitaMono, 0.0f);
rejects("negative-px", kAdwaitaMono, -3.0f);
// Positive, but far below one texel of ink: the bake itself is what fails here,
// which is the "a bake that produced no ink" path rather than a bad argument.
rejects("blank-bake", kAdwaitaMono, 0.1f);
// And the one failure that is the caller's own: nowhere to put the font.
plot::Status no_destination;
PLOT_CHECK(!plot::text::TrueTypeFont::from_file(kAdwaitaMono, 20.0f, nullptr, &no_destination));
PLOT_CHECK(!static_cast<bool>(no_destination));
PLOT_CHECK(unchanged());
}
// A renderer draws with the backend it was given, and re-uploads when that
// backend re-bakes. The same plot is drawn four times: with the embedded face,
// with the TrueType bake at 20 px (a size the embedded bake does not have), with
// that same installed backend re-baked to a coverage of two glyphs, and finally
// by a *fresh* renderer handed the same backend in its current state. The last
// comparison is the one that pins the re-upload: the fresh renderer's atlas is
// uploaded from the backend as it is now, so a stale texture left in the first
// renderer -- the failure the revision check exists to prevent -- cannot match
// it, while an atlas that was re-uploaded must match it pixel for pixel.
PLOT_TEST(ttf_backend_renders_through_the_renderer) {
if (!have_adwaita_mono()) {
skip("ttf_backend_renders_through_the_renderer", "no system Adwaita Mono to bake");
return;
}
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
const plot::f32 px = 20.0f; // against the embedded face's 13 px bake
plot::text::TrueTypeFont font;
PLOT_CHECK(plot::text::TrueTypeFont::from_file(kAdwaitaMono, px, &font, &st));
PLOT_CHECK(font.em_px() != plot::render::EmbeddedMonoFont::kEmPx);
plot::Renderer renderer;
PLOT_CHECK(renderer.init(&st));
// The embedded default, whatever the renderer calls it: the built-in backend is
// the only EmbeddedMonoFont a renderer can be holding at this point.
PLOT_CHECK(dynamic_cast<const plot::render::EmbeddedMonoFont*>(renderer.text_backend()) != nullptr);
plot::Plot plot = text_plot();
const plot::u64 embedded_ink = frame_signature(ctx, renderer, plot);
const plot::f32 embedded_cell = renderer.axis_font_px();
renderer.set_text_backend(&font);
PLOT_CHECK(renderer.text_backend() == &font);
const plot::u64 ttf_ink = frame_signature(ctx, renderer, plot);
PLOT_CHECK(ttf_ink != embedded_ink); // the frame really is drawn from this atlas
// The geometry the renderer lays the labels out with is the bake's, at the
// style's requested em: the wrapper scales by requested_px / em_px(), so a 20 px
// bake drawn at 12 px has a 12/20 cell -- not the embedded 13 px face's.
const plot::f32 requested = 12.0f; // Style's default axis_font_px
PLOT_CHECK_NEAR(renderer.axis_font_px(), font.height() * (requested / px), 1e-3);
PLOT_CHECK(std::fabs(renderer.axis_font_px() - embedded_cell) > 0.5f);
// And the plot was handed the renderer's wrapper over that backend, so the
// strips it measured and the glyphs it drew are one set of numbers.
PLOT_CHECK(plot.text_backend() != nullptr);
PLOT_CHECK_NEAR(plot.text_backend()->em_px(), requested, 1e-3);
// A re-bake of the *installed* backend: same object, same pointer, new atlas
// and a new revision -- nothing but the revision tells the renderer to upload.
const plot::u32 narrow[] = {'T', 'V'};
const plot::u32 before = font.revision();
font.set_coverage(narrow);
PLOT_CHECK(font.revision() > before);
// Two codepoints and the fallback box: the re-bake resized the atlas, which is
// the second thing the renderer has to notice (the texture is not the old one).
PLOT_CHECK(font.glyph_count() == 3u);
PLOT_CHECK(font.atlas_width() == font.cell_width() * 3u);
const plot::u64 narrow_ink = frame_signature(ctx, renderer, plot);
PLOT_CHECK(narrow_ink != ttf_ink);
// A fresh renderer, handed the same backend as it is now: its atlas can only
// have come from the current bake, so the first renderer's frame matching it is
// what says its own re-upload happened.
plot::Renderer fresh;
PLOT_CHECK(fresh.init(&st));
fresh.set_text_backend(&font);
plot::Plot fresh_plot = text_plot();
const plot::u64 fresh_ink = frame_signature(ctx, fresh, fresh_plot);
PLOT_CHECK(narrow_ink == fresh_ink);
ctx.destroy();
}
// The isolation the whole task is about: the optional backend's third-party
// rasteriser is in neither plot_core's archive nor any public header outside
// text/. Both halves are read, not assumed -- the archive is listed with nm and
// the listing is required to be a listing (it names plot's own symbols), and the
// headers are walked and counted so an empty directory cannot pass.
PLOT_TEST(plot_core_stays_free_of_stb_truetype) {
const Command listing = run_command(std::string("nm -A '") + PLOT_CORE_ARCHIVE + "' 2>&1");
PLOT_CHECK(listing.ran);
PLOT_CHECK(listing.exit_status == 0);
PLOT_CHECK(listing.output.size() > 4000u); // something was listed
PLOT_CHECK(listing.output.find("4plot") != std::string::npos); // and it is plot's
PLOT_CHECK(listing.output.find("stbtt") == std::string::npos); // no stb_truetype symbol
PLOT_CHECK(listing.output.find("stb_truetype") == std::string::npos);
const std::filesystem::path root = std::filesystem::path(PLOT_SOURCE_DIR) / "include" / "plot";
std::error_code ec;
std::size_t headers = 0;
std::vector<std::string> offenders;
for (const auto& entry : std::filesystem::recursive_directory_iterator(root, ec)) {
if (ec || !entry.is_regular_file() || entry.path().extension() != ".hpp") {
continue;
}
// plot/text/ is where the class lives: that is where the vendored header is
// named, in the provenance comment and nowhere else. Everything else must not
// mention the rasteriser at all.
if (entry.path().parent_path().filename() == "text") {
continue;
}
++headers;
const std::string source = read_text_file(entry.path());
PLOT_CHECK(!source.empty());
if (source.find("stbtt") != std::string::npos ||
source.find("stb_truetype") != std::string::npos) {
offenders.push_back(entry.path().filename().string());
}
}
PLOT_CHECK(!ec);
// The walk really walked: the public surface is four dozen headers, and an empty
// or failed traversal would make the scan above pass for the wrong reason.
PLOT_CHECK(headers > 40u);
PLOT_CHECK(offenders.empty());
}
// The configure-time coverage check, and the one hole in it. Three runs of the
// same script: the real tree, where the optional target's header is exempt by
// name and the run says so; the same tree with that exemption emptied, where the
// header is reported missing -- which is what proves the exemption is the only
// reason it is not; and a tree with a genuinely new header in it, which must fail
// exactly as it always has. The last is what keeps the exemption from becoming a
// way to add a public header unnoticed.
PLOT_TEST(umbrella_check_exempts_optional_target_headers) {
namespace fs = std::filesystem;
const fs::path source = PLOT_SOURCE_DIR;
const std::string include_dir = (source / "include").string();
const std::string umbrella = (source / "include" / "plot" / "plot.hpp").string();
const std::string script = (source / "cmake" / "plot_umbrella_check.cmake").string();
const std::string command = std::string(PLOT_CMAKE_COMMAND) + " -DPLOT_INCLUDE_DIR=" + include_dir +
" -DPLOT_UMBRELLA=" + umbrella;
const std::string tail = " -P " + script + " 2>&1";
// The tree as it is: every public header is covered, and the one the umbrella
// deliberately does not name is named as exempt rather than as missing.
const Command real = run_command(command + tail);
PLOT_CHECK(real.ran);
PLOT_CHECK(real.exit_status == 0);
PLOT_CHECK(real.output.find("plot/text/true_type_font.hpp") != std::string::npos);
// The same run with the exemption emptied: the header is now reported missing,
// which is the check doing its job on a header it did not previously see.
const Command strict = run_command(command + " -DPLOT_OPTIONAL_HEADERS=" + tail);
PLOT_CHECK(strict.ran);
PLOT_CHECK(strict.exit_status != 0);
PLOT_CHECK(strict.output.find("plot/text/true_type_font.hpp") != std::string::npos);
// And a genuinely new public header: added to a copy of the tree, it fails the
// check the same way -- so the failure path is still the failure path.
std::error_code ec;
const fs::path probe = fs::temp_directory_path(ec) /
("plot_umbrella_probe-" + std::to_string(::getpid()));
PLOT_CHECK(!ec);
fs::remove_all(probe, ec);
fs::create_directories(probe, ec);
PLOT_CHECK(!ec);
fs::copy(source / "include" / "plot", probe / "plot", fs::copy_options::recursive, ec);
PLOT_CHECK(!ec);
write_text_file(probe / "plot" / "core" / "plot_temp_probe.hpp", "#pragma once\n");
const Command uncovered = run_command(std::string(PLOT_CMAKE_COMMAND) + " -DPLOT_INCLUDE_DIR=" +
probe.string() + " -DPLOT_UMBRELLA=" +
(probe / "plot" / "plot.hpp").string() + tail);
fs::remove_all(probe, ec);
PLOT_CHECK(uncovered.ran);
PLOT_CHECK(uncovered.exit_status != 0);
PLOT_CHECK(uncovered.output.find("plot/core/plot_temp_probe.hpp") != std::string::npos);
}
+5079
View File
File diff suppressed because it is too large. Load diff