feat(text): optional vendored stb_truetype backend for runtime external fonts
This commit is contained in:
1 parent
daff60e3f0
commit
14d664a246
12 files changed
+6510
-18
No files matched your search
@@ -43,6 +43,33 @@ target_compile_features(plot_core PUBLIC cxx_std_20)
|
|||||||
target_link_libraries(plot_core PRIVATE ${CMAKE_DL_LIBS})
|
target_link_libraries(plot_core PRIVATE ${CMAKE_DL_LIBS})
|
||||||
plot_apply_warnings(plot_core)
|
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
|
# 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
|
# adapter they demo. Both are opt-in, so the core library and its tests still build
|
||||||
# on a bare toolchain.
|
# on a bare toolchain.
|
||||||
@@ -120,7 +147,15 @@ if(PLOT_INSTALL)
|
|||||||
ARCHIVE DESTINATION lib
|
ARCHIVE DESTINATION lib
|
||||||
LIBRARY DESTINATION lib
|
LIBRARY DESTINATION lib
|
||||||
RUNTIME DESTINATION bin)
|
RUNTIME DESTINATION bin)
|
||||||
|
# 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)
|
install(DIRECTORY include/plot DESTINATION include)
|
||||||
|
else()
|
||||||
|
install(DIRECTORY include/plot DESTINATION include
|
||||||
|
PATTERN "true_type_font.hpp" EXCLUDE)
|
||||||
|
endif()
|
||||||
|
|
||||||
install(EXPORT plotTargets
|
install(EXPORT plotTargets
|
||||||
NAMESPACE plot::
|
NAMESPACE plot::
|
||||||
|
|||||||
@@ -6,6 +6,18 @@
|
|||||||
# */detail/ instrumentation, which is documented as not part of the surface and
|
# */detail/ instrumentation, which is documented as not part of the surface and
|
||||||
# deliberately stays out of the umbrella.
|
# 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
|
# 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
|
# 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`.
|
# 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):
|
# 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_INCLUDE_DIR the installed-header root, e.g. <source>/include
|
||||||
# PLOT_UMBRELLA the umbrella header, e.g. <PLOT_INCLUDE_DIR>/plot/plot.hpp
|
# 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)
|
if(NOT DEFINED PLOT_INCLUDE_DIR)
|
||||||
message(FATAL_ERROR "plot: PLOT_INCLUDE_DIR is not set")
|
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.
|
# Every other public header is named by the umbrella.
|
||||||
set(plot_umbrella_missing "")
|
set(plot_umbrella_missing "")
|
||||||
|
set(plot_umbrella_exempt "")
|
||||||
foreach(header IN LISTS plot_umbrella_headers)
|
foreach(header IN LISTS plot_umbrella_headers)
|
||||||
if(header MATCHES "/detail/" OR header STREQUAL plot_umbrella_relative)
|
if(header MATCHES "/detail/" OR header STREQUAL plot_umbrella_relative)
|
||||||
continue()
|
continue()
|
||||||
endif()
|
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)
|
string(FIND "${plot_umbrella_text}" "#include <${header}>" plot_umbrella_pos)
|
||||||
if(plot_umbrella_pos EQUAL -1)
|
if(plot_umbrella_pos EQUAL -1)
|
||||||
list(APPEND plot_umbrella_missing "${header}")
|
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.")
|
"if it is implementation-only and not part of the public surface.")
|
||||||
endif()
|
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
|
# And every header the umbrella names is a file that exists (a rename or a
|
||||||
# deleted header cannot leave a dangling include behind).
|
# deleted header cannot leave a dangling include behind).
|
||||||
string(REGEX MATCHALL "#include <plot/[^>]+>" plot_umbrella_includes "${plot_umbrella_text}")
|
string(REGEX MATCHALL "#include <plot/[^>]+>" plot_umbrella_includes "${plot_umbrella_text}")
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ namespace plot::render {
|
|||||||
/// The renderer's built-in text backend: an anti-aliased greyscale atlas of
|
/// The renderer's built-in text backend: an anti-aliased greyscale atlas of
|
||||||
/// Adwaita Mono, baked at authoring time and committed as data.
|
/// Adwaita Mono, baked at authoring time and committed as data.
|
||||||
///
|
///
|
||||||
/// There is no font file, FreeType or stb_truetype behind this: the coverage
|
/// There is no font file, FreeType or third-party rasteriser behind this: the
|
||||||
/// bytes and the glyph table are generated by `tools/fonts/gen_atlas.py` into
|
/// coverage bytes and the glyph table are generated by `tools/fonts/gen_atlas.py`
|
||||||
/// `src/render/embedded_mono_font.cpp`, so `plot_core` stays dependency-free and
|
/// into `src/render/embedded_mono_font.cpp`, so `plot_core` stays dependency-free
|
||||||
/// nothing rasterises at build or run time. That file carries the provenance --
|
/// 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
|
/// -- 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
|
/// 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
|
/// 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
|
/// `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
|
/// A codepoint outside the coverage set draws the baked fallback box, so a label
|
||||||
/// the atlas cannot spell is visible rather than silently blank.
|
/// 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 {
|
class EmbeddedMonoFont final : public TextBackend {
|
||||||
public:
|
public:
|
||||||
/// The size the atlas was baked at, in font pixels, and the coverage samples
|
/// 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.
|
/// `Unsupported`; anything the driver refuses is `GLFailure` plus its log.
|
||||||
bool init(Status* st = nullptr) noexcept;
|
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
|
/// 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
|
/// `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
|
/// bottom-left origin once, clears that region to `style().background` and
|
||||||
@@ -387,10 +413,14 @@ class Renderer {
|
|||||||
/// numbers.
|
/// numbers.
|
||||||
void draw_legend(const Plot& plot, const PlotLayout& layout);
|
void draw_legend(const Plot& plot, const PlotLayout& layout);
|
||||||
|
|
||||||
/// The built-in backend the renderer owns. Every use of it goes through the
|
/// The built-in backend the renderer owns, and the face the frames draw with:
|
||||||
/// `TextBackend` interface -- `self`-typed nowhere -- so swapping it for
|
/// `set_text_backend()` writes `font_`, which starts here and comes back here
|
||||||
/// another face is a change to this member alone.
|
/// 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_;
|
render::EmbeddedMonoFont default_font_;
|
||||||
|
const render::TextBackend* font_ = &default_font_;
|
||||||
AxisFont font_backend_;
|
AxisFont font_backend_;
|
||||||
render::Style style_;
|
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
|
||||||
+30
-10
@@ -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 {
|
void warn_replaced_text_backend(const render::TextBackend* previous) noexcept {
|
||||||
std::fprintf(stderr,
|
std::fprintf(stderr,
|
||||||
"plot::Renderer::draw: replacing the Plot's TextBackend (%p) with the renderer's "
|
"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));
|
static_cast<const void*>(previous));
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -185,6 +186,23 @@ u32 Renderer::AxisFont::revision() const { return font_ != nullptr ? font_->revi
|
|||||||
|
|
||||||
// ------------------------------------------------------------------ init
|
// ------------------------------------------------------------------ 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 {
|
bool Renderer::init(Status* st) noexcept {
|
||||||
if (!gl::available()) {
|
if (!gl::available()) {
|
||||||
return fail(st, ErrorCode::Unsupported,
|
return fail(st, ErrorCode::Unsupported,
|
||||||
@@ -202,7 +220,7 @@ bool Renderer::init(Status* st) noexcept {
|
|||||||
const render::UploadMode mode =
|
const render::UploadMode mode =
|
||||||
persistent ? render::UploadMode::PersistentMapped : render::UploadMode::Orphan;
|
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
|
// 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
|
// 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
|
// 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
|
// 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.
|
// 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
|
// 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
|
// rather than left to whichever glyph is staged first: the instances carry rect
|
||||||
// origins, and the draw carries this size (see TextBackend::cell_uv).
|
// 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) {
|
void Renderer::prepare_plot(Plot& plot) {
|
||||||
#ifndef NDEBUG
|
#ifndef NDEBUG
|
||||||
// The renderer owns the text of the frames it draws: every label goes through
|
// 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
|
// the text pipeline in `font_` -- the embedded face, or whatever a host put
|
||||||
// whatever a host installed. A host backend is therefore replaced on the first
|
// there with Renderer::set_text_backend() -- so the plot is handed this
|
||||||
// draw -- said out loud in a debug build, since its disappearance is otherwise
|
// backend's metrics whatever it was given. A backend installed on the *Plot* is
|
||||||
// silent -- and a Plot nobody draws keeps it (see Plot::set_text_backend()).
|
// 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();
|
if (const render::TextBackend* previous = plot.text_backend();
|
||||||
previous != nullptr && previous != &font_backend_) {
|
previous != nullptr && previous != &font_backend_) {
|
||||||
warn_replaced_text_backend(previous);
|
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) {
|
Vec2 Renderer::stage_text(Vec2 pos_px, const char* utf8) {
|
||||||
// Text is staged in viewport pixels: the text program has no offset uniform.
|
// 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 {
|
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&) {});
|
[](Vec2, const render::Glyph&) {});
|
||||||
}
|
}
|
||||||
|
|
||||||
f32 Renderer::stage_text_clipped(Vec2 pos_px, const char* utf8, f32 budget_px, Vec2 axis) {
|
f32 Renderer::stage_text_clipped(Vec2 pos_px, const char* utf8, f32 budget_px, Vec2 axis) {
|
||||||
const f32 scale = font_backend_.scale();
|
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) {
|
[this, scale, axis](Vec2 pen, const render::Glyph& glyph) {
|
||||||
glyphs_.add_glyph(pen, glyph, scale, axis);
|
glyphs_.add_glyph(pen, glyph, scale, axis);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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/" "")
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
@@ -75,6 +75,24 @@ target_sources(plot_tests PRIVATE test_qt_pointer.cpp)
|
|||||||
target_sources(plot_tests PRIVATE test_gtk_input.cpp)
|
target_sources(plot_tests PRIVATE test_gtk_input.cpp)
|
||||||
target_include_directories(plot_tests PRIVATE "${CMAKE_SOURCE_DIR}/adapters")
|
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
|
# Same switches the library is built with: the cost tests read the counters
|
||||||
# behind them (see include/plot/data/detail/{cover,merge}_count.hpp).
|
# behind them (see include/plot/data/detail/{cover,merge}_count.hpp).
|
||||||
target_compile_definitions(plot_tests PRIVATE PLOT_PYRAMID_COUNT_COVERS
|
target_compile_definitions(plot_tests PRIVATE PLOT_PYRAMID_COUNT_COVERS
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
Vendored
+5079
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user