ASCII digits, #, *, © and ® are shaped with the emoji font whenever no requested family resolves, because select_font appends GenericFamily::Emoji for any character with the Unicode Emoji property — not just those that default to emoji presentation.
Reported from Blitz via DioxusLabs/blitz#688, where @nicoburns suggested it belongs here. The repro below is pure Parley, no Blitz.
Repro
parley at a0752c7 (main, 2026-08-10), Linux x86_64, fontconfig, with NotoColorEmoji.ttf installed.
Add a workspace member examples/emoji_repro:
# examples/emoji_repro/Cargo.toml
[package]
name = "emoji_repro"
edition = "2024"
publish = false
[dependencies]
parley = { workspace = true, features = ["system"] }
skrifa = { workspace = true, features = ["std"] }
// examples/emoji_repro/src/main.rs
use parley::{
FontContext, FontFamily, GlyphRun, LayoutContext, PositionedLayoutItem, StyleProperty,
};
use skrifa::{MetadataProvider, raw::FontRef};
fn family_name(run: &GlyphRun<'_, [u8; 4]>) -> String {
let font = run.run().font();
let data = font.font.data.as_ref();
match FontRef::from_index(data, font.font.index) {
Ok(font_ref) => font_ref
.localized_strings(skrifa::string::StringId::FAMILY_NAME)
.english_or_first()
.map(|s| s.chars().collect::<String>())
.unwrap_or_else(|| "<no name table>".to_string()),
Err(e) => format!("<unreadable: {e}>"),
}
}
fn main() {
let requested = std::env::args()
.nth(1)
.unwrap_or_else(|| "NoSuchFontAtAll".to_string());
let text = "0123456789 abcdefghij #*+,-. \u{00a9}\u{00ae} \u{00e9}\u{00f1}";
let mut font_cx = FontContext::new();
let mut layout_cx: LayoutContext<[u8; 4]> = LayoutContext::new();
let mut builder = layout_cx.ranged_builder(&mut font_cx, text, 1.0, true);
// `css:foo, bar` goes through CSS parsing (so generics resolve as generics);
// anything else is pushed as a single *named* family.
let family = match requested.strip_prefix("css:") {
Some(source) => FontFamily::Source(source.into()),
None => FontFamily::named(&requested),
};
builder.push_default(StyleProperty::FontFamily(family));
builder.push_default(StyleProperty::FontSize(30.0));
let mut layout = builder.build(text);
layout.break_all_lines(None);
println!("requested font-family: {requested:?}");
for line in layout.lines() {
for item in line.items() {
let PositionedLayoutItem::GlyphRun(glyph_run) = item else {
continue;
};
let range = glyph_run.run().text_range();
println!("{:<20} {:?}", family_name(&glyph_run), &text[range]);
}
}
}
Actual
$ cargo run -p emoji_repro
requested font-family: "NoSuchFontAtAll"
Noto Color Emoji "0123456789"
Noto Sans " abcdefghij "
Noto Color Emoji "#*"
Noto Sans "+,-. "
Noto Color Emoji "©®"
Noto Sans " éñ"
Expected
All of it in Noto Sans — the same font script fallback already picks for the letters in the same run.
The affected set is exactly 0–9 # * © ®: the Emoji=Yes characters outside the emoji blocks. $ , . + - / % are Common-script too and are unaffected, because they are not Emoji=Yes.
Controls
$ cargo run -p emoji_repro -- "Noto Sans"
Noto Sans "0123456789 abcdefghij #*+,-. ©® éñ"
$ cargo run -p emoji_repro -- "css:sans-serif"
Noto Sans "0123456789 abcdefghij #*+,-. ©® éñ"
$ cargo run -p emoji_repro -- "css:NoSuchFontAtAll, sans-serif"
Noto Sans "0123456789 abcdefghij #*+,-. ©® éñ"
$ cargo run -p emoji_repro -- "sans-serif" # quoted in CSS, so a *named* family
Noto Color Emoji "0123456789"
...
So the trigger is precisely no requested family resolves. As long as one does, it covers the digits and wins on complete coverage before the appended emoji generic is reached, and nothing looks wrong.
Mechanism
parley_engine/src/shape/cluster.rs:354 sets the cluster's is_emoji from CharInfo::is_emoji_or_pictograph(), which parley_data_gen/src/lib.rs:41-42 builds as Emoji ∪ Extended_Pictographic. The Unicode Emoji property is Yes for 0–9, #, *, © and ® with no VS16 and no keycap sequence — those characters are Emoji_Presentation=No, i.e. they default to text presentation per UTR #51.
parley/src/shape/mod.rs:328-332 then does:
if is_emoji {
let emoji_family = QueryFamily::Generic(GenericFamily::Emoji);
self.query.set_families(fonts.chain(once(emoji_family)));
self.fonts_id = None;
}
Because the emoji generic joins the explicit family list, it is consulted ahead of script fallback. When fonts is empty or unresolvable, the emoji family is the first thing that resolves, it covers the digit completely, matches_with stops there, and fallback — which serves every other character in the same run — never runs.
There is already a TODO - make emoji detection more complete at parley_engine/src/shape/cluster.rs:351, and two #[allow(dead_code, reason = "To be used in more complete emoji checking, in select_font")] bits (VARIATION_SELECTOR_MASK, REGION_INDICATOR_MASK) at parley_engine/src/analysis.rs:197-206, so this looks like known-incomplete ground rather than a surprise.
Suggested fix
Drive the emoji-family append from default emoji presentation rather than from Emoji: Emoji_Presentation=Yes, or an explicit emoji presentation sequence (base + U+FE0F), or a keycap sequence (base + U+FE0F + U+20E3). A base followed by U+FE0E should stay on the text path.
That wants a new property bit rather than a redefinition of the existing one: is_emoji_or_pictograph also feeds cluster-level deletion in parley/src/editing/editor.rs:297, where the wider Extended_Pictographic set is the right one. The same change would also fix text-presentation pictographs like ☺ U+263A.
Related: #492 (configuring preferred emoji presentation) is the explicit-override half of this; this issue is the default-presentation half. #695 (renderability axis) would independently stop a renderer that cannot paint CBDT/COLR from being handed such a font, which is what turned this into invisible digits rather than merely differently-styled ones downstream.
Happy to open a PR for the Emoji_Presentation gating if you'd like it — say which shape you'd prefer for the new bit.
Notes
Linux/fontconfig only so far. On macOS the emoji generic resolves to Apple Color Emoji and the digits are likely to paint as something, so this may present as wrong-font rather than missing-glyph there.
ASCII digits,
#,*,©and®are shaped with the emoji font whenever no requested family resolves, becauseselect_fontappendsGenericFamily::Emojifor any character with the UnicodeEmojiproperty — not just those that default to emoji presentation.Reported from Blitz via DioxusLabs/blitz#688, where @nicoburns suggested it belongs here. The repro below is pure Parley, no Blitz.
Repro
parleyata0752c7(main, 2026-08-10), Linux x86_64, fontconfig, withNotoColorEmoji.ttfinstalled.Add a workspace member
examples/emoji_repro:Actual
Expected
All of it in
Noto Sans— the same font script fallback already picks for the letters in the same run.The affected set is exactly
0–9#*©®: theEmoji=Yescharacters outside the emoji blocks.$ , . + - / %are Common-script too and are unaffected, because they are notEmoji=Yes.Controls
So the trigger is precisely no requested family resolves. As long as one does, it covers the digits and wins on complete coverage before the appended emoji generic is reached, and nothing looks wrong.
Mechanism
parley_engine/src/shape/cluster.rs:354sets the cluster'sis_emojifromCharInfo::is_emoji_or_pictograph(), whichparley_data_gen/src/lib.rs:41-42builds asEmoji ∪ Extended_Pictographic. The UnicodeEmojiproperty isYesfor0–9,#,*,©and®with no VS16 and no keycap sequence — those characters areEmoji_Presentation=No, i.e. they default to text presentation per UTR #51.parley/src/shape/mod.rs:328-332then does:Because the emoji generic joins the explicit family list, it is consulted ahead of script fallback. When
fontsis empty or unresolvable, the emoji family is the first thing that resolves, it covers the digit completely,matches_withstops there, and fallback — which serves every other character in the same run — never runs.There is already a
TODO - make emoji detection more completeatparley_engine/src/shape/cluster.rs:351, and two#[allow(dead_code, reason = "To be used in more complete emoji checking, in select_font")]bits (VARIATION_SELECTOR_MASK,REGION_INDICATOR_MASK) atparley_engine/src/analysis.rs:197-206, so this looks like known-incomplete ground rather than a surprise.Suggested fix
Drive the emoji-family append from default emoji presentation rather than from
Emoji:Emoji_Presentation=Yes, or an explicit emoji presentation sequence (base + U+FE0F), or a keycap sequence (base + U+FE0F + U+20E3). A base followed by U+FE0E should stay on the text path.That wants a new property bit rather than a redefinition of the existing one:
is_emoji_or_pictographalso feeds cluster-level deletion inparley/src/editing/editor.rs:297, where the widerExtended_Pictographicset is the right one. The same change would also fix text-presentation pictographs like☺U+263A.Related: #492 (configuring preferred emoji presentation) is the explicit-override half of this; this issue is the default-presentation half. #695 (renderability axis) would independently stop a renderer that cannot paint CBDT/COLR from being handed such a font, which is what turned this into invisible digits rather than merely differently-styled ones downstream.
Happy to open a PR for the
Emoji_Presentationgating if you'd like it — say which shape you'd prefer for the new bit.Notes
Linux/fontconfig only so far. On macOS the emoji generic resolves to Apple Color Emoji and the digits are likely to paint as something, so this may present as wrong-font rather than missing-glyph there.