From 9bc156d63906aa1c42671c8acfda64a59945c111 Mon Sep 17 00:00:00 2001 From: Nils Schimmelmann Date: Tue, 15 Sep 2026 21:32:56 -0500 Subject: [PATCH] Replace pre-baked bitmap fonts with a runtime glyph atlas The map font was three pre-baked Cantarell BMFont atlases (18/27/36px), picked by device pixel ratio, limited to Latin-1. Replace them with a streaming glyph cache (src/font/GlyphAtlas) that rasterizes glyphs on demand from any installed QFont at the exact physical pixel size they are drawn at. - Text is shaped with QTextLayout, so kerning, ligatures and font fallback (color emoji, non-Latin scripts) come for free; callers now pass UTF-8. - Glyphs are rasterized with full hinting via QPainter::drawGlyphRun into a shelf-packed RGBA8 atlas; monochrome glyphs store coverage in alpha and color glyphs (detected per glyph) store straight RGBA in the same texture, discriminated by a per-vertex flag. - The atlas starts at 256^2 and doubles on demand up to min(2048, GL_MAX_TEXTURE_SIZE). Texture coordinates are atlas texels normalized in the vertex shader, so growing the texture in place never invalidates cached text meshes. Dirty regions are uploaded with glTexSubImage2D before a mesh is created or drawn; worker threads only touch the CPU-side image under a mutex. - The font is re-initialized (and text meshes rebuilt) whenever its spec goes stale: family/point size from the preferences, logical DPI, or device pixel ratio. - "Map font point size" is a real point size (default 11pt, matching the old Cantarell18 look); the embedded Cantarell-Regular.ttf remains the default family. Co-Authored-By: Claude Opus 5 --- src/CMakeLists.txt | 2 + src/configuration/NamedConfig.h | 2 +- src/configuration/configuration.cpp | 7 + src/configuration/configuration.h | 2 + src/display/Characters.cpp | 2 +- src/display/Connections.cpp | 2 +- src/display/Infomarks.cpp | 3 +- src/display/MapCanvasWindow.h | 6 + src/display/mapcanvas.cpp | 7 + src/display/mapcanvas.h | 10 + src/display/mapcanvas_gl.cpp | 23 +- src/font/GlyphAtlas.cpp | 385 ++++++++++ src/font/GlyphAtlas.h | 162 +++++ src/global/ConfigConsts-Computed.h | 11 + src/global/window_utils.cpp | 17 + src/global/window_utils.h | 12 + src/mainwindow/mainwindow.cpp | 10 +- src/opengl/Font.cpp | 752 +++++--------------- src/opengl/Font.h | 9 +- src/opengl/OpenGLTypes.h | 7 +- src/opengl/legacy/FontMesh3d.h | 3 + src/preferences/clientpage.cpp | 24 +- src/preferences/graphicspage.cpp | 25 + src/preferences/graphicspage.h | 1 + src/preferences/graphicspage.ui | 166 +++-- src/resources/fonts/Cantarell-Regular.ttf | Bin 0 -> 43156 bytes src/resources/fonts/Cantarell18.bmfc | 55 -- src/resources/fonts/Cantarell18.fnt | 201 ------ src/resources/fonts/Cantarell18_0.png | Bin 14802 -> 0 bytes src/resources/fonts/Cantarell27.bmfc | 55 -- src/resources/fonts/Cantarell27.fnt | 201 ------ src/resources/fonts/Cantarell27_0.png | Bin 25516 -> 0 bytes src/resources/fonts/Cantarell36.bmfc | 55 -- src/resources/fonts/Cantarell36.fnt | 201 ------ src/resources/fonts/Cantarell36_0.png | Bin 36399 -> 0 bytes src/resources/mmapper2.qrc | 7 +- src/resources/shaders/legacy/font/frag.glsl | 14 +- src/resources/shaders/legacy/font/vert.glsl | 10 +- tests/CMakeLists.txt | 24 + tests/TestFont.cpp | 249 +++++++ 40 files changed, 1282 insertions(+), 1440 deletions(-) create mode 100644 src/font/GlyphAtlas.cpp create mode 100644 src/font/GlyphAtlas.h create mode 100644 src/resources/fonts/Cantarell-Regular.ttf delete mode 100644 src/resources/fonts/Cantarell18.bmfc delete mode 100644 src/resources/fonts/Cantarell18.fnt delete mode 100644 src/resources/fonts/Cantarell18_0.png delete mode 100644 src/resources/fonts/Cantarell27.bmfc delete mode 100644 src/resources/fonts/Cantarell27.fnt delete mode 100644 src/resources/fonts/Cantarell27_0.png delete mode 100644 src/resources/fonts/Cantarell36.bmfc delete mode 100644 src/resources/fonts/Cantarell36.fnt delete mode 100644 src/resources/fonts/Cantarell36_0.png create mode 100644 tests/TestFont.cpp diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 2103d0561..b7eb179e6 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -2,6 +2,8 @@ set(mmapper_SRCS main.cpp adventure/AdventureLogModel.cpp adventure/AdventureLogModel.h + font/GlyphAtlas.cpp + font/GlyphAtlas.h adventure/adventuresession.cpp adventure/adventuresession.h adventure/adventuretracker.cpp diff --git a/src/configuration/NamedConfig.h b/src/configuration/NamedConfig.h index 14e72a823..82ebc6a42 100644 --- a/src/configuration/NamedConfig.h +++ b/src/configuration/NamedConfig.h @@ -15,7 +15,7 @@ class NODISCARD NamedConfig final private: std::string m_name; ChangeMonitor m_changeMonitor; - T m_value = 0; + T m_value{}; bool m_notifying = false; public: diff --git a/src/configuration/configuration.cpp b/src/configuration/configuration.cpp index 4ebe6032d..89dc485a4 100644 --- a/src/configuration/configuration.cpp +++ b/src/configuration/configuration.cpp @@ -304,6 +304,8 @@ ConstString KEY_USE_INTERNAL_EDITOR = "Use internal editor"; ConstString KEY_USE_TRILINEAR_FILTERING = "Use trilinear filtering"; ConstString KEY_WEATHER_ATMOSPHERE_INTENSITY = "weather.atmosphereIntensity"; ConstString KEY_WEATHER_PRECIPITATION_INTENSITY = "weather.precipitationIntensity"; +ConstString KEY_MAP_FONT_FAMILY = "Map font family"; +ConstString KEY_MAP_FONT_SIZE = "Map font point size"; ConstString KEY_WEATHER_TIME_OF_DAY_INTENSITY = "weather.todIntensity"; ConstString KEY_WINDOW_GEOMETRY = "Window Geometry"; ConstString KEY_WINDOW_STATE = "Window State"; @@ -686,6 +688,9 @@ void Configuration::CanvasSettings::read(const QSettings &conf) .append(DEFAULT_MMAPPER_SUBDIR) .append(DEFAULT_RESOURCES_SUBDIR)) .toString(); + mapFontFamily.set(conf.value(KEY_MAP_FONT_FAMILY, mapFontFamily.get()).toString()); + mapFontPointSize.set(conf.value(KEY_MAP_FONT_SIZE, mapFontPointSize.get()).toInt()); + mapFontPointSize.clamp(6, 72); showMissingMapId.set(conf.value(KEY_SHOW_MISSING_MAP_ID, true).toBool()); showUnsavedChanges.set(conf.value(KEY_SHOW_UNSAVED_CHANGES, true).toBool()); showUnmappedExits.set(conf.value(KEY_DRAW_NOT_MAPPED_EXITS, true).toBool()); @@ -897,6 +902,8 @@ NODISCARD static auto getQColorName(const XNamedColor &color) void Configuration::CanvasSettings::write(QSettings &conf) const { conf.setValue(KEY_RESOURCES_DIRECTORY, resourcesDirectory); + conf.setValue(KEY_MAP_FONT_FAMILY, mapFontFamily.get()); + conf.setValue(KEY_MAP_FONT_SIZE, mapFontPointSize.get()); conf.setValue(KEY_SHOW_MISSING_MAP_ID, showMissingMapId.get()); conf.setValue(KEY_SHOW_UNSAVED_CHANGES, showUnsavedChanges.get()); conf.setValue(KEY_DRAW_NOT_MAPPED_EXITS, showUnmappedExits.get()); diff --git a/src/configuration/configuration.h b/src/configuration/configuration.h index e2fa18c8a..64223a8b0 100644 --- a/src/configuration/configuration.h +++ b/src/configuration/configuration.h @@ -182,6 +182,8 @@ class NODISCARD Configuration final NamedConfig showMissingMapId{"SHOW_MISSING_MAPID", false}; NamedConfig showUnsavedChanges{"SHOW_UNSAVED_CHANGES", false}; NamedConfig showUnmappedExits{"SHOW_UNMAPPED_EXITS", false}; + NamedConfig mapFontFamily{"MAP_FONT_FAMILY", QStringLiteral("Cantarell")}; + NamedConfig mapFontPointSize{"MAP_FONT_POINT_SIZE", platformPointSize(11)}; bool drawUpperLayersTextured = false; bool drawDoorNames = false; bool softwareOpenGL = false; diff --git a/src/display/Characters.cpp b/src/display/Characters.cpp index 93b17e82e..5ec665e4b 100644 --- a/src/display/Characters.cpp +++ b/src/display/Characters.cpp @@ -544,7 +544,7 @@ void MapCanvas::drawGroupCharacters(CharacterBatch &batch, ServerRoomId yourServ QString name = character.getLabel().isEmpty() ? character.getName().toQString() : character.getLabel().toQString(); if (!name.isEmpty()) { - batch.drawName(pos, mmqt::toStdStringLatin1(name), color); + batch.drawName(pos, mmqt::toStdStringUtf8(name), color); } } diff --git a/src/display/Connections.cpp b/src/display/Connections.cpp index dea36f946..8f1726a0c 100644 --- a/src/display/Connections.cpp +++ b/src/display/Connections.cpp @@ -223,7 +223,7 @@ void ConnectionDrawer::drawRoomDoorName(const RoomHandle &sourceRoom, static const auto bg = Colors::black.withAlpha(0.4f); const glm::vec3 pos{xy, static_cast(m_currentLayer)}; m_roomNameBatch.emplace_back(GLText{pos, - mmqt::toStdStringLatin1(name), // GL font is latin1 + mmqt::toStdStringUtf8(name), Colors::white, bg, FontFormatFlags{FontFormatFlagEnum::HALIGN_CENTER}}); diff --git a/src/display/Infomarks.cpp b/src/display/Infomarks.cpp index 0cbd49053..0c5786c0f 100644 --- a/src/display/Infomarks.cpp +++ b/src/display/Infomarks.cpp @@ -263,9 +263,8 @@ void MapCanvas::drawInfomark(InfomarksBatch &batch, switch (infoMarkType) { case InfomarkTypeEnum::TEXT: { const auto utf8 = marker.getText().getStdStringViewUtf8(); - const auto latin1_to_render = charset::conversion::utf8ToLatin1(utf8); // GL font is latin1 batch.renderText(pos, - latin1_to_render, + std::string{utf8}, textColor(bgColor), bgColor, fontFormatFlag, diff --git a/src/display/MapCanvasWindow.h b/src/display/MapCanvasWindow.h index 9f21681e7..ff98c5023 100644 --- a/src/display/MapCanvasWindow.h +++ b/src/display/MapCanvasWindow.h @@ -24,6 +24,7 @@ #include #include +#include #include class ConnectionSelection; @@ -63,6 +64,11 @@ class NODISCARD_QOBJECT MapCanvasWindow final : public QOpenGLWindow, private Ma // MapCanvasHost void virt_requestCanvasUpdate() final { update(); } NODISCARD qreal virt_hostDevicePixelRatio() const final { return devicePixelRatioF(); } + NODISCARD qreal virt_hostLogicalDpi() const final + { + const QScreen *const s = screen(); + return (s != nullptr) ? s->logicalDotsPerInchY() : 96.0; + } NODISCARD QSize virt_hostSize() const final { return QSize(QOpenGLWindow::width(), QOpenGLWindow::height()); diff --git a/src/display/mapcanvas.cpp b/src/display/mapcanvas.cpp index 34fdfe499..6a928e99f 100644 --- a/src/display/mapcanvas.cpp +++ b/src/display/mapcanvas.cpp @@ -1420,6 +1420,13 @@ void MapCanvas::screenChanged() const auto newDpi = static_cast(currentDpr()); const auto oldDpi = gl.getHostDevicePixelRatio(); + // same DPR but different logical DPI (e.g. per-monitor font scaling) + if (utils::isSameFloat(newDpi, oldDpi) + && !utils::isSameFloat(currentLogicalDpi(), getGLFont().getLogicalDpi())) { + m_pendingFontReload = true; + m_frameManager.requestUpdate(); + } + if (!utils::isSameFloat(newDpi, oldDpi)) { log(QString("Display: %1 DPI").arg(static_cast(newDpi))); diff --git a/src/display/mapcanvas.h b/src/display/mapcanvas.h index 3eae2e918..70777385e 100644 --- a/src/display/mapcanvas.h +++ b/src/display/mapcanvas.h @@ -73,6 +73,9 @@ class NODISCARD MapCanvasHost // The host's current device pixel ratio. NODISCARD qreal hostDevicePixelRatio() const { return virt_hostDevicePixelRatio(); } + // The logical DPI of the host's current screen. + NODISCARD qreal hostLogicalDpi() const { return virt_hostLogicalDpi(); } + // The host's current size in logical pixels. NODISCARD QSize hostSize() const { return virt_hostSize(); } @@ -92,6 +95,7 @@ class NODISCARD MapCanvasHost private: virtual void virt_requestCanvasUpdate() = 0; NODISCARD virtual qreal virt_hostDevicePixelRatio() const = 0; + NODISCARD virtual qreal virt_hostLogicalDpi() const = 0; NODISCARD virtual QSize virt_hostSize() const = 0; virtual void virt_setHostCursor(Qt::CursorShape shape) = 0; virtual void virt_applyPresentViewport() {} @@ -204,6 +208,8 @@ class NODISCARD_QOBJECT MapCanvas final : public QObject, bool m_pendingUpdateTextures = false; std::optional m_pendingDpr; std::optional m_pendingRenderScale; + // font preference or logical DPI changed; applied like m_pendingRenderScale + bool m_pendingFontReload = false; // Touch long-press -> context menu. A left press synthesized from touch // arms the timer; moving past the drag threshold or releasing disarms it @@ -236,6 +242,10 @@ class NODISCARD_QOBJECT MapCanvas final : public QObject, private: NODISCARD qreal currentDpr() const { return m_host.hostDevicePixelRatio(); } + NODISCARD float currentLogicalDpi() const + { + return static_cast(m_host.hostLogicalDpi()); + } // Configuration::canvas.renderScale (a percentage) as the factor the // offscreen FBO is rendered at; see Legacy::Functions::setRenderScale(). NODISCARD static float configuredRenderScale(); diff --git a/src/display/mapcanvas_gl.cpp b/src/display/mapcanvas_gl.cpp index 0b26c2a64..c352dac6a 100644 --- a/src/display/mapcanvas_gl.cpp +++ b/src/display/mapcanvas_gl.cpp @@ -341,7 +341,7 @@ bool MapCanvas::hostInitializeGL() initTextures(); auto &font = getGLFont(); font.setTextureId(allocateTextureId()); - font.init(); + font.init(currentLogicalDpi()); updateTextures(); // compile all shaders @@ -386,6 +386,14 @@ bool MapCanvas::hostInitializeGL() this->requestUpdateTextures(); }); + const auto requestFontReload = [this]() { + // Deferred like m_pendingRenderScale: needs the GL context. + m_pendingFontReload = true; + m_frameManager.requestUpdate(); + }; + setConfig().canvas.mapFontFamily.registerChangeCallback(m_lifetime, requestFontReload); + setConfig().canvas.mapFontPointSize.registerChangeCallback(m_lifetime, requestFontReload); + // NOTE: The host facade is responsible for connecting to // QOpenGLContext::aboutToBeDestroyed (see MapCanvas::initializeGL()); the // core has no notion of a QOpenGLContext or its lifetime. @@ -610,7 +618,7 @@ void MapCanvas::applyPendingGLWork() getOpenGL().setDevicePixelRatio(newDpi); auto &font = getGLFont(); font.cleanup(); - font.init(); + font.init(currentLogicalDpi()); } if (m_pendingRenderScale.has_value()) { const float newScale = *m_pendingRenderScale; @@ -622,7 +630,7 @@ void MapCanvas::applyPendingGLWork() // and the FBO is sized from it. auto &font = getGLFont(); font.cleanup(); - font.init(); + font.init(currentLogicalDpi()); m_batches.resetExistingMeshesButKeepPendingRemesh(); markMultisamplingDirty(); } @@ -630,6 +638,13 @@ void MapCanvas::applyPendingGLWork() if (std::exchange(m_pendingUpdateTextures, false)) { updateTextures(); } + if (std::exchange(m_pendingFontReload, false)) { + auto &font = getGLFont(); + font.cleanup(); + font.init(currentLogicalDpi()); + // existing text meshes reference the old atlas + m_pendingForceUpdateMeshes = true; + } if (std::exchange(m_pendingForceUpdateMeshes, false)) { forceUpdateMeshes(); } @@ -948,7 +963,7 @@ void MapCanvas::hostPaintGL() auto y = lineHeight; const auto print = [lineHeight, rightMargin, &text, &y](const QString &msg) { text.emplace_back(glm::vec3(rightMargin, y, 0), - mmqt::toStdStringLatin1(msg), // GL font is latin1 + mmqt::toStdStringUtf8(msg), Colors::white, Colors::black.withAlpha(0.4f), FontFormatFlags{FontFormatFlagEnum::HALIGN_RIGHT}); diff --git a/src/font/GlyphAtlas.cpp b/src/font/GlyphAtlas.cpp new file mode 100644 index 000000000..4ddb55bac --- /dev/null +++ b/src/font/GlyphAtlas.cpp @@ -0,0 +1,385 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2025 The MMapper Authors + +#include "GlyphAtlas.h" + +#include "../global/utils.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace font { + +namespace { + +// bounds the shaped-string memo; re-shaping is cheap, glyphs stay cached +constexpr size_t MAX_SHAPED_STRINGS = 1u << 16; + +// solid white block at the atlas origin for the background and underline "glyphs" +constexpr int WHITE_BLOCK_SIZE = 4; + +// same test as FreeType's FT_HAS_COLOR +NODISCARD bool hasColorTables(const QRawFont &rawFont) +{ + for (const char *const table : {"CBDT", "sbix", "COLR", "SVG "}) { + if (!rawFont.fontTable(table).isEmpty()) { + return true; + } + } + return false; +} + +NODISCARD int roundUpPow2(int n) +{ + return static_cast(utils::nextPowerOfTwo(static_cast(std::max(1, n)))); +} + +} // namespace + +int pointSizeToPhysicalPixels(const int pointSize, + const float logicalDpi, + const float devicePixelRatio) +{ + const float dpi = (logicalDpi > 0.f) ? logicalDpi : 96.f; + const float dpr = (devicePixelRatio > 0.f) ? devicePixelRatio : 1.f; + return std::max(1, + static_cast(std::lround(static_cast(pointSize) * dpi / 72.f * dpr))); +} + +GlyphAtlas::GlyphAtlas(const Config &config) + : m_font{std::invoke([&config]() { + QFont f = config.font; + // NOTE: glyphs are displayed 1:1, so hinting is pure win + // (the pre-baked BMFont atlases were hinted, too). + f.setHintingPreference(QFont::PreferFullHinting); + f.setStyleStrategy(QFont::PreferAntialias); + return f; + })} + , m_maxSize{std::max(MIN_SIZE, roundUpPow2(config.maxSize))} +{ + const QFontMetrics fm(m_font); + m_lineHeight = std::max(1, fm.height()); + m_ascent = std::max(1, fm.ascent()); + + const int initial = std::clamp(roundUpPow2(config.initialSize), MIN_SIZE, m_maxSize); + m_image = QImage(initial, initial, QImage::Format_RGBA8888); + m_image.fill(Qt::transparent); + + // white block goes first so its location is fixed + for (int y = 0; y < WHITE_BLOCK_SIZE; ++y) { + std::memset(m_image.scanLine(y), 0xFF, WHITE_BLOCK_SIZE * 4); + } + m_shelf = Shelf{WHITE_BLOCK_SIZE, 0, WHITE_BLOCK_SIZE}; + + // interior of the block, so linear filtering at the quad edges stays white + m_background = AtlasGlyph{1, 1, 2, 2, 0, 0, false, false}; + // 1px tall, just below the baseline + m_underline = AtlasGlyph{1, 1, 2, 1, 0, -1, false, false}; + + m_resized = true; + m_dirty = m_image.rect(); +} + +GlyphAtlas::~GlyphAtlas() = default; + +ShapedText GlyphAtlas::shape(const std::string_view utf8) const +{ + const std::lock_guard lock(m_mutex); + + std::string key{utf8}; + if (const auto it = m_shaped.find(key); it != m_shaped.end()) { + return it->second; + } + + ShapedText result = shapeLocked(utf8); + if (m_shaped.size() >= MAX_SHAPED_STRINGS) { + m_shaped.clear(); + } + m_shaped.emplace(std::move(key), result); + return result; +} + +ShapedText GlyphAtlas::shapeLocked(const std::string_view utf8) const +{ + ShapedText result; + if (utf8.empty()) { + return result; + } + + QString str = QString::fromUtf8(utf8.data(), static_cast(utf8.size())); + // single line only + str.replace(QChar::LineFeed, QChar::Space); + str.replace(QChar::CarriageReturn, QChar::Space); + + QTextLayout layout(str, m_font); + QTextOption option; + option.setWrapMode(QTextOption::NoWrap); + layout.setTextOption(option); + layout.setCacheEnabled(false); + layout.beginLayout(); + const QTextLine line = layout.createLine(); + layout.endLayout(); + + if (!line.isValid()) { + return result; + } + + result.advance = qRound(line.naturalTextWidth()); + + // one run per (fallback) font + for (const QGlyphRun &run : layout.glyphRuns()) { + const QRawFont rawFont = run.rawFont(); + const QList indexes = run.glyphIndexes(); + const QList positions = run.positions(); + const auto count = std::min(indexes.size(), positions.size()); + result.glyphs.reserve(result.glyphs.size() + static_cast(count)); + for (qsizetype i = 0; i < count; ++i) { + const AtlasGlyph &glyph = lookupOrRasterizeLocked(rawFont, indexes[i]); + result.glyphs.push_back(ShapedGlyph{glyph, qRound(positions[i].x())}); + } + } + + return result; +} + +const AtlasGlyph &GlyphAtlas::lookupOrRasterizeLocked(const QRawFont &rawFont, + const uint32_t glyphIndex) const +{ + // glyph indexes are only unique within a font + const QString fontKey = rawFont.familyName() + QChar('|') + rawFont.styleName(); + auto fontIt = m_fonts.find(fontKey); + if (fontIt == m_fonts.end()) { + fontIt = m_fonts.insert(fontKey, + FontInfo{static_cast(m_fonts.size()), + hasColorTables(rawFont)}); + } + const FontInfo &info = fontIt.value(); + const uint64_t key = (static_cast(info.id) << 32u) | glyphIndex; + + if (const auto it = m_glyphs.find(key); it != m_glyphs.end()) { + return it->second; + } + return m_glyphs.emplace(key, rasterizeLocked(rawFont, glyphIndex, info.isColorFont)) + .first->second; +} + +AtlasGlyph GlyphAtlas::rasterizeLocked(const QRawFont &rawFont, + const uint32_t glyphIndex, + const bool isColorFont) const +{ + AtlasGlyph empty; + empty.isEmpty = true; + + const QRectF bbox = rawFont.boundingRect(glyphIndex); + if (bbox.isEmpty()) { + return empty; // e.g. space + } + + // NOTE: hinting can push pixels outside the outline bbox, + // so render with slack and crop to the actual ink. + constexpr int SLACK = 2; + const int left = static_cast(std::floor(bbox.left())) - SLACK; + const int top = static_cast(std::floor(bbox.top())) - SLACK; + const int w = static_cast(std::ceil(bbox.right())) - left + SLACK; + const int h = static_cast(std::ceil(bbox.bottom())) - top + SLACK; + if (w <= 0 || h <= 0) { + return empty; + } + + QImage img(w, h, QImage::Format_ARGB32_Premultiplied); + img.fill(Qt::transparent); + { + QGlyphRun run; + run.setRawFont(rawFont); + run.setGlyphIndexes({glyphIndex}); + run.setPositions({QPointF(0, 0)}); + + QPainter painter(&img); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setRenderHint(QPainter::TextAntialiasing, true); + // mono glyphs come out premultiplied white (a,a,a,a); + // color fonts ignore the pen + painter.setPen(Qt::white); + painter.drawGlyphRun(QPointF(-left, -top), run); + } + + int minX = w; + int minY = h; + int maxX = -1; + int maxY = -1; + bool isColor = false; + for (int y = 0; y < h; ++y) { + const QRgb *const row = reinterpret_cast(img.constScanLine(y)); + for (int x = 0; x < w; ++x) { + const QRgb px = row[x]; + const int a = qAlpha(px); + if (a == 0) { + continue; + } + minX = std::min(minX, x); + maxX = std::max(maxX, x); + minY = std::min(minY, y); + maxY = std::max(maxY, y); + // NOTE: color fonts still have monochrome glyphs (e.g. digits), + // which render in the pen color and must stay tintable. + if (isColorFont && (qRed(px) != a || qGreen(px) != a || qBlue(px) != a)) { + isColor = true; + } + } + } + if (maxX < 0) { + return empty; + } + + const int inkW = maxX - minX + 1; + const int inkH = maxY - minY + 1; + const int cellW = inkW + 2 * PADDING; + const int cellH = inkH + 2 * PADDING; + + const std::optional cell = allocateLocked(cellW, cellH); + if (!cell) { + return empty; + } + + // straight alpha for BlendModeEnum::TRANSPARENCY + const QImage ink = img.copy(minX, minY, inkW, inkH).convertToFormat(QImage::Format_RGBA8888); + for (int y = 0; y < inkH; ++y) { + const uchar *const src = ink.constScanLine(y); + uchar *const dst = m_image.scanLine(cell->y() + PADDING + y) + (cell->x() + PADDING) * 4; + std::memcpy(dst, src, static_cast(inkW) * 4); + if (!isColor) { + // coverage in alpha; white RGB so the shader can tint + for (int x = 0; x < inkW; ++x) { + dst[x * 4 + 0] = 0xFF; + dst[x * 4 + 1] = 0xFF; + dst[x * 4 + 2] = 0xFF; + } + } + } + markDirtyLocked(QRect(*cell, QSize(cellW, cellH))); + + AtlasGlyph g; + g.x = cell->x(); + g.y = cell->y(); + g.width = cellW; + g.height = cellH; + g.xoffset = (left + minX) - PADDING; + // ink bottom is (top + maxY + 1) in y-down; flip to y-up + g.yoffset = -(top + maxY + 1) - PADDING; + g.isColor = isColor; + g.isEmpty = false; + return g; +} + +std::optional GlyphAtlas::allocateLocked(const int width, const int height) const +{ + for (;;) { + const int atlasW = m_image.width(); + const int atlasH = m_image.height(); + + if (m_shelf.x + width > atlasW) { + // start a new shelf + m_shelf.y += m_shelf.height; + m_shelf.x = 0; + m_shelf.height = 0; + } + + if (m_shelf.y + height > atlasH || width > atlasW) { + if (!growLocked()) { + if (!m_full) { + m_full = true; + qWarning() << "Font atlas is full at" << atlasW << "x" << atlasH + << "; further new glyphs will not be displayed."; + } + return std::nullopt; + } + continue; + } + + const QPoint result{m_shelf.x, m_shelf.y}; + m_shelf.x += width; + m_shelf.height = std::max(m_shelf.height, height); + return result; + } +} + +bool GlyphAtlas::growLocked() const +{ + const int oldSize = m_image.width(); + if (oldSize >= m_maxSize) { + return false; + } + const int newSize = std::min(m_maxSize, oldSize * 2); + + QImage bigger(newSize, newSize, QImage::Format_RGBA8888); + bigger.fill(Qt::transparent); + for (int y = 0; y < m_image.height(); ++y) { + std::memcpy(bigger.scanLine(y), m_image.constScanLine(y), static_cast(oldSize) * 4); + } + m_image = std::move(bigger); + + // NOTE: glyph rects are texel coords, so they stay valid; + // only the texture needs to be reallocated. + m_resized = true; + m_dirty = m_image.rect(); + return true; +} + +void GlyphAtlas::markDirtyLocked(const QRect &rect) const +{ + m_dirty = m_dirty.isNull() ? rect : m_dirty.united(rect); +} + +std::optional GlyphAtlas::takePendingUpload() const +{ + const std::lock_guard lock(m_mutex); + if (!m_resized && m_dirty.isNull()) { + return std::nullopt; + } + + AtlasUpload upload; + upload.resized = m_resized; + upload.atlasWidth = m_image.width(); + upload.atlasHeight = m_image.height(); + upload.rect = m_resized ? m_image.rect() : m_dirty; + // copy() is tightly packed (RGBA8 rows are 4-byte aligned) + upload.image = m_image.copy(upload.rect); + + m_resized = false; + m_dirty = QRect(); + return upload; +} + +QSize GlyphAtlas::getSize() const +{ + const std::lock_guard lock(m_mutex); + return m_image.size(); +} + +bool GlyphAtlas::isFull() const +{ + const std::lock_guard lock(m_mutex); + return m_full; +} + +QImage GlyphAtlas::copyImage() const +{ + const std::lock_guard lock(m_mutex); + return m_image.copy(); +} + +size_t GlyphAtlas::getNumGlyphs() const +{ + const std::lock_guard lock(m_mutex); + return m_glyphs.size(); +} + +} // namespace font diff --git a/src/font/GlyphAtlas.h b/src/font/GlyphAtlas.h new file mode 100644 index 000000000..0b45a87f3 --- /dev/null +++ b/src/font/GlyphAtlas.h @@ -0,0 +1,162 @@ +#pragma once +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2025 The MMapper Authors + +#include "../global/RuleOf5.h" +#include "../global/macros.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +class QRawFont; + +namespace font { + +struct NODISCARD AtlasGlyph final +{ + // atlas texel rect, upper left origin; includes PADDING on every side + int x = 0; + int y = 0; + int width = 0; + int height = 0; + // quad offset from the pen: x to the right, y UP from the baseline + int xoffset = 0; + int yoffset = 0; + // color glyphs (emoji) are straight RGBA; otherwise RGB is white and A is coverage + bool isColor = false; + // no ink (e.g. space), or didn't fit; don't draw + bool isEmpty = true; +}; + +struct NODISCARD ShapedGlyph final +{ + AtlasGlyph glyph; + int x = 0; // pen offset from the start of the string +}; + +struct NODISCARD ShapedText final +{ + std::vector glyphs; + int advance = 0; +}; + +// atlas pixels the GL texture doesn't have yet +struct NODISCARD AtlasUpload final +{ + QImage image; // RGBA8888, straight alpha; a copy of `rect` + QRect rect; + // the atlas grew, so the texture must be reallocated (rect covers everything) + bool resized = false; + int atlasWidth = 0; + int atlasHeight = 0; +}; + +// Glyphs are shaped with QTextLayout and rasterized on demand into a +// shelf-packed RGBA8 image that doubles as needed (up to maxSize). +// +// NOTE: The cache is logically const, so every method is const. Batch +// generation calls shape() from worker threads; the GL thread calls +// takePendingUpload(). Everything mutable is guarded by m_mutex. +class NODISCARD GlyphAtlas final +{ +public: + // transparent texels around each glyph, so linear filtering can't bleed + static constexpr int PADDING = 1; + static constexpr int MIN_SIZE = 256; + + struct NODISCARD Config final + { + QFont font; + int initialSize = MIN_SIZE; + // NOTE: ES 3.0 / WebGL 2 only guarantee GL_MAX_TEXTURE_SIZE >= 2048. + int maxSize = 2048; + }; + +private: + struct NODISCARD Shelf final + { + int x = 0; + int y = 0; + int height = 0; + }; + + mutable std::mutex m_mutex; + const QFont m_font; + const int m_maxSize; + int m_lineHeight = 0; + int m_ascent = 0; + + mutable QImage m_image; + mutable Shelf m_shelf; + mutable bool m_full = false; + + struct NODISCARD FontInfo final + { + uint32_t id = 0; + bool isColorFont = false; + }; + mutable QHash m_fonts; // family|style (fallback fonts) + mutable std::unordered_map m_glyphs; + mutable std::unordered_map m_shaped; + + AtlasGlyph m_background; + AtlasGlyph m_underline; + + mutable QRect m_dirty; + mutable bool m_resized = false; + +public: + explicit GlyphAtlas(const Config &config); + ~GlyphAtlas(); + DELETE_CTORS_AND_ASSIGN_OPS(GlyphAtlas); + +public: + NODISCARD const QFont &getFont() const { return m_font; } + NODISCARD int getLineHeight() const { return m_lineHeight; } + NODISCARD int getAscent() const { return m_ascent; } + NODISCARD int getMaxSize() const { return m_maxSize; } + // solid white blocks, so background and underline draw in the same batch + NODISCARD const AtlasGlyph &getBackground() const { return m_background; } + NODISCARD const AtlasGlyph &getUnderline() const { return m_underline; } + +public: + // single line; rasterizes any glyphs not already in the atlas + NODISCARD ShapedText shape(std::string_view utf8) const; + NODISCARD int getAdvance(std::string_view utf8) const { return shape(utf8).advance; } + +public: + // GL thread: what changed since the last call, if anything + NODISCARD std::optional takePendingUpload() const; + NODISCARD QSize getSize() const; + // hit maxSize; new glyphs are being dropped + NODISCARD bool isFull() const; + NODISCARD QImage copyImage() const; + NODISCARD size_t getNumGlyphs() const; + +private: + // caller must hold m_mutex + NODISCARD ShapedText shapeLocked(std::string_view utf8) const; + NODISCARD const AtlasGlyph &lookupOrRasterizeLocked(const QRawFont &rawFont, + uint32_t glyphIndex) const; + NODISCARD AtlasGlyph rasterizeLocked(const QRawFont &rawFont, + uint32_t glyphIndex, + bool isColorFont) const; + NODISCARD std::optional allocateLocked(int width, int height) const; + NODISCARD bool growLocked() const; + void markDirtyLocked(const QRect &rect) const; +}; + +NODISCARD int pointSizeToPhysicalPixels(int pointSize, float logicalDpi, float devicePixelRatio); + +} // namespace font diff --git a/src/global/ConfigConsts-Computed.h b/src/global/ConfigConsts-Computed.h index 14d66b409..03c684cfd 100644 --- a/src/global/ConfigConsts-Computed.h +++ b/src/global/ConfigConsts-Computed.h @@ -59,6 +59,17 @@ static inline constexpr PlatformEnum CURRENT_PLATFORM = std::invoke([]() constex #endif }); +// NOTE: Qt reports 72 dpi on mac but 96 everywhere else, so the same point +// size renders 3/4 as large on mac. Use this for default font sizes. +NODISCARD static inline constexpr int platformPointSize(const int pointSizeAt96Dpi) +{ + if constexpr (CURRENT_PLATFORM == PlatformEnum::Mac) { + return (pointSizeAt96Dpi * 4 + 1) / 3; // rounded + } else { + return pointSizeAt96Dpi; + } +} + static inline constexpr EnvironmentEnum CURRENT_ENVIRONMENT = std::invoke( []() constexpr -> EnvironmentEnum { #if Q_PROCESSOR_WORDSIZE == 4 diff --git a/src/global/window_utils.cpp b/src/global/window_utils.cpp index b46dcf221..7a276897d 100644 --- a/src/global/window_utils.cpp +++ b/src/global/window_utils.cpp @@ -5,6 +5,8 @@ #include "utils.h" +#include +#include #include #include #include @@ -94,3 +96,18 @@ void mmqt::popupMenu(std::unique_ptr menu, const QPoint &globalPos) raw->setAttribute(Qt::WA_DeleteOnClose); raw->popup(globalPos); } + +QFontDialog &mmqt::showFontDialog(QWidget *const parent, + const QFont &initial, + const QString &title, + const QFontDialog::FontDialogOptions options, + std::function onAccepted) +{ + auto *const dialog = new QFontDialog(initial, parent); + dialog->setWindowTitle(title); + dialog->setOptions(options | QFontDialog::DontUseNativeDialog); + dialog->setAttribute(Qt::WA_DeleteOnClose); + QObject::connect(dialog, &QFontDialog::fontSelected, dialog, std::move(onAccepted)); + dialog->open(); + return *dialog; +} diff --git a/src/global/window_utils.h b/src/global/window_utils.h index 2073e1770..0baf8de2e 100644 --- a/src/global/window_utils.h +++ b/src/global/window_utils.h @@ -2,8 +2,12 @@ // SPDX-License-Identifier: GPL-2.0-or-later // Copyright (C) 2024 The MMapper Authors +#include #include +#include + +class QFont; class QMenu; class QMessageBox; class QObject; @@ -28,4 +32,12 @@ extern QMessageBox &showCritical(QWidget *parent, const QString &title, const QS // Non-blocking replacement for QMenu::exec(): the menu is shown at a global // position and deletes itself when closed. extern void popupMenu(std::unique_ptr menu, const QPoint &globalPos); +// Non-blocking replacement for QFontDialog::getFont(): onAccepted is only +// called if the user picks a font. Always uses Qt's own dialog, because the +// native mac font panel doesn't list fonts added with addApplicationFont(). +extern QFontDialog &showFontDialog(QWidget *parent, + const QFont &initial, + const QString &title, + QFontDialog::FontDialogOptions options, + std::function onAccepted); } // namespace mmqt diff --git a/src/mainwindow/mainwindow.cpp b/src/mainwindow/mainwindow.cpp index 8647333d4..2f8111d5b 100644 --- a/src/mainwindow/mainwindow.cpp +++ b/src/mainwindow/mainwindow.cpp @@ -88,6 +88,11 @@ NODISCARD static const char *basic_plural(size_t n) static void addApplicationFont() { + // the map's default font (see GLFont::init) + if (QFontDatabase::addApplicationFont(":/fonts/Cantarell-Regular.ttf") == -1) { + qWarning() << "Unable to load bundled Cantarell font"; + } + const auto id = QFontDatabase::addApplicationFont(":/fonts/DejaVuSansMono.ttf"); const auto family = QFontDatabase::applicationFontFamilies(id); if (family.isEmpty()) { @@ -96,12 +101,9 @@ static void addApplicationFont() // Use the application font here because we can guarantee that resources have been loaded. // REVISIT: Move this to the configuration? if (getConfig().integratedClient.font.isEmpty()) { - // TODO: Explain why mac is 20% larger. - static constinit int defaultFontSize = (CURRENT_PLATFORM == PlatformEnum::Mac) ? 12 - : 10; QFont defaultClientFont; defaultClientFont.setFamily(family.front()); - defaultClientFont.setPointSize(defaultFontSize); + defaultClientFont.setPointSize(platformPointSize(10)); defaultClientFont.setStyleStrategy(QFont::PreferAntialias); setConfig().integratedClient.font = defaultClientFont.toString(); } diff --git a/src/opengl/Font.cpp b/src/opengl/Font.cpp index fddbb6f83..7cdc3944c 100644 --- a/src/opengl/Font.cpp +++ b/src/opengl/Font.cpp @@ -6,38 +6,30 @@ #include "Font.h" #include "../configuration/configuration.h" -#include "../display/Filenames.h" -#include "../display/MapCanvasData.h" #include "../display/Textures.h" +#include "../font/GlyphAtlas.h" #include "../global/ConfigConsts.h" -#include "../global/hash.h" #include "../global/utils.h" #include "FontFormatFlags.h" #include "OpenGL.h" +#include #include -#include -#include +#include #include #include #include -#include #include #include #include #include +#include +#include #include #include -static const bool VERBOSE_FONT_DEBUG = std::invoke([]() -> bool { - if (auto opt = utils::getEnvBool("MMAPPER_VERBOSE_FONT_DEBUG")) { - return opt.value(); - } - return false; -}); - // NOTE: Rect doesn't actually include the hi value. struct NODISCARD Rect final { @@ -49,281 +41,25 @@ struct NODISCARD Rect final NODISCARD glm::ivec2 size() const { return {width(), height()}; } }; -NODISCARD static bool intersects(const Rect &a, const Rect &b) -{ -#define OVERLAPS(_xy) ((a.lo._xy) < (b.hi._xy) && (b.lo._xy) < (a.hi._xy)) - return OVERLAPS(x) && OVERLAPS(y); -#undef OVERLAPS -} - -using IntPair = std::pair; - -template<> -struct std::hash -{ - std::size_t operator()(const IntPair &ip) const noexcept - { -#define CAST(i) static_cast(static_cast(i)) - return numeric_hash(CAST(ip.first) | (CAST(ip.second) << 32u)); -#undef CAST - } -}; - +// NOTE: shared with the worker threads that generate text meshes. struct NODISCARD FontMetrics final { - static constexpr int UNDERLINE_ID = -257; - static constexpr int BACKGROUND_ID = -258; - - struct NODISCARD Glyph final - { - int id = 0; - int x = 0; - int y = 0; - int width = 0; - int height = 0; - int xoffset = 0; - int yoffset = 0; - int xadvance = 0; - - Glyph() = default; - ~Glyph() = default; - DEFAULT_CTORS_AND_ASSIGN_OPS(Glyph); - - // used by most cases - explicit Glyph(const int id_, - const int x_, - const int y_, - const int width_, - const int height_, - const int xoffset_, - const int yoffset_, - const int xadvance_) - : id{id_} - , x{x_} - , y{y_} - , width{width_} - , height{height_} - , xoffset{xoffset_} - , yoffset{yoffset_} - , xadvance{xadvance_} - {} - - // used for underline - explicit Glyph(const int id_, - const int x_, - const int y_, - const int width_, - const int height_, - const int xoffset_, - const int yoffset_) - : id{id_} - , x{x_} - , y{y_} - , width{width_} - , height{height_} - , xoffset{xoffset_} - , yoffset{yoffset_} - {} - - // used for background - explicit Glyph(const int id_, const int x_, const int y_, const int width_, const int height_) - : id{id_} - , x{x_} - , y{y_} - , width{width_} - , height{height_} - {} - - NODISCARD glm::ivec2 getPosition() const { return glm::ivec2{x, y}; } - NODISCARD glm::ivec2 getSize() const { return glm::ivec2{width, height}; } - NODISCARD glm::ivec2 getOffset() const { return glm::ivec2{xoffset, yoffset}; } - - NODISCARD Rect getRect() const - { - const auto lo = getPosition(); - return Rect{lo, lo + getSize()}; - } - }; - - /// In this - /// forum post, the Angelcode BMFont author "WitchLord" says: For example, the kerning pair for the - /// letters A and T is usually a negative value to make the characters display a bit closer together, while the - /// kerning pair for the letters A and M is usually a positive value. - /// - /// We know that that BMFont generates `Kerning 65 (aka "A") 84 (aka "T") -1` for `:/fonts/DejaVuSans16.fnt`, - /// so the amount must be added to the advance / xoffset. - struct NODISCARD Kerning final - { - int first = 0; - int second = 0; - int amount = 0; - - Kerning() = default; - ~Kerning() = default; - DEFAULT_CTORS_AND_ASSIGN_OPS(Kerning); - - Kerning(const int first_, const int second_, const int amount_) - : first{first_} - , second{second_} - , amount{amount_} - {} - }; - struct NODISCARD Common final { int lineHeight = 0; int base = 0; - int scaleW = 0; - int scaleH = 0; - int marginX = 0; - int marginY = 0; + int marginX = 2; + int marginY = 1; }; - std::optional background; - std::optional underline; - + font::GlyphAtlas atlas; Common common; - // REVISIT: Since we only support latin-1, it might make sense to just have fixed - // size lookup tables such as array and array - // for an index into a vector of kernings, rather than using std::unordered_map. - std::vector raw_glyphs; - std::vector raw_kernings; - std::unordered_map glyphs; - std::unordered_map kernings; - - NODISCARD QString init(const QString &); - - NODISCARD const Glyph *lookupGlyph(const int i) const - { - const auto it = glyphs.find(i); - return (it == glyphs.end()) ? nullptr : it->second; - } - - NODISCARD const Glyph *lookupGlyph(const char c) const - { - return lookupGlyph(static_cast(static_cast(c))); - } - - NODISCARD const Glyph *getBackground() const - { - return background ? &background.value() : nullptr; - } - NODISCARD const Glyph *getUnderline() const { return underline ? &underline.value() : nullptr; } - - NODISCARD bool tryAddBackgroundGlyph(QImage &img) - { - const int w = common.scaleW; - const int h = common.scaleH; - - // must not overlap underline - const Rect ourGlyph{{w - 4, 0}, {w, 4}}; - - for (const Glyph &glyph : raw_glyphs) { - if (intersects(glyph.getRect(), ourGlyph)) { - qWarning() << "Glyph" << glyph.id << "overlaps expected background location"; - return false; - } - } - - if (VERBOSE_FONT_DEBUG) { - qDebug() << "Adding background glyph"; - } - // glyph location uses lower left origin - background.emplace(BACKGROUND_ID, common.scaleW - 3, 1, 2, 2); - - // note: the current image still uses UPPER left origin, - // but it will be flipped after this function. - for (int dy = -4; dy < 0; ++dy) { - for (int dx = -4; dx < 0; ++dx) { - img.setPixelColor(w + dx, h + dy, Qt::white); - } - } - return true; - } - - NODISCARD bool tryAddUnderlineGlyph(QImage &img) + explicit FontMetrics(const font::GlyphAtlas::Config &config) + : atlas{config} { - const int w = common.scaleW; - const int h = common.scaleH; - const Rect ourGlyph{{w - 12, 0}, {w - 8, 4}}; - - // must not overlap background - for (const Glyph &glyph : raw_glyphs) { - if (intersects(glyph.getRect(), ourGlyph)) { - qWarning() << "Glyph" << glyph.id << "overlaps expected underline location"; - return false; - } - } - - if (VERBOSE_FONT_DEBUG) { - qDebug() << "Adding underline glyph"; - } - // glyph location uses lower left origin - underline.emplace(UNDERLINE_ID, common.scaleW - 11, 1, 2, 1, 0, -1); - - // note: the current image still uses UPPER left origin, - // but it will be flipped after this function. - for (int dy = -4; dy < 0; ++dy) { - const auto color = (dy == -2) ? QColor{Qt::white} : QColor(0, 0, 0, 0); - for (int dx = -12; dx < -8; ++dx) { - img.setPixelColor(w + dx, h + dy, color); - } - } - return true; - } - - void tryAddSyntheticGlyphs(QImage &img) - { - if (img.width() != common.scaleW || img.height() != common.scaleH) { - qWarning() << "Image is the wrong size"; - return; - } - - std::ignore = tryAddBackgroundGlyph(img); - std::ignore = tryAddUnderlineGlyph(img); - } - - NODISCARD const Kerning *lookupKerning(const Glyph *const prev, const Glyph *const current) const - { - if (prev == nullptr || current == nullptr) { - return nullptr; - } - - const auto it = kernings.find(IntPair{prev->id, current->id}); - return (it == kernings.end()) ? nullptr : it->second; - } - - template - void foreach_glyph(const std::string_view msg, EmitGlyph &&emitGlyph) const - { - const Glyph *prev = nullptr; - for (const char &c : msg) { - const Glyph *const current = lookupGlyph(c); - if (current != nullptr) { - emitGlyph(current, lookupKerning(prev, current)); - prev = current; - } else if (auto oops = lookupGlyph(char_consts::C_QUESTION_MARK)) { - qWarning() << "Unable to lookup glyph" << QString(QChar(c)); - emitGlyph(oops, lookupKerning(prev, oops)); - prev = oops; - } else { - prev = nullptr; - } - } - } - - NODISCARD int measureWidth(const std::string_view msg) const - { - int width = 0; - foreach_glyph(msg, [&width](const Glyph *const g, const Kerning *const k) { - width += g->xadvance; - if (k != nullptr) { - // kerning amount is added to the advance - width += k->amount; - } - }); - return width; + common.lineHeight = atlas.getLineHeight(); + common.base = atlas.getAscent(); } void getFontBatchRawData(const GLText *text, @@ -339,147 +75,6 @@ void getFontBatchRawData(const FontMetrics &fm, fm.getFontBatchRawData(text, count, output); } -struct NODISCARD PrintedChar final -{ - int id = 0; -}; - -QDebug operator<<(QDebug os, PrintedChar c); -QDebug operator<<(QDebug os, PrintedChar c) -{ - os.nospace(); - os << c.id << " (aka " << QString(QChar(c.id)) << ")"; - os.space(); - return os; -} - -QString FontMetrics::init(const QString &fontFilename) -{ - qInfo() << "Loading font from " << fontFilename; - - QFile f(fontFilename); - if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { - qCritical() << "Unable to load font"; - abort(); - } - - raw_glyphs.reserve(256); - raw_kernings.reserve(1024); - - QFileInfo info(fontFilename); - QString imageFilename; - - bool hasCommon = false; - - QXmlStreamReader xml(&f); - while (!xml.atEnd() && !xml.hasError()) { - if (xml.readNextStartElement()) { - const auto &attr = xml.attributes(); - if (xml.name() == QStringLiteral("common")) { - if (hasCommon) { - assert(false); - continue; - } - hasCommon = true; - // - const int lineHeight = attr.value("lineHeight").toInt(); - const int base = attr.value("base").toInt(); - const int scaleW = attr.value("scaleW").toInt(); - const int scaleH = attr.value("scaleH").toInt(); - const int marginX = 2; - const int marginY = 1; - if (VERBOSE_FONT_DEBUG) { - qDebug() << "Common" << lineHeight << base << scaleW << scaleH << marginX - << marginY; - } - common = Common{lineHeight, base, scaleW, scaleH, marginX, marginY}; - - } else if (xml.name() == QStringLiteral("char")) { - if (!hasCommon) { - assert(false); - continue; - } - - if (attr.value("page").toInt() != 0 || attr.value("chnl").toInt() != 15) { - assert(false); - continue; - } - - // - const int id = attr.value("id").toInt(); - - const int x = attr.value("x").toInt(); - const int y = attr.value("y").toInt(); - const int width = attr.value("width").toInt(); - const int height = attr.value("height").toInt(); - const int xoffset = attr.value("xoffset").toInt(); - const int yoffset = attr.value("yoffset").toInt(); - const int xadvance = attr.value("xadvance").toInt(); - - // REVISIT: should these be offset by -1? - const int y2 = common.scaleH - (y + height); - const int yoffset2 = common.base - (yoffset + height); - - if (VERBOSE_FONT_DEBUG) { - qDebug() << "Glyph" << PrintedChar{id} << x << y << width << height << xoffset - << yoffset << xadvance << "--->" << y2 << yoffset2; - } - - raw_glyphs.emplace_back(id, x, y2, width, height, xoffset, yoffset2, xadvance); - - } else if (xml.name() == QStringLiteral("kerning")) { - if (!hasCommon) { - assert(false); - continue; - } - - // - const int first = attr.value("first").toInt(); - const int second = attr.value("second").toInt(); - const int amount = attr.value("amount").toInt(); - if (VERBOSE_FONT_DEBUG) { - qDebug() << "Kerning" << PrintedChar{first} << PrintedChar{second} << amount; - } - raw_kernings.emplace_back(first, second, amount); - } else if (xml.name() == QStringLiteral("page")) { - const int id = attr.value("id").toInt(); - if (id != 0) { - continue; - } - const auto &file = attr.value("file").toString(); - const auto &path = info.dir().canonicalPath() + "/" + file; - - const bool exists = QFile{path}.exists(); - if (exists) { - imageFilename = path; - } - - if (VERBOSE_FONT_DEBUG) { - QDebug &&os = qDebug(); - os << "page" << id << file; - os.nospace(); - os << "(aka " << path << ")"; - os.space(); - os << (!exists ? "Does not exist." : "Exists."); - } - } - } - } - - qInfo() << "Loaded" << raw_glyphs.size() << "glyphs and" << raw_kernings.size() << "kernings"; - - for (const Glyph &glyph : raw_glyphs) { - assert(isClamped(glyph.id, 0, 255)); - glyphs[glyph.id] = &glyph; - } - - for (const Kerning &kerning : raw_kernings) { - kernings[IntPair{kerning.first, kerning.second}] = &kerning; - } - - return imageFilename; -} - class NODISCARD FontBatchBuilder final { private: @@ -533,16 +128,17 @@ class NODISCARD FontBatchBuilder final private: const FontMetrics &m_fm; - const glm::ivec2 m_iTexSize{}; std::vector &m_verts3d; Opts m_opts; Bounds m_bounds; + font::ShapedText m_shaped; int m_xlinepos = 0; bool m_noOutput = false; void resetPerStringData() { m_opts.reset(); m_bounds = Bounds(); + m_shaped = font::ShapedText{}; m_xlinepos = 0; m_noOutput = false; } @@ -550,13 +146,14 @@ class NODISCARD FontBatchBuilder final public: explicit FontBatchBuilder(const FontMetrics &fm, std::vector &output) : m_fm{fm} - , m_iTexSize{fm.common.scaleW, fm.common.scaleH} , m_verts3d{output} {} - NODISCARD glm::vec2 getTexCoord(const glm::ivec2 iTexCoord) const + // NOTE: tex coords are atlas texels (upper left origin); the vertex shader + // divides by textureSize(), so the atlas can grow under existing meshes. + NODISCARD static glm::vec2 getTexCoord(const glm::ivec2 iTexCoord) { - return glm::vec2(iTexCoord) / glm::vec2(m_iTexSize); + return glm::vec2(iTexCoord); } // REVISIT: This could be done in the shader, @@ -575,30 +172,26 @@ class NODISCARD FontBatchBuilder final return pos; } - void emitGlyphQuad(const bool isEmpty, - const glm::ivec2 iVertex00, - const glm::ivec2 iTexCoord00, - const glm::ivec2 iglyphSize) + void emitGlyphQuad(const glm::ivec2 iVertex00, const font::AtlasGlyph &g) { - const auto emitWithOffset = - [this, isEmpty, &iVertex00, &iTexCoord00](const glm::ivec2 pixelOffset) -> void { + const auto emitWithOffset = [this, &iVertex00, &g](const glm::ivec2 pixelOffset) -> void { const glm::ivec2 relativeVertPos = iVertex00 + pixelOffset; - if (!isEmpty) { - // side-effect: updates bounds; this must come before return - m_bounds.include(relativeVertPos); - } + // side-effect: updates bounds; this must come before return + m_bounds.include(relativeVertPos); if (m_noOutput) { return; } - const glm::vec2 tc = getTexCoord(iTexCoord00 + pixelOffset); + // vertex y goes up, but atlas rows go down + const glm::ivec2 iTexCoord{g.x + pixelOffset.x, g.y + (g.height - pixelOffset.y)}; + const glm::vec2 tc = getTexCoord(iTexCoord); const glm::vec2 vert = transformVert(relativeVertPos); - m_verts3d.emplace_back(m_opts.pos, m_opts.fgColor, tc, vert); + m_verts3d.emplace_back(m_opts.pos, m_opts.fgColor, tc, vert, g.isColor ? 1.f : 0.f); }; - const auto &x = iglyphSize.x; - const auto &y = iglyphSize.y; + const auto &x = g.width; + const auto &y = g.height; // 3-2 // | | // 0-1 @@ -608,39 +201,28 @@ class NODISCARD FontBatchBuilder final emitWithOffset(glm::ivec2(0, y)); } - void emitGlyph(const FontMetrics::Glyph *const g, const FontMetrics::Kerning *const k) + void emitGlyphs(const int wordOffset, const bool output) { - assert(isClamped(g->id, 0, 255)); - const auto glyphSize = glm::ivec2(g->width, g->height); - const auto iTexCoord00 = glm::ivec2(g->x, g->y); - if (k != nullptr) { - // kerning amount is added to the advance - m_xlinepos += k->amount; - } - const auto iVertex00 = glm::ivec2(m_xlinepos + g->xoffset, g->yoffset); - m_xlinepos += g->xadvance; - emitGlyphQuad(std::isspace(g->id), iVertex00, iTexCoord00, glyphSize); - } - - void call_foreach_glyph(const int wordOffset, const bool output) - { - const auto emitGlyphLambda = [this](const FontMetrics::Glyph *const g, - const FontMetrics::Kerning *const k) { - emitGlyph(g, k); - }; - m_noOutput = !output; - m_xlinepos = wordOffset; - m_fm.foreach_glyph(m_opts.msg, emitGlyphLambda); + for (const font::ShapedGlyph &sg : m_shaped.glyphs) { + const font::AtlasGlyph &g = sg.glyph; + if (g.isEmpty) { + continue; + } + const glm::ivec2 iVertex00{wordOffset + sg.x + g.xoffset, g.yoffset}; + emitGlyphQuad(iVertex00, g); + } + m_xlinepos = wordOffset + m_shaped.advance; } void addString(const GLText &text) { resetPerStringData(); this->m_opts = Opts{text}; + m_shaped = m_fm.atlas.shape(m_opts.msg); int wordOffset = 0; - call_foreach_glyph(wordOffset, false); + emitGlyphs(wordOffset, false); // measurement, background color, and underline. { @@ -660,6 +242,11 @@ class NODISCARD FontBatchBuilder final #undef ADD }; + const auto texRect = [](const font::AtlasGlyph &g) -> Rect { + const glm::ivec2 lo{g.x, g.y}; + return Rect{lo, lo + glm::ivec2{g.width, g.height}}; + }; + const glm::ivec2 margin{m_fm.common.marginX, m_fm.common.marginY}; const auto &lo = m_bounds.minVertPos; const auto &hi = m_bounds.maxVertPos; @@ -676,120 +263,181 @@ class NODISCARD FontBatchBuilder final } if (m_opts.optBgColor) { - if (const FontMetrics::Glyph *const background = m_fm.getBackground()) { - quad(m_opts.optBgColor.value(), - Rect{lo - margin, hi + margin}, - background->getRect()); - } + // bounds include the glyph padding; shrink back to the ink + const glm::ivec2 pad{font::GlyphAtlas::PADDING}; + quad(m_opts.optBgColor.value(), + Rect{lo + pad - margin, hi - pad + margin}, + texRect(m_fm.atlas.getBackground())); } if (m_opts.wantUnderline) { - if (const FontMetrics::Glyph *const underline = m_fm.getUnderline()) { - const auto usize = underline->getSize(); - const auto offset = underline->getOffset() + glm::ivec2{wordOffset, 0}; - quad(m_opts.fgColor, - Rect{offset, offset + glm::ivec2{m_xlinepos, usize.y}}, - underline->getRect()); - } + const font::AtlasGlyph &underline = m_fm.atlas.getUnderline(); + const glm::ivec2 offset{wordOffset + underline.xoffset, underline.yoffset}; + quad(m_opts.fgColor, + Rect{offset, offset + glm::ivec2{m_xlinepos, underline.height}}, + texRect(underline)); } } // note: 2nd call includes wordOffset that can be modified // above if caller requested HALIGN_CENTER or HALIGN_RIGHT. - call_foreach_glyph(wordOffset, true); + emitGlyphs(wordOffset, true); } }; +void FontMetrics::getFontBatchRawData(const GLText *const text, + const size_t count, + std::vector &output) const +{ + if (count == 0) { + return; + } + + const auto before = output.size(); + const auto end = text + count; + + // upper bound: utf8 byte count >= glyph count + const size_t maxExpectedVerts = std::invoke([text, end]() -> size_t { + size_t numQuads = 0; + for (const GLText *it = text; it != end; ++it) { + numQuads += it->text.size() + (it->bgcolor.has_value() ? 1 : 0) + + (it->fontFormatFlag.contains(FontFormatFlagEnum::UNDERLINE) ? 1 : 0); + } + return 4 * numQuads; + }); + + output.reserve(before + maxExpectedVerts); + + FontBatchBuilder fontBatchBuilder{*this, output}; + for (const GLText *it = text; it != end; ++it) { + fontBatchBuilder.addString(*it); + } + assert(output.size() <= before + maxExpectedVerts); +} + GLFont::GLFont(OpenGL &gl) : m_gl(gl) {} GLFont::~GLFont() = default; -NODISCARD static QString getFontFilename(const float devicePixelRatio) +NODISCARD static int getMaxTextureSize() { - const char *const FONT_KEY = "MMAPPER_FONT"; - const char *const font = "Cantarell"; - const char *const size = std::invoke([devicePixelRatio]() -> const char * { - if (devicePixelRatio > 1.75f) { - return "36"; - } - if (devicePixelRatio > 1.25f) { - return "27"; - } - return "18"; - }); - const QString fontFilename = QString(":/fonts/%1%2.fnt").arg(font).arg(size); - if (qEnvironmentVariableIsSet(FONT_KEY)) { - const QString tmp = qgetenv(FONT_KEY); - if (QFile{tmp}.exists()) { - qInfo() << "Using value from" << FONT_KEY << "to override font from" << fontFilename - << "to" << tmp; - return tmp; - } else { - qInfo() << "Path in" << FONT_KEY << "is invalid."; - } - } else if (IS_DEBUG_BUILD) { - qInfo() << "Note: You can override the font with" << FONT_KEY; + // NOTE: ES 3.0 / WebGL 2 only guarantee 2048; that's ~10k glyphs (16 MiB). + constexpr GLint DESIRED = 2048; + GLint maxSize = 0; + if (QOpenGLContext *const ctx = QOpenGLContext::currentContext()) { + ctx->functions()->glGetIntegerv(GL_MAX_TEXTURE_SIZE, &maxSize); } - - if (!QFile{fontFilename}.exists()) { - qWarning() << fontFilename << "does not exist."; + if (maxSize <= 0) { + return DESIRED; } - - return fontFilename; + return std::min(maxSize, DESIRED); } -void GLFont::init() +void GLFont::init(const float logicalDpi) { assert(m_gl.isRendererInitialized()); - auto pfm = std::make_shared(); - m_fontMetrics = pfm; - const auto fontFilename = getFontFilename(m_gl.getDevicePixelRatio()); - - auto &fm = *pfm; - const QString imageFilename = fm.init(fontFilename); - if (!QFile{imageFilename}.exists()) { - qWarning() << "invalid font filename" << imageFilename; - } + const auto &canvas = getConfig().canvas; + const QString family = canvas.mapFontFamily.get(); + const int pointSize = canvas.mapFontPointSize.get(); + const float dpr = m_gl.getDevicePixelRatio(); + m_logicalDpi = logicalDpi; + + // the font shader works in physical pixels, so rasterize at that size + const int pixelSize = font::pointSizeToPhysicalPixels(pointSize, logicalDpi, dpr); + QFont qfont(family); + qfont.setPixelSize(pixelSize); + // NOTE: Cantarell has no symbols or emoji. Prefer the platform's color + // emoji font (whichever exists); the bundled DejaVu Sans Mono then + // guarantees the monochrome symbol blocks (arrows, U+2600, dingbats). + qfont.setFamilies({family, + QStringLiteral("Apple Color Emoji"), + QStringLiteral("Segoe UI Emoji"), + QStringLiteral("Noto Color Emoji"), + QStringLiteral("DejaVu Sans Mono")}); + + font::GlyphAtlas::Config config; + config.font = qfont; + config.maxSize = getMaxTextureSize(); + + qInfo() << "Map font:" << family << pointSize << "pt =" << pixelSize << "px (dpi" << logicalDpi + << ", dpr" << dpr << ")"; + + m_fontMetrics = std::make_shared(config); if (m_texture) { m_texture->clearId(); } - // REVISIT: can this avoid switching to a different MMTexture object? + // NOTE: created empty; syncTexture() allocates and later grows it in place, + // so meshes holding this MMTexture stay valid. m_texture = MMTexture::alloc( QOpenGLTexture::Target::Target2D, - [&fm, &imageFilename](QOpenGLTexture &tex) -> void { - QImage img{imageFilename}; - fm.tryAddSyntheticGlyphs(img); - img = img.mirrored(); - - const QImage converted = img.convertToFormat(QImage::Format_RGBA8888); - tex.setFormat(QOpenGLTexture::TextureFormat::RGBA8_UNorm); + [](QOpenGLTexture &tex) -> void { tex.setMinMagFilters(QOpenGLTexture::Filter::Linear, QOpenGLTexture::Filter::Linear); + tex.setWrapMode(QOpenGLTexture::ClampToEdge); tex.setAutoMipMapGenerationEnabled(false); - tex.setMipLevels(0); - tex.setSize(converted.width(), converted.height()); - tex.allocateStorage(); - tex.setData(0, - QOpenGLTexture::PixelFormat::RGBA, - QOpenGLTexture::PixelType::UInt8, - converted.constBits()); }, true); - - // Each new MMTexture gets assigned the same old ID. - m_texture->setId(m_id); m_gl.setTextureLookup(m_id, m_texture); + + syncTexture(); +} + +void GLFont::syncTexture() +{ + if (!m_fontMetrics || !m_texture) { + return; + } + + const std::optional upload = m_fontMetrics->atlas.takePendingUpload(); + if (!upload) { + return; + } + + QOpenGLTexture &tex = deref(m_texture->get()); + const QImage &img = upload->image; + assert(img.format() == QImage::Format_RGBA8888); + + if (upload->resized || !tex.isStorageAllocated()) { + if (tex.isCreated()) { + tex.destroy(); + } + // RGBA8888 is byte-ordered (endian independent) and straight alpha + tex.setFormat(QOpenGLTexture::TextureFormat::RGBA8_UNorm); + tex.setMipLevels(1); + tex.setSize(upload->atlasWidth, upload->atlasHeight); + tex.allocateStorage(QOpenGLTexture::PixelFormat::RGBA, QOpenGLTexture::PixelType::UInt8); + tex.setMinMagFilters(QOpenGLTexture::Filter::Linear, QOpenGLTexture::Filter::Linear); + tex.setWrapMode(QOpenGLTexture::ClampToEdge); + tex.setAutoMipMapGenerationEnabled(false); + if (!tex.isStorageAllocated()) { + qWarning() << "Unable to allocate" << upload->atlasWidth << "x" << upload->atlasHeight + << "font atlas texture"; + return; + } + } + + assert(img.width() == upload->rect.width() && img.height() == upload->rect.height()); + tex.setData(upload->rect.x(), + upload->rect.y(), + 0, + upload->rect.width(), + upload->rect.height(), + 1, + QOpenGLTexture::PixelFormat::RGBA, + QOpenGLTexture::PixelType::UInt8, + img.constBits()); } void GLFont::cleanup() { m_fontMetrics.reset(); m_texture.reset(); + m_logicalDpi = 0.f; } int GLFont::getFontHeight() const @@ -799,10 +447,14 @@ int GLFont::getFontHeight() const std::optional GLFont::getGlyphAdvance(const char c) const { - if (const FontMetrics::Glyph *const g = getFontMetrics().lookupGlyph(c)) { - return g->xadvance; + if (c == '\0') { + return std::nullopt; } - return std::nullopt; + const auto shaped = getFontMetrics().atlas.shape(std::string_view{&c, 1}); + if (shaped.glyphs.empty()) { + return std::nullopt; + } + return shaped.advance; } glm::ivec2 GLFont::getScreenCenter() const @@ -810,36 +462,6 @@ glm::ivec2 GLFont::getScreenCenter() const return m_gl.getPhysicalViewport().offset + m_gl.getPhysicalViewport().size / 2; } -void FontMetrics::getFontBatchRawData(const GLText *const text, - const size_t count, - std::vector &output) const -{ - if (count == 0) { - return; - } - - const auto before = output.size(); - const auto end = text + count; - - const size_t expectedVerts = std::invoke([text, end]() -> size_t { - int numGlyphs = 0; - for (const GLText *it = text; it != end; ++it) { - numGlyphs += static_cast(it->text.size()) + (it->bgcolor.has_value() ? 1 : 0) - + (it->fontFormatFlag.contains(FontFormatFlagEnum::UNDERLINE) ? 1 : 0); - } - return 4 * static_cast(numGlyphs); - }); - - output.reserve(before + expectedVerts); - - auto &fm = *this; - FontBatchBuilder fontBatchBuilder{fm, output}; - for (const GLText *it = text; it != end; ++it) { - fontBatchBuilder.addString(*it); - } - assert(output.size() == before + expectedVerts); -} - void GLFont::render2dTextImmediate(const View text) { if (text.empty()) { @@ -867,6 +489,7 @@ void GLFont::render3dTextImmediate(const View rawVerts) return; } + syncTexture(); m_gl.renderFont3d(m_texture, rawVerts); } @@ -889,6 +512,8 @@ std::vector GLFont::getFontMeshIntermediate(const View text) UniqueMesh GLFont::getFontMesh(const View rawVerts) { + // the verts may reference glyphs added by a worker thread + syncTexture(); return m_gl.createFontMesh(m_texture, DrawModeEnum::QUADS, rawVerts); } @@ -896,11 +521,10 @@ void GLFont::renderTextCentered(const QString &text, const Color color, const std::optional bgcolor) { - // here we're converting to latin1 because we cannot display unicode codepoints above 255 const auto center = glm::vec2{getScreenCenter()}; render2dTextImmediate( std::vector{GLText{glm::vec3{center, 0.f}, - mmqt::toStdStringLatin1(text), // GL font is latin1 + mmqt::toStdStringUtf8(text), color, bgcolor, FontFormatFlags{FontFormatFlagEnum::HALIGN_CENTER}}}); diff --git a/src/opengl/Font.h b/src/opengl/Font.h index fab28dbc1..7919b45a6 100644 --- a/src/opengl/Font.h +++ b/src/opengl/Font.h @@ -55,6 +55,7 @@ class NODISCARD GLFont final SharedMMTexture m_texture; MMTextureId m_id = INVALID_MM_TEXTURE_ID; std::shared_ptr m_fontMetrics; + float m_logicalDpi = 0.f; public: explicit GLFont(OpenGL &gl); @@ -77,8 +78,14 @@ class NODISCARD GLFont final assert(m_id == INVALID_MM_TEXTURE_ID); m_id = id; } - void init(); + // NOTE: call cleanup() + init() again if the font, DPI, or DPR changes. + void init(float logicalDpi); void cleanup(); + NODISCARD float getLogicalDpi() const { return m_logicalDpi; } + +private: + // GL thread only + void syncTexture(); public: NODISCARD int getFontHeight() const; diff --git a/src/opengl/OpenGLTypes.h b/src/opengl/OpenGLTypes.h index 45f742d1a..301985100 100644 --- a/src/opengl/OpenGLTypes.h +++ b/src/opengl/OpenGLTypes.h @@ -116,15 +116,20 @@ struct NODISCARD FontVert3d final Color color; glm::vec2 tex{}; glm::vec2 vert{}; // screen space + // 0 = monochrome signed-distance-field glyph (tinted by `color`), + // 1 = full-color glyph (e.g. an emoji, sampled as-is from the texture). + float isColor = 0.f; explicit FontVert3d(const glm::vec3 base_, const Color color_, const glm::vec2 tex_, - const glm::vec2 vert_) + const glm::vec2 vert_, + const float isColor_ = 0.f) : base{base_} , color{color_} , tex{tex_} , vert{vert_} + , isColor{isColor_} {} }; diff --git a/src/opengl/legacy/FontMesh3d.h b/src/opengl/legacy/FontMesh3d.h index e64e86f74..b22ae67df 100644 --- a/src/opengl/legacy/FontMesh3d.h +++ b/src/opengl/legacy/FontMesh3d.h @@ -44,17 +44,20 @@ class NODISCARD SimpleFont3dMesh : public SimpleMesh static_assert(sizeof(std::declval().color) == 4 * sizeof(uint8_t)); static_assert(sizeof(std::declval().tex) == 2 * sizeof(GLfloat)); static_assert(sizeof(std::declval().vert) == 2 * sizeof(GLfloat)); + static_assert(sizeof(std::declval().isColor) == 1 * sizeof(GLfloat)); Functions &gl = Base::m_functions; CHECK_ATTR_LOC(0, "aBase"); CHECK_ATTR_LOC(1, "aColor"); CHECK_ATTR_LOC(2, "aTexCoord"); CHECK_ATTR_LOC(3, "aVert"); + CHECK_ATTR_LOC(4, "aIsColor"); gl.enableAttrib(0, 3, GL_FLOAT, GL_FALSE, vertSize, VPO(base)); gl.enableAttrib(1, 4, GL_UNSIGNED_BYTE, GL_TRUE, vertSize, VPO(color)); gl.enableAttrib(2, 2, GL_FLOAT, GL_FALSE, vertSize, VPO(tex)); gl.enableAttrib(3, 2, GL_FLOAT, GL_FALSE, vertSize, VPO(vert)); + gl.enableAttrib(4, 1, GL_FLOAT, GL_FALSE, vertSize, VPO(isColor)); } }; diff --git a/src/preferences/clientpage.cpp b/src/preferences/clientpage.cpp index 758d8c2eb..3157dacab 100644 --- a/src/preferences/clientpage.cpp +++ b/src/preferences/clientpage.cpp @@ -7,6 +7,7 @@ #include "../configuration/configuration.h" #include "../global/ConfigConsts-Computed.h" #include "../global/macros.h" +#include "../global/window_utils.h" #include "ui_clientpage.h" #include @@ -210,20 +211,17 @@ void ClientPage::updateFontAndColors() void ClientPage::slot_onChangeFont() { - auto &fontDescription = setConfig().integratedClient.font; QFont oldFont; - oldFont.fromString(fontDescription); - - bool ok = false; - const QFont newFont = QFontDialog::getFont(&ok, - oldFont, - this, - "Select Font", - QFontDialog::MonospacedFonts); - if (ok) { - fontDescription = newFont.toString(); - updateFontAndColors(); - } + oldFont.fromString(getConfig().integratedClient.font); + + mmqt::showFontDialog(this, + oldFont, + "Select Font", + QFontDialog::MonospacedFonts, + [this](const QFont &newFont) { + setConfig().integratedClient.font = newFont.toString(); + updateFontAndColors(); + }); } void ClientPage::slot_onChangeBackgroundColor() diff --git a/src/preferences/graphicspage.cpp b/src/preferences/graphicspage.cpp index 00a053ae2..982e0f3ae 100644 --- a/src/preferences/graphicspage.cpp +++ b/src/preferences/graphicspage.cpp @@ -6,6 +6,7 @@ #include "../configuration/configuration.h" #include "../global/utils.h" +#include "../global/window_utils.h" #include "../opengl/OpenGLConfig.h" #include "AdvancedGraphics.h" #include "ui_graphicspage.h" @@ -100,6 +101,21 @@ GraphicsPage::GraphicsPage(QWidget *parent) this, &GraphicsPage::slot_drawUpperLayersTexturedStateChanged); + connect(ui->mapFontPushButton, &QAbstractButton::clicked, this, [this]() { + const auto &canvas = getConfig().canvas; + const QFont currentFont(canvas.mapFontFamily.get(), canvas.mapFontPointSize.get()); + mmqt::showFontDialog(this, + currentFont, + QStringLiteral("Select Map Font"), + {}, + [this](const QFont &newFont) { + setConfig().canvas.mapFontFamily.set(newFont.family()); + setConfig().canvas.mapFontPointSize.set(newFont.pointSize()); + syncMapFontButton(); + graphicsSettingsChanged(); + }); + }); + connect(ui->weatherAtmosphereSlider, &QSlider::valueChanged, this, [this](const int value) { setConfig().canvas.weatherAtmosphereIntensity.set(value); graphicsSettingsChanged(); @@ -172,6 +188,8 @@ void GraphicsPage::slot_loadConfig() ui->drawNotMappedExits->setChecked(settings.showUnmappedExits.get()); ui->drawDoorNames->setChecked(settings.drawDoorNames); + syncMapFontButton(); + ui->weatherAtmosphereSlider->setValue(settings.weatherAtmosphereIntensity.get()); ui->weatherPrecipitationSlider->setValue(settings.weatherPrecipitationIntensity.get()); ui->weatherTimeOfDaySlider->setValue(settings.weatherTimeOfDayIntensity.get()); @@ -195,6 +213,13 @@ void GraphicsPage::syncAntialiasingSamplesComboBox() : ""); } +void GraphicsPage::syncMapFontButton() +{ + const auto &canvas = getConfig().canvas; + ui->mapFontPushButton->setText( + QString("%1 %2pt").arg(canvas.mapFontFamily.get()).arg(canvas.mapFontPointSize.get())); +} + void GraphicsPage::changeColorClicked(XNamedColor &namedColor, QPushButton *const pushButton) { const QColor origColor = namedColor.getColor().getQColor(); diff --git a/src/preferences/graphicspage.h b/src/preferences/graphicspage.h index 6370c4320..5020c8972 100644 --- a/src/preferences/graphicspage.h +++ b/src/preferences/graphicspage.h @@ -32,6 +32,7 @@ class NODISCARD_QOBJECT GraphicsPage final : public QWidget private: void changeColorClicked(XNamedColor &color, QPushButton *pushButton); void syncAntialiasingSamplesComboBox(); + void syncMapFontButton(); void graphicsSettingsChanged() { emit sig_graphicsSettingsChanged(); } Ui::GraphicsPage *const ui; std::unique_ptr m_advanced; diff --git a/src/preferences/graphicspage.ui b/src/preferences/graphicspage.ui index a07fa56d0..6f72d96a8 100644 --- a/src/preferences/graphicspage.ui +++ b/src/preferences/graphicspage.ui @@ -30,7 +30,7 @@ 9 - + Qt::Orientation::Vertical @@ -43,44 +43,6 @@ - - - - Room Details - - - - 9 - - - 9 - - - 9 - - - 9 - - - 6 - - - - - Texture upper layers - - - - - - - Render hidden doors - - - - - - @@ -199,7 +161,98 @@ + + + + Map Font + + + + + + Font: + + + + + + + Select Font... + + + + + + + + + + Room Details + + + + 9 + + + 9 + + + 9 + + + 9 + + + 6 + + + + + Texture upper layers + + + + + + + Render hidden doors + + + + + + + + + Mapping Hints + + + + + + Show missing map ids + + + + + + + Show unmapped exits + + + + + + + Show unsaved changes + + + + + + + Colors @@ -291,37 +344,7 @@ - - - - Mapping Hints - - - - - - Show missing map ids - - - - - - - Show unmapped exits - - - - - - - Show unsaved changes - - - - - - - + Weather and Atmosphere @@ -408,7 +431,7 @@ - + Advanced Settings @@ -420,6 +443,7 @@ antialiasingSamplesComboBox trilinearFilteringCheckBox + mapFontPushButton drawUpperLayersTextured drawDoorNames drawUnsavedChanges diff --git a/src/resources/fonts/Cantarell-Regular.ttf b/src/resources/fonts/Cantarell-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..535b58cf27e19eb327e6c96dc35df07a744bb26c GIT binary patch literal 43156 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 i%WdvhBrKk)@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/resources/fonts/Cantarell18_0.png b/src/resources/fonts/Cantarell18_0.png deleted file mode 100644 index 0b106e2bac1557fa7e4cbe4e53875f6220863360..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14802 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 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/resources/fonts/Cantarell27_0.png b/src/resources/fonts/Cantarell27_0.png deleted file mode 100644 index 36a9683651deb984da35daadb5f243f1ee698190..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25516 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 A3;+NC diff --git a/src/resources/fonts/Cantarell36.bmfc b/src/resources/fonts/Cantarell36.bmfc deleted file mode 100644 index 4020eb722..000000000 --- a/src/resources/fonts/Cantarell36.bmfc +++ /dev/null @@ -1,55 +0,0 @@ -# AngelCode Bitmap Font Generator configuration file -fileVersion=1 - -# font settings -fontName=Cantarell -fontFile= -charSet=0 -fontSize=36 -aa=4 -scaleH=100 -useSmoothing=1 -isBold=0 -isItalic=0 -useUnicode=1 -disableBoxChars=1 -outputInvalidCharGlyph=0 -dontIncludeKerningPairs=0 -useHinting=1 -renderFromOutline=1 -useClearType=1 - -# character alignment -paddingDown=0 -paddingUp=0 -paddingRight=0 -paddingLeft=0 -spacingHoriz=1 -spacingVert=1 -useFixedHeight=0 -forceZero=0 - -# output file -outWidth=256 -outHeight=256 -outBitDepth=32 -fontDescFormat=1 -fourChnlPacked=0 -textureFormat=png -textureCompression=0 -alphaChnl=0 -redChnl=4 -greenChnl=4 -blueChnl=4 -invA=0 -invR=0 -invG=0 -invB=0 - -# outline -outlineThickness=0 - -# selected chars -chars=32-126,160-255 - -# imported icon images diff --git a/src/resources/fonts/Cantarell36.fnt b/src/resources/fonts/Cantarell36.fnt deleted file mode 100644 index 56f9379c8..000000000 --- a/src/resources/fonts/Cantarell36.fnt +++ /dev/null @@ -1,201 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/resources/fonts/Cantarell36_0.png b/src/resources/fonts/Cantarell36_0.png deleted file mode 100644 index 36c64e7989670ba69ca917b6f2acf0f0114f4f3b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 36399 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 z%0DK_o?Cj)g;BvDn?v*6>K#>)7}b4Q({+_1$@S>QKF+CtEeEW zXxYVaE5waP4IJpr_KMBrJ2WXnq926u%TsmnD5;}!u(t2XilW44y%1G4D$|j~Mm-et zN%ZGZmW2}|^h1?LuC(%s3SoLLKIB|tL}v*$B*q^#speJ5?@kD@-mE5<%k7_g1=|*y zi%xfZV!}N#IBgo0b36h?G@v5WlYp!7rs|9Pz?*KObe5pRlJ~>E>uWq*b@hco?_NK_3{6aDU1(!Jj89fm`M=+9o$@gguBY zri2wsi2lXRR(<>g0nDF8h71HO1gf}CDquzxn)yx4a-*k3a{nrH`4qKFwHbz{fg_ST zjk?|c8GAc^Q!aJf8IxkoZ!TzhH@;N+zSl_&C_e$kWxgzY5X$0?Nxd_I$k7kuyF&Pr zOUZPlr`%-7cW6i26h0h~H8MvA+03nssh-mA6hR#~r#|iNxXfBONr^>7*znWDOtq2` z-PbRY8*FN3Q?9Z_DKH@IE%gx(tN4QA-qo9GlPKVfI*xsB-B}!CeJ~m8L~s*(yk80t?hOuS6k&%`o{~%b&lM5X_HJsR7Q$l=I%?2 zko^u3-bwfXz|I)-$?mV12AhpQTS)#`A!_;3aZF#6?*(ZTPs-7yU$s^@FXktlO7i-n zX)x#5^OwNyLI{x=hXC=uf!%k6!C_F~ro;3VEP>2ms5d{0@5>^~HrI{HqgAB_Cr+!k zvZP)+uEm#reLto-agPVzLE$--q2=ohEBiy>3$?d8z^aMbWb z%aZcm?ZKSpyYzs1M9GlkQALw@El)J))pj<$u})r`LS;a}*kV1qp8qe4@hCu5-cl7L zL0+p;aBEJP`YO64=gxg~-cJuN2L_s7zlG zkAPjoCkt1evUgt;=zB+pW}wF&YV zvQ3im)jJb0_0KX)BMwAE2^j`K*fM-$w0ZIg(LQ+nZi#+)mmMK^8M|aIPmyRnwB203 zjL0$j>Jn-qz1G%Xl`9Wxi$1N%OrzxtIF1KLH>@}BM-_A8?<)6M{^(J#e*JLoDxXG; z6w41O@R4g=I{31(pRuZE8IYawnRXd^Ru!L#OUfQ^W0w+{e8nU@UDGLL`+Fuw?A0Yi zM|_#Igc?_uv_&QPr=Ovz^o^b@se}OYE64YZP`{2y=t$?(LzS9G2{cYz#2BH&Wa1Q{-a3+;nQT|rx66yHocp^~ z@uq$ekm)ht=lW94`m~Np1um$%E>zJZ@y!F9di(}nkJ(~0>TNkpn`Bo=lZb)bdVvc_ z>MbfD?0R! mIV3?Y&4}dIIpP0TbOR`HMvF;ZPM?$9mf=G)y=I7W-2VUp&Kyzz diff --git a/src/resources/mmapper2.qrc b/src/resources/mmapper2.qrc index 1854f1359..e91141126 100644 --- a/src/resources/mmapper2.qrc +++ b/src/resources/mmapper2.qrc @@ -6,12 +6,7 @@ LICENSE.OPENSSL LICENSE.QTKEYCHAIN emojis/short-codes.json - fonts/Cantarell18.fnt - fonts/Cantarell18_0.png - fonts/Cantarell27.fnt - fonts/Cantarell27_0.png - fonts/Cantarell36.fnt - fonts/Cantarell36_0.png + fonts/Cantarell-Regular.ttf fonts/DejaVuSansMono.ttf fonts/LICENSE icons/affect-bashed.png diff --git a/src/resources/shaders/legacy/font/frag.glsl b/src/resources/shaders/legacy/font/frag.glsl index bd4322425..c3282ce2d 100644 --- a/src/resources/shaders/legacy/font/frag.glsl +++ b/src/resources/shaders/legacy/font/frag.glsl @@ -1,14 +1,26 @@ // SPDX-License-Identifier: GPL-2.0-or-later // Copyright (C) 2019 The MMapper Authors +// The font atlas is a single straight-alpha RGBA texture holding two kinds +// of glyphs: +// - monochrome glyphs: RGB is opaque white and A is the antialiased +// coverage, so the texel is simply modulated by the text color. +// - color glyphs (e.g. emoji): RGBA holds the actual glyph color, sampled +// as-is and only faded by the text alpha. uniform sampler2D uFontTexture; in vec4 vColor; in vec2 vTexCoord; +flat in float vIsColor; out vec4 vFragmentColor; void main() { - vFragmentColor = vColor * texture(uFontTexture, vTexCoord); + vec4 texel = texture(uFontTexture, vTexCoord); + if (vIsColor > 0.5) { + vFragmentColor = vec4(texel.rgb, texel.a * vColor.a); + } else { + vFragmentColor = vColor * texel; + } } diff --git a/src/resources/shaders/legacy/font/vert.glsl b/src/resources/shaders/legacy/font/vert.glsl index 902f16b26..283741595 100644 --- a/src/resources/shaders/legacy/font/vert.glsl +++ b/src/resources/shaders/legacy/font/vert.glsl @@ -3,14 +3,19 @@ uniform mat4 uMVP3D; uniform ivec4 uPhysViewport; +// Only used for its size: texture coordinates arrive in atlas texels so +// the atlas can grow without invalidating meshes built earlier. +uniform sampler2D uFontTexture; layout(location = 0) in vec3 aBase; // address in world space layout(location = 1) in vec4 aColor; -layout(location = 2) in vec2 aTexCoord; +layout(location = 2) in vec2 aTexCoord; // atlas texels, upper-left origin layout(location = 3) in vec2 aVert; // offset in raw pixels +layout(location = 4) in float aIsColor; out vec4 vColor; out vec2 vTexCoord; +flat out float vIsColor; // [0, 1]^2 to pixels vec2 convertScreen01toPhysPixels(vec2 pos) @@ -87,6 +92,7 @@ vec4 computePosition() void main() { vColor = aColor; - vTexCoord = aTexCoord; + vTexCoord = aTexCoord / vec2(textureSize(uFontTexture, 0)); + vIsColor = aIsColor; gl_Position = computePosition(); } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index a136b9f4d..ea907e5dc 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -23,6 +23,30 @@ if(MMAPPER_IS_DEBUG) endif() endif() +# Glyph atlas +set(font_SRCS + ../src/font/GlyphAtlas.cpp + ../src/font/GlyphAtlas.h +) +set(TestFont_SRCS TestFont.cpp) +add_executable(TestFont ${TestFont_SRCS} ${font_SRCS}) +add_dependencies(TestFont mm_test mm_global) +target_link_libraries(TestFont + mm_test + mm_global + Qt6::Gui + Qt6::Test + Qt6::Widgets + coverage_config) +set_target_properties( + TestFont PROPERTIES + CXX_STANDARD 20 + CXX_STANDARD_REQUIRED ON + CXX_EXTENSIONS OFF + COMPILE_FLAGS "${WARNING_FLAGS}" +) +add_test(NAME TestFont COMMAND TestFont) + # Common test library add_library(mm_test STATIC ../src/configuration/configuration.cpp diff --git a/tests/TestFont.cpp b/tests/TestFont.cpp new file mode 100644 index 000000000..3b257435d --- /dev/null +++ b/tests/TestFont.cpp @@ -0,0 +1,249 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2025 The MMapper Authors + +#include "../src/configuration/configuration.h" +#include "../src/font/GlyphAtlas.h" + +#include +#include + +class TestFont final : public QObject +{ + Q_OBJECT + +private: + QString m_family; + + NODISCARD QFont makeFont(const int pixelSize) const + { + QFont f(m_family); + f.setPixelSize(pixelSize); + return f; + } + +private slots: + void initTestCase() + { + setEnteredMain(); + const QString ttfPath = QFINDTESTDATA("../src/resources/fonts/Cantarell-Regular.ttf"); + QVERIFY(!ttfPath.isEmpty()); + const int fontId = QFontDatabase::addApplicationFont(ttfPath); + QVERIFY(fontId != -1); + m_family = QFontDatabase::applicationFontFamilies(fontId).at(0); + } + + void testPointSizeConversion() + { + // 11pt at 96 dpi is the ~15px em that master's baked Cantarell18 atlas had. + QCOMPARE(font::pointSizeToPhysicalPixels(11, 96.f, 1.f), 15); + QCOMPARE(font::pointSizeToPhysicalPixels(11, 96.f, 2.f), 29); + QCOMPARE(font::pointSizeToPhysicalPixels(12, 72.f, 1.f), 12); + // garbage in -> sane defaults + QCOMPARE(font::pointSizeToPhysicalPixels(11, 0.f, 0.f), 15); + QVERIFY(font::pointSizeToPhysicalPixels(0, 96.f, 1.f) >= 1); + } + + void testShapeBasics() + { + font::GlyphAtlas::Config config; + config.font = makeFont(15); + const font::GlyphAtlas atlas(config); + + QVERIFY(atlas.getLineHeight() > 0); + QVERIFY(atlas.getAscent() > 0); + QVERIFY(atlas.getAscent() <= atlas.getLineHeight()); + + const auto shaped = atlas.shape("Black Hill"); + QCOMPARE(shaped.glyphs.size(), static_cast(10)); + // The advance is what Qt's own layout says it is (same hinting). + const QFontMetrics fm(atlas.getFont()); + QCOMPARE(shaped.advance, fm.horizontalAdvance(QStringLiteral("Black Hill"))); + // ...and in the ballpark of master's baked Cantarell18 atlas (54px), + // to catch a gross scale regression. + QVERIFY2(shaped.advance >= 45 && shaped.advance <= 75, + qPrintable(QString::number(shaped.advance))); + + // Pen positions are monotonic for LTR text. + for (size_t i = 1; i < shaped.glyphs.size(); ++i) { + QVERIFY(shaped.glyphs[i].x >= shaped.glyphs[i - 1].x); + } + + // Space has no ink; letters do, and they carry padding. + QVERIFY(shaped.glyphs[5].glyph.isEmpty); + const auto &B = shaped.glyphs[0].glyph; + QVERIFY(!B.isEmpty); + QVERIFY(!B.isColor); + QVERIFY(B.width > 2 * font::GlyphAtlas::PADDING); + QVERIFY(B.height > 2 * font::GlyphAtlas::PADDING); + // 'B' sits on the baseline: quad bottom is one padding texel below it. + QCOMPARE(B.yoffset, -font::GlyphAtlas::PADDING); + // and its top is roughly the cap height + QVERIFY(B.yoffset + B.height >= 8); + QVERIFY(B.yoffset + B.height <= 14); + + // Distinct glyphs are rasterized once: B,l,a,c,k,H,i (l twice). + QCOMPARE(atlas.getNumGlyphs(), static_cast(8)); // includes the space + std::ignore = atlas.shape("Black Hill"); + QCOMPARE(atlas.getNumGlyphs(), static_cast(8)); + + // Empty and whitespace-only strings are harmless. + QCOMPARE(atlas.shape("").advance, 0); + QVERIFY(atlas.shape("").glyphs.empty()); + QVERIFY(atlas.shape(" ").advance > 0); + } + + void testAtlasPixels() + { + font::GlyphAtlas::Config config; + config.font = makeFont(15); + const font::GlyphAtlas atlas(config); + + const auto shaped = atlas.shape("H"); + const auto &g = shaped.glyphs.at(0).glyph; + QVERIFY(!g.isEmpty); + + const QImage img = atlas.copyImage(); + QCOMPARE(img.format(), QImage::Format_RGBA8888); + QCOMPARE(img.width(), font::GlyphAtlas::MIN_SIZE); + + // The synthetic white block backs the background/underline quads. + const auto &bg = atlas.getBackground(); + for (int y = bg.y; y < bg.y + bg.height; ++y) { + for (int x = bg.x; x < bg.x + bg.width; ++x) { + QCOMPARE(img.pixel(x, y), qRgba(255, 255, 255, 255)); + } + } + + // Padding ring is fully transparent, interior has white ink with coverage in alpha. + int opaque = 0; + for (int y = g.y; y < g.y + g.height; ++y) { + for (int x = g.x; x < g.x + g.width; ++x) { + const QRgb px = img.pixel(x, y); + const bool onRing = x < g.x + font::GlyphAtlas::PADDING + || x >= g.x + g.width - font::GlyphAtlas::PADDING + || y < g.y + font::GlyphAtlas::PADDING + || y >= g.y + g.height - font::GlyphAtlas::PADDING; + if (onRing) { + QCOMPARE(qAlpha(px), 0); + } else if (qAlpha(px) != 0) { + QCOMPARE(qRed(px), 255); + QCOMPARE(qGreen(px), 255); + QCOMPARE(qBlue(px), 255); + if (qAlpha(px) == 255) { + ++opaque; + } + } + } + } + // A hinted 'H' at 15px has solid stems, not just gray smear. + QVERIFY(opaque > 0); + } + + void testPendingUploads() + { + font::GlyphAtlas::Config config; + config.font = makeFont(15); + const font::GlyphAtlas atlas(config); + + // The initial (empty) atlas needs a full upload. + auto first = atlas.takePendingUpload(); + QVERIFY(first.has_value()); + QVERIFY(first->resized); + QCOMPARE(first->rect, QRect(0, 0, first->atlasWidth, first->atlasHeight)); + QCOMPARE(first->image.size(), first->rect.size()); + QVERIFY(!atlas.takePendingUpload().has_value()); + + // New glyphs only dirty the region they were packed into. + const auto shaped = atlas.shape("xyz"); + auto second = atlas.takePendingUpload(); + QVERIFY(second.has_value()); + QVERIFY(!second->resized); + QVERIFY(second->rect.width() < first->atlasWidth); + for (const auto &sg : shaped.glyphs) { + QVERIFY(second->rect.contains( + QRect(sg.glyph.x, sg.glyph.y, sg.glyph.width, sg.glyph.height))); + } + QCOMPARE(second->image.size(), second->rect.size()); + QCOMPARE(second->image.format(), QImage::Format_RGBA8888); + QVERIFY(!atlas.takePendingUpload().has_value()); + + // Re-shaping cached text uploads nothing. + std::ignore = atlas.shape("xyz"); + QVERIFY(!atlas.takePendingUpload().has_value()); + } + + void testGrowth() + { + font::GlyphAtlas::Config config; + config.font = makeFont(120); + config.initialSize = 256; + config.maxSize = 1024; + const font::GlyphAtlas atlas(config); + std::ignore = atlas.takePendingUpload(); + + const auto before = atlas.shape("AB"); + QCOMPARE(atlas.getSize(), QSize(256, 256)); + + // Enough 120px glyphs to overflow 256^2 several times. + const auto shaped = atlas.shape("CDEFGHIJKLMNOPQRSTUVWXYZ"); + QVERIFY(atlas.getSize().width() > 256); + QVERIFY(atlas.getSize().width() <= 1024); + QVERIFY(!atlas.isFull()); + + auto upload = atlas.takePendingUpload(); + QVERIFY(upload.has_value()); + QVERIFY(upload->resized); + QCOMPARE(upload->image.size(), atlas.getSize()); + + // Glyph rects survive growth unchanged (texel coordinates). + const auto after = atlas.shape("AB"); + QCOMPARE(after.glyphs.at(0).glyph.x, before.glyphs.at(0).glyph.x); + QCOMPARE(after.glyphs.at(0).glyph.y, before.glyphs.at(0).glyph.y); + for (const auto &sg : shaped.glyphs) { + QVERIFY(!sg.glyph.isEmpty); + QVERIFY(sg.glyph.x + sg.glyph.width <= atlas.getSize().width()); + QVERIFY(sg.glyph.y + sg.glyph.height <= atlas.getSize().height()); + } + } + + void testFullAtlasDegradesGracefully() + { + font::GlyphAtlas::Config config; + config.font = makeFont(200); + config.initialSize = 256; + config.maxSize = 256; + const font::GlyphAtlas atlas(config); + + const auto shaped = atlas.shape("ABCDEFGHIJ"); + QVERIFY(atlas.isFull()); + QCOMPARE(atlas.getSize(), QSize(256, 256)); + // Layout still works; glyphs that didn't fit are simply not drawn. + QVERIFY(shaped.advance > 0); + QCOMPARE(shaped.glyphs.size(), static_cast(10)); + bool anyEmpty = false; + for (const auto &sg : shaped.glyphs) { + anyEmpty |= sg.glyph.isEmpty; + } + QVERIFY(anyEmpty); + } + + void testUnicodeAndFallback() + { + font::GlyphAtlas::Config config; + config.font = makeFont(15); + const font::GlyphAtlas atlas(config); + + // Latin-1 and general punctuation shape to one glyph each. + QCOMPARE(atlas.shape("\xC3\xA9").glyphs.size(), static_cast(1)); // é + QCOMPARE(atlas.shape("\xE2\x80\x94").glyphs.size(), static_cast(1)); // em dash + + // Emoji go through font fallback. Whether a color font exists depends + // on the system, so only require that shaping doesn't blow up. + const auto emoji = atlas.shape("\xE2\x9A\xA1\xEF\xB8\x8F"); // U+26A1 U+FE0F + QVERIFY(!emoji.glyphs.empty()); + QVERIFY(emoji.advance > 0); + } +}; + +QTEST_MAIN(TestFont) +#include "TestFont.moc"