From 8220b4bbc3d2a81edb64f61d215b6f31384b84e6 Mon Sep 17 00:00:00 2001 From: Kane Wang Date: Thu, 13 Aug 2026 17:01:56 +0800 Subject: [PATCH 1/6] Select fonts by requested emoji presentation --- fontique/src/charmap.rs | 33 +++- fontique/src/collection/query.rs | 4 + fontique/src/font.rs | 20 +++ fontique/src/lib.rs | 2 +- parley/src/shape/mod.rs | 96 ++++++++++-- parley/src/tests/mod.rs | 1 + parley/src/tests/test_font_selection.rs | 141 ++++++++++++++++++ .../assets/fonts/noto_emoji/LICENSE.txt | 93 ++++++++++++ .../fonts/noto_emoji/NotoEmoji-Subset.ttf | Bin 0 -> 7956 bytes parley_dev/assets/fonts/noto_emoji/README.md | 15 ++ 10 files changed, 387 insertions(+), 18 deletions(-) create mode 100644 parley/src/tests/test_font_selection.rs create mode 100644 parley_dev/assets/fonts/noto_emoji/LICENSE.txt create mode 100644 parley_dev/assets/fonts/noto_emoji/NotoEmoji-Subset.ttf create mode 100644 parley_dev/assets/fonts/noto_emoji/README.md diff --git a/fontique/src/charmap.rs b/fontique/src/charmap.rs index ecf0b24f4..389bbb984 100644 --- a/fontique/src/charmap.rs +++ b/fontique/src/charmap.rs @@ -8,14 +8,18 @@ use read_fonts::{ FontData, FontRead, FontRef, TableProvider, TopLevelTable, - tables::cmap::{Cmap, CmapSubtable}, + tables::cmap::{Cmap, Cmap14, CmapSubtable}, types::GlyphId, }; +pub use read_fonts::tables::cmap::MapVariant; + /// Metadata for constructing a character map from font data. #[derive(Copy, Clone, PartialEq, Eq, Debug)] pub struct CharmapIndex { subtable_offset: u32, + /// Offset of the format 14 subtable, or 0 when the font has none. + uvs_subtable_offset: u32, is_symbol: bool, is_mac_roman: bool, } @@ -31,8 +35,14 @@ impl CharmapIndex { .map(|rec| rec.offset())?; let (_, rec, _) = cmap.best_subtable()?; let subtable_offset = cmap_offset.checked_add(rec.subtable_offset().to_u32())?; + let uvs_subtable_offset = cmap + .uvs_subtable() + .and_then(|(index, _)| cmap.encoding_records().get(index as usize)) + .and_then(|rec| cmap_offset.checked_add(rec.subtable_offset().to_u32())) + .unwrap_or_default(); Some(Self { subtable_offset, + uvs_subtable_offset, is_symbol: rec.is_symbol(), is_mac_roman: rec.is_mac_roman(), }) @@ -42,8 +52,13 @@ impl CharmapIndex { pub fn charmap<'a>(&self, font_data: &'a [u8]) -> Option> { let subtable_data = font_data.get(self.subtable_offset as usize..)?; let subtable = CmapSubtable::read(FontData::new(subtable_data)).ok()?; + let uvs_subtable = (self.uvs_subtable_offset != 0) + .then(|| font_data.get(self.uvs_subtable_offset as usize..)) + .flatten() + .and_then(|data| Cmap14::read(FontData::new(data)).ok()); Some(Charmap { subtable, + uvs_subtable, is_symbol: self.is_symbol, is_mac_roman: self.is_mac_roman, }) @@ -54,6 +69,7 @@ impl CharmapIndex { #[derive(Clone)] pub struct Charmap<'a> { subtable: CmapSubtable<'a>, + uvs_subtable: Option>, is_symbol: bool, is_mac_roman: bool, } @@ -96,6 +112,21 @@ impl Charmap<'_> { } result.map(|gid| gid.to_u32()) } + + /// Maps a [variation sequence] to a glyph identifier using the format 14 subtable. + /// + /// [`MapVariant::UseDefault`] means the nominal glyph for `codepoint` is the right one. + /// + /// [variation sequence]: https://www.unicode.org/reports/tr51/#Emoji_Variation_Sequences + pub fn map_variant( + &self, + codepoint: impl Into, + selector: impl Into, + ) -> Option { + self.uvs_subtable + .as_ref()? + .map_variant(codepoint.into(), selector.into()) + } } #[rustfmt::skip] diff --git a/fontique/src/collection/query.rs b/fontique/src/collection/query.rs index ad1786377..3cc6dc057 100644 --- a/fontique/src/collection/query.rs +++ b/fontique/src/collection/query.rs @@ -234,6 +234,8 @@ pub struct QueryFont { pub synthesis: Synthesis, /// Data used for constructing a character map for this font. pub charmap_index: CharmapIndex, + /// Whether the font has a color glyph table (`COLR`, `CBDT` or `sbix`). + pub has_color_tables: bool, } impl QueryFont { @@ -280,6 +282,7 @@ fn load_bucket<'a>( attributes.weight, ), charmap_index: font_info.charmap_index(), + has_color_tables: font_info.has_color_tables(), }); } if fonts.is_empty() { @@ -319,6 +322,7 @@ fn load_font<'a>( index: blob_index, synthesis, charmap_index: font_info.charmap_index(), + has_color_tables: font_info.has_color_tables(), }); if let Entry::Ok(font) = status { Some(font) diff --git a/fontique/src/font.rs b/fontique/src/font.rs index 33d66690d..a9334c564 100644 --- a/fontique/src/font.rs +++ b/fontique/src/font.rs @@ -14,6 +14,19 @@ use smallvec::SmallVec; type AxisVec = SmallVec<[AxisInfo; 1]>; +const SBIX: Tag = Tag::new(b"sbix"); +const COLR: Tag = Tag::new(b"COLR"); +const CPAL: Tag = Tag::new(b"CPAL"); +const CBDT: Tag = Tag::new(b"CBDT"); +const CBLC: Tag = Tag::new(b"CBLC"); + +/// Color tables in the pairs Blink's `ColorTableLookup` accepts. +fn has_color_tables(font: &FontRef<'_>) -> bool { + font.table_data(SBIX).is_some() + || (font.table_data(COLR).is_some() && font.table_data(CPAL).is_some()) + || (font.table_data(CBDT).is_some() && font.table_data(CBLC).is_some()) +} + /// Representation of a single font in a family. #[derive(Clone, Debug)] pub struct FontInfo { @@ -25,6 +38,7 @@ pub struct FontInfo { axes: AxisVec, attr_axes: u8, charmap_index: CharmapIndex, + has_color_tables: bool, } impl FontInfo { @@ -204,6 +218,11 @@ impl FontInfo { self.attr_axes & OPTICAL_SIZE_AXIS != 0 } + /// Returns `true` if the font has a color glyph table (`COLR`, `CBDT` or `sbix`). + pub fn has_color_tables(&self) -> bool { + self.has_color_tables + } + /// Returns the index used for constructing a [Charmap] for this font. /// /// [Charmap]: crate::Charmap @@ -255,6 +274,7 @@ impl FontInfo { axes, attr_axes, charmap_index, + has_color_tables: has_color_tables(font), }) } diff --git a/fontique/src/lib.rs b/fontique/src/lib.rs index 9d41c424d..9b1f420b8 100644 --- a/fontique/src/lib.rs +++ b/fontique/src/lib.rs @@ -58,7 +58,7 @@ pub use linebender_resource_handle::Blob; pub use script::ScriptExt; pub use attributes::Attributes; -pub use charmap::{Charmap, CharmapIndex}; +pub use charmap::{Charmap, CharmapIndex, MapVariant}; pub use collection::{Collection, CollectionOptions, Query, QueryFamily, QueryFont, QueryStatus}; pub use fallback::FallbackKey; pub use family::{FamilyId, FamilyInfo}; diff --git a/parley/src/shape/mod.rs b/parley/src/shape/mod.rs index ddbf5fe89..593c39f2e 100644 --- a/parley/src/shape/mod.rs +++ b/parley/src/shape/mod.rs @@ -347,43 +347,107 @@ impl<'a, 'b, B: Brush> parley_engine::FontSelector for FontSelector<'a, 'b, B> { self.features = self.rcx.features(style.font_features).unwrap_or(&[]); } + // `U+FE0F` requests a color glyph and `U+FE0E` a monochrome one. + // Within each font, an exact cmap format 14 mapping beats the + // color-table heuristic. Fonts with the matching glyph style come + // first, and mismatched fonts stay as a fallback so the base + // character still renders. + // See . + let variation_sequences: SmallVec<[(char, char); 2]> = cluster + .chars() + .windows(2) + .filter(|pair| matches!(pair[1].ch, '\u{FE0E}' | '\u{FE0F}')) + .map(|pair| (pair[0].ch, pair[1].ch)) + .collect(); + let requested_color = variation_sequences + .first() + .map(|(_, selector)| *selector == '\u{FE0F}'); + let mut selected_font = None; let mut best_coverage = Coverage::NONE; + let mut mismatched_font = None; + let mut mismatched_coverage = Coverage::NONE; self.query.matches_with(|font| { let Some(charmap) = font.charmap() else { return fontique::QueryStatus::Continue; }; + let mut matches_presentation = + requested_color.is_none_or(|color| font.has_color_tables == color); + let mut exact_variant = !variation_sequences.is_empty(); + let mut variant_bases: SmallVec<[char; 2]> = SmallVec::new(); + for &(base, selector) in &variation_sequences { + match charmap.map_variant(base, selector) { + Some(fontique::MapVariant::Variant(glyph)) if glyph.to_u32() != 0 => { + matches_presentation = true; + variant_bases.push(base); + } + // The nominal glyph is declared correct for this sequence. + Some(fontique::MapVariant::UseDefault) => matches_presentation = true, + _ => exact_variant = false, + } + } + let coverage = cluster.calculate_coverage( |ch| { - charmap - .map(ch) - .map(|g| { - // Any non-zero value indicates the existence of a glyph. - g != 0 - }) - .unwrap_or_default() + // A variant mapping covers its base character even when + // the nominal character map does not encode it. + variant_bases.contains(&ch) + || charmap + .map(ch) + .map(|g| { + // Any non-zero value indicates the existence of a glyph. + g != 0 + }) + .unwrap_or_default() }, self.analysis_data_sources, ); - if coverage > best_coverage { + // Exact mappings win only when the font also covers the rest of + // the cluster, so a partial font cannot shadow a complete one. + if exact_variant && coverage.is_complete() { selected_font = Some(SelectedFont { font: font.clone() }); best_coverage = coverage; - - if coverage.is_complete() { - fontique::QueryStatus::Stop - } else { - fontique::QueryStatus::Continue - } + return fontique::QueryStatus::Stop; + } + let candidate_coverage = if matches_presentation { + best_coverage } else { - if selected_font.is_none() { + mismatched_coverage + }; + if coverage > candidate_coverage { + if matches_presentation { selected_font = Some(SelectedFont { font: font.clone() }); + best_coverage = coverage; + if coverage.is_complete() { + return fontique::QueryStatus::Stop; + } + } else { + mismatched_font = Some(SelectedFont { font: font.clone() }); + mismatched_coverage = coverage; + } + } else if selected_font.is_none() && mismatched_font.is_none() { + let fallback = Some(SelectedFont { font: font.clone() }); + if matches_presentation { + selected_font = fallback; + } else { + mismatched_font = fallback; } - fontique::QueryStatus::Continue } + fontique::QueryStatus::Continue }); + // A zero-coverage font must not shadow one that can render the base + // character, whichever bucket each landed in. + let selected_font = if best_coverage > Coverage::NONE { + selected_font + } else if mismatched_coverage > Coverage::NONE { + mismatched_font + } else { + selected_font.or(mismatched_font) + }; + selected_font .map(|selected_font| selected_font.font) .map(|font| FontInstance { diff --git a/parley/src/tests/mod.rs b/parley/src/tests/mod.rs index 7adaa3385..ea88d08c0 100644 --- a/parley/src/tests/mod.rs +++ b/parley/src/tests/mod.rs @@ -3,5 +3,6 @@ mod test_analysis; mod test_builders; +mod test_font_selection; mod test_layout; mod utils; diff --git a/parley/src/tests/test_font_selection.rs b/parley/src/tests/test_font_selection.rs new file mode 100644 index 000000000..6bfbd38b6 --- /dev/null +++ b/parley/src/tests/test_font_selection.rs @@ -0,0 +1,141 @@ +// Copyright 2026 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Font selection for emoji variation sequences. + +use std::borrow::Cow; +use std::path::Path; +use std::sync::Arc; +use std::vec::Vec; + +use fontique::{Collection, CollectionOptions, MapVariant, SourceCache}; +use peniko::Blob; + +use super::utils::ColorBrush; +use crate::{FontContext, FontFamily, LayoutContext, StyleProperty}; + +const MONO_FONT: &str = "noto_emoji/NotoEmoji-Subset.ttf"; +const COLOR_FONT: &str = "noto_color_emoji/NotoColorEmoji-Subset.ttf"; +const CBDT_FONT: &str = "noto_color_emoji/NotoColorEmoji-CBTF-Subset.ttf"; + +fn font_blob(file: &str) -> Blob { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../parley_dev/assets/fonts") + .join(file); + Blob::new(Arc::new(std::fs::read(path).unwrap())) +} + +fn font_context(files: &[&str]) -> (FontContext, Vec) { + let mut collection = Collection::new(CollectionOptions { + shared: false, + system_fonts: false, + }); + let mut ids = Vec::new(); + for file in files { + let blob = font_blob(file); + ids.push(blob.id()); + collection.register_fonts(blob, None); + } + let fcx = FontContext { + collection, + source_cache: SourceCache::default(), + }; + (fcx, ids) +} + +/// Returns the blob id of the font behind each run, consecutive duplicates removed. +fn selected_font_ids(fcx: &mut FontContext, families: &str, text: &str) -> Vec { + let mut lcx: LayoutContext = LayoutContext::new(); + let mut builder = lcx.ranged_builder(fcx, text, 1.0, true); + + builder.push_default(StyleProperty::FontFamily(FontFamily::Source( + Cow::Borrowed(families), + ))); + + let mut layout = builder.build(text); + layout.break_all_lines(None); + + let mut ids = Vec::new(); + for line in layout.lines() { + for run in line.runs() { + let id = run.font().font.data.id(); + if ids.last() != Some(&id) { + ids.push(id); + } + } + } + ids +} + +#[test] +fn variation_selectors_select_presentation_over_stack_order() { + let (mut fcx, ids) = font_context(&[MONO_FONT, COLOR_FONT]); + let (mono, color) = (ids[0], ids[1]); + + // U+270C bare, with VS16, and with VS15. + let text = "\u{270C}\u{270C}\u{FE0F}\u{270C}\u{FE0E}"; + + // The bare codepoint follows the stack. VS16 still reaches the color font. + assert_eq!( + selected_font_ids(&mut fcx, "Noto Emoji, Noto Color Emoji", text), + [mono, color, mono], + ); + + // VS15 still reaches the monochrome font. + assert_eq!( + selected_font_ids(&mut fcx, "Noto Color Emoji, Noto Emoji", text), + [color, mono], + ); +} + +#[test] +fn mismatched_presentation_still_renders_the_base_character() { + let (mut fcx, ids) = font_context(&[MONO_FONT]); + + // VS16 with no color font available falls back to the monochrome glyph. + assert_eq!( + selected_font_ids(&mut fcx, "Noto Emoji", "\u{270C}\u{FE0F}"), + [ids[0]], + ); +} + +#[test] +fn a_matched_font_without_the_glyph_does_not_shadow_coverage() { + let (mut fcx, ids) = font_context(&["roboto_fonts/Roboto-Regular.ttf", COLOR_FONT]); + + // Roboto matches the requested text presentation but has no U+270C glyph, + // so the color font's base glyph still wins over rendering nothing. + assert_eq!( + selected_font_ids(&mut fcx, "Roboto, Noto Color Emoji", "\u{270C}\u{FE0E}"), + [ids[1]], + ); +} + +fn charmap_index(fcx: &mut FontContext, family: &str) -> fontique::CharmapIndex { + let family_id = fcx.collection.family_id(family).unwrap(); + let family = fcx.collection.family(family_id).unwrap(); + family.fonts()[0].charmap_index() +} + +#[test] +fn charmap_maps_variation_sequences_through_cmap_format_14() { + let (mut fcx, _) = font_context(&[CBDT_FONT, MONO_FONT]); + + // The CBDT subset declares its nominal glyphs correct for its FE0F sequences. + let cbdt = font_blob(CBDT_FONT); + let charmap = charmap_index(&mut fcx, "Noto Color Emoji CBTF") + .charmap(cbdt.data()) + .unwrap(); + assert!(matches!( + charmap.map_variant('\u{2705}', '\u{FE0F}'), + Some(MapVariant::UseDefault) + )); + assert_eq!(charmap.map_variant('\u{2705}', '\u{FE0E}'), None); + + // The monochrome subset has no format 14 subtable. + let mono = font_blob(MONO_FONT); + let mono_charmap = charmap_index(&mut fcx, "Noto Emoji") + .charmap(mono.data()) + .unwrap(); + assert_eq!(mono_charmap.map_variant('\u{270C}', '\u{FE0F}'), None); +} diff --git a/parley_dev/assets/fonts/noto_emoji/LICENSE.txt b/parley_dev/assets/fonts/noto_emoji/LICENSE.txt new file mode 100644 index 000000000..979c943ef --- /dev/null +++ b/parley_dev/assets/fonts/noto_emoji/LICENSE.txt @@ -0,0 +1,93 @@ +Copyright 2021 Google Inc. All Rights Reserved. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/parley_dev/assets/fonts/noto_emoji/NotoEmoji-Subset.ttf b/parley_dev/assets/fonts/noto_emoji/NotoEmoji-Subset.ttf new file mode 100644 index 0000000000000000000000000000000000000000..c075cd3b1a3eb3b2be01f2d4e584056bc698b66d GIT binary patch literal 7956 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 lWr`VwUk9o6m{C8kVe$Xwc?$3UAf|2Ql{} Date: Thu, 13 Aug 2026 17:24:33 +0800 Subject: [PATCH 2/6] Update color table comments --- fontique/src/collection/query.rs | 2 +- fontique/src/font.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/fontique/src/collection/query.rs b/fontique/src/collection/query.rs index 3cc6dc057..fe77208e4 100644 --- a/fontique/src/collection/query.rs +++ b/fontique/src/collection/query.rs @@ -234,7 +234,7 @@ pub struct QueryFont { pub synthesis: Synthesis, /// Data used for constructing a character map for this font. pub charmap_index: CharmapIndex, - /// Whether the font has a color glyph table (`COLR`, `CBDT` or `sbix`). + /// Whether the font has a color glyph table. pub has_color_tables: bool, } diff --git a/fontique/src/font.rs b/fontique/src/font.rs index a9334c564..14fb26bde 100644 --- a/fontique/src/font.rs +++ b/fontique/src/font.rs @@ -218,7 +218,7 @@ impl FontInfo { self.attr_axes & OPTICAL_SIZE_AXIS != 0 } - /// Returns `true` if the font has a color glyph table (`COLR`, `CBDT` or `sbix`). + /// Returns `true` if the font has a color glyph table. pub fn has_color_tables(&self) -> bool { self.has_color_tables } From 16dd4b545097d911b6c76fe1babf6bf15d183604 Mon Sep 17 00:00:00 2001 From: Kane Wang Date: Thu, 17 Sep 2026 02:14:10 +0800 Subject: [PATCH 3/6] Match emoji presentation per variation sequence Each variation sequence now resolves independently: an exact cmap format 14 mapping short-circuits the color-table heuristic for that sequence alone, and a font matches the requested presentation only when every sequence in the cluster is satisfied. A variant mapping no longer claims coverage for repeated occurrences of the same base character. Fonts are now bucketed by presentation before coverage is compared, so a zero-coverage mismatched font no longer blocks a later matching one. The exact-mapping early stop was redundant with the completeness check below it. --- parley/src/shape/mod.rs | 74 +++++++++++++++++++---------------------- 1 file changed, 35 insertions(+), 39 deletions(-) diff --git a/parley/src/shape/mod.rs b/parley/src/shape/mod.rs index 593c39f2e..023af7a25 100644 --- a/parley/src/shape/mod.rs +++ b/parley/src/shape/mod.rs @@ -353,15 +353,16 @@ impl<'a, 'b, B: Brush> parley_engine::FontSelector for FontSelector<'a, 'b, B> { // first, and mismatched fonts stay as a fallback so the base // character still renders. // See . - let variation_sequences: SmallVec<[(char, char); 2]> = cluster - .chars() + let chars = cluster.chars(); + let variation_sequences: SmallVec<[VariationSequence; 2]> = chars .windows(2) .filter(|pair| matches!(pair[1].ch, '\u{FE0E}' | '\u{FE0F}')) - .map(|pair| (pair[0].ch, pair[1].ch)) + .map(|pair| VariationSequence { + base: pair[0].ch, + selector: pair[1].ch, + base_is_unique: chars.iter().filter(|c| c.ch == pair[0].ch).count() == 1, + }) .collect(); - let requested_color = variation_sequences - .first() - .map(|(_, selector)| *selector == '\u{FE0F}'); let mut selected_font = None; let mut best_coverage = Coverage::NONE; @@ -373,19 +374,21 @@ impl<'a, 'b, B: Brush> parley_engine::FontSelector for FontSelector<'a, 'b, B> { return fontique::QueryStatus::Continue; }; - let mut matches_presentation = - requested_color.is_none_or(|color| font.has_color_tables == color); - let mut exact_variant = !variation_sequences.is_empty(); + let mut matches_presentation = true; let mut variant_bases: SmallVec<[char; 2]> = SmallVec::new(); - for &(base, selector) in &variation_sequences { - match charmap.map_variant(base, selector) { + for sequence in &variation_sequences { + match charmap.map_variant(sequence.base, sequence.selector) { Some(fontique::MapVariant::Variant(glyph)) if glyph.to_u32() != 0 => { - matches_presentation = true; - variant_bases.push(base); + if sequence.base_is_unique { + variant_bases.push(sequence.base); + } } // The nominal glyph is declared correct for this sequence. - Some(fontique::MapVariant::UseDefault) => matches_presentation = true, - _ => exact_variant = false, + Some(fontique::MapVariant::UseDefault) => {} + _ => { + matches_presentation &= + font.has_color_tables == (sequence.selector == '\u{FE0F}'); + } } } @@ -404,36 +407,21 @@ impl<'a, 'b, B: Brush> parley_engine::FontSelector for FontSelector<'a, 'b, B> { }, self.analysis_data_sources, ); - // Exact mappings win only when the font also covers the rest of - // the cluster, so a partial font cannot shadow a complete one. - if exact_variant && coverage.is_complete() { - selected_font = Some(SelectedFont { font: font.clone() }); - best_coverage = coverage; - return fontique::QueryStatus::Stop; - } - let candidate_coverage = if matches_presentation { - best_coverage - } else { - mismatched_coverage - }; - if coverage > candidate_coverage { - if matches_presentation { + if matches_presentation { + if coverage > best_coverage { selected_font = Some(SelectedFont { font: font.clone() }); best_coverage = coverage; if coverage.is_complete() { return fontique::QueryStatus::Stop; } - } else { - mismatched_font = Some(SelectedFont { font: font.clone() }); - mismatched_coverage = coverage; - } - } else if selected_font.is_none() && mismatched_font.is_none() { - let fallback = Some(SelectedFont { font: font.clone() }); - if matches_presentation { - selected_font = fallback; - } else { - mismatched_font = fallback; + } else if selected_font.is_none() { + selected_font = Some(SelectedFont { font: font.clone() }); } + } else if coverage > mismatched_coverage { + mismatched_font = Some(SelectedFont { font: font.clone() }); + mismatched_coverage = coverage; + } else if mismatched_font.is_none() { + mismatched_font = Some(SelectedFont { font: font.clone() }); } fontique::QueryStatus::Continue }); @@ -515,6 +503,14 @@ fn any_font(query: &mut Query<'_>) -> Option { found } +/// A base character followed by an emoji variation selector. +struct VariationSequence { + base: char, + selector: char, + /// Whether `base` occurs only once in the cluster. + base_is_unique: bool, +} + struct SelectedFont { font: QueryFont, } From 81cd73753f9d948b168259e6e04c6ccc6188f0ec Mon Sep 17 00:00:00 2001 From: Kane Wang Date: Thu, 17 Sep 2026 02:16:09 +0800 Subject: [PATCH 4/6] Trim comments that narrate the font selection branches --- parley/src/shape/mod.rs | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/parley/src/shape/mod.rs b/parley/src/shape/mod.rs index 023af7a25..c3a9bc7f4 100644 --- a/parley/src/shape/mod.rs +++ b/parley/src/shape/mod.rs @@ -348,10 +348,6 @@ impl<'a, 'b, B: Brush> parley_engine::FontSelector for FontSelector<'a, 'b, B> { } // `U+FE0F` requests a color glyph and `U+FE0E` a monochrome one. - // Within each font, an exact cmap format 14 mapping beats the - // color-table heuristic. Fonts with the matching glyph style come - // first, and mismatched fonts stay as a fallback so the base - // character still renders. // See . let chars = cluster.chars(); let variation_sequences: SmallVec<[VariationSequence; 2]> = chars @@ -394,8 +390,6 @@ impl<'a, 'b, B: Brush> parley_engine::FontSelector for FontSelector<'a, 'b, B> { let coverage = cluster.calculate_coverage( |ch| { - // A variant mapping covers its base character even when - // the nominal character map does not encode it. variant_bases.contains(&ch) || charmap .map(ch) @@ -426,8 +420,7 @@ impl<'a, 'b, B: Brush> parley_engine::FontSelector for FontSelector<'a, 'b, B> { fontique::QueryStatus::Continue }); - // A zero-coverage font must not shadow one that can render the base - // character, whichever bucket each landed in. + // Presentation only outranks coverage while there is any coverage to compare. let selected_font = if best_coverage > Coverage::NONE { selected_font } else if mismatched_coverage > Coverage::NONE { @@ -507,7 +500,8 @@ fn any_font(query: &mut Query<'_>) -> Option { struct VariationSequence { base: char, selector: char, - /// Whether `base` occurs only once in the cluster. + /// Coverage is matched by character value, so a variant mapping only proves + /// coverage of an unrepeated base. base_is_unique: bool, } From 4960f41ded5e2521517b84b4aa342978f621d98d Mon Sep 17 00:00:00 2001 From: Kane Wang Date: Thu, 17 Sep 2026 04:08:17 +0800 Subject: [PATCH 5/6] Parse the format 14 cmap subtable lazily in map_variant --- fontique/src/charmap.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/fontique/src/charmap.rs b/fontique/src/charmap.rs index 389bbb984..86e2c4f85 100644 --- a/fontique/src/charmap.rs +++ b/fontique/src/charmap.rs @@ -52,13 +52,13 @@ impl CharmapIndex { pub fn charmap<'a>(&self, font_data: &'a [u8]) -> Option> { let subtable_data = font_data.get(self.subtable_offset as usize..)?; let subtable = CmapSubtable::read(FontData::new(subtable_data)).ok()?; - let uvs_subtable = (self.uvs_subtable_offset != 0) + let uvs_subtable_data = (self.uvs_subtable_offset != 0) .then(|| font_data.get(self.uvs_subtable_offset as usize..)) .flatten() - .and_then(|data| Cmap14::read(FontData::new(data)).ok()); + .map(FontData::new); Some(Charmap { subtable, - uvs_subtable, + uvs_subtable_data, is_symbol: self.is_symbol, is_mac_roman: self.is_mac_roman, }) @@ -69,7 +69,7 @@ impl CharmapIndex { #[derive(Clone)] pub struct Charmap<'a> { subtable: CmapSubtable<'a>, - uvs_subtable: Option>, + uvs_subtable_data: Option>, is_symbol: bool, is_mac_roman: bool, } @@ -123,8 +123,8 @@ impl Charmap<'_> { codepoint: impl Into, selector: impl Into, ) -> Option { - self.uvs_subtable - .as_ref()? + Cmap14::read(self.uvs_subtable_data?) + .ok()? .map_variant(codepoint.into(), selector.into()) } } From 3ee0b7d392ec83916dad01b275584cb651e9aa96 Mon Sep 17 00:00:00 2001 From: Kane Wang Date: Thu, 17 Sep 2026 04:38:43 +0800 Subject: [PATCH 6/6] Reuse the cluster's presentation selector flag `CharCluster::fill` already classifies presentation selectors per character, so the flag comes for free and replaces a scan of every cluster in `select_font`. --- parley/src/shape/mod.rs | 24 ++++++++++++++---------- parley_engine/src/shape/cluster.rs | 8 ++++++++ 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/parley/src/shape/mod.rs b/parley/src/shape/mod.rs index c3a9bc7f4..3f609b4ac 100644 --- a/parley/src/shape/mod.rs +++ b/parley/src/shape/mod.rs @@ -349,16 +349,20 @@ impl<'a, 'b, B: Brush> parley_engine::FontSelector for FontSelector<'a, 'b, B> { // `U+FE0F` requests a color glyph and `U+FE0E` a monochrome one. // See . - let chars = cluster.chars(); - let variation_sequences: SmallVec<[VariationSequence; 2]> = chars - .windows(2) - .filter(|pair| matches!(pair[1].ch, '\u{FE0E}' | '\u{FE0F}')) - .map(|pair| VariationSequence { - base: pair[0].ch, - selector: pair[1].ch, - base_is_unique: chars.iter().filter(|c| c.ch == pair[0].ch).count() == 1, - }) - .collect(); + let mut variation_sequences: SmallVec<[VariationSequence; 2]> = SmallVec::new(); + if cluster.has_presentation_selector() { + let chars = cluster.chars(); + variation_sequences.extend( + chars + .windows(2) + .filter(|pair| matches!(pair[1].ch, '\u{FE0E}' | '\u{FE0F}')) + .map(|pair| VariationSequence { + base: pair[0].ch, + selector: pair[1].ch, + base_is_unique: chars.iter().filter(|c| c.ch == pair[0].ch).count() == 1, + }), + ); + } let mut selected_font = None; let mut best_coverage = Coverage::NONE; diff --git a/parley_engine/src/shape/cluster.rs b/parley_engine/src/shape/cluster.rs index fb7ec4fa4..46ea3d0d9 100644 --- a/parley_engine/src/shape/cluster.rs +++ b/parley_engine/src/shape/cluster.rs @@ -22,6 +22,7 @@ pub struct CharCluster { comp: Form, decomp: Form, emoji_presentation_style: EmojiPresentationStyle, + has_presentation_selector: bool, } impl CharCluster { @@ -42,6 +43,11 @@ impl CharCluster { pub fn is_emoji(&self) -> bool { self.emoji_presentation_style.is_emoji() } + + #[inline(always)] + pub fn has_presentation_selector(&self) -> bool { + self.has_presentation_selector + } } /// Source range of a cluster in code units. @@ -236,6 +242,7 @@ impl CharCluster { self.comp.clear(); self.decomp.clear(); self.emoji_presentation_style = EmojiPresentationStyle::Default; + self.has_presentation_selector = false; } #[inline(always)] @@ -432,6 +439,7 @@ impl CharCluster { ); is_emoji_presentation_selector = category.is_presentation_selector(); + self.has_presentation_selector |= is_emoji_presentation_selector; emoji_dfa.step_record(category); }