feat(core): render local CJK glyphs at 2x texture resolution (#4304)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Bart Louwers <bart@emeel.net>
This commit is contained in:
Taku Fukada
2026-07-01 20:52:01 +09:00
committed by GitHub
parent 60f15190c3
commit 887e6021e6
22 changed files with 357 additions and 84 deletions

View File

@@ -59,6 +59,15 @@ PremultipliedImage LocalGlyphRasterizer::drawGlyphBitmap(const std::string& font
static_cast<jni::jchar>(glyphID)));
}
float LocalGlyphRasterizer::getLastGlyphTop() {
UniqueEnv env{AttachEnv()};
static auto& javaClass = jni::Class<LocalGlyphRasterizer>::Singleton(*env);
static auto getLastGlyphTop = javaClass.GetMethod<jni::jfloat()>(*env, "getLastGlyphTop");
return javaObject.Call(*env, getLastGlyphTop);
}
void LocalGlyphRasterizer::registerNative(jni::JNIEnv& env) {
jni::Class<LocalGlyphRasterizer>::Singleton(env);
}
@@ -84,6 +93,8 @@ public:
return androidLocalGlyphRasterizer.drawGlyphBitmap(*fontFamily, bold, glyphID);
}
float getLastGlyphTop() { return androidLocalGlyphRasterizer.getLastGlyphTop(); }
private:
std::optional<std::string> 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]);

View File

@@ -4,6 +4,8 @@
#include <jni/jni.hpp>
#include <cstdint>
/*
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::Object<LocalGlyphRasterizer>, jni::EnvAttachingDeleter> javaObject;

View File

@@ -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<String, Typeface> 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;
}
}

View File

@@ -6,6 +6,7 @@
#include <mbgl/interface/native_apple_interface.h>
#include <cmath>
#include <unordered_map>
#import <CoreText/CoreText.h>
@@ -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<CGColorSpaceRef, CGColorSpaceRef, CGColorSpaceRelease>;
using CGContextHandle = CFHandle<CGContextRef, CGContextRef, CGContextRelease>;
using CFStringRefHandle = CFHandle<CFStringRef, CFTypeRef, CFRelease>;
@@ -35,7 +44,7 @@ using CTLineRefHandle = CFHandle<CTLineRef, CFTypeRef, CFRelease>;
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<CTLineRef, CFTypeRef, CFRelease>;
This is a first step toward fully local font rendering:
<https://github.com/mapbox/mapbox-gl-native/issues/7862>. 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<CGSize> 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<CGFloat>(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];

View File

@@ -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<uint16_t, uint16_t>{range.first, range.second}));

View File

@@ -7,9 +7,15 @@
#include <QtGui/QImage>
#include <QtGui/QPainter>
#include <qglobal.h>
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<std::string>& fontFamily_);
@@ -25,7 +31,7 @@ LocalGlyphRasterizer::Impl::Impl(const std::optional<std::string>& 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<double>(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<uint8_t[]>(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<uint8_t[]>(static_cast<size_t>(size.width) * size.height);
for (uint32_t y = 0; y < size.height; ++y) {
std::memcpy(img.get() + static_cast<size_t>(y) * size.width, image.constScanLine(y), size.width);
}
glyph.bitmap = AlphaImage{size, std::move(img)};