LibGfx+LibWeb: Make emoji font fallback presentation-aware

Previously, font selection ignored the Unicode emoji presentation of a
code point. Emoji-capable code points were always resolved through
pre-baked color emoji and symbol fonts. Text-default code points which
lacked the `Emoji_Presentation` property, were therefore rendered as
color emoji rather than text. We now classify each code point's default
presentation from its `Emoji_Presentation` property and any trailing
variation selector.
This commit is contained in:
Tim Ledbetter 2026-06-19 12:33:20 +01:00 committed by Andreas Kling
parent bebcf95883
commit 57103da08d
10 changed files with 124 additions and 46 deletions

View file

@ -50,11 +50,11 @@ RefPtr<Gfx::Font> FontDatabase::get(FlyString const& family, float point_size, u
return m_system_font_provider->get_font(family, point_size, weight, width, slope, font_variation_settings, shape_features);
}
RefPtr<Gfx::Font> FontDatabase::get_font_for_code_point(u32 code_point, float point_size, u16 weight, u16 width, u8 slope)
RefPtr<Gfx::Font> FontDatabase::get_font_for_code_point(u32 code_point, float point_size, u16 weight, u16 width, u8 slope, bool prefer_color_emoji)
{
CodePointFallbackKey key { code_point, weight, width, slope };
CodePointFallbackKey key { code_point, weight, width, slope, prefer_color_emoji };
auto& entry = m_code_point_fallback_cache.ensure(key, [&]() -> CodePointFallbackEntry {
auto typeface_or_error = TypefaceSkia::find_typeface_for_code_point(code_point, weight, width, slope);
auto typeface_or_error = TypefaceSkia::find_typeface_for_code_point(code_point, weight, width, slope, prefer_color_emoji);
if (typeface_or_error.is_error() || !typeface_or_error.value())
return { {}, nullptr };

View file

@ -21,6 +21,7 @@ struct CodePointFallbackKey {
u16 weight { 0 };
u16 width { 0 };
u8 slope { 0 };
bool prefer_color_emoji { false };
bool operator==(CodePointFallbackKey const&) const = default;
@ -28,7 +29,7 @@ struct CodePointFallbackKey {
{
return pair_int_hash(
pair_int_hash(code_point, weight),
pair_int_hash(width, slope));
pair_int_hash(pair_int_hash(width, slope), prefer_color_emoji));
}
};
@ -47,7 +48,7 @@ public:
SystemFontProvider& install_system_font_provider(NonnullOwnPtr<SystemFontProvider>);
RefPtr<Gfx::Font> get(FlyString const& family, float point_size, unsigned weight, unsigned width, unsigned slope, Optional<FontVariationSettings> const& font_variation_settings = {}, Optional<Gfx::ShapeFeatures> const& shape_features = {});
RefPtr<Gfx::Font> get_font_for_code_point(u32 code_point, float point_size, u16 weight, u16 width, u8 slope);
RefPtr<Gfx::Font> get_font_for_code_point(u32 code_point, float point_size, u16 weight, u16 width, u8 slope, bool prefer_color_emoji);
void for_each_typeface_with_family_name(FlyString const& family_name, Function<void(Typeface const&)>);
[[nodiscard]] StringView system_font_provider_name() const;

View file

@ -305,12 +305,15 @@ ErrorOr<RefPtr<TypefaceSkia>> TypefaceSkia::match_family_style(StringView family
return typeface_from_skia_typeface(move(skia_typeface));
}
ErrorOr<RefPtr<TypefaceSkia>> TypefaceSkia::find_typeface_for_code_point(u32 code_point, u16 weight, u16 width, u8 slope)
ErrorOr<RefPtr<TypefaceSkia>> TypefaceSkia::find_typeface_for_code_point(u32 code_point, u16 weight, u16 width, u8 slope, bool prefer_color_emoji)
{
SkFontStyle style(weight, width, slope_to_skia_slant(slope));
// The "und-Zsye" language tag steers the font matcher towards a color emoji font. Without it, a text-presentation
// font is preferred for emoji-capable code points.
char const* emoji_locale[] = { "und-Zsye" };
auto skia_typeface = font_manager().matchFamilyStyleCharacter(
nullptr, style, nullptr, 0, code_point);
nullptr, style, prefer_color_emoji ? emoji_locale : nullptr, prefer_color_emoji ? 1 : 0, code_point);
if (!skia_typeface)
return RefPtr<TypefaceSkia> {};

View file

@ -31,7 +31,7 @@ public:
static ErrorOr<NonnullRefPtr<TypefaceSkia>> load_from_buffer(ReadonlyBytes, u32 ttc_index = 0);
static ErrorOr<RefPtr<TypefaceSkia>> match_system_ui(SystemUIFontKind, float point_size, u16 weight, double width, u8 slope);
static ErrorOr<RefPtr<TypefaceSkia>> match_family_style(StringView family_name, u16 weight, u16 width, u8 slope);
static ErrorOr<RefPtr<TypefaceSkia>> find_typeface_for_code_point(u32 code_point, u16 weight, u16 width, u8 slope);
static ErrorOr<RefPtr<TypefaceSkia>> find_typeface_for_code_point(u32 code_point, u16 weight, u16 width, u8 slope, bool prefer_color_emoji);
static Optional<FlyString> resolve_generic_family(StringView family_name, u16 weight, u8 slope);
RefPtr<TypefaceSkia const> clone_with_variations(Vector<FontVariationAxis> const& axes) const;

View file

@ -5,9 +5,24 @@
*/
#include <LibGfx/FontCascadeList.h>
#include <LibUnicode/CharacterTypes.h>
namespace Gfx {
EmojiPresentationResult emoji_presentation_for_code_point(u32 code_point, Optional<u32> next_code_point)
{
// VARIATION SELECTOR-16 (emoji)
if (next_code_point == 0xFE0Fu)
return { EmojiPresentation::Emoji, ForcedPresentation::Yes };
// VARIATION SELECTOR-15 (text)
if (next_code_point == 0xFE0Eu)
return { EmojiPresentation::Text, ForcedPresentation::Yes };
if (Unicode::code_point_has_emoji_presentation_property(code_point))
return { EmojiPresentation::Emoji, ForcedPresentation::No };
return { EmojiPresentation::Text, ForcedPresentation::No };
}
void FontCascadeList::add(NonnullRefPtr<Font const> font)
{
m_fonts.append({ move(font), {} });
@ -58,7 +73,12 @@ void FontCascadeList::extend(FontCascadeList const& other)
m_pending_faces.extend(other.m_pending_faces);
}
Gfx::Font const& FontCascadeList::font_for_code_point(u32 code_point, TriggerPendingLoads trigger_pending_loads) const
void FontCascadeList::extend_fallback(FontCascadeList const& other)
{
m_fallback_fonts.extend(other.m_fonts);
}
Gfx::Font const& FontCascadeList::font_for_code_point(u32 code_point, TriggerPendingLoads trigger_pending_loads, EmojiPresentationResult emoji_presentation) const
{
// Only the text-shaping paths pass TriggerPendingLoads::Yes. Probes that don't
// lead to a glyph being drawn (the U+0020 check used to compute first-available-
@ -80,37 +100,68 @@ Gfx::Font const& FontCascadeList::font_for_code_point(u32 code_point, TriggerPen
});
}
if (code_point < m_ascii_cache.size()) {
auto use_ascii_cache = code_point < m_ascii_cache.size() && emoji_presentation.presentation == EmojiPresentation::Text && emoji_presentation.forced == ForcedPresentation::No;
if (use_ascii_cache) {
if (auto const* cached = m_ascii_cache[code_point])
return *cached;
}
auto cache_and_return = [&](Font const& font) -> Font const& {
if (code_point < m_ascii_cache.size())
if (use_ascii_cache)
m_ascii_cache[code_point] = &font;
return font;
};
for (auto const& entry : m_fonts) {
if (entry.range_data.has_value()) {
if (!entry.range_data->enclosing_range.contains(code_point))
continue;
for (auto const& range : entry.range_data->unicode_ranges) {
if (range.contains(code_point) && entry.font->contains_glyph(code_point))
return cache_and_return(*entry.font);
}
} else if (entry.font->contains_glyph(code_point)) {
return cache_and_return(*entry.font);
auto presentation_matches = [wants_emoji = emoji_presentation.presentation == EmojiPresentation::Emoji](Font const& font) {
return font.is_emoji_font() == wants_emoji;
};
auto entry_contains_glyph = [code_point](Entry const& entry) {
if (!entry.range_data.has_value())
return entry.font->contains_glyph(code_point);
if (!entry.range_data->enclosing_range.contains(code_point))
return false;
for (auto const& range : entry.range_data->unicode_ranges) {
if (range.contains(code_point) && entry.font->contains_glyph(code_point))
return true;
}
return false;
};
Font const* author_glyph_match = nullptr;
for (auto const& entry : m_fonts) {
if (!entry_contains_glyph(entry))
continue;
if (emoji_presentation.forced == ForcedPresentation::No || presentation_matches(*entry.font))
return cache_and_return(*entry.font);
if (!author_glyph_match)
author_glyph_match = entry.font.ptr();
}
Font const* fallback_glyph_match = nullptr;
for (auto const& entry : m_fallback_fonts) {
if (!entry_contains_glyph(entry))
continue;
if (presentation_matches(*entry.font))
return cache_and_return(*entry.font);
if (!fallback_glyph_match)
fallback_glyph_match = entry.font.ptr();
}
if (m_system_font_fallback_callback) {
if (auto fallback = m_system_font_fallback_callback(code_point, first())) {
m_fonts.append({ fallback.release_nonnull(), {} });
return cache_and_return(*m_fonts.last().font);
if (auto fallback = m_system_font_fallback_callback(code_point, emoji_presentation.presentation, first())) {
if (presentation_matches(*fallback) || (!author_glyph_match && !fallback_glyph_match)) {
m_fallback_fonts.append({ fallback.release_nonnull(), {} });
return cache_and_return(*m_fallback_fonts.last().font);
}
}
}
if (author_glyph_match)
return cache_and_return(*author_glyph_match);
if (fallback_glyph_match)
return cache_and_return(*fallback_glyph_match);
return cache_and_return(*m_last_resort_font);
}

View file

@ -14,9 +14,26 @@
namespace Gfx {
enum class EmojiPresentation : u8 {
Text,
Emoji,
};
enum class ForcedPresentation : u8 {
No,
Yes,
};
struct EmojiPresentationResult {
EmojiPresentation presentation { EmojiPresentation::Text };
ForcedPresentation forced { ForcedPresentation::No };
};
EmojiPresentationResult emoji_presentation_for_code_point(u32 code_point, Optional<u32> next_code_point);
class FontCascadeList : public RefCounted<FontCascadeList> {
public:
using SystemFontFallbackCallback = Function<RefPtr<Font const>(u32, Font const&)>;
using SystemFontFallbackCallback = Function<RefPtr<Font const>(u32, EmojiPresentation, Font const&)>;
static NonnullRefPtr<FontCascadeList> create()
{
@ -42,6 +59,8 @@ public:
void extend(FontCascadeList const& other);
void extend_fallback(FontCascadeList const& other);
// A pending-face fetch should only be initiated for codepoints that are actually
// being shaped into glyph runs. Callers that merely probe the cascade (e.g. the
// U+0020 check in "first available font" metrics) pass No so that probing does
@ -50,7 +69,7 @@ public:
No,
Yes,
};
Gfx::Font const& font_for_code_point(u32 code_point, TriggerPendingLoads = TriggerPendingLoads::No) const;
Gfx::Font const& font_for_code_point(u32 code_point, TriggerPendingLoads = TriggerPendingLoads::No, EmojiPresentationResult = {}) const;
bool equals(FontCascadeList const& other) const;
@ -99,6 +118,7 @@ public:
private:
RefPtr<Font const> m_last_resort_font;
mutable Vector<Entry> m_fonts;
mutable Vector<Entry> m_fallback_fonts;
mutable Vector<NonnullRefPtr<PendingFace>> m_pending_faces;
SystemFontFallbackCallback m_system_font_fallback_callback;

View file

@ -655,26 +655,25 @@ NonnullRefPtr<Gfx::FontCascadeList const> FontComputer::compute_font_for_style_v
font_list->add(*default_font);
}
// Add emoji and symbol fonts
for (auto font_name : Platform::FontPlugin::the().symbol_font_names()) {
if (auto other_font_list = find_font(font_name)) {
font_list->extend(*other_font_list);
}
}
// The default font is already included in the font list, but we explicitly set it
// as the last-resort font. This ensures that if none of the specified fonts contain
// the requested code point, there is still a font available to provide a fallback glyph.
font_list->set_last_resort_font(*default_font);
if (!Platform::FontPlugin::the().is_layout_test_mode()) {
font_list->set_system_font_fallback_callback([](u32 code_point, Gfx::Font const& reference_font) -> RefPtr<Gfx::Font const> {
if (Platform::FontPlugin::the().is_layout_test_mode()) {
for (auto font_name : Platform::FontPlugin::the().symbol_font_names()) {
if (auto other_font_list = find_font(font_name))
font_list->extend_fallback(*other_font_list);
}
} else {
font_list->set_system_font_fallback_callback([](u32 code_point, Gfx::EmojiPresentation presentation, Gfx::Font const& reference_font) -> RefPtr<Gfx::Font const> {
return Gfx::FontDatabase::the().get_font_for_code_point(
code_point,
reference_font.point_size(),
reference_font.weight(),
reference_font.typeface().width(),
reference_font.slope());
reference_font.slope(),
presentation == Gfx::EmojiPresentation::Emoji);
});
}

View file

@ -758,7 +758,7 @@ Gfx::Font const& TextNode::ChunkIterator::font_for_space(size_t at_index, u32 sp
for (size_t i = at_index; i < m_view.length_in_code_units();) {
auto cp = m_view.code_point_at(i);
if (!is_interword_space(cp) && cp != '\t' && cp != '\n') {
auto const& font = m_font_cascade_list.font_for_code_point(cp, Gfx::FontCascadeList::TriggerPendingLoads::Yes);
auto const& font = m_font_cascade_list.font_for_code_point(cp, Gfx::FontCascadeList::TriggerPendingLoads::Yes, emoji_presentation_at(i, cp));
if (!font.is_emoji_font() && has_glyph(font))
return font;
// Text is coming from an emoji face; we'll fall back to (3).
@ -771,6 +771,16 @@ Gfx::Font const& TextNode::ChunkIterator::font_for_space(size_t at_index, u32 sp
return m_font_cascade_list.font_for_code_point(space_code_point, Gfx::FontCascadeList::TriggerPendingLoads::Yes);
}
Gfx::EmojiPresentationResult TextNode::ChunkIterator::emoji_presentation_at(size_t code_unit_offset, u32 code_point) const
{
auto next_offset = code_unit_offset + AK::UnicodeUtils::code_unit_length_for_code_point(code_point);
Optional<u32> next_code_point;
if (next_offset < m_view.length_in_code_units())
next_code_point = m_view.code_point_at(next_offset);
return Gfx::emoji_presentation_for_code_point(code_point, next_code_point);
}
Optional<TextNode::Chunk> TextNode::ChunkIterator::next_without_peek()
{
if (m_current_index >= m_view.length_in_code_units())
@ -795,7 +805,7 @@ Optional<TextNode::Chunk> TextNode::ChunkIterator::next_without_peek()
auto const& expected_font_for = [&](u32 cp) -> Gfx::Font const& {
return is_interword_space(cp)
? font_for_space(m_current_index, cp)
: m_font_cascade_list.font_for_code_point(cp, Gfx::FontCascadeList::TriggerPendingLoads::Yes);
: m_font_cascade_list.font_for_code_point(cp, Gfx::FontCascadeList::TriggerPendingLoads::Yes, emoji_presentation_at(m_current_index, cp));
};
auto const& font = expected_font_for(current_code_point());

View file

@ -72,6 +72,7 @@ public:
[[nodiscard]] bool is_at_line_break_opportunity() const;
[[nodiscard]] Gfx::Font const& font_for_space(size_t at_index, u32 space_code_point) const;
[[nodiscard]] Gfx::EmojiPresentationResult emoji_presentation_at(size_t code_unit_offset, u32 code_point) const;
bool const m_should_wrap_lines;
bool const m_should_respect_linebreaks;

View file

@ -52,15 +52,8 @@ FontPlugin::FontPlugin(bool is_layout_test_mode, Gfx::SystemFontProvider* font_p
m_default_fixed_width_font = Gfx::FontDatabase::the().get(default_fixed_width_font_name, 12.0, 400, Gfx::FontWidth::Normal, 0);
VERIFY(m_default_fixed_width_font);
if (is_layout_test_mode) {
if (is_layout_test_mode)
m_symbol_font_names = { "Noto Emoji"_fly_string };
} else {
#ifdef AK_OS_MACOS
m_symbol_font_names = { "Apple Color Emoji"_fly_string, "Apple Symbols"_fly_string };
#else
m_symbol_font_names = { "Noto Color Emoji"_fly_string, "Noto Sans Symbols"_fly_string };
#endif
}
}
FontPlugin::~FontPlugin() = default;