VisuTwin Canvas
C++ 3D Engine — Metal Backend
Loading...
Searching...
No Matches
fontResource.cpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2025-2026 Arnis Lektauers
3#include "fontResource.h"
4
5#include <algorithm>
6#include <cctype>
7#include <cmath>
8#include <fstream>
9#include <memory>
10#include <optional>
11#include <string>
12
13#include <stb_image.h>
14#include <spdlog/spdlog.h>
15
17
18namespace visutwin::canvas
19{
20 namespace
21 {
22 bool parseNumberField(const std::string& block, const std::string& key, float& out)
23 {
24 const std::string marker = "\"" + key + "\":";
25 const size_t p = block.find(marker);
26 if (p == std::string::npos) {
27 return false;
28 }
29 size_t i = p + marker.size();
30 while (i < block.size() && std::isspace(static_cast<unsigned char>(block[i]))) {
31 i++;
32 }
33 size_t j = i;
34 while (j < block.size()) {
35 const char c = block[j];
36 if ((c >= '0' && c <= '9') || c == '-' || c == '+' || c == '.' || c == 'e' || c == 'E') {
37 j++;
38 } else {
39 break;
40 }
41 }
42 if (j <= i) {
43 return false;
44 }
45 out = std::stof(block.substr(i, j - i));
46 return true;
47 }
48
49 bool parseIntField(const std::string& block, const std::string& key, int& out)
50 {
51 float v = 0.0f;
52 if (!parseNumberField(block, key, v)) {
53 return false;
54 }
55 out = static_cast<int>(std::lround(v));
56 return true;
57 }
58
59 std::optional<std::string> readTextFile(const std::string& path)
60 {
61 std::ifstream input(path, std::ios::binary);
62 if (!input.is_open()) {
63 return std::nullopt;
64 }
65 std::string data((std::istreambuf_iterator<char>(input)), std::istreambuf_iterator<char>());
66 return data;
67 }
68
69 std::string replaceExtensionWithPng(const std::string& path)
70 {
71 const size_t dot = path.find_last_of('.');
72 if (dot == std::string::npos) {
73 return path + ".png";
74 }
75 return path.substr(0, dot) + ".png";
76 }
77
78 std::optional<size_t> findMatchingBrace(const std::string& text, const size_t openPos)
79 {
80 if (openPos >= text.size() || text[openPos] != '{') {
81 return std::nullopt;
82 }
83 int depth = 0;
84 bool inString = false;
85 bool escaped = false;
86 for (size_t i = openPos; i < text.size(); ++i) {
87 const char c = text[i];
88 if (inString) {
89 if (escaped) {
90 escaped = false;
91 continue;
92 }
93 if (c == '\\') {
94 escaped = true;
95 continue;
96 }
97 if (c == '"') {
98 inString = false;
99 }
100 continue;
101 }
102
103 if (c == '"') {
104 inString = true;
105 continue;
106 }
107 if (c == '{') {
108 depth++;
109 } else if (c == '}') {
110 depth--;
111 if (depth == 0) {
112 return i;
113 }
114 }
115 }
116 return std::nullopt;
117 }
118
119 bool parseStrictInt(const std::string& token, int& out)
120 {
121 if (token.empty()) {
122 return false;
123 }
124 for (const char c : token) {
125 if (c < '0' || c > '9') {
126 return false;
127 }
128 }
129 try {
130 out = std::stoi(token);
131 return true;
132 } catch (...) {
133 return false;
134 }
135 }
136
137 float median3(const float a, const float b, const float c)
138 {
139 return std::max(std::min(a, b), std::min(std::max(a, b), c));
140 }
141 }
142
143 // DEVIATION: parser is a lightweight JSON-string scanner for bitmap-font schema.
144 std::optional<FontResource*> loadBitmapFontResource(const std::string& jsonPath,
145 const std::shared_ptr<GraphicsDevice>& graphicsDevice)
146 {
147 if (!graphicsDevice) {
148 return std::nullopt;
149 }
150
151 const auto jsonText = readTextFile(jsonPath);
152 if (!jsonText.has_value()) {
153 return std::nullopt;
154 }
155 const std::string& text = *jsonText;
156
157 auto* font = new FontResource();
158
159 {
160 const std::string mapMarker = "\"maps\":[{";
161 const size_t mapPos = text.find(mapMarker);
162 if (mapPos != std::string::npos) {
163 const size_t mapStart = text.find('{', mapPos);
164 const size_t mapEnd = text.find('}', mapStart);
165 if (mapStart != std::string::npos && mapEnd != std::string::npos && mapEnd > mapStart) {
166 const std::string mapBlock = text.substr(mapStart, mapEnd - mapStart + 1);
167 parseIntField(mapBlock, "width", font->atlasWidth);
168 parseIntField(mapBlock, "height", font->atlasHeight);
169 }
170 }
171 }
172
173 {
174 const std::string charsMarker = "\"chars\":{";
175 const size_t charsPos = text.find(charsMarker);
176 if (charsPos != std::string::npos) {
177 const size_t objStart = text.find('{', charsPos);
178 const auto objEndOpt = objStart != std::string::npos ? findMatchingBrace(text, objStart) : std::nullopt;
179 if (!objEndOpt.has_value() || *objEndOpt <= objStart) {
180 delete font;
181 return std::nullopt;
182 }
183 const std::string charsBlock = text.substr(objStart + 1, *objEndOpt - objStart - 1);
184
185 size_t p = 0;
186 while (true) {
187 const size_t keyStart = charsBlock.find('"', p);
188 if (keyStart == std::string::npos) break;
189 const size_t keyEnd = charsBlock.find('"', keyStart + 1);
190 if (keyEnd == std::string::npos) break;
191 int charId = 0;
192 if (!parseStrictInt(charsBlock.substr(keyStart + 1, keyEnd - keyStart - 1), charId)) {
193 p = keyEnd + 1;
194 continue;
195 }
196 const size_t glyphObjStart = charsBlock.find('{', keyEnd);
197 if (glyphObjStart == std::string::npos) break;
198 const auto glyphObjEndOpt = findMatchingBrace(charsBlock, glyphObjStart);
199 if (!glyphObjEndOpt.has_value() || *glyphObjEndOpt <= glyphObjStart) break;
200
201 const std::string block = charsBlock.substr(glyphObjStart, *glyphObjEndOpt - glyphObjStart + 1);
202 FontGlyph glyph{};
203 glyph.id = charId;
204 parseNumberField(block, "x", glyph.x);
205 parseNumberField(block, "y", glyph.y);
206 parseNumberField(block, "width", glyph.width);
207 parseNumberField(block, "height", glyph.height);
208 parseNumberField(block, "xadvance", glyph.xadvance);
209 parseNumberField(block, "xoffset", glyph.xoffset);
210 parseNumberField(block, "yoffset", glyph.yoffset);
211 font->glyphs[glyph.id] = glyph;
212
213 font->lineHeight = std::max(font->lineHeight, glyph.height);
214 p = *glyphObjEndOpt + 1;
215 }
216 }
217 }
218
219 {
220 const std::string kerningMarker = "\"kerning\":{";
221 const size_t kernPos = text.find(kerningMarker);
222 if (kernPos != std::string::npos) {
223 const size_t objStart = text.find('{', kernPos);
224 const auto objEndOpt = objStart != std::string::npos ? findMatchingBrace(text, objStart) : std::nullopt;
225 if (objEndOpt.has_value() && *objEndOpt > objStart) {
226 const std::string block = text.substr(objStart, *objEndOpt - objStart + 1);
227 size_t p = 0;
228 while (true) {
229 const size_t k1 = block.find('"', p);
230 if (k1 == std::string::npos) break;
231 const size_t k2 = block.find('"', k1 + 1);
232 if (k2 == std::string::npos) break;
233 int left = 0;
234 if (!parseStrictInt(block.substr(k1 + 1, k2 - k1 - 1), left)) {
235 p = k2 + 1;
236 continue;
237 }
238 const size_t subObjStart = block.find('{', k2);
239 if (subObjStart == std::string::npos) break;
240 const auto subObjEndOpt = findMatchingBrace(block, subObjStart);
241 if (!subObjEndOpt.has_value()) break;
242 const size_t subObjEnd = *subObjEndOpt;
243 const std::string sub = block.substr(subObjStart, subObjEnd - subObjStart + 1);
244
245 size_t q = 0;
246 while (true) {
247 const size_t r1 = sub.find('"', q);
248 if (r1 == std::string::npos) break;
249 const size_t r2 = sub.find('"', r1 + 1);
250 if (r2 == std::string::npos) break;
251 int right = 0;
252 if (!parseStrictInt(sub.substr(r1 + 1, r2 - r1 - 1), right)) {
253 q = r2 + 1;
254 continue;
255 }
256 float value = 0.0f;
257 const std::string numKey = "\"" + std::to_string(right) + "\":";
258 const size_t vPos = sub.find(numKey, r2);
259 if (vPos != std::string::npos) {
260 const size_t nStart = vPos + numKey.size();
261 size_t nEnd = nStart;
262 while (nEnd < sub.size()) {
263 const char c = sub[nEnd];
264 if ((c >= '0' && c <= '9') || c == '-' || c == '+' || c == '.') {
265 nEnd++;
266 } else {
267 break;
268 }
269 }
270 if (nEnd > nStart) {
271 value = std::stof(sub.substr(nStart, nEnd - nStart));
272 const uint64_t key = (static_cast<uint64_t>(static_cast<uint32_t>(left)) << 32u) |
273 static_cast<uint32_t>(right);
274 font->kerning[key] = value;
275 }
276 }
277 q = r2 + 1;
278 }
279 p = subObjEnd + 1;
280 }
281 }
282 }
283 }
284
285 const std::string atlasPath = replaceExtensionWithPng(jsonPath);
286 int w = 0;
287 int h = 0;
288 int channels = 0;
289 stbi_set_flip_vertically_on_load(false);
290 stbi_uc* pixels = stbi_load(atlasPath.c_str(), &w, &h, &channels, STBI_rgb_alpha);
291 if (!pixels || w <= 0 || h <= 0) {
292 delete font;
293 if (pixels) {
294 stbi_image_free(pixels);
295 }
296 return std::nullopt;
297 }
298
299 const size_t pixelCount = static_cast<size_t>(w) * static_cast<size_t>(h);
300 const bool likelySdfMsdf = text.find("\"range\":") != std::string::npos;
301
302 // Current renderer path is bitmap-style. If the font looks like SDF/MSDF
303 // (upstream courier does), pre-bake distance field into alpha coverage.
304 if (likelySdfMsdf) {
305 size_t midCountPos = 0;
306 size_t midCountNeg = 0;
307 std::vector<uint8_t> alphaPos(pixelCount);
308 std::vector<uint8_t> alphaNeg(pixelCount);
309 for (size_t i = 0; i < pixelCount; ++i) {
310 const float r = static_cast<float>(pixels[i * 4u + 0u]) / 255.0f;
311 const float g = static_cast<float>(pixels[i * 4u + 1u]) / 255.0f;
312 const float b = static_cast<float>(pixels[i * 4u + 2u]) / 255.0f;
313 const float sd = median3(r, g, b) - 0.5f;
314 const float covPos = std::clamp(sd * 8.0f + 0.5f, 0.0f, 1.0f);
315 const float covNeg = std::clamp((-sd) * 8.0f + 0.5f, 0.0f, 1.0f);
316 const uint8_t aPos = static_cast<uint8_t>(std::lround(covPos * 255.0f));
317 const uint8_t aNeg = static_cast<uint8_t>(std::lround(covNeg * 255.0f));
318 alphaPos[i] = aPos;
319 alphaNeg[i] = aNeg;
320 if (aPos > 10 && aPos < 245) {
321 midCountPos++;
322 }
323 if (aNeg > 10 && aNeg < 245) {
324 midCountNeg++;
325 }
326 }
327 const bool useNeg = midCountNeg > midCountPos;
328 for (size_t i = 0; i < pixelCount; ++i) {
329 pixels[i * 4u + 0u] = 255;
330 pixels[i * 4u + 1u] = 255;
331 pixels[i * 4u + 2u] = 255;
332 pixels[i * 4u + 3u] = useNeg ? alphaNeg[i] : alphaPos[i];
333 }
334 spdlog::info(
335 "Font atlas '{}' detected as SDF/MSDF; baked {} signed-distance coverage to alpha (mid-pos={}, mid-neg={}).",
336 atlasPath,
337 useNeg ? "negative" : "positive",
338 midCountPos,
339 midCountNeg
340 );
341 }
342
343 // Fallback: some atlases still have flat alpha; lift coverage from RGB.
344 uint8_t minA = 255;
345 uint8_t maxA = 0;
346 for (size_t i = 0; i < pixelCount; ++i) {
347 const uint8_t a = pixels[i * 4u + 3u];
348 minA = std::min(minA, a);
349 maxA = std::max(maxA, a);
350 }
351 if (minA == maxA) {
352 for (size_t i = 0; i < pixelCount; ++i) {
353 const uint8_t r = pixels[i * 4u + 0u];
354 const uint8_t g = pixels[i * 4u + 1u];
355 const uint8_t b = pixels[i * 4u + 2u];
356 const uint8_t cov = std::max(r, std::max(g, b));
357 pixels[i * 4u + 0u] = 255;
358 pixels[i * 4u + 1u] = 255;
359 pixels[i * 4u + 2u] = 255;
360 pixels[i * 4u + 3u] = cov;
361 }
362 spdlog::info("Font atlas '{}' had flat alpha; applied RGB->alpha bitmap fallback.", atlasPath);
363 }
364
365 uint8_t outMinA = 255;
366 uint8_t outMaxA = 0;
367 for (size_t i = 0; i < pixelCount; ++i) {
368 const uint8_t a = pixels[i * 4u + 3u];
369 outMinA = std::min(outMinA, a);
370 outMaxA = std::max(outMaxA, a);
371 }
372 spdlog::info("Font atlas '{}' output alpha range: {}..{}", atlasPath, outMinA, outMaxA);
373
374 TextureOptions options;
375 options.width = static_cast<uint32_t>(w);
376 options.height = static_cast<uint32_t>(h);
378 options.mipmaps = false;
381 options.numLevels = 1;
382 options.name = "font-atlas";
383
384 auto* texture = new Texture(graphicsDevice.get(), options);
385 texture->setEncoding(TextureEncoding::Default);
386 const size_t dataSize = pixelCount * 4u;
387 texture->setLevelData(0, reinterpret_cast<const uint8_t*>(pixels), dataSize);
388 texture->upload();
389 stbi_image_free(pixels);
390
391 font->texture = texture;
392 if (font->atlasWidth <= 0) font->atlasWidth = w;
393 if (font->atlasHeight <= 0) font->atlasHeight = h;
394 if (font->lineHeight <= 0.0f) font->lineHeight = 64.0f;
395 spdlog::info("Loaded bitmap font '{}': atlas={}x{}, glyphs={}, kerning={}",
396 jsonPath, font->atlasWidth, font->atlasHeight, font->glyphs.size(), font->kerning.size());
397 return font;
398 }
399}
GPU texture resource supporting 2D, cubemap, volume, and array formats with mipmap management.
Definition texture.h:57
std::optional< FontResource * > loadBitmapFontResource(const std::string &jsonPath, const std::shared_ptr< GraphicsDevice > &graphicsDevice)