//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 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(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('A')); font.GetLineWidth(text, 6.0f); std::int32_t aSmall = font.AdvanceUnits(static_cast('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(font.AdvanceUnits(0x00E9)); Check(static_cast(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; }