diff --git a/CHANGELOG.md b/CHANGELOG.md index 788be0840..e0ebd68c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### ✨ New features - *...Add new stuff here...* +- [core] Locally rasterized CJK glyphs now render at 2x texture resolution, preserving fine strokes. Mirrors [maplibre-gl-js#3006](https://github.com/maplibre/maplibre-gl-js/pull/3006). - [core] Added new map observer events: onPreCompileShader, onPostCompileShader, onShaderCompileFailed, onGlyphsLoaded, onGlyphsError, onGlyphsRequested, onTileAction, onSpriteLoaded, onSpriteError, onSpriteRequested ([#2694](https://github.com/maplibre/maplibre-native/pull/2694)). - [core] Add WebP image decoding support to default platform (Linux, Windows) - [core] All CMake properties are now prefixed `MLN_*` [1054](https://github.com/maplibre/maplibre-native/pull/1054). diff --git a/include/mbgl/text/glyph.hpp b/include/mbgl/text/glyph.hpp index f5a67be55..7694ca3a2 100644 --- a/include/mbgl/text/glyph.hpp +++ b/include/mbgl/text/glyph.hpp @@ -60,14 +60,17 @@ GlyphRange getGlyphRange(GlyphID glyph); struct GlyphMetrics { uint32_t width = 0; uint32_t height = 0; - int32_t left = 0; - int32_t top = 0; + float left = 0; + float top = 0; uint32_t advance = 0; + // True when the glyph bitmap is rendered at 2x texture resolution + // (e.g. local CJK glyphs). + bool isDoubleResolution = false; }; inline bool operator==(const GlyphMetrics &lhs, const GlyphMetrics &rhs) { return lhs.width == rhs.width && lhs.height == rhs.height && lhs.left == rhs.left && lhs.top == rhs.top && - lhs.advance == rhs.advance; + lhs.advance == rhs.advance && lhs.isDoubleResolution == rhs.isDoubleResolution; } class Glyph { diff --git a/include/mbgl/text/glyph_range.hpp b/include/mbgl/text/glyph_range.hpp index 461496a85..6f4f65db4 100644 --- a/include/mbgl/text/glyph_range.hpp +++ b/include/mbgl/text/glyph_range.hpp @@ -31,8 +31,9 @@ public: constexpr uint32_t GLYPHS_PER_GLYPH_RANGE = 256; constexpr uint32_t GLYPH_RANGES_PER_FONT_STACK = 256; -// 256 - 126 ranges skipped w/ i18n::allowsFixedWidthGlyphGeneration -constexpr uint32_t NON_IDEOGRAPH_GLYPH_RANGES_PER_FONT_STACK = 130; +// Verified against i18n::glyphRangeIsEntirelyLocallyGenerated by +// i18n.GlyphRangeLocalGenerationCountMatchesConstant. +constexpr uint32_t NON_IDEOGRAPH_GLYPH_RANGES_PER_FONT_STACK = 97; } // end namespace mbgl diff --git a/platform/android/MapLibreAndroid/src/cpp/text/local_glyph_rasterizer.cpp b/platform/android/MapLibreAndroid/src/cpp/text/local_glyph_rasterizer.cpp index f313a2d32..e529b5dc0 100644 --- a/platform/android/MapLibreAndroid/src/cpp/text/local_glyph_rasterizer.cpp +++ b/platform/android/MapLibreAndroid/src/cpp/text/local_glyph_rasterizer.cpp @@ -59,6 +59,15 @@ PremultipliedImage LocalGlyphRasterizer::drawGlyphBitmap(const std::string& font static_cast(glyphID))); } +float LocalGlyphRasterizer::getLastGlyphTop() { + UniqueEnv env{AttachEnv()}; + + static auto& javaClass = jni::Class::Singleton(*env); + static auto getLastGlyphTop = javaClass.GetMethod(*env, "getLastGlyphTop"); + + return javaObject.Call(*env, getLastGlyphTop); +} + void LocalGlyphRasterizer::registerNative(jni::JNIEnv& env) { jni::Class::Singleton(env); } @@ -84,6 +93,8 @@ public: return androidLocalGlyphRasterizer.drawGlyphBitmap(*fontFamily, bold, glyphID); } + float getLastGlyphTop() { return androidLocalGlyphRasterizer.getLastGlyphTop(); } + private: std::optional fontFamily; android::LocalGlyphRasterizer androidLocalGlyphRasterizer; @@ -98,6 +109,11 @@ bool LocalGlyphRasterizer::canRasterizeGlyph(const FontStack&, GlyphID glyphID) return util::i18n::allowsFixedWidthGlyphGeneration(glyphID) && impl->isConfigured(); } +// 2x bitmap with 1x logical metrics. +constexpr uint32_t kLocalGlyphTextureScale = 2; +constexpr uint32_t kLocalGlyphLogicalSize = 30; +constexpr uint32_t kLocalGlyphBitmapSize = kLocalGlyphLogicalSize * kLocalGlyphTextureScale; + Glyph LocalGlyphRasterizer::rasterizeGlyph(const FontStack& fontStack, GlyphID glyphID) { Glyph fixedMetrics; if (!impl->isConfigured()) { @@ -106,19 +122,21 @@ Glyph LocalGlyphRasterizer::rasterizeGlyph(const FontStack& fontStack, GlyphID g fixedMetrics.id = glyphID; - Size size(35, 35); - - fixedMetrics.metrics.width = size.width; - fixedMetrics.metrics.height = size.height; - fixedMetrics.metrics.left = -2; - fixedMetrics.metrics.top = -5; + // Metrics are reported in 1x logical units; the bitmap is allocated at 2x. + fixedMetrics.metrics.width = kLocalGlyphLogicalSize; + fixedMetrics.metrics.height = kLocalGlyphLogicalSize; + fixedMetrics.metrics.left = -1.5f; fixedMetrics.metrics.advance = 24; + fixedMetrics.metrics.isDoubleResolution = true; PremultipliedImage rgbaBitmap = impl->drawGlyphBitmap(fontStack, glyphID); + fixedMetrics.metrics.top = impl->getLastGlyphTop(); - // Copy alpha values from RGBA bitmap into the AlphaImage output - fixedMetrics.bitmap = AlphaImage(size); - for (uint32_t i = 0; i < size.width * size.height; i++) { + // Copy alpha values from RGBA bitmap into the AlphaImage output. The Java side + // produces a kLocalGlyphBitmapSize x kLocalGlyphBitmapSize bitmap. + const Size bitmapSize(kLocalGlyphBitmapSize, kLocalGlyphBitmapSize); + fixedMetrics.bitmap = AlphaImage(bitmapSize); + for (uint32_t i = 0; i < bitmapSize.width * bitmapSize.height; i++) { fixedMetrics.bitmap.data[i] = 0xff - round(0.2126 * rgbaBitmap.data[4 * i] + 0.7152 * rgbaBitmap.data[4 * i + 1] + 0.0722 * rgbaBitmap.data[4 * i + 2]); diff --git a/platform/android/MapLibreAndroid/src/cpp/text/local_glyph_rasterizer_jni.hpp b/platform/android/MapLibreAndroid/src/cpp/text/local_glyph_rasterizer_jni.hpp index d48a69d5e..4aa9375df 100644 --- a/platform/android/MapLibreAndroid/src/cpp/text/local_glyph_rasterizer_jni.hpp +++ b/platform/android/MapLibreAndroid/src/cpp/text/local_glyph_rasterizer_jni.hpp @@ -4,6 +4,8 @@ #include +#include + /* android::LocalGlyphRasterizer is the JNI wrapper of org/maplibre/android/text/LocalGlyphRasterizer @@ -24,6 +26,7 @@ public: LocalGlyphRasterizer(); PremultipliedImage drawGlyphBitmap(const std::string& fontFamily, const bool bold, const char16_t glyphID); + float getLastGlyphTop(); private: jni::Global, jni::EnvAttachingDeleter> javaObject; diff --git a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/text/LocalGlyphRasterizer.java b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/text/LocalGlyphRasterizer.java index e0fd02d13..0c05cd5d9 100644 --- a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/text/LocalGlyphRasterizer.java +++ b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/text/LocalGlyphRasterizer.java @@ -4,38 +4,76 @@ import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; +import android.graphics.Rect; import android.graphics.Typeface; import androidx.annotation.Keep; import androidx.annotation.NonNull; import androidx.annotation.WorkerThread; +import java.util.HashMap; +import java.util.Map; + /** * LocalGlyphRasterizer is the Android-specific platform implementation used * by the portable local_glyph_rasterizer.hpp */ @Keep public class LocalGlyphRasterizer { + private static final int TEXTURE_SCALE = 2; + private static final int BITMAP_SIZE = 60; + private static final int TEXT_SIZE = 48; + private static final int RASTER_BUFFER = 3 * TEXTURE_SCALE; + private static final float TOP_ADJUSTMENT = 26.5f; + private static final float BASELINE_X = 10; + private final Bitmap bitmap; @NonNull private final Paint paint; @NonNull private final Canvas canvas; + @NonNull + private final Rect textBounds; + @NonNull + // Tiny cache (font stacks × 2 for bold/regular) so glyphs don't + // pay Typeface.create's cache lookup and internal lock per call. + private final Map typefaceCache; + // Updated by drawGlyphBitmap so native code can read the measured top without + // allocating a per-glyph result object. + private float glyphTop; LocalGlyphRasterizer() { /* - 35x35px dimensions are hardwired to match local_glyph_rasterizer.cpp - These dimensions are large enough to draw a 24 point character in the middle - of the bitmap (y: 20) with some buffer around the edge + 60x60px dimensions are hardwired to match local_glyph_rasterizer.cpp. + Glyphs are drawn at 2x texture resolution, while logical metrics stay at 1x. + These dimensions are large enough to draw a 48 px character in the middle + of the bitmap with some buffer around the edge. */ - bitmap = Bitmap.createBitmap(35, 35, Bitmap.Config.ARGB_8888); + bitmap = Bitmap.createBitmap(BITMAP_SIZE, BITMAP_SIZE, Bitmap.Config.ARGB_8888); paint = new Paint(); paint.setAntiAlias(true); - paint.setTextSize(24); + paint.setTextSize(TEXT_SIZE); canvas = new Canvas(); canvas.setBitmap(bitmap); + textBounds = new Rect(); + typefaceCache = new HashMap<>(); + } + + private void setTypeface(String fontFamily, boolean bold) { + String key = (bold ? "bold:" : "regular:") + fontFamily; + Typeface typeface = typefaceCache.get(key); + if (typeface == null) { + typeface = Typeface.create(fontFamily, bold ? Typeface.BOLD : Typeface.NORMAL); + typefaceCache.put(key, typeface); + } + paint.setTypeface(typeface); + } + + private int measureGlyphTop(String glyph) { + paint.getTextBounds(glyph, 0, glyph.length(), textBounds); + return Math.max(0, -textBounds.top); } /*** @@ -51,9 +89,17 @@ public class LocalGlyphRasterizer { */ @WorkerThread protected Bitmap drawGlyphBitmap(String fontFamily, boolean bold, char glyphID) { - paint.setTypeface(Typeface.create(fontFamily, bold ? Typeface.BOLD : Typeface.NORMAL)); + setTypeface(fontFamily, bold); + String glyph = String.valueOf(glyphID); + int glyphTopPx = measureGlyphTop(glyph); + glyphTop = glyphTopPx / (float) TEXTURE_SCALE - TOP_ADJUSTMENT; canvas.drawColor(Color.WHITE); - canvas.drawText(String.valueOf(glyphID), 5, 25, paint); + canvas.drawText(glyph, BASELINE_X, RASTER_BUFFER + glyphTopPx, paint); return bitmap; } + + @WorkerThread + protected float getLastGlyphTop() { + return glyphTop; + } } diff --git a/platform/darwin/core/local_glyph_rasterizer.mm b/platform/darwin/core/local_glyph_rasterizer.mm index 931bf2dfb..8c558e61e 100644 --- a/platform/darwin/core/local_glyph_rasterizer.mm +++ b/platform/darwin/core/local_glyph_rasterizer.mm @@ -6,6 +6,7 @@ #include +#include #include #import @@ -19,6 +20,14 @@ namespace mbgl { +// 2x bitmap with 1x logical metrics. +constexpr uint32_t kLocalGlyphTextureScale = 2; +constexpr CGFloat kLocalGlyphFontSize = util::ONE_EM * kLocalGlyphTextureScale; +constexpr uint32_t kLocalGlyphLogicalSize = 30; +constexpr uint32_t kLocalGlyphBitmapSize = kLocalGlyphLogicalSize * kLocalGlyphTextureScale; +constexpr CGFloat kLocalGlyphRasterBuffer = Glyph::borderSize * kLocalGlyphTextureScale; +constexpr CGFloat kLocalGlyphTopAdjustment = 26.5; + using CGColorSpaceHandle = CFHandle; using CGContextHandle = CFHandle; using CFStringRefHandle = CFHandle; @@ -35,7 +44,7 @@ using CTLineRefHandle = CFHandle; typesetting text using glyph sheets downloaded from a server. This implementation is similar to the local glyph rasterization in GL JS: - - Only CJK glyphs are drawn locally, because it’s much more noticeable when a + - Only CJK-related BMP ranges are drawn locally, because it’s much more noticeable when a non-CJK font mismatches the style than when a CJK font mismatches the style. (Unlike GL JS, this implementation does respect the font’s metrics, so it does work with variable-width fonts.) @@ -44,8 +53,6 @@ using CTLineRefHandle = CFHandle; This is a first step toward fully local font rendering: . Further improvements: - - Make sure the font size is 24 points (`util::ONE_EM`) at all times, not the - system default of 12 points, to avoid blobbiness after rasterization. - Sniff an appropriate font weight and style from the font stack’s font names, as GL JS and the Android map SDK do. - Enable local glyph rasterization for all writing systems, not just CJK. This @@ -124,7 +131,7 @@ public: if (!fontNames.count) { NSDictionary *fontAttributes = @{ - (NSString *)kCTFontSizeAttribute : @(util::ONE_EM), + (NSString *)kCTFontSizeAttribute : @(kLocalGlyphFontSize), }; return CTFontDescriptorCreateWithAttributes((CFDictionaryRef)fontAttributes); } @@ -136,7 +143,7 @@ public: CFArrayCreateMutable(kCFAllocatorDefault, fontNames.count, &kCFTypeArrayCallBacks)); for (NSString *name in [fontNames subarrayWithRange:NSMakeRange(1, fontNames.count - 1)]) { NSDictionary *fontAttributes = @{ - (NSString *)kCTFontSizeAttribute : @(util::ONE_EM), + (NSString *)kCTFontSizeAttribute : @(kLocalGlyphFontSize), // This attribute is technically supposed to be a font’s // PostScript name, but Core Text will fall back to matching // font display names and font family names. @@ -154,7 +161,7 @@ public: kCTFontCascadeListAttribute, }; CFTypeRef values[] = { - (__bridge CFNumberRef) @(util::ONE_EM), + (__bridge CFNumberRef) @(kLocalGlyphFontSize), mainFontName, *fallbackDescriptors, }; @@ -241,9 +248,11 @@ PremultipliedImage drawGlyphBitmap(GlyphID glyphID, CTFontRef font, GlyphMetrics throw std::runtime_error("Unable to create line from attributed string"); } - Size size(35, 35); - metrics.width = size.width; - metrics.height = size.height; + // Bitmap is allocated at 2x texture resolution; metrics report 1x logical size. + Size size(kLocalGlyphBitmapSize, kLocalGlyphBitmapSize); + metrics.width = kLocalGlyphLogicalSize; + metrics.height = kLocalGlyphLogicalSize; + metrics.isDoubleResolution = true; PremultipliedImage rgbaBitmap(size); @@ -270,16 +279,27 @@ PremultipliedImage drawGlyphBitmap(GlyphID glyphID, CTFontRef font, GlyphMetrics CFRange wholeRunRange = CFRangeMake(0, CTRunGetGlyphCount(glyphRun)); std::vector advances(wholeRunRange.length); CTRunGetAdvances(glyphRun, wholeRunRange, advances.data()); - metrics.advance = std::round(advances[0].width); + // CTRunGetAdvances returns advances measured at the rasterization (2x) font + // size; report the logical 1x value using the historical integer metric. + metrics.advance = std::round(advances[0].width / static_cast(kLocalGlyphTextureScale)); - // Mimic glyph PBF metrics. - metrics.left = Glyph::borderSize; - metrics.top = -Glyph::borderSize; + const CGRect glyphBounds = CTRunGetImageBounds(glyphRun, *context, wholeRunRange); + CGFloat glyphTop = 0.0; + if (!CGRectIsNull(glyphBounds)) { + const CGFloat maxY = CGRectGetMaxY(glyphBounds); + if (std::isfinite(maxY)) { + glyphTop = maxY; + } + } - // Move the text upward to avoid clipping off descenders. - CGFloat descent; - CTRunGetTypographicBounds(glyphRun, wholeRunRange, NULL, &descent, NULL); - CGContextSetTextPosition(*context, 0.0, descent); + // GL JS/TinySDF records glyphTop per character and applies a calibrated + // topAdjustment. Keep the same model here so fallback fonts do not inherit a + // platform-wide fixed top value. + metrics.left = Glyph::borderSize + 0.5f; + metrics.top = glyphTop / kLocalGlyphTextureScale - kLocalGlyphTopAdjustment; + + const CGFloat textBaselineY = size.height - kLocalGlyphRasterBuffer - glyphTop; + CGContextSetTextPosition(*context, 0.0, textBaselineY); CTLineDraw(*line, *context); @@ -318,8 +338,9 @@ Glyph LocalGlyphRasterizer::rasterizeGlyph(const FontStack &fontStack, GlyphID g PremultipliedImage rgbaBitmap = drawGlyphBitmap(glyphID, *font, manufacturedGlyph.metrics, isBold); - Size size(manufacturedGlyph.metrics.width, manufacturedGlyph.metrics.height); + const Size size = rgbaBitmap.size; // Copy alpha values from RGBA bitmap into the AlphaImage output + // The bitmap may be larger than the logical metrics (2x texture resolution). manufacturedGlyph.bitmap = AlphaImage(size); for (uint32_t i = 0; i < size.width * size.height; i++) { manufacturedGlyph.bitmap.data[i] = rgbaBitmap.data[4 * i + 3]; diff --git a/platform/default/src/mbgl/storage/offline_download.cpp b/platform/default/src/mbgl/storage/offline_download.cpp index 4a48c584c..6e840715c 100644 --- a/platform/default/src/mbgl/storage/offline_download.cpp +++ b/platform/default/src/mbgl/storage/offline_download.cpp @@ -341,9 +341,10 @@ void OfflineDownload::activateDownload() { const bool includeIdeographs = std::visit([](auto& reg) { return reg.includeIdeographs; }, definition); for (const auto& fontStack : parser.fontStacks()) { for (char16_t i = 0; i < GLYPH_RANGES_PER_FONT_STACK; i++) { - // Assumes that if a glyph range starts with fixed width/ideographic - // characters, the entire range will be fixed width. - if (includeIdeographs || !util::i18n::allowsFixedWidthGlyphGeneration(i * GLYPHS_PER_GLYPH_RANGE)) { + // Only skip a range when every codepoint in it can be + // generated locally; some ranges (e.g. 0xFF00) are mixed. + if (includeIdeographs || + !util::i18n::glyphRangeIsEntirelyLocallyGenerated(i * GLYPHS_PER_GLYPH_RANGE)) { auto range = getGlyphRange(i * GLYPHS_PER_GLYPH_RANGE); queueResource(Resource::glyphs( parser.glyphURL, fontStack, std::pair{range.first, range.second})); diff --git a/platform/qt/src/mbgl/local_glyph_rasterizer.cpp b/platform/qt/src/mbgl/local_glyph_rasterizer.cpp index bbae70a72..61ecd7ee4 100644 --- a/platform/qt/src/mbgl/local_glyph_rasterizer.cpp +++ b/platform/qt/src/mbgl/local_glyph_rasterizer.cpp @@ -7,9 +7,15 @@ #include #include #include - namespace mbgl { +// 2x bitmap with 1x logical metrics. +constexpr int kLocalGlyphTextureScale = 2; +constexpr uint32_t kLocalGlyphLogicalSize = 30; +constexpr uint32_t kLocalGlyphBitmapSize = kLocalGlyphLogicalSize * kLocalGlyphTextureScale; +constexpr int kLocalGlyphRasterBuffer = Glyph::borderSize * kLocalGlyphTextureScale; +constexpr double kLocalGlyphTopAdjustment = 26.5; + class LocalGlyphRasterizer::Impl { public: Impl(const std::optional& fontFamily_); @@ -25,7 +31,7 @@ LocalGlyphRasterizer::Impl::Impl(const std::optional& fontFamily_) : fontFamily(fontFamily_) { if (isConfigured()) { font.setFamily(QString::fromStdString(*fontFamily)); - font.setPixelSize(util::ONE_EM); + font.setPixelSize(util::ONE_EM * kLocalGlyphTextureScale); metrics = QFontMetrics(font); } } @@ -53,25 +59,39 @@ Glyph LocalGlyphRasterizer::rasterizeGlyph(const FontStack&, GlyphID glyphID) { return glyph; } - glyph.metrics.width = impl->metrics->horizontalAdvance(glyphID.complex.code); - glyph.metrics.height = impl->metrics->height(); - glyph.metrics.left = 3; - glyph.metrics.top = -8; + // QFontMetrics returns measurements at the 2x rasterization pixel size; the + // glyph's metrics are reported in 1x logical units. + const QString text(QChar(glyphID.complex.code)); + const int advance2x = impl->metrics->horizontalAdvance(glyphID.complex.code); + const int height2x = impl->metrics->height(); + const QRect glyphBounds = impl->metrics->tightBoundingRect(text); + const int glyphTop2x = std::max(0, -glyphBounds.top()); + glyph.metrics.width = advance2x / kLocalGlyphTextureScale; + glyph.metrics.height = height2x / kLocalGlyphTextureScale; + glyph.metrics.left = 3.5f; + glyph.metrics.top = glyphTop2x / static_cast(kLocalGlyphTextureScale) - kLocalGlyphTopAdjustment; glyph.metrics.advance = glyph.metrics.width; + glyph.metrics.isDoubleResolution = true; - // Set width of a glyph's backing image to be util::ONE_EM. - Size size(util::ONE_EM, glyph.metrics.height); + // The backing image is drawn at 2x texture resolution. + Size size(kLocalGlyphBitmapSize, kLocalGlyphBitmapSize); QImage image(QSize(size.width, size.height), QImage::Format_Alpha8); image.fill(qRgba(0, 0, 0, 0)); QPainter painter(&image); painter.setFont(impl->font); painter.setRenderHints(QPainter::TextAntialiasing); - // Render at constant baseline, to align with glyphs that are rendered by node-fontnik. - painter.drawText(QPointF(0, 20), QString(QChar(glyphID.complex.code))); + // Align the glyph using its measured top, matching the local TinySDF path + // in GL JS rather than relying on a platform-wide fixed top value. + const double baselineY = kLocalGlyphRasterBuffer + glyphTop2x; + painter.drawText(QPointF(0, baselineY), text); - auto img = std::make_unique(image.sizeInBytes()); - memcpy(img.get(), image.constBits(), image.sizeInBytes()); + // QImage::Format_Alpha8 may pad each row for alignment. Copy row-by-row + // using the unpadded width so AlphaImage's tight layout matches. + auto img = std::make_unique(static_cast(size.width) * size.height); + for (uint32_t y = 0; y < size.height; ++y) { + std::memcpy(img.get() + static_cast(y) * size.width, image.constScanLine(y), size.width); + } glyph.bitmap = AlphaImage{size, std::move(img)}; diff --git a/src/mbgl/text/glyph_manager.cpp b/src/mbgl/text/glyph_manager.cpp index a617e9e56..59e6eae48 100644 --- a/src/mbgl/text/glyph_manager.cpp +++ b/src/mbgl/text/glyph_manager.cpp @@ -71,7 +71,8 @@ void GlyphManager::getGlyphs(GlyphRequestor& requestor, GlyphDependencies glyphD Glyph GlyphManager::generateLocalSDF(const FontStack& fontStack, GlyphID glyphID) { Glyph local = localGlyphRasterizer->rasterizeGlyph(fontStack, glyphID); - local.bitmap = util::transformRasterToSDF(local.bitmap, 8, .25); + const double radius = local.metrics.isDoubleResolution ? 16.0 : 8.0; + local.bitmap = util::transformRasterToSDF(local.bitmap, radius, .25); return local; } diff --git a/src/mbgl/text/local_glyph_rasterizer.hpp b/src/mbgl/text/local_glyph_rasterizer.hpp index 69550b3ed..fa931c34e 100644 --- a/src/mbgl/text/local_glyph_rasterizer.hpp +++ b/src/mbgl/text/local_glyph_rasterizer.hpp @@ -11,21 +11,13 @@ namespace mbgl { the glyph and appropriate metrics. GlyphManager will then use TinySDF to transform the rasterized bitmap into an SDF. - The JS equivalent of this functionality will only generate glyphs in the - 'CJK Unified Ideographs' and 'Hangul Syllables' ranges, for which it can - get away with rendering a fixed 30px square image and GlyphMetrics of: - - width: 24, - height: 24, - left: 0, - top: -8, - advance: 24 - - The JS equivalent also uses heuristic evaluation of the font stack name - to control the font-weight it uses during rasterization. + The shared default codepoint filter is + util::i18n::allowsFixedWidthGlyphGeneration. + Following maplibre-gl-js, platform implementations use 2x bitmaps with + 1x metrics via GlyphMetrics::isDoubleResolution. It is left to platform-specific implementation to decide how best to - map a FontStack to a particular rasterization. + map a FontStack to a particular rasterization (font, weight, style). The default implementation simply refuses to rasterize any glyphs. */ diff --git a/src/mbgl/text/quads.cpp b/src/mbgl/text/quads.cpp index 800121af4..e5e7fde68 100644 --- a/src/mbgl/text/quads.cpp +++ b/src/mbgl/text/quads.cpp @@ -267,11 +267,15 @@ SymbolQuads getGlyphQuads(const Shaping& shapedText, builtInOffset = {0.0f, 0.0f}; } + // rectBuffer is in 1x logical units; only rect.w/h carry the texture + // scale. GL JS keeps 2x glyphs centered by storing fractional + // left/top metrics, so native local glyphs do the same. + const float textureScale = positionedGlyph.metrics.isDoubleResolution ? 2.0f : 1.0f; const float x1 = (positionedGlyph.metrics.left - rectBuffer) * positionedGlyph.scale - halfAdvance + builtInOffset.x; const float y1 = (-positionedGlyph.metrics.top - rectBuffer) * positionedGlyph.scale + builtInOffset.y; - const float x2 = x1 + rect.w * positionedGlyph.scale / pixelRatio; - const float y2 = y1 + rect.h * positionedGlyph.scale / pixelRatio; + const float x2 = x1 + rect.w / textureScale * positionedGlyph.scale / pixelRatio; + const float y2 = y1 + rect.h / textureScale * positionedGlyph.scale / pixelRatio; Point tl{x1, y1}; Point tr{x2, y1}; diff --git a/src/mbgl/util/i18n.cpp b/src/mbgl/util/i18n.cpp index b2b35ddb9..8926a110f 100644 --- a/src/mbgl/util/i18n.cpp +++ b/src/mbgl/util/i18n.cpp @@ -18,6 +18,10 @@ namespace { return codepoint >= (first) && codepoint <= (last); \ } +inline bool isInRange(char16_t codepoint, char16_t first, char16_t last) { + return codepoint >= first && codepoint <= last; +} + // The following table comes from . // Keep it synchronized with . @@ -411,8 +415,59 @@ bool allowsIdeographicBreaking(char16_t chr) { } bool allowsFixedWidthGlyphGeneration(char16_t chr) { - // Mirrors conservative set of characters used in glyph_manager.js/_tinySDF - return isInCJKUnifiedIdeographs(chr) || isInHangulSyllables(chr) || isInKatakana(chr) || isInHiragana(chr); + // Mirrors the BMP ranges in GL JS's codePointUsesLocalIdeographFontFamily. + // Astral-plane ranges are not included because GlyphID only carries BMP + // codepoints for local glyph generation. + return + // Bopomofo tone marks. + isInRange(chr, 0x02EA, 0x02EB) || + // Hangul Jamo. + isInHangulJamo(chr) || + // CJK Radicals Supplement, Kangxi Radicals. + isInCJKRadicalsSupplement(chr) || isInKangxiRadicals(chr) || + // CJK Symbols and Punctuation, Hiragana, Katakana. + isInCJKSymbolsandPunctuation(chr) || isInHiragana(chr) || isInKatakana(chr) || + // Bopomofo. + isInRange(chr, 0x3105, 0x312F) || + // Hangul Compatibility Jamo. + isInRange(chr, 0x3131, 0x318E) || + // Bopomofo Extended through CJK Unified Ideographs Extension A. + isInBopomofoExtended(chr) || isInCJKStrokes(chr) || isInKatakanaPhoneticExtensions(chr) || + isInEnclosedCJKLettersandMonths(chr) || isInCJKCompatibility(chr) || isInCJKUnifiedIdeographsExtensionA(chr) || + // CJK Unified Ideographs and Yi Syllables. + isInCJKUnifiedIdeographs(chr) || isInRange(chr, 0xA000, 0xA48C) || + // Yi Radicals. + isInRange(chr, 0xA490, 0xA4C6) || + // Hangul Jamo Extended-A. + isInRange(chr, 0xA960, 0xA97C) || + // Hangul Syllables and Hangul Jamo Extended-B. + isInHangulSyllables(chr) || isInRange(chr, 0xD7B0, 0xD7C6) || + // Hangul Jamo Extended-B. + isInRange(chr, 0xD7CB, 0xD7FB) || + // CJK Compatibility Ideographs. + isInRange(chr, 0xF900, 0xFA6D) || + // CJK Compatibility Ideographs. + isInRange(chr, 0xFA70, 0xFAD9) || + // Vertical Forms. + isInVerticalForms(chr) || + // CJK Compatibility Forms. + isInCJKCompatibilityForms(chr) || + // Halfwidth and Fullwidth Forms. + isInHalfwidthandFullwidthForms(chr); +} + +bool glyphRangeIsEntirelyLocallyGenerated(char16_t rangeStart) { + // Some ranges (notably 0xFF00, where Halfwidth/Fullwidth Forms ends at + // 0xFFEF and the Specials block continues to 0xFFFD) mix locally-rendered + // and non-local codepoints, so it is not safe to infer the whole range + // from the first codepoint alone. + constexpr uint32_t glyphsPerRange = 256; + for (uint32_t offset = 0; offset < glyphsPerRange; ++offset) { + if (!allowsFixedWidthGlyphGeneration(static_cast(rangeStart + offset))) { + return false; + } + } + return true; } bool allowsVerticalWritingMode(const std::u16string& string) { diff --git a/src/mbgl/util/i18n.hpp b/src/mbgl/util/i18n.hpp index 6d53aa371..0367b800f 100644 --- a/src/mbgl/util/i18n.hpp +++ b/src/mbgl/util/i18n.hpp @@ -27,6 +27,10 @@ bool allowsIdeographicBreaking(char16_t chr); advances */ bool allowsFixedWidthGlyphGeneration(char16_t chr); +/** Returns whether the whole 256-codepoint glyph range can be generated + locally. Use this for range-level decisions because some ranges are mixed. */ +bool glyphRangeIsEntirelyLocallyGenerated(char16_t rangeStart); + /** Returns whether any substring of the given string can be drawn as vertical text with upright glyphs. */ bool allowsVerticalWritingMode(const std::u16string& string); diff --git a/test/fixtures/local_glyphs/noto_sans_cjk_kr_regular_qt/expected.png b/test/fixtures/local_glyphs/noto_sans_cjk_kr_regular_qt/expected.png index 0d6907517..34b176881 100644 Binary files a/test/fixtures/local_glyphs/noto_sans_cjk_kr_regular_qt/expected.png and b/test/fixtures/local_glyphs/noto_sans_cjk_kr_regular_qt/expected.png differ diff --git a/test/fixtures/local_glyphs/ping_fang/expected.png b/test/fixtures/local_glyphs/ping_fang/expected.png index 9bb78c9f7..a4e34459b 100644 Binary files a/test/fixtures/local_glyphs/ping_fang/expected.png and b/test/fixtures/local_glyphs/ping_fang/expected.png differ diff --git a/test/fixtures/local_glyphs/ping_fang_semibold/expected.png b/test/fixtures/local_glyphs/ping_fang_semibold/expected.png index 25ceda681..f52fdf0fa 100644 Binary files a/test/fixtures/local_glyphs/ping_fang_semibold/expected.png and b/test/fixtures/local_glyphs/ping_fang_semibold/expected.png differ diff --git a/test/fixtures/local_glyphs/ping_fang_with_bold_in_style/expected.png b/test/fixtures/local_glyphs/ping_fang_with_bold_in_style/expected.png index 8665da2ea..11b1fee9b 100644 Binary files a/test/fixtures/local_glyphs/ping_fang_with_bold_in_style/expected.png and b/test/fixtures/local_glyphs/ping_fang_with_bold_in_style/expected.png differ diff --git a/test/storage/offline_download.test.cpp b/test/storage/offline_download.test.cpp index 50fb6404e..62ebce412 100644 --- a/test/storage/offline_download.test.cpp +++ b/test/storage/offline_download.test.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -346,10 +347,10 @@ TEST(OfflineDownload, ExcludeIdeographs) { observer->statusChangedFn = [&](OfflineRegionStatus status) { if (status.complete()) { EXPECT_EQ(status.completedTileCount, status.requiredTileCount); - EXPECT_EQ(138u, - status.completedResourceCount); // 130 glyphs, 2 sprite images, - // 2 sprite jsons, 1 tile, 1 - // style, source, image + EXPECT_EQ(NON_IDEOGRAPH_GLYPH_RANGES_PER_FONT_STACK + 8u, + status.completedResourceCount); // glyph ranges, 2 sprite images, + // 2 sprite jsons, 1 tile, 1 style, + // source, image EXPECT_EQ(test.size, status.completedResourceSize); download.setState(OfflineRegionDownloadState::Inactive); diff --git a/test/text/glyph_manager.test.cpp b/test/text/glyph_manager.test.cpp index eea738737..176894225 100644 --- a/test/text/glyph_manager.test.cpp +++ b/test/text/glyph_manager.test.cpp @@ -119,6 +119,13 @@ public: } }; +class KatakanaOnlyLocalGlyphRasterizer : public StubLocalGlyphRasterizer { +public: + bool canRasterizeGlyph(const FontStack&, GlyphID glyphID) override { + return static_cast(glyphID) == u'テ'; + } +}; + class StubGlyphManagerObserver : public GlyphManagerObserver { public: void onGlyphsLoaded(const FontStack& fontStack, const GlyphRange& glyphRange) override { @@ -148,7 +155,11 @@ public: StubFileSource fileSource; StubGlyphManagerObserver observer; StubGlyphRequestor requestor; - GlyphManager glyphManager{std::make_unique()}; + GlyphManager glyphManager; + + GlyphManagerTest( + std::unique_ptr localGlyphRasterizer = std::make_unique()) + : glyphManager(std::move(localGlyphRasterizer)) {} void run(const std::string& url, GlyphDependencies dependencies) { // Squelch logging. @@ -317,8 +328,40 @@ TEST(GlyphManager, LoadLocalCJKGlyph) { GlyphDependencies{.glyphs = {{{{"Test Stack"}}, {u'中'}}}, .shapes = {}}); } -TEST(GlyphManager, LoadLocalCJKGlyphAfterLoadingRangeFromURL) { +TEST(GlyphManager, LoadLocalCJKSymbols) { GlyphManagerTest test; + int glyphResponses = 0; + + test.fileSource.glyphsResponse = [&](const Resource&) { + glyphResponses++; + return std::optional(); + }; + + test.observer.glyphsLoaded = [&](const FontStack&, const GlyphRange&) { + glyphResponses++; + }; + + test.requestor.glyphsAvailable = [&](GlyphMap glyphs) { + EXPECT_EQ(glyphResponses, 0); // Local generation should prevent requesting any glyphs. + + const auto& testPositions = glyphs.at(FontStackHasher()({{"Test Stack"}})); + + ASSERT_EQ(testPositions.size(), 2u); + ASSERT_EQ(testPositions.count(u'〒'), 1u); + ASSERT_EQ(testPositions.count(u'!'), 1u); + + test.end(); + }; + + test.run("test/fixtures/resources/glyphs.pbf", + GlyphDependencies{.glyphs = {{{{"Test Stack"}}, {u'〒', u'!'}}}, .shapes = {}}); +} + +TEST(GlyphManager, LocalCJKGlyphPrefersLocalRasterizationOverRange) { + // Verifies that locally-rasterized glyphs (like テ) are produced by the + // local rasterizer instead of using a glyph from an already-loaded PBF + // range. + GlyphManagerTest test(std::make_unique()); bool firstGlyphResponse = false; test.fileSource.glyphsResponse = [&](const Resource&) { @@ -337,9 +380,6 @@ TEST(GlyphManager, LoadLocalCJKGlyphAfterLoadingRangeFromURL) { ASSERT_EQ(testPositions.size(), 1u); ASSERT_EQ(testPositions.count(u'々'), 1u); - // Katakana letter te, should be locally rasterized - // instead of using the glyph recieved from the range - // for the ideagraphic mark test.glyphManager.getGlyphs(test.requestor, GlyphDependencies{.glyphs = {{{{"Test Stack"}}, {u'テ'}}}, // 0x30c6 .shapes = {}}, @@ -443,3 +483,16 @@ TEST(GlyphManager, ImmediateFileSource) { test.run("test/fixtures/resources/glyphs.pbf", GlyphDependencies{.glyphs = {{{{"Test Stack"}}, {u'a', u'å', u' '}}}, .shapes = {}}); } + +// Guards against silent drift between allowsFixedWidthGlyphGeneration's range +// coverage and the precomputed NON_IDEOGRAPH_GLYPH_RANGES_PER_FONT_STACK used +// for offline download progress estimation. +TEST(i18n, GlyphRangeLocalGenerationCountMatchesConstant) { + uint32_t locallyGeneratedRanges = 0; + for (uint32_t i = 0; i < GLYPH_RANGES_PER_FONT_STACK; ++i) { + if (util::i18n::glyphRangeIsEntirelyLocallyGenerated(static_cast(i * GLYPHS_PER_GLYPH_RANGE))) { + ++locallyGeneratedRanges; + } + } + EXPECT_EQ(GLYPH_RANGES_PER_FONT_STACK - locallyGeneratedRanges, NON_IDEOGRAPH_GLYPH_RANGES_PER_FONT_STACK); +} diff --git a/test/text/local_glyph_rasterizer.test.cpp b/test/text/local_glyph_rasterizer.test.cpp index 1922aacd9..94929a12a 100644 --- a/test/text/local_glyph_rasterizer.test.cpp +++ b/test/text/local_glyph_rasterizer.test.cpp @@ -65,7 +65,7 @@ TEST(LocalGlyphRasterizer, PingFang) { test.map.getStyle().loadJSON(util::read_file("test/fixtures/local_glyphs/mixed.json")); #if defined(__APPLE__) && !defined(__QT__) - test.checkRendering("ping_fang", 0.0161); + test.checkRendering("ping_fang", 0.025); #elif defined(__QT__) test.checkRendering("ping_fang_qt"); #endif // defined(__APPLE__) @@ -77,7 +77,7 @@ TEST(LocalGlyphRasterizer, PingFangWithBoldInStyle) { ss << std::regex_replace( util::read_file("test/fixtures/local_glyphs/mixed.json"), std::regex("NotoCJK"), "NotoCJK Bold"); test.map.getStyle().loadJSON(ss.str()); - test.checkRendering("ping_fang_with_bold_in_style"); + test.checkRendering("ping_fang_with_bold_in_style", 0.05); } #if !defined(__QT__) @@ -85,7 +85,7 @@ TEST(LocalGlyphRasterizer, PingFangSemibold) { LocalGlyphRasterizerTest test(std::string("PingFang TC Semibold")); test.map.getStyle().loadJSON(util::read_file("test/fixtures/local_glyphs/mixed.json")); - test.checkRendering("ping_fang_semibold", 0.0161); + test.checkRendering("ping_fang_semibold", 0.025); } #endif // !defined(__QT__) diff --git a/test/text/quads.test.cpp b/test/text/quads.test.cpp index af88238b0..424cef718 100644 --- a/test/text/quads.test.cpp +++ b/test/text/quads.test.cpp @@ -10,6 +10,55 @@ using namespace mbgl; using namespace mbgl::style; +// Atlas padding is not scaled with textureScale, so a 2x glyph quad is 1 +// logical pixel smaller than its 1x equivalent. GL JS-style fractional metrics +// keep its center aligned with the 1x quad. +TEST(getGlyphQuads, DoubleResolutionGlyphPreservesLogicalQuadSize) { + GlyphMetrics metrics1x; + metrics1x.width = 24; + metrics1x.height = 24; + metrics1x.left = 0; + metrics1x.top = -8; + metrics1x.advance = 24; + metrics1x.isDoubleResolution = false; + + GlyphMetrics metrics2x = metrics1x; + metrics2x.left += 0.5f; + metrics2x.top -= 0.5f; + metrics2x.isDoubleResolution = true; + + // Rects reflect the real DynamicTextureAtlas output: bitmap.size + 2 * + // ImagePosition::padding (= 2). 1x bitmap 30 -> rect 32; 2x bitmap 60 -> rect 62. + Shaping shapedText1x; + shapedText1x.positionedLines.emplace_back(); + shapedText1x.positionedLines.back().positionedGlyphs.emplace_back( + PositionedGlyph(u'A', 0.0f, 0.0f, false, 0, 1.0f, Rect(0, 0, 32, 32), metrics1x, std::nullopt)); + + Shaping shapedText2x; + shapedText2x.positionedLines.emplace_back(); + shapedText2x.positionedLines.back().positionedGlyphs.emplace_back( + PositionedGlyph(u'A', 0.0f, 0.0f, false, 0, 1.0f, Rect(0, 0, 62, 62), metrics2x, std::nullopt)); + + SymbolLayoutProperties::Evaluated layout; + SymbolQuads quads1x = getGlyphQuads(shapedText1x, {{0, 0}}, layout, SymbolPlacementType::Point, {}, false); + SymbolQuads quads2x = getGlyphQuads(shapedText2x, {{0, 0}}, layout, SymbolPlacementType::Point, {}, false); + + ASSERT_EQ(quads1x.size(), 1u); + ASSERT_EQ(quads2x.size(), 1u); + + // The 2x quad is 1 logical pixel smaller, and shifted by 0.5px to keep the + // same center as the 1x quad. + EXPECT_FLOAT_EQ(quads1x[0].tl.x + 0.5f, quads2x[0].tl.x); + EXPECT_FLOAT_EQ(quads1x[0].tl.y + 0.5f, quads2x[0].tl.y); + EXPECT_FLOAT_EQ(quads1x[0].br.x - 0.5f, quads2x[0].br.x); + EXPECT_FLOAT_EQ(quads1x[0].br.y - 0.5f, quads2x[0].br.y); + + EXPECT_FLOAT_EQ(quads1x[0].br.x - quads1x[0].tl.x, 32.0f); + EXPECT_FLOAT_EQ(quads2x[0].br.x - quads2x[0].tl.x, 31.0f); + EXPECT_FLOAT_EQ(quads1x[0].br.y - quads1x[0].tl.y, 32.0f); + EXPECT_FLOAT_EQ(quads2x[0].br.y - quads2x[0].tl.y, 31.0f); +} + TEST(getIconQuads, normal) { SymbolLayoutProperties::Evaluated layout; Anchor anchor(2.0, 3.0, 0.0, 0);