diff --git a/implementations/Crafter.Graphics-Font.cpp b/implementations/Crafter.Graphics-Font.cpp index 99f62c3..6b310c8 100644 --- a/implementations/Crafter.Graphics-Font.cpp +++ b/implementations/Crafter.Graphics-Font.cpp @@ -56,6 +56,23 @@ Font::Font(const std::filesystem::path& fontFilePath) { this->ascent = ascent; this->descent = descent; this->lineGap = lineGap; + + asciiAdvance_.fill(-1); +} + +std::int32_t Font::AdvanceUnits(std::uint32_t cp) { + if (cp < asciiAdvance_.size()) { + if (asciiAdvance_[cp] < 0) { + int advance = 0, lsb = 0; + stbtt_GetCodepointHMetrics(&font, static_cast(cp), &advance, &lsb); + asciiAdvance_[cp] = advance; + } + return asciiAdvance_[cp]; + } + if (auto it = advanceUnits_.find(cp); it != advanceUnits_.end()) return it->second; + int advance = 0, lsb = 0; + stbtt_GetCodepointHMetrics(&font, static_cast(cp), &advance, &lsb); + return advanceUnits_.emplace(cp, advance).first->second; } std::uint32_t Font::GetLineWidth(const std::string_view text, float size) { @@ -65,9 +82,7 @@ std::uint32_t Font::GetLineWidth(const std::string_view text, float size) { while (i < text.size()) { std::uint32_t cp = DecodeUtf8(text, i); if (cp == 0) break; - int advance, lsb; - stbtt_GetCodepointHMetrics(&font, static_cast(cp), &advance, &lsb); - lineWidth += (int)(advance * scale); + lineWidth += (int)(AdvanceUnits(cp) * scale); } return lineWidth; } @@ -82,9 +97,7 @@ std::size_t Font::NearestCursorByte(std::string_view text, float size, float tar while (i < text.size()) { std::uint32_t cp = DecodeUtf8(text, i); // i advances to the next boundary if (cp == 0) break; - int advance, lsb; - stbtt_GetCodepointHMetrics(&font, static_cast(cp), &advance, &lsb); - cumWidth += (int)(advance * scale); // match GetLineWidth's per-glyph rounding + cumWidth += (int)(AdvanceUnits(cp) * scale); // match GetLineWidth's per-glyph rounding float d = std::abs(target - cumWidth); if (d < bestDist) { bestDist = d; diff --git a/interfaces/Crafter.Graphics-Font.cppm b/interfaces/Crafter.Graphics-Font.cppm index 14710d9..759c98b 100644 --- a/interfaces/Crafter.Graphics-Font.cppm +++ b/interfaces/Crafter.Graphics-Font.cppm @@ -72,5 +72,17 @@ namespace Crafter { float LineHeight(float size); float AscentPx(float size); float ScaleForSize(float size); + + // Horizontal advance for `cp` in unscaled font units, cached per Font. + // Stored in font units (not pixels) so it can be rescaled for any + // `size`; Glyph::advance in the FontAtlas is baked at kBaseSize and is + // wrong at other sizes, so it cannot be reused here. ASCII lands in a + // flat array (the common path for caret hit-testing, which rescans the + // whole field per character), everything else in the map. + std::int32_t AdvanceUnits(std::uint32_t cp); + + private: + std::array asciiAdvance_; // -1 = uncached + std::unordered_map advanceUnits_; }; } diff --git a/project.cpp b/project.cpp index c086c3e..24d56eb 100644 --- a/project.cpp +++ b/project.cpp @@ -442,6 +442,35 @@ extern "C" Configuration CrafterBuildProject(std::span a fc.GetInterfacesAndImplementations(ifaces, flushImpls); cfg.tests.push_back(std::move(flushTest)); + // Issue #57: Font::GetLineWidth memoises per-codepoint advances in + // font units (Font::AdvanceUnits) and rescales per call, instead of + // calling stbtt_GetCodepointHMetrics for every glyph on every caret + // query. Pure CPU — Font only touches stb_truetype — so this drives + // the public API directly with no Vulkan device. The font file is + // copied next to the binary; the test also probes the project root. + Test advTest; + Configuration& vc = advTest.config; + vc.path = cfg.path; + vc.name = "FontAdvanceCache"; + vc.outputName = "FontAdvanceCache"; + vc.type = ConfigurationType::Executable; + vc.target = cfg.target; + vc.march = cfg.march; + vc.mtune = cfg.mtune; + vc.debug = cfg.debug; + vc.sysroot = cfg.sysroot; + vc.dependencies = cfg.dependencies; + vc.externalDependencies = cfg.externalDependencies; + vc.compileFlags = cfg.compileFlags; + vc.linkFlags = cfg.linkFlags; + vc.defines = cfg.defines; + vc.cFiles = cfg.cFiles; + vc.files = { fs::path("tests/FontAdvanceCache/font.ttf") }; + std::vector advImpls(impls.begin(), impls.end()); + advImpls.emplace_back("tests/FontAdvanceCache/main"); + vc.GetInterfacesAndImplementations(ifaces, advImpls); + cfg.tests.push_back(std::move(advTest)); + // Issue #50: uiResolveScreenPixel now gates its per-pixel clip-rect // compares on the reserved kUIFlagClip bit, which FillHeader sets only // when the clip rect is narrower than the surface. The decision lives diff --git a/tests/FontAdvanceCache/font.ttf b/tests/FontAdvanceCache/font.ttf new file mode 100644 index 0000000..f27f4ff Binary files /dev/null and b/tests/FontAdvanceCache/font.ttf differ diff --git a/tests/FontAdvanceCache/main.cpp b/tests/FontAdvanceCache/main.cpp new file mode 100644 index 0000000..d123790 --- /dev/null +++ b/tests/FontAdvanceCache/main.cpp @@ -0,0 +1,144 @@ +/* +Crafter®.Graphics +Copyright (C) 2026 Catcrafts® +catcrafts.net + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License version 3.0 as published by the Free Software Foundation; + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +// 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; +}