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

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

No files matched your search

+58
View File
@@ -0,0 +1,58 @@
# The optional runtime-font target: plot::text::TrueTypeFont, which bakes a real
# TrueType/OpenType file into a fixed-cell coverage atlas at run time. It is the
# only target in the project that reaches the vendored third_party/stb_truetype.h,
# and the top-level CMakeLists.txt adds this directory only when PLOT_BUILD_TTF_FONT
# is on -- so plot_core, its public headers and every other target stay
# third-party-free, and a build with the option off neither compiles nor links any
# of it.
#
# Two targets, not one, and for the reason the whole task exists: stb_truetype is
# third-party source, so it is compiled in its own object library to which
# plot_apply_warnings() is *not* applied. The project's warning set stays on our
# own code (and stays clean under it) without a single per-file suppression to
# quiet someone else's header -- see cmake/plot_options.cmake, whose warning
# policy is "never suppressed per file".
add_library(plot_stb_truetype OBJECT stb_truetype_impl.cpp)
# The vendored header's directory is PRIVATE and on this object library alone:
# nothing else in the build can reach stb_truetype.h even by accident, and a
# consumer of the installed target needs none of it (the parser is compiled into
# the archive, so only our own header is on the interface).
target_include_directories(plot_stb_truetype PRIVATE "${CMAKE_SOURCE_DIR}/third_party")
target_compile_features(plot_stb_truetype PRIVATE cxx_std_20)
# The backend itself. Its objects come from the object library above, so the
# archive is self-contained: a consumer links plot::font_ttf and nothing else, and
# no non-exported target leaks into the install set's link interface.
add_library(plot_font_ttf STATIC
true_type_font.cpp
$<TARGET_OBJECTS:plot_stb_truetype>)
add_library(plot::font_ttf ALIAS plot_font_ttf)
# The installed tree names it plot::font_ttf, like the build tree (see
# plot_core's EXPORT_NAME).
set_target_properties(plot_font_ttf PROPERTIES EXPORT_NAME font_ttf)
# The public include root is the project's own `include/`, because that is where
# the target's header is reached from: <plot/text/true_type_font.hpp>. The build
# and install interfaces are the same pair plot_core declares, spelled here
# because this target is the one that owns the header.
target_include_directories(plot_font_ttf PUBLIC
$<BUILD_INTERFACE:${CMAKE_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:include>)
# PRIVATE, like the object library's: this target's own source includes the
# vendored header for its declarations (the implementation is the object library
# above), and nothing that links plot::font_ttf inherits the path -- a consumer
# needs none of it, because stb_truetype is compiled into the archive.
target_include_directories(plot_font_ttf PRIVATE "${CMAKE_SOURCE_DIR}/third_party")
target_compile_features(plot_font_ttf PUBLIC cxx_std_20)
target_link_libraries(plot_font_ttf PUBLIC plot::core)
plot_apply_warnings(plot_font_ttf)
# It joins the install and export set: it has no dependency a consumer would have
# to re-find (the vendored header is compiled in, and third_party/ is not
# installed at all), so find_package(plot) + target_link_libraries(consumer
# PRIVATE plot::font_ttf) works from an installed prefix whenever the option was
# on -- and plotConfig.cmake then needs no find_dependency() for it. The reason
# string is what the configure log prints.
plot_record_adapter_export(plot_font_ttf TRUE
"its only third-party dependency is compiled in from third_party/" "")
+16
View File
@@ -0,0 +1,16 @@
// The vendored rasteriser's single implementation unit: the one translation unit
// in this project that defines STB_TRUETYPE_IMPLEMENTATION, so the ~5000-line
// stb_truetype.h is compiled exactly once, into the optional plot_font_ttf
// target, and never into plot_core. It is a separate translation unit -- and its
// own object-library target in src/text/CMakeLists.txt -- so that the project's
// warning set (-Wall -Wextra -Wpedantic -Wconversion -Wshadow) covers our own
// sources without covering third-party code, and so that no warning is ever
// suppressed per file to accommodate it (see plot_apply_warnings in
// cmake/plot_options.cmake).
//
// stb_truetype v1.26 -- public domain, Sean Barrett / RAD Game Tools. The
// vendored copy at third_party/stb_truetype.h is pinned by the sha256 recorded in
// the wrapper header include/plot/text/true_type_font.hpp, which is also where
// the source URL and the retrieval date live.
#define STB_TRUETYPE_IMPLEMENTATION
#include <stb_truetype.h>
+529
View File
@@ -0,0 +1,529 @@
// The optional TrueType backend: the one place in the project that parses a font
// file at run time, and the only consumer of the vendored third_party/stb_truetype.h
// (see src/text/stb_truetype_impl.cpp for its single implementation unit, and
// include/plot/text/true_type_font.hpp for its provenance). Built only by the
// PLOT_BUILD_TTF_FONT target, so nothing here is compiled or linked into
// plot_core.
//
// The bake follows tools/fonts/gen_atlas.py's cell scheme, deliberately: a
// proportional face is drawn into a uniform cell rather than trimmed to its ink,
// because the text pipeline stages one rect origin per glyph and carries the
// rect's size once per draw (see render::TextBackend::cell_uv). The cell is as
// wide as the widest advance *or* the widest ink's right edge -- the pen must
// never leave the cell, and neither may a glyph's ink -- and as tall as the
// face's ascent-to-descent extent, extended when a baked glyph's own bitmap box
// needs more room than the face declares. The pen starts at the cell's left edge
// and the baseline sits `ascent()` below the cell's top, so the ink is blitted at
// the offset its bitmap box reports from the pen and every rect stays one size.
#include <plot/text/true_type_font.hpp>
#include <stb_truetype.h>
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <filesystem>
#include <fstream>
#include <new>
#include <system_error>
#include <vector>
namespace plot::text {
namespace {
/// The fallback box, in font units relative to the baseline: the same outline
/// `tools/fonts/gen_atlas.py` bakes for the embedded face (its FALLBACK_OUTER and
/// FALLBACK_INNER), so a codepoint neither backend can spell shows the same mark.
/// It is drawn from geometry rather than from the face, because the face may have
/// no usable `.notdef` and a fallback that depends on one is not a fallback.
constexpr f32 kFallbackOuterX0 = 60.0f;
constexpr f32 kFallbackOuterY0 = -100.0f;
constexpr f32 kFallbackOuterX1 = 580.0f;
constexpr f32 kFallbackOuterY1 = 700.0f;
constexpr f32 kFallbackInnerX0 = 160.0f;
constexpr f32 kFallbackInnerY0 = 0.0f;
constexpr f32 kFallbackInnerX1 = 480.0f;
constexpr f32 kFallbackInnerY1 = 600.0f;
/// A codepoint the face can draw, with the advance the pen moves by: pass one of
/// a bake, whose only job is to size the cell every one of them is drawn into.
struct Drawable {
u32 codepoint = 0;
f32 advance = 0.0f;
};
/// One bake's complete output, so `from_file()` and `set_coverage()` can build a
/// new one and swap it in only when it succeeded: a failed bake leaves the
/// previous atlas -- and every glyph pointer a caller may be holding -- exactly
/// where it was.
struct Bake {
u32 cell_w = 0;
u32 cell_h = 0;
i32 baseline = 0;
std::vector<u32> codepoints;
std::vector<render::Glyph> glyphs;
render::Glyph fallback{};
std::vector<u8> atlas;
u32 atlas_w = 0;
u32 atlas_h = 0;
};
bool fail(Status* st, ErrorCode code, const char* message) noexcept {
if (st != nullptr) {
*st = Status{code, message};
}
return false;
}
u32 read_be16(std::span<const u8> bytes, std::size_t at) noexcept {
return (static_cast<u32>(bytes[at]) << 8) | static_cast<u32>(bytes[at + 1]);
}
u32 read_be32(std::span<const u8> bytes, std::size_t at) noexcept {
return (static_cast<u32>(bytes[at]) << 24) | (static_cast<u32>(bytes[at + 1]) << 16) |
(static_cast<u32>(bytes[at + 2]) << 8) | static_cast<u32>(bytes[at + 3]);
}
/// True when `bytes` opens with a font signature *and* the table directory that
/// follows fits inside it.
///
/// stb_truetype does no range checking of a font file's offsets -- its own header
/// says so, in capitals -- and its table scan reads `numTables` 16-byte records
/// from offset 12 whatever the file's length is. Handing it a text file would
/// therefore read past the buffer: an immediate error under the address
/// sanitizer, and a plain out-of-bounds read everywhere else. So the directory the
/// scan walks is validated here, before the parser sees a byte. What is inside
/// those tables stays stb's business: a *random* file is rejected here, and a file
/// *crafted* to be hostile remains out of scope, as the vendored header warns.
bool has_sfnt_directory(std::span<const u8> bytes) noexcept {
if (bytes.size() < 12u) {
return false;
}
const u32 version = read_be32(bytes, 0u);
constexpr u32 kTrueType = 0x00010000u;
constexpr u32 kAppleTrue = 0x74727565u; // 'true'
constexpr u32 kOpenTypeCff = 0x4F54544Fu; // 'OTTO'
constexpr u32 kTypeOne = 0x74797031u; // 'typ1'
constexpr u32 kCollection = 0x74746366u; // 'ttcf'
if (version == kCollection) {
// stb reads a collection's first font offset at byte 12, so the header has to
// be that long before it is asked for one.
return bytes.size() >= 16u;
}
if (version != kTrueType && version != kAppleTrue && version != kOpenTypeCff &&
version != kTypeOne) {
return false;
}
const u64 directory_end = 12u + 16u * static_cast<u64>(read_be16(bytes, 4u));
return directory_end <= static_cast<u64>(bytes.size());
}
/// Blits `bitmap` into `slot`'s cell, `x`/`y` being the ink's top-left in cell
/// coordinates. Ink outside the cell is dropped rather than written into the
/// neighbouring cell: a face whose ink overhangs its advance would otherwise
/// corrupt whichever glyph owns the next column, and clipping is what the
/// embedded generator does at the same boundary. Returns the texels written.
u32 blit_cell(std::vector<u8>& atlas, const Bake& bake, u32 slot, const u8* bitmap, i32 width,
i32 height, i32 x, i32 y) noexcept {
u32 written = 0;
for (i32 row = 0; row < height; ++row) {
const i32 cell_y = y + row;
if (cell_y < 0 || cell_y >= static_cast<i32>(bake.cell_h)) {
continue;
}
for (i32 column = 0; column < width; ++column) {
const u8 value = bitmap[static_cast<std::size_t>(row) * static_cast<std::size_t>(width) +
static_cast<std::size_t>(column)];
if (value == 0u) {
continue;
}
const i32 cell_x = x + column;
if (cell_x < 0 || cell_x >= static_cast<i32>(bake.cell_w)) {
continue;
}
atlas[static_cast<std::size_t>(cell_y) * bake.atlas_w +
static_cast<std::size_t>(slot) * bake.cell_w + static_cast<std::size_t>(cell_x)] = value;
++written;
}
}
return written;
}
/// Draws the fallback box into `slot`'s cell: the outline between the outer and
/// inner rectangles, roughly one texel thick at the bake's scale. It is geometry,
/// not a rasterised glyph, so the box is there for a face with no usable
/// `.notdef` too.
void blit_fallback_box(std::vector<u8>& atlas, const Bake& bake, u32 slot, f32 scale) noexcept {
const auto to_x = [scale](f32 units) { return static_cast<i32>(std::lround(units * scale)); };
const auto to_y = [&bake, scale](f32 units) {
return bake.baseline - static_cast<i32>(std::lround(units * scale));
};
const i32 outer_left = to_x(kFallbackOuterX0);
const i32 outer_right = to_x(kFallbackOuterX1);
const i32 outer_top = to_y(kFallbackOuterY1);
const i32 outer_bottom = to_y(kFallbackOuterY0);
const i32 inner_left = to_x(kFallbackInnerX0);
const i32 inner_right = to_x(kFallbackInnerX1);
const i32 inner_top = to_y(kFallbackInnerY1);
const i32 inner_bottom = to_y(kFallbackInnerY0);
for (i32 y = outer_top; y < outer_bottom; ++y) {
if (y < 0 || y >= static_cast<i32>(bake.cell_h)) {
continue;
}
for (i32 x = outer_left; x < outer_right; ++x) {
if (x < 0 || x >= static_cast<i32>(bake.cell_w)) {
continue;
}
if (x >= inner_left && x < inner_right && y >= inner_top && y < inner_bottom) {
continue; // the hole the box is drawn around
}
atlas[static_cast<std::size_t>(y) * bake.atlas_w +
static_cast<std::size_t>(slot) * bake.cell_w + static_cast<std::size_t>(x)] = 255u;
}
}
}
/// Bakes `coverage` at `scale` into `out`: the cell, the glyph table and the
/// single-row atlas, in two passes over the face. False, with `*st` filled in,
/// when no requested codepoint produced ink -- an atlas whose only mark is the
/// fallback box is the "everything the caller asked for came out blank" failure
/// this backend exists to refuse (see `TrueTypeFont::from_file`). `out` is only
/// meaningful when this returns true.
bool bake_face(const stbtt_fontinfo& info, f32 scale, std::span<const u32> coverage, Bake* out,
Status* st) {
i32 ascent_units = 0;
i32 descent_units = 0;
i32 line_gap = 0;
stbtt_GetFontVMetrics(&info, &ascent_units, &descent_units, &line_gap);
const f32 ascent_f = static_cast<f32>(ascent_units) * scale;
const f32 descent_f = -static_cast<f32>(descent_units) * scale;
std::vector<u32> requested(coverage.begin(), coverage.end());
std::sort(requested.begin(), requested.end());
requested.erase(std::unique(requested.begin(), requested.end()), requested.end());
// Pass one: what every drawable codepoint would need. A codepoint the face has
// no glyph for is dropped here rather than given a blank cell -- the draw then
// falls back to the box, which is visible, where a blank cell renders nothing
// and says so to nobody.
std::vector<Drawable> drawable;
drawable.reserve(requested.size());
f32 widest_advance = 0.0f;
i32 highest = 0;
i32 lowest = 0;
i32 rightmost = 0;
for (const u32 codepoint : requested) {
if (codepoint > 0x10FFFFu) {
continue; // kInvalidCodepoint, and anything else that is not a scalar
}
if (stbtt_FindGlyphIndex(&info, static_cast<i32>(codepoint)) == 0) {
continue;
}
i32 x0 = 0;
i32 y0 = 0;
i32 x1 = 0;
i32 y1 = 0;
stbtt_GetCodepointBitmapBox(&info, static_cast<i32>(codepoint), scale, scale, &x0, &y0, &x1,
&y1);
i32 advance_units = 0;
i32 bearing_units = 0;
stbtt_GetCodepointHMetrics(&info, static_cast<i32>(codepoint), &advance_units, &bearing_units);
const f32 advance = static_cast<f32>(advance_units) * scale;
widest_advance = std::max(widest_advance, advance);
highest = std::min(highest, y0);
lowest = std::max(lowest, y1);
rightmost = std::max(rightmost, x1);
drawable.push_back(Drawable{codepoint, advance});
}
// The cell: wide enough for the widest advance *and* the widest ink (the
// generator's rule -- an overhanging glyph stays inside its own cell instead of
// reaching into the next one), as tall as the face's ascent-to-descent extent,
// and never so short that a baked bitmap box hangs out of it.
const u32 cell_w = std::max<u32>(
1u,
static_cast<u32>(std::ceil(std::max(widest_advance, static_cast<f32>(rightmost)) - 1e-4f)));
i32 baseline = static_cast<i32>(std::floor(ascent_f));
baseline = std::max(baseline, -highest); // room for the tallest ink above the pen
baseline = std::max(baseline, 0);
const u32 extent = static_cast<u32>(std::ceil(ascent_f + descent_f - 1e-4f));
const u32 below = static_cast<u32>(std::max(0, baseline + lowest));
const u32 cell_h = std::max<u32>(1u, std::max(extent, below));
out->cell_w = cell_w;
out->cell_h = cell_h;
out->baseline = baseline;
const u32 slots = static_cast<u32>(drawable.size()) + 1u; // the fallback box is the last slot
out->atlas_w = cell_w * slots;
out->atlas_h = cell_h;
out->atlas.assign(static_cast<std::size_t>(out->atlas_w) * out->atlas_h, 0u);
out->codepoints.clear();
out->codepoints.reserve(drawable.size());
out->glyphs.clear();
out->glyphs.reserve(slots);
// Every glyph's rect is the same one cell, so the rect is built once per slot
// and only its `u0` moves. The v range is the whole atlas height, which is one
// cell: the atlas is a single row, so `cell_uv()` is `(cell_w / atlas_w, 1)`.
const f32 du = static_cast<f32>(cell_w) / static_cast<f32>(out->atlas_w);
const f32 dv = static_cast<f32>(cell_h) / static_cast<f32>(out->atlas_h);
const auto glyph_for_slot = [&](u32 slot, f32 advance) {
const f32 u0 = static_cast<f32>(slot * cell_w) / static_cast<f32>(out->atlas_w);
return render::Glyph{u0, 0.0f, u0 + du, dv, 0.0f, 0.0f, advance};
};
// Pass two: rasterise and blit. The offset `stbtt_GetCodepointBitmap` reports is
// the box pass one measured -- stb derives both from the same glyph box -- which
// is why placing the ink at it is what keeps the bearing.
u32 inked = 0;
for (std::size_t i = 0; i < drawable.size(); ++i) {
const Drawable& glyph = drawable[i];
i32 width = 0;
i32 height = 0;
i32 x = 0;
i32 y = 0;
u8* bitmap = stbtt_GetCodepointBitmap(&info, scale, scale, static_cast<i32>(glyph.codepoint),
&width, &height, &x, &y);
if (bitmap != nullptr) {
inked += blit_cell(out->atlas, *out, static_cast<u32>(i), bitmap, width, height, x,
baseline + y);
stbtt_FreeBitmap(bitmap, nullptr);
}
out->codepoints.push_back(glyph.codepoint);
out->glyphs.push_back(glyph_for_slot(static_cast<u32>(i), glyph.advance));
}
if (inked == 0u) {
return fail(st, ErrorCode::InvalidArgument,
"the bake produced no ink: none of the requested codepoints drew anything at this "
"em size");
}
// The fallback slot, last: the embedded face's box, with the widest advance so a
// run of missing glyphs takes the room of a real one.
const u32 fallback_slot = static_cast<u32>(drawable.size());
blit_fallback_box(out->atlas, *out, fallback_slot, scale);
out->fallback = glyph_for_slot(fallback_slot, widest_advance);
return true;
}
} // namespace
struct TrueTypeFont::Impl {
/// The whole file, because `stbtt_fontinfo` holds pointers into it and a font
/// is not readable piecemeal: the parser walks tables wherever they are.
std::vector<u8> file;
stbtt_fontinfo info{};
f32 px = 0.0f;
f32 scale = 0.0f;
/// The coverage a re-point at another file inherits, so a host's choice of
/// codepoints survives a `from_file()`.
std::vector<u32> coverage;
Bake bake;
/// Counts the bakes this object has performed, so a face installed over one the
/// renderer has already uploaded never carries the number it uploaded.
u32 revision = 0;
};
TrueTypeFont::TrueTypeFont() noexcept = default;
TrueTypeFont::~TrueTypeFont() = default;
std::span<const u32> TrueTypeFont::default_coverage() noexcept {
// The embedded font's coverage: printable ASCII, the codepoints
// `plot::format_si()` emits, and the two dashes a plotting label uses. The
// fallback box is not a codepoint, so it is not in the list -- it is what every
// codepoint *outside* the list draws.
static const std::vector<u32> kCoverage = [] {
std::vector<u32> codepoints;
codepoints.reserve(100u);
for (u32 codepoint = 0x20u; codepoint <= 0x7Eu; ++codepoint) {
codepoints.push_back(codepoint);
}
codepoints.push_back(0x00B0u); // degree
codepoints.push_back(0x00B5u); // micro
codepoints.push_back(0x03A9u); // ohm
codepoints.push_back(0x2013u); // en dash
codepoints.push_back(0x2014u); // em dash
return codepoints;
}();
return kCoverage;
}
bool TrueTypeFont::from_file(const char* utf8_path, f32 px, TrueTypeFont* out, Status* st) {
if (out == nullptr) {
return fail(st, ErrorCode::InvalidArgument,
"TrueTypeFont::from_file() needs somewhere to put the font");
}
if (utf8_path == nullptr || utf8_path[0] == '\0') {
return fail(st, ErrorCode::InvalidArgument, "TrueTypeFont::from_file() needs a font file path");
}
if (!std::isfinite(px) || !(px > 0.0f)) {
return fail(st, ErrorCode::InvalidArgument,
"a font em size must be a positive, finite number of pixels");
}
std::error_code ec;
const std::filesystem::path path{utf8_path};
const std::filesystem::file_status path_status = std::filesystem::status(path, ec);
if (ec || !std::filesystem::exists(path_status)) {
return fail(st, ErrorCode::InvalidArgument, "no such font file");
}
if (!std::filesystem::is_regular_file(path_status)) {
return fail(st, ErrorCode::InvalidArgument, "the font path is not a regular file");
}
const std::uintmax_t size = std::filesystem::file_size(path, ec);
if (ec || size == 0u) {
return fail(st, ErrorCode::InvalidArgument, "the font file is empty or its size is unreadable");
}
try {
// Everything is built in a candidate and swapped in at the end, so a failure
// at any step leaves `*out` exactly as it was -- including the atlas and the
// glyph pointers a caller may still be drawing from.
Impl candidate;
candidate.px = px;
if (out->impl_ != nullptr) {
candidate.coverage = out->impl_->coverage;
candidate.revision = out->impl_->revision;
} else {
candidate.coverage.assign(default_coverage().begin(), default_coverage().end());
}
candidate.file.resize(static_cast<std::size_t>(size));
std::ifstream in(path, std::ios::binary);
if (!in) {
return fail(st, ErrorCode::InvalidArgument, "the font file cannot be opened");
}
in.read(reinterpret_cast<char*>(candidate.file.data()),
static_cast<std::streamsize>(candidate.file.size()));
if (!in) {
return fail(st, ErrorCode::InvalidArgument, "the font file cannot be read");
}
const std::span<const u8> bytes{candidate.file};
// The directory is validated before stb sees the file at all -- and again at
// the font the collection header points at, if this is a collection, because
// that offset comes out of the file too (see has_sfnt_directory).
int offset = -1;
if (has_sfnt_directory(bytes)) {
offset = stbtt_GetFontOffsetForIndex(candidate.file.data(), 0);
}
if (offset < 0 || static_cast<std::uintmax_t>(offset) >= size ||
!has_sfnt_directory(bytes.subspan(static_cast<std::size_t>(offset)))) {
return fail(st, ErrorCode::InvalidArgument, "the file is not a font");
}
if (stbtt_InitFont(&candidate.info, candidate.file.data(), offset) == 0) {
return fail(st, ErrorCode::InvalidArgument,
"the font cannot be parsed: a TrueType outline needs a table this file lacks");
}
candidate.scale = stbtt_ScaleForPixelHeight(&candidate.info, px);
if (!(candidate.scale > 0.0f)) {
return fail(st, ErrorCode::InvalidArgument, "the face declares no size to scale from");
}
if (!bake_face(candidate.info, candidate.scale, candidate.coverage, &candidate.bake, st)) {
return false;
}
candidate.revision += 1u;
out->impl_ = std::make_unique<Impl>(std::move(candidate));
} catch (const std::bad_alloc&) {
return fail(st, ErrorCode::OutOfMemory, "the font atlas could not be allocated");
}
return true;
}
void TrueTypeFont::set_coverage(std::span<const u32> codepoints) {
Impl* impl = impl_.get();
if (impl == nullptr) {
return; // no face: nothing to bake, and nothing to re-bake
}
std::vector<u32> coverage(codepoints.begin(), codepoints.end());
try {
Bake candidate;
if (!bake_face(impl->info, impl->scale, coverage, &candidate, nullptr)) {
// A re-bake that produced no ink cannot be reported through this interface
// -- there is no Status to fill in -- so the previous atlas stays. Losing a
// working font to an argument this backend cannot answer for is worse than
// ignoring the request.
return;
}
impl->coverage = std::move(coverage);
impl->bake = std::move(candidate);
impl->revision += 1u;
} catch (const std::bad_alloc&) {
// Same reasoning: the atlas that is already there is a font that works.
}
}
f32 TrueTypeFont::measure(const char* utf8) const {
if (utf8 == nullptr || impl_ == nullptr) {
return 0.0f;
}
const Bake& bake = impl_->bake;
f32 width = 0.0f;
for (const char* p = utf8; *p != '\0';) {
const u32 codepoint = render::next_codepoint(p);
const auto found = std::lower_bound(bake.codepoints.begin(), bake.codepoints.end(), codepoint);
width += (found != bake.codepoints.end() && *found == codepoint)
? bake.glyphs[static_cast<std::size_t>(found - bake.codepoints.begin())].advance
: bake.fallback.advance;
}
return width;
}
f32 TrueTypeFont::height() const {
return impl_ != nullptr ? static_cast<f32>(impl_->bake.cell_h) : 0.0f;
}
f32 TrueTypeFont::ascent() const {
return impl_ != nullptr ? static_cast<f32>(impl_->bake.baseline) : 0.0f;
}
f32 TrueTypeFont::em_px() const { return impl_ != nullptr ? impl_->px : 0.0f; }
const render::Glyph* TrueTypeFont::glyph(u32 codepoint) const {
if (impl_ == nullptr) {
return nullptr; // no face: the legal "no text" backend, drawn as nothing
}
const Bake& bake = impl_->bake;
const auto found = std::lower_bound(bake.codepoints.begin(), bake.codepoints.end(), codepoint);
if (found != bake.codepoints.end() && *found == codepoint) {
return &bake.glyphs[static_cast<std::size_t>(found - bake.codepoints.begin())];
}
return &bake.fallback;
}
Vec2 TrueTypeFont::cell_uv() const {
if (impl_ == nullptr || impl_->bake.atlas_w == 0u) {
return Vec2{};
}
return Vec2{static_cast<f32>(impl_->bake.cell_w) / static_cast<f32>(impl_->bake.atlas_w),
static_cast<f32>(impl_->bake.cell_h) / static_cast<f32>(impl_->bake.atlas_h)};
}
const u8* TrueTypeFont::atlas_pixels() const {
if (impl_ == nullptr || impl_->bake.atlas.empty()) {
return nullptr;
}
return impl_->bake.atlas.data();
}
u32 TrueTypeFont::atlas_width() const { return impl_ != nullptr ? impl_->bake.atlas_w : 0u; }
u32 TrueTypeFont::atlas_height() const { return impl_ != nullptr ? impl_->bake.atlas_h : 0u; }
u32 TrueTypeFont::revision() const { return impl_ != nullptr ? impl_->revision : 0u; }
u32 TrueTypeFont::glyph_count() const noexcept {
return impl_ != nullptr ? static_cast<u32>(impl_->bake.glyphs.size()) + 1u : 0u;
}
u32 TrueTypeFont::cell_width() const noexcept {
return impl_ != nullptr ? impl_->bake.cell_w : 0u;
}
u32 TrueTypeFont::cell_height() const noexcept {
return impl_ != nullptr ? impl_->bake.cell_h : 0u;
}
} // namespace plot::text