fix(text): carry a glyph's negative bearing and harden the bake's extents

This commit is contained in:
Martino Ferrari committed 2026-09-28 16:32:18 +02:00
1 parent b0f9fbbb35
commit d853db62ae
5 files changed
+470 -60

No files matched your search

+10
View File
@@ -91,6 +91,16 @@ if(TARGET plot::font_ttf)
target_compile_definitions(plot_tests PRIVATE
PLOT_CORE_ARCHIVE="$<TARGET_FILE:plot_core>"
PLOT_CMAKE_COMMAND="${CMAKE_COMMAND}")
# The cases compare a bake against the rasteriser's own glyph bitmaps, which is
# the only reference that can tell a complete cell from a clipped one, and for
# that the test needs the vendored header's *declarations* (its implementation is
# already in the binary through plot::font_ttf). This is the optional target's
# own test, compiled only when that target exists, and it is the one target
# outside src/text that sees third_party/: the isolation this project promises --
# plot_core's archive and every public header under include/plot/ -- is what
# plot_core_stays_free_of_stb_truetype keeps asserting, and it is unaffected by
# which include paths a test binary has.
target_include_directories(plot_tests PRIVATE "${CMAKE_SOURCE_DIR}/third_party")
endif()
# Same switches the library is built with: the cost tests read the counters
+353 -23
View File
@@ -24,6 +24,13 @@
#include <plot/render/renderer.hpp>
#include <plot/text/true_type_font.hpp>
// Declarations only, for the reference the bake is measured against: the vendored
// rasteriser's own glyph bitmaps and boxes. Its implementation is already in this
// binary -- plot_tests links plot::font_ttf, which compiles it in -- so this is a
// call, not a second copy of third-party code (see tests/CMakeLists.txt for why
// this one target has third_party/ on its include path).
#include <stb_truetype.h>
#include <algorithm>
#include <cmath>
#include <cstdio>
@@ -45,6 +52,21 @@ namespace {
/// equal terms.
constexpr const char* kAdwaitaMono = "/usr/share/fonts/Adwaita/AdwaitaMono-Regular.ttf";
/// Italic faces to look for, in the order the case prefers them: every one of
/// these leaves some glyph's ink *left* of its pen (an oblique or italic design
/// slants the outline over the advance that starts at the pen), which is the shape
/// the cell's left edge has to accommodate. Distro paths differ, so the list names
/// the layouts of both the Debian-style and the Arch-style font trees.
constexpr const char* kItalicCandidates[] = {
"/usr/share/fonts/TTF/DejaVuSerif-Italic.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSerif-Italic.ttf",
"/usr/share/fonts/TTF/TypesetterSlab-Italic.ttf",
"/usr/share/fonts/gsfonts/NimbusRoman-Italic.otf",
"/usr/share/fonts/opentype/urw-base35/NimbusRoman-Italic.otf",
"/usr/share/fonts/liberation/LiberationSerif-Italic.ttf",
"/usr/share/fonts/TTF/LiberationSerif-Italic.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;
@@ -92,6 +114,161 @@ plot::u32 slot_of(const plot::text::TrueTypeFont& font, const plot::render::Glyp
return static_cast<plot::u32>(std::lround(cells));
}
/// The vendored rasteriser, driven by the test the way the backend drives it, so
/// a bake can be compared against the glyphs it was *supposed* to produce.
///
/// This is the only reference that can tell a complete cell from a clipped one:
/// the atlas is self-consistent either way, so a cell that quietly lost its
/// leftmost ink column looks exactly like a cell that never had one. Nothing about
/// the cell's size or placement is recomputed here -- that would only repeat the
/// arithmetic under test -- the reference is stb_truetype's own bitmap and box.
class ReferenceRasteriser {
public:
/// Reads `path` and parses it. False (with the case's skip reason printed by the
/// caller) when the file is missing or the parser refuses it.
bool open(const char* path) {
std::ifstream in(path, std::ios::binary);
if (!in) {
return false;
}
bytes_.assign(std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>());
if (bytes_.empty()) {
return false;
}
const int offset = stbtt_GetFontOffsetForIndex(bytes_.data(), 0);
return offset >= 0 && stbtt_InitFont(&info_, bytes_.data(), offset) != 0;
}
/// The scale the backend uses for `px` em, which is the rasteriser's own.
float scale(f32 px) const { return stbtt_ScaleForPixelHeight(&info_, px); }
/// The ink bitmap and box of `codepoint` at `scale`: the bytes the cell must
/// hold, and where, in pixels from the pen. The caller frees it.
plot::u8* bitmap(u32 codepoint, float scale, plot::i32* width, plot::i32* height, plot::i32* x, plot::i32* y) const {
int w = 0;
int h = 0;
int bx = 0;
int by = 0;
plot::u8* pixels = stbtt_GetCodepointBitmap(&info_, scale, scale, static_cast<int>(codepoint), &w, &h,
&bx, &by);
*width = w;
*height = h;
*x = bx;
*y = by;
return pixels;
}
/// The ink box stb reports for `codepoint`: the *geometric* box, which is what
/// the bake sizes its cells from and is worth more than the bitmap here -- a
/// bitmap cannot say whether a codepoint has ink left of its pen.
void box(u32 codepoint, float scale, plot::i32* x0, plot::i32* y0, plot::i32* x1, plot::i32* y1) const {
int ix0 = 0;
int iy0 = 0;
int ix1 = 0;
int iy1 = 0;
stbtt_GetCodepointBitmapBox(&info_, static_cast<int>(codepoint), scale, scale, &ix0, &iy0,
&ix1, &iy1);
*x0 = ix0;
*y0 = iy0;
*x1 = ix1;
*y1 = iy1;
}
bool has_glyph(u32 codepoint) const {
return stbtt_FindGlyphIndex(&info_, static_cast<int>(codepoint)) != 0;
}
private:
std::vector<plot::u8> bytes_;
stbtt_fontinfo info_{};
};
/// False, with `why` filled in, unless `codepoint`'s cell holds *exactly* the
/// bitmap the rasteriser produced for it, placed where the glyph's bearing puts
/// the pen: bit for bit, texel for texel, with no texel of the bitmap left outside
/// the cell. A column dropped because the cell could not hold it (the negative
/// bearing an italic glyph has), ink placed at the wrong offset, or a bake that
/// lost its shape, all fail here, and none of them is visible to an assertion
/// about ink being *present*.
bool cell_holds_reference(const plot::text::TrueTypeFont& font, const ReferenceRasteriser& face,
float scale, plot::u32 codepoint, std::string* why) {
const plot::render::Glyph* glyph = font.glyph(codepoint);
if (glyph == nullptr) {
*why = "no glyph";
return false;
}
plot::i32 width = 0;
plot::i32 height = 0;
plot::i32 ink_x = 0;
plot::i32 ink_y = 0;
plot::u8* bitmap = face.bitmap(codepoint, scale, &width, &height, &ink_x, &ink_y);
if (bitmap == nullptr) {
return true; // no ink to compare: a blank cell is the whole truth here
}
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();
const plot::u32 slot = slot_of(font, *glyph);
// Where the cell's own coordinates put the pen: the bearing is the cell corner's
// offset from the pen, so the pen is that much *into* the cell.
const plot::i32 pen_x = static_cast<plot::i32>(std::lround(-static_cast<double>(glyph->xoff)));
const plot::i32 pen_y = static_cast<plot::i32>(std::lround(static_cast<double>(font.ascent())));
bool ok = true;
for (plot::i32 row = 0; row < height && ok; ++row) {
for (plot::i32 column = 0; column < width; ++column) {
const plot::i32 cell_x = pen_x + ink_x + column;
const plot::i32 cell_y = pen_y + ink_y + row;
plot::u8 held = 0;
if (cell_x >= 0 && cell_y >= 0 && cell_x < static_cast<plot::i32>(cell_w) &&
cell_y < static_cast<plot::i32>(cell_h)) {
held = atlas[static_cast<std::size_t>(cell_y) * atlas_w +
static_cast<std::size_t>(slot) * cell_w + static_cast<std::size_t>(cell_x)];
}
const plot::u8 want =
bitmap[static_cast<std::size_t>(row) * static_cast<std::size_t>(width) +
static_cast<std::size_t>(column)];
if (held == want) {
continue;
}
*why = "U+" + std::to_string(codepoint) + " bitmap texel (column " + std::to_string(column) +
", row " + std::to_string(row) + ") is " + std::to_string(want) +
" but its cell holds " + std::to_string(held) +
(held == 0 && want != 0 ? " -- the ink was dropped" : " -- the ink is misplaced");
ok = false;
break;
}
}
stbtt_FreeBitmap(bitmap, nullptr);
return ok;
}
/// `cell_holds_reference`, as a case asserts it: the diagnostic the reference
/// produced is the failure message, because "which texel of which codepoint" is
/// the whole value of the check.
void expect_cell_holds_reference(const plot::text::TrueTypeFont& font,
const ReferenceRasteriser& face, float scale,
plot::u32 codepoint) {
std::string why;
if (!cell_holds_reference(font, face, scale, codepoint, &why)) {
throw ::plottest::Failure{why};
}
}
/// The first italic face from `kItalicCandidates` that is installed and parses,
/// with `face` opened on it. Null when none is: the case that wants one is about a
/// shape only an italic face has, and says so rather than passing on a face that
/// does not.
const char* open_an_italic_face(ReferenceRasteriser* face) {
for (const char* candidate : kItalicCandidates) {
std::error_code ec;
if (std::filesystem::is_regular_file(candidate, ec) && face->open(candidate)) {
return candidate;
}
}
return nullptr;
}
/// 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
@@ -242,6 +419,18 @@ PLOT_TEST(ttf_backend_bakes_the_requested_coverage) {
}
}
// And the cells hold the glyphs the rasteriser produced, whole: for every
// codepoint, the bitmap stb_truetype hands back -- itself trimmed to its ink, so
// it is the one artefact that knows what the cell must contain -- must be in the
// cell texel for texel, at the offset the glyph's bearing names. This is the
// assertion that catches ink a cell boundary dropped, which no assertion about
// ink being *present* can see.
ReferenceRasteriser face;
PLOT_CHECK(face.open(kAdwaitaMono));
for (const plot::u32 codepoint : coverage) {
expect_cell_holds_reference(font, face, face.scale(px), codepoint);
}
// 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);
@@ -260,13 +449,62 @@ PLOT_TEST(ttf_backend_bakes_the_requested_coverage) {
}
// 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.
// directory, a file that is not a font, an em size that is not positive or is
// absurd, and a bake whose coverage came out blank. Each reports through a
// `Status`, each leaves the object it was handed exactly as it was, and none of
// them takes the process with it.
//
// It has two halves on purpose. The rejections that need no face run against a
// default-constructed backend, whose untouched state is the fontless one -- so
// they are exercised on a machine with no system face at all, which is a machine
// this project supports. The stronger half -- a *loaded* font, with an atlas, a
// cell and glyph pointers that must survive a refused re-point -- needs a face, and
// says so by itself rather than taking the other half's coverage down with it.
PLOT_TEST(ttf_failure_paths_report_status) {
// --- without a face: what "untouched" means for an object with nothing to lose
plot::text::TrueTypeFont fontless;
const auto still_fontless = [&] {
return fontless.glyph('A') == nullptr && fontless.glyph(plot::render::kInvalidCodepoint) ==
nullptr &&
fontless.atlas_pixels() == nullptr && fontless.atlas_width() == 0u &&
fontless.atlas_height() == 0u && fontless.cell_width() == 0u &&
fontless.cell_height() == 0u && fontless.glyph_count() == 0u &&
fontless.revision() == 0u && fontless.em_px() == 0.0f &&
fontless.measure("A") == 0.0f;
};
PLOT_CHECK(still_fontless());
const auto refuses = [&](const char* what, const char* path, plot::f32 px) {
plot::Status failure;
PLOT_CHECK(!plot::text::TrueTypeFont::from_file(path, px, &fontless, &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(still_fontless());
std::printf("[fail-%s] %s\n", what, failure.message);
};
refuses("missing", "/nonexistent/plot/no-such-font.ttf", 20.0f);
refuses("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.
refuses("not-a-font", PLOT_SOURCE_DIR "/CMakeLists.txt", 20.0f);
refuses("zero-px", kAdwaitaMono, 0.0f);
refuses("negative-px", kAdwaitaMono, -3.0f);
// Past the em ceiling the rasteriser's own pixel arithmetic would leave `int`,
// so it is refused as out of range before it is scaled by anything.
refuses("beyond-the-em-ceiling", kAdwaitaMono, 1.0e8f);
// Nowhere to put the font is the caller's own mistake, refused before the path is
// even looked at -- which is why a path that does not exist still serves here.
plot::Status no_destination;
PLOT_CHECK(!plot::text::TrueTypeFont::from_file("/nonexistent/plot/no-such-font.ttf", 20.0f,
nullptr, &no_destination));
PLOT_CHECK(!static_cast<bool>(no_destination));
PLOT_CHECK(still_fontless());
// --- with a face: the state that must survive is a real one
if (!have_adwaita_mono()) {
skip("ttf_failure_paths_report_status", "no system Adwaita Mono to bake");
skip("ttf_failure_paths_report_status",
"no system Adwaita Mono, so the loaded-state half did not run");
return;
}
plot::text::TrueTypeFont font;
@@ -282,35 +520,127 @@ PLOT_TEST(ttf_failure_paths_report_status) {
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) {
const auto survives = [&](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);
std::printf("[fail-with-a-face-%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);
survives("missing", "/nonexistent/plot/no-such-font.ttf", 20.0f);
survives("directory", "/usr/share/fonts", 20.0f);
survives("not-a-font", PLOT_SOURCE_DIR "/CMakeLists.txt", 20.0f);
survives("zero-px", kAdwaitaMono, 0.0f);
survives("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));
survives("blank-bake", kAdwaitaMono, 0.1f);
PLOT_CHECK(!plot::text::TrueTypeFont::from_file(kAdwaitaMono, 20.0f, nullptr, &st));
PLOT_CHECK(unchanged());
}
// A face whose ink reaches *left* of the pen is the case a cell that started at
// the pen cannot hold: an italic or oblique design slants its outlines over the
// advance, so some glyphs' bitmap boxes begin at a negative x, and a bake that
// placed such a bitmap at the pen would drop the columns that fell outside --
// silently, because the bitmap the rasteriser hands over is already trimmed to its
// ink, so a lost column looks exactly like a column that was never there.
//
// Two things are asserted, and both fail against a bake without the left widening:
// every cell holds its glyph whole (bit for bit, against the rasteriser's own
// bitmap), and the bearings say so -- a negative `xoff` is how the pipeline knows
// to place the cell corner right of the pen. Which codepoints count as "left of
// the pen" is decided by the *rasteriser's* boxes, not by the bake's bearings:
// asking the code under test whether there was ink to clip would let a bake that
// clipped it also claim there was nothing to clip.
PLOT_TEST(ttf_negative_bearing_glyphs_keep_their_ink) {
constexpr const char* kCase = "ttf_negative_bearing_glyphs_keep_their_ink";
ReferenceRasteriser face;
const char* path = open_an_italic_face(&face);
if (path == nullptr) {
skip(kCase, "no italic face from the candidate list is installed");
return;
}
const plot::f32 px = 20.0f;
plot::text::TrueTypeFont font;
plot::Status st;
PLOT_CHECK(plot::text::TrueTypeFont::from_file(path, px, &font, &st));
const float scale = face.scale(px);
// Every covered codepoint, whole -- the same reference check the other cases
// make, on a face where a cell boundary has something to drop.
for (const plot::u32 codepoint : plot::text::TrueTypeFont::default_coverage()) {
expect_cell_holds_reference(font, face, scale, codepoint);
}
plot::u32 left_of_the_pen = 0;
for (const plot::u32 codepoint : plot::text::TrueTypeFont::default_coverage()) {
if (!face.has_glyph(codepoint)) {
continue;
}
plot::i32 x0 = 0;
plot::i32 y0 = 0;
plot::i32 x1 = 0;
plot::i32 y1 = 0;
face.box(codepoint, scale, &x0, &y0, &x1, &y1);
if (x0 >= 0) {
continue;
}
++left_of_the_pen;
const plot::render::Glyph* glyph = font.glyph(codepoint);
PLOT_CHECK(glyph != nullptr);
PLOT_CHECK(glyph->xoff < 0.0f); // the cell corner is right of the pen
// ... by at least the overhang the rasteriser reports, so no ink is left outside.
PLOT_CHECK(static_cast<plot::i32>(std::lround(-static_cast<double>(glyph->xoff))) >= -x0);
// And the cell still reaches past the advance, not into it: the pen's own step
// must stay inside the cell the rect declares.
PLOT_CHECK(static_cast<plot::f32>(font.cell_width()) + glyph->xoff >= glyph->advance);
}
if (left_of_the_pen == 0u) {
skip(kCase, "this italic face draws no covered glyph left of its pen");
return;
}
std::printf("[italic] %s: %u covered codepoints have ink left of the pen\n", path,
left_of_the_pen);
}
// The two bounds on a bake's size, both of which refuse *before* allocating: an
// atlas that would not fit is a bad argument rather than an allocation failure,
// and an em past what this backend will rasterise is refused rather than narrowed
// into an integer it does not fit (see kMaxEmPx in src/text/true_type_font.cpp).
// The `InvalidArgument` code is what pins the order -- a bake that got as far as
// allocating would report `OutOfMemory` instead.
PLOT_TEST(ttf_refuses_a_bake_it_cannot_hold) {
constexpr const char* kCase = "ttf_refuses_a_bake_it_cannot_hold";
if (!have_adwaita_mono()) {
skip(kCase, "no system Adwaita Mono to bake");
return;
}
plot::text::TrueTypeFont font;
plot::Status st;
// A million-pixel em is a legal size and an impossible atlas: 101 cells of about
// 5.1e5 x 1e6 texels. Refused as an argument, not as 5.1e13 bytes that failed to
// allocate.
PLOT_CHECK(!plot::text::TrueTypeFont::from_file(kAdwaitaMono, 1.0e6f, &font, &st));
PLOT_CHECK(st.code == plot::ErrorCode::InvalidArgument);
PLOT_CHECK(!static_cast<bool>(st));
PLOT_CHECK(font.atlas_pixels() == nullptr);
PLOT_CHECK(font.glyph_count() == 0u);
std::printf("[bounds-1000000px] %s\n", st.message);
// Past the em ceiling, before any of that arithmetic runs.
PLOT_CHECK(!plot::text::TrueTypeFont::from_file(kAdwaitaMono, 1.0e8f, &font, &st));
PLOT_CHECK(st.code == plot::ErrorCode::InvalidArgument);
std::printf("[bounds-100000000px] %s\n", st.message);
// And neither bound is over-eager: the sizes a plot really draws at still bake,
// including one well past the embedded face's 13 px and the 20 px this suite
// bakes elsewhere.
PLOT_CHECK(plot::text::TrueTypeFont::from_file(kAdwaitaMono, 96.0f, &font, &st));
PLOT_CHECK(font.cell_width() > 0u);
PLOT_CHECK(font.glyph_count() == 101u);
PLOT_CHECK(font.atlas_pixels() != nullptr);
}
// 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