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

+63 -20
View File
@@ -10,11 +10,15 @@
// 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.
// never leave the cell, and neither may a glyph's ink, in *either* direction: the
// cell also steps right of the pen by the deepest backward overhang any covered
// glyph has, because an italic glyph's ink starts left of its pen, and a cell
// whose left edge was the pen would clip that overhang away without saying so.
// The cell is 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, and the
// baseline sits `ascent()` below its top, so every glyph's ink is blitted at the
// offset its bitmap box reports from the pen, into a cell it cannot leave, and
// every rect stays one size.
#include <plot/text/true_type_font.hpp>
@@ -46,6 +50,21 @@ constexpr f32 kFallbackInnerY0 = 0.0f;
constexpr f32 kFallbackInnerX1 = 480.0f;
constexpr f32 kFallbackInnerY1 = 600.0f;
/// The largest texel extent and the largest em this backend will compute with.
///
/// Both exist to keep a float-to-integer conversion inside its target's range --
/// [conv.fpint] makes one outside it undefined, and `from_file()` accepts any
/// positive `px`, so without a bound a caller could hand the bake a number whose
/// floor or ceiling does not fit in the `int`/`u32` the cell is computed in. The
/// em ceiling is the tighter one on purpose: it also keeps the *vendored*
/// rasteriser's own pixel arithmetic inside `int`, since stb scales a glyph's
/// outline by the same factor and narrows it to pixels itself. A million-pixel em
/// is already a thousand times any legible bake, so nothing real is refused: an
/// over-sized request still gets a `Status`, from the atlas-size check rather than
/// from here (see `bake_face`).
constexpr f32 kMaxTexels = 1.0e9f;
constexpr f32 kMaxEmPx = 1.0e7f;
/// 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 {
@@ -153,9 +172,13 @@ u32 blit_cell(std::vector<u8>& atlas, const Bake& bake, u32 slot, const u8* bitm
/// 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)); };
/// `.notdef` too. It is placed in the same coordinates the glyphs are -- font
/// units from the pen, into a cell that sits `shift` texels right of it.
void blit_fallback_box(std::vector<u8>& atlas, const Bake& bake, u32 slot, f32 scale,
u32 shift) noexcept {
const auto to_x = [scale, shift](f32 units) {
return static_cast<i32>(std::lround(units * scale)) + static_cast<i32>(shift);
};
const auto to_y = [&bake, scale](f32 units) {
return bake.baseline - static_cast<i32>(std::lround(units * scale));
};
@@ -212,6 +235,7 @@ bool bake_face(const stbtt_fontinfo& info, f32 scale, std::span<const u32> cover
f32 widest_advance = 0.0f;
i32 highest = 0;
i32 lowest = 0;
i32 leftmost = 0;
i32 rightmost = 0;
for (const u32 codepoint : requested) {
if (codepoint > 0x10FFFFu) {
@@ -233,6 +257,7 @@ bool bake_face(const stbtt_fontinfo& info, f32 scale, std::span<const u32> cover
widest_advance = std::max(widest_advance, advance);
highest = std::min(highest, y0);
lowest = std::max(lowest, y1);
leftmost = std::min(leftmost, x0);
rightmost = std::max(rightmost, x1);
drawable.push_back(Drawable{codepoint, advance});
}
@@ -241,16 +266,26 @@ bool bake_face(const stbtt_fontinfo& info, f32 scale, std::span<const u32> cover
// 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. Each extent is
// clamped before it is narrowed to texels: a float-to-unsigned conversion of a
// negative value is undefined, and a face whose metrics or ink are degenerate
// enough to produce one must land on a one-texel cell, not on undefined
// behaviour.
// clamped at both ends before it is narrowed to texels: a float-to-integer
// conversion outside the target type's range is undefined, and neither a
// degenerate face nor an absurd em size may reach that -- they land on a
// one-texel cell or on the atlas-size refusal below instead.
const auto ceil_to_texels = [](f32 value) {
return static_cast<u32>(std::ceil(std::max(0.0f, value - 1e-4f)));
return static_cast<u32>(std::ceil(std::min(std::max(0.0f, value - 1e-4f), kMaxTexels)));
};
const u32 cell_w =
std::max<u32>(1u, ceil_to_texels(std::max(widest_advance, static_cast<f32>(rightmost))));
i32 baseline = static_cast<i32>(std::floor(ascent_f));
// The cell sits `shift` texels right of the pen, because the pen is where the
// *advance* starts and not where the ink does: an italic glyph reaches left of
// its pen (`U+006A` in half the italic faces on a stock Linux box), and a cell
// whose left edge was the pen would drop that overhang -- silently, since the
// trimmed bitmap stb returns is exactly what would be clipped. Widening the
// same way the right edge is widened is what makes the rect uniform *and*
// lossless; `-shift` is then the non-positive bearing `glyph()` reports, and
// the pipeline places the cell corner at the pen plus that.
const u32 shift =
leftmost < 0 ? static_cast<u32>(-static_cast<std::int64_t>(leftmost)) : 0u;
const u32 cell_w = std::max<u32>(
1u, shift + ceil_to_texels(std::max(widest_advance, static_cast<f32>(rightmost))));
i32 baseline = static_cast<i32>(std::floor(std::min(ascent_f, kMaxTexels)));
baseline = std::max(baseline, -highest); // room for the tallest ink above the pen
baseline = std::max(baseline, 0);
const u32 below = static_cast<u32>(std::max(0, baseline + lowest));
@@ -288,7 +323,7 @@ bool bake_face(const stbtt_fontinfo& info, f32 scale, std::span<const u32> cover
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};
return render::Glyph{u0, 0.0f, u0 + du, dv, -static_cast<f32>(shift), 0.0f, advance};
};
// Pass two: rasterise and blit. The offset `stbtt_GetCodepointBitmap` reports is
@@ -304,8 +339,8 @@ bool bake_face(const stbtt_fontinfo& info, f32 scale, std::span<const u32> cover
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);
inked += blit_cell(out->atlas, *out, static_cast<u32>(i), bitmap, width, height,
x + static_cast<i32>(shift), baseline + y);
stbtt_FreeBitmap(bitmap, nullptr);
}
out->codepoints.push_back(glyph.codepoint);
@@ -321,7 +356,7 @@ bool bake_face(const stbtt_fontinfo& info, f32 scale, std::span<const u32> cover
// 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);
blit_fallback_box(out->atlas, *out, fallback_slot, scale, shift);
out->fallback = glyph_for_slot(fallback_slot, widest_advance);
return true;
}
@@ -380,6 +415,14 @@ bool TrueTypeFont::from_file(const char* utf8_path, f32 px, TrueTypeFont* out, S
return fail(st, ErrorCode::InvalidArgument,
"a font em size must be a positive, finite number of pixels");
}
if (px > kMaxEmPx) {
// Not merely pedantry: the rasteriser scales outlines by this number and
// narrows them to pixels itself, so an em past this bound would overflow
// arithmetic this backend does not control. A size this large is not a font
// size, and saying so beats undefined behaviour (see kMaxEmPx).
return fail(st, ErrorCode::InvalidArgument,
"the font em size is larger than this backend will rasterise");
}
std::error_code ec;
const std::filesystem::path path{utf8_path};