Crafter.Graphics/tests/FontAdvanceCache/main.cpp

128 lines
5.8 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Issue #57: Font::GetLineWidth used to call stbtt_GetCodepointHMetrics for
// every glyph on every call. Caret hit-testing (InputField_HitTestCursor)
// rescans the whole field once per character, so that is O(n²) HMetrics calls.
// Advances are now memoised per Font in *font units* (Font::AdvanceUnits):
// ASCII in a flat array, the rest in a map, rescaled per call.
//
// The cache must be in unscaled units, NOT pixels — the FontAtlas's
// Glyph::advance is baked at kBaseSize and is wrong at any other size, so it
// cannot be reused here. What is asserted:
//
// - AdvanceUnits is size-independent: the value for a codepoint is identical
// no matter which size was queried first (proves it stores units, not px).
// - GetLineWidth equals the documented pipeline: sum of per-glyph
// (int)(units * scaleForSize), with the truncation applied PER GLYPH.
// - Repeated calls (cache hits) are byte-identical to the first (miss).
// - Width scales with size — a 2× size is wider, ruling out a baked-at-one-
// size advance that would make every size render the same width.
// - The empty string is zero.
// - Codepoints above ASCII (the map path) behave like the array path.
//
// Pure CPU: Font only touches stb_truetype, so no Vulkan device is needed.
#include <cstdlib>
import Crafter.Graphics;
import std;
using namespace Crafter;
namespace {
int failures = 0;
void Check(bool ok, std::string_view what) {
std::println("{} {}", ok ? "PASS" : "FAIL", what);
if (!ok) ++failures;
}
std::filesystem::path FindFont() {
for (const char* cand : {
"font.ttf",
"tests/FontAdvanceCache/font.ttf",
"../../examples/HelloUI/font.ttf" }) {
if (std::filesystem::exists(cand)) return cand;
}
return "font.ttf";
}
// The exact contract GetLineWidth must honour: truncate each glyph's scaled
// advance to int independently, then accumulate. Rescaling the cached unit
// advance per call is what keeps it correct at sizes other than kBaseSize.
std::uint32_t ExpectedWidth(Font& font, std::string_view text, float size) {
float scale = font.ScaleForSize(size);
std::uint32_t w = 0;
std::size_t i = 0;
while (i < text.size()) {
std::uint32_t cp = DecodeUtf8(text, i);
if (cp == 0) break;
w += static_cast<int>(font.AdvanceUnits(cp) * scale);
}
return w;
}
} // namespace
int main() {
Font font(FindFont());
const std::string_view text = "Hello, World!";
// ── 1. AdvanceUnits is size-independent (stored in font units). ─────────
// Prime the cache via a large size, capture the unit advance, then prime
// again via a tiny size. The unit value must not move — if the cache stored
// pixels it would differ wildly between the two priming sizes.
font.GetLineWidth(text, 96.0f);
std::int32_t aBig = font.AdvanceUnits(static_cast<std::uint32_t>('A'));
font.GetLineWidth(text, 6.0f);
std::int32_t aSmall = font.AdvanceUnits(static_cast<std::uint32_t>('A'));
Check(aBig == aSmall && aBig > 0,
"AdvanceUnits('A') is a stable, positive font-unit value across sizes");
// ── 2. GetLineWidth matches the per-glyph-truncated, rescaled pipeline. ─
bool pipelineOk = true;
for (float size : {6.0f, 11.0f, 18.0f, 32.0f, 64.0f, 100.0f}) {
std::uint32_t got = font.GetLineWidth(text, size);
std::uint32_t exp = ExpectedWidth(font, text, size);
if (got != exp) {
pipelineOk = false;
std::println(" size {}: got {} expected {}", size, got, exp);
}
}
Check(pipelineOk, "GetLineWidth == sum of per-glyph (int)(units * scale) at every size");
// ── 3. Cache hits are byte-identical to the first (miss) call. ──────────
std::uint32_t first = font.GetLineWidth(text, 18.0f);
bool stable = true;
for (int k = 0; k < 8; ++k) stable = stable && (font.GetLineWidth(text, 18.0f) == first);
Check(stable && first > 0, "repeated GetLineWidth calls are identical (cache hit == miss)");
// ── 4. Width scales with size — not baked at a single size. ─────────────
std::uint32_t w18 = font.GetLineWidth(text, 18.0f);
std::uint32_t w36 = font.GetLineWidth(text, 36.0f);
Check(w36 > w18 && w36 > w18 + (w18 / 2),
"doubling the size roughly doubles the width (advance is rescaled, not baked)");
// ── 5. Empty string is zero. ────────────────────────────────────────────
Check(font.GetLineWidth("", 18.0f) == 0, "empty string has zero width");
// ── 6. Non-ASCII codepoints (the map path) behave like the array path. ──
// U+00E9 'é' (UTF-8 0xC3 0xA9) is > 127 so it lands in advanceUnits_, not
// the flat ASCII array. Its advance must be stable and a prefix narrower
// than the whole.
const std::string_view accented = "café"; // 'é' is two UTF-8 bytes
std::uint32_t accWidth = font.GetLineWidth(accented, 24.0f);
std::uint32_t accExp = ExpectedWidth(font, accented, 24.0f);
Check(accWidth == accExp && accWidth > font.GetLineWidth("caf", 24.0f),
"non-ASCII codepoint goes through the map path and widens the line");
std::uint32_t eFirst = static_cast<std::uint32_t>(font.AdvanceUnits(0x00E9));
Check(static_cast<std::uint32_t>(font.AdvanceUnits(0x00E9)) == eFirst,
"AdvanceUnits for a mapped codepoint is stable across calls");
if (failures == 0) std::println("\nAll font advance-cache checks passed.");
else std::println("\n{} font advance-cache check(s) FAILED.", failures);
return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
}