feat(text): optional vendored stb_truetype backend for runtime external fonts
This commit is contained in:
1 parent
daff60e3f0
commit
14d664a246
12 files changed
+6511
-19
No files matched your search
@@ -75,6 +75,24 @@ target_sources(plot_tests PRIVATE test_qt_pointer.cpp)
|
||||
target_sources(plot_tests PRIVATE test_gtk_input.cpp)
|
||||
target_include_directories(plot_tests PRIVATE "${CMAKE_SOURCE_DIR}/adapters")
|
||||
|
||||
# The optional runtime-font backend brings its own cases, and they exist only when
|
||||
# its target does: the header, the class and the archive are all absent otherwise,
|
||||
# so there would be nothing to compile them against (see PLOT_BUILD_TTF_FONT in the
|
||||
# top-level CMakeLists.txt). Guarded on the target rather than on the option, like
|
||||
# the adapter cases above, so a target that exists for any reason gets its cases.
|
||||
#
|
||||
# Two paths are handed over as definitions because a case needs them and should
|
||||
# not guess: the core archive the "no stb_truetype in plot_core" case inspects
|
||||
# (a generator expression, so it is this build's own archive) and the cmake the
|
||||
# umbrella check is re-run with.
|
||||
if(TARGET plot::font_ttf)
|
||||
target_sources(plot_tests PRIVATE test_true_type_font.cpp)
|
||||
target_link_libraries(plot_tests PRIVATE plot::font_ttf)
|
||||
target_compile_definitions(plot_tests PRIVATE
|
||||
PLOT_CORE_ARCHIVE="$<TARGET_FILE:plot_core>"
|
||||
PLOT_CMAKE_COMMAND="${CMAKE_COMMAND}")
|
||||
endif()
|
||||
|
||||
# Same switches the library is built with: the cost tests read the counters
|
||||
# behind them (see include/plot/data/detail/{cover,merge}_count.hpp).
|
||||
target_compile_definitions(plot_tests PRIVATE PLOT_PYRAMID_COUNT_COVERS
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user