diff --git a/fontique/src/collection/mod.rs b/fontique/src/collection/mod.rs index 08ad5af76..ddbd53f66 100644 --- a/fontique/src/collection/mod.rs +++ b/fontique/src/collection/mod.rs @@ -318,22 +318,19 @@ impl Inner { /// Returns the family object for the given family identifier. pub fn family(&mut self, id: FamilyId) -> Option { self.sync_shared(); + if let Some(family) = self.data.families.get(&id) { - family.as_ref().cloned() - } else { - #[cfg(feature = "system")] - if let Some(system) = &self.system { - let family = system.fonts.lock().unwrap().family(id); - self.data.families.insert(id, family.clone()); - family - } else { - None - } - #[cfg(not(feature = "system"))] - { - None - } + return family.as_ref().cloned(); } + + #[cfg(feature = "system")] + if let Some(system) = &self.system { + let family = system.fonts.lock().unwrap().family(id); + self.data.families.insert(id, family.clone()); + return family; + } + + None } /// Returns the family object for the given name. diff --git a/parley/src/analysis/cluster.rs b/parley/src/analysis/cluster.rs index c2d6228e3..9890faec0 100644 --- a/parley/src/analysis/cluster.rs +++ b/parley/src/analysis/cluster.rs @@ -4,7 +4,7 @@ use alloc::vec::Vec; use icu_normalizer::properties::Decomposed; -use crate::analysis::AnalysisDataSources; +use crate::{analysis::AnalysisDataSources, emoji::EmojiPresentationStyle}; /// The maximum number of characters in a single cluster. const MAX_CLUSTER_SIZE: usize = 32; @@ -12,11 +12,11 @@ const MAX_CLUSTER_SIZE: usize = 32; #[derive(Debug, Default)] pub(crate) struct CharCluster { pub chars: Vec, - pub is_emoji: bool, pub map_len: u8, pub start: u32, pub end: u32, pub force_normalize: bool, + pub emoji_presentation_style: EmojiPresentationStyle, comp: Form, decomp: Form, form: FormKind, @@ -52,6 +52,8 @@ pub(crate) struct Char { /// Indexes into the list of styles for the containing text run, to find the style applicable /// to this character. pub style_index: u16, + /// Whether the emoji presentation selector + pub is_emoji_presentation_selector: bool, } pub(crate) type GlyphId = u16; @@ -93,7 +95,6 @@ pub(crate) enum Status { impl CharCluster { pub(crate) fn clear(&mut self) { self.chars.clear(); - self.is_emoji = false; self.map_len = 0; self.start = 0; self.end = 0; @@ -102,6 +103,7 @@ impl CharCluster { self.decomp.clear(); self.form = FormKind::Original; self.best_ratio = 0.; + self.emoji_presentation_style = EmojiPresentationStyle::Default; } #[inline(always)] @@ -351,17 +353,23 @@ impl<'a> Mapper<'a> { } let mut mapped = 0; for (c, g) in self.chars.iter().zip(glyphs.iter_mut()) { - if !c.contributes_to_shaping { - *g = f(c.ch); - if self.map_len == 1 { - mapped += 1; - } - } else { - let gid = f(c.ch); - *g = gid; - if gid != 0 { + *g = f(c.ch); + + // If the color emoji has a presentation style, ignore the variation selector. + if c.is_emoji_presentation_selector { + mapped += 1; + continue; + } + + if c.contributes_to_shaping { + if *g != 0 { mapped += 1; } + continue; + } + + if self.map_len == 1 { + mapped += 1; } } let ratio = mapped as f32 / self.map_len as f32; diff --git a/parley/src/analysis/mod.rs b/parley/src/analysis/mod.rs index 460e78d96..4974dd782 100644 --- a/parley/src/analysis/mod.rs +++ b/parley/src/analysis/mod.rs @@ -13,9 +13,13 @@ use icu_normalizer::properties::{ CanonicalComposition, CanonicalCompositionBorrowed, CanonicalDecomposition, CanonicalDecompositionBorrowed, }; -use icu_properties::props::{BidiMirroringGlyph, GeneralCategory, GraphemeClusterBreak, Script}; +use icu_properties::props::{ + BidiMirroringGlyph, EmojiModifier, EmojiModifierBase, EmojiPresentation, GeneralCategory, + GraphemeClusterBreak, Script, +}; use icu_properties::{ - CodePointMapData, CodePointMapDataBorrowed, PropertyNamesShort, PropertyNamesShortBorrowed, + CodePointMapData, CodePointMapDataBorrowed, CodePointSetData, CodePointSetDataBorrowed, + PropertyNamesShort, PropertyNamesShortBorrowed, }; use icu_segmenter::options::{LineBreakOptions, LineBreakWordOption, WordBreakInvariantOptions}; use icu_segmenter::{ @@ -92,6 +96,21 @@ impl AnalysisDataSources { fn brackets(&self) -> CodePointMapDataBorrowed<'_, BidiMirroringGlyph> { const { CodePointMapData::new() } } + + #[inline(always)] + pub(crate) fn emoji_modifier(&self) -> CodePointSetDataBorrowed<'_> { + const { CodePointSetData::new::() } + } + + #[inline(always)] + pub(crate) fn emoji_modifier_base(&self) -> CodePointSetDataBorrowed<'_> { + const { CodePointSetData::new::() } + } + + #[inline(always)] + pub(crate) fn emoji_presentation(&self) -> CodePointSetDataBorrowed<'_> { + const { CodePointSetData::new::() } + } } #[derive(Copy, Clone, Debug, PartialEq, Eq)] diff --git a/parley/src/emoji/dfa.rs b/parley/src/emoji/dfa.rs new file mode 100644 index 000000000..fe8be1846 --- /dev/null +++ b/parley/src/emoji/dfa.rs @@ -0,0 +1,256 @@ +// Copyright 2026 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use super::types::{EmojiPresentationStyle, EmojiSegmentationCategory, EmojiSequence, EmojiState}; + +/// The transition table for Emoji DFA. +/// +/// +static DFA_TRANS: [[u8; 13]; 14] = { + use EmojiSegmentationCategory as Category; + use EmojiState as State; + + let mut t = [[0; 13]; 14]; + + /// Add a state transition to the DFA transition table. + macro_rules! add { + ($state:expr, $category:expr, $next_state:expr) => { + t[$state.as_usize()][$category.as_usize()] = $next_state.as_u8() + }; + } + + // Text and Emoji presentation sequences + { + add!(State::Start, Category::Emoji, State::Emoji); + + add!(State::Start, Category::EmojiPresentation, State::Emoji); + + // Text presentation sequence + // + // + add!(State::Emoji, Category::Vs15, State::Terminal); + + // Emoji presentation sequence + // + // + add!(State::Emoji, Category::Vs16, State::OptionalZwj); + + // ZWJ + add!(State::Emoji, Category::Zwj, State::Zwj); + } + + // Emoji modifier sequence + // + // + { + add!( + State::Start, + Category::EmojiModifierBase, + State::EmojiModifierBase + ); + + add!(State::EmojiModifierBase, Category::Vs16, State::OptionalZwj); + add!(State::EmojiModifierBase, Category::Zwj, State::Zwj); + add!( + State::EmojiModifierBase, + Category::EmojiModifier, + State::OptionalZwj + ); + + // other + add!(State::Start, Category::EmojiModifier, State::Terminal); + } + + // Emoji flag sequence -- A sequence of two Regional Indicator characters. + // + // + { + add!(State::Start, Category::Ri, State::Ri); + + add!(State::Ri, Category::Ri, State::Terminal); + } + + // Emoji tag sequence (ETS). + // + // + { + add!(State::Start, Category::TagBase, State::TagBase); + + add!(State::TagBase, Category::Vs15, State::Terminal); + add!(State::TagBase, Category::Vs16, State::OptionalZwj); + add!(State::TagBase, Category::TagSpec, State::TagSpec); + add!(State::TagBase, Category::TagEnd, State::TagEmpty); // without any `TagSpec` + add!(State::TagBase, Category::Zwj, State::Zwj); + + // (seq)+ + add!(State::TagSpec, Category::TagSpec, State::TagSpec); + add!(State::TagSpec, Category::TagEnd, State::Terminal); + } + + // Emoji keycap sequence. + // + // + { + add!(State::Start, Category::KeycapBase, State::KeycapBase); + + add!(State::KeycapBase, Category::KeycapEnd, State::Terminal); + add!(State::KeycapBase, Category::Vs15, State::KeycapVs); + add!(State::KeycapBase, Category::Vs16, State::KeycapVs); + + add!(State::KeycapVs, Category::KeycapEnd, State::Terminal); + } + + // Emoji ZWJ sequence. + // + // + { + add!(State::OptionalZwj, Category::Zwj, State::Zwj); + + // (zwj emoji_zwj_element)+ + add!(State::Zwj, Category::Emoji, State::Emoji); + add!(State::Zwj, Category::EmojiPresentation, State::Emoji); + add!( + State::Zwj, + Category::EmojiModifierBase, + State::EmojiModifierBase + ); + } + + t +}; + +#[derive(Clone, Copy, Debug)] +pub(crate) struct EmojiDFA { + state: EmojiState, + // (state, category) + recorded: (u16, u16), +} + +impl EmojiDFA { + const DEFAULT: Self = Self { + state: EmojiState::Start, + recorded: (0, 0), + }; + + #[inline] + pub(crate) const fn new() -> Self { + Self::DEFAULT + } + + #[inline] + pub(crate) const fn step(&mut self, category: EmojiSegmentationCategory) { + self.state = EmojiState::from_u8(DFA_TRANS[self.state.as_usize()][category.as_usize()]); + } + + #[inline] + pub(crate) const fn step_record(&mut self, category: EmojiSegmentationCategory) { + self.step(category); + + if self.is_rejected() || self.is_started() { + return; + } + + self.recorded.0 |= 1 << self.state.as_u8(); + self.recorded.1 |= 1 << category.as_u8(); + } + + #[inline] + pub(crate) const fn is_rejected(self) -> bool { + self.state.eq(EmojiState::Reject) + } + + #[inline] + pub(crate) const fn is_started(self) -> bool { + self.state.eq(EmojiState::Start) + } + + #[allow(unused)] + #[inline] + pub(crate) const fn is_accepting(self) -> bool { + const START: u8 = EmojiState::Terminal.as_u8(); + const END: u8 = EmojiState::Ri.as_u8(); + + let cur = self.state.as_u8(); + + START <= cur && cur <= END + } + + #[inline] + pub(crate) const fn contains_state(self, state: EmojiState) -> bool { + self.recorded.0 & (1 << state.as_u8()) != 0 + } + + #[inline] + pub(crate) const fn contains_category(self, category: EmojiSegmentationCategory) -> bool { + self.recorded.1 & (1 << category.as_u8()) != 0 + } + + #[inline] + pub(crate) const fn sequence(self) -> EmojiSequence { + if self.contains_category(EmojiSegmentationCategory::Zwj) { + return EmojiSequence::Zwj; + } + + if self.contains_state(EmojiState::TagBase) + && self.contains_state(EmojiState::Terminal) + && !self.contains_category(EmojiSegmentationCategory::Vs15) + { + return EmojiSequence::Tag; + } + + if self.contains_state(EmojiState::Ri) && self.contains_state(EmojiState::Terminal) { + return EmojiSequence::Flag; + } + + if self.contains_category(EmojiSegmentationCategory::EmojiModifierBase) + && self.contains_category(EmojiSegmentationCategory::EmojiModifier) + { + return EmojiSequence::Modifier; + } + + if self.contains_category(EmojiSegmentationCategory::KeycapBase) + && self.contains_category(EmojiSegmentationCategory::Vs16) + && self.contains_category(EmojiSegmentationCategory::KeycapEnd) + { + return EmojiSequence::Keycap; + } + + if self.contains_category(EmojiSegmentationCategory::KeycapEnd) + && self.contains_category(EmojiSegmentationCategory::Vs16) + { + return EmojiSequence::Keycap; + } + + EmojiSequence::Basic + } + + #[inline] + pub(crate) const fn presentation_style(self) -> EmojiPresentationStyle { + if self.contains_category(EmojiSegmentationCategory::Vs15) { + return EmojiPresentationStyle::Text; + } + if self.contains_category(EmojiSegmentationCategory::Vs16) { + return EmojiPresentationStyle::Emoji; + } + + if self.contains_category(EmojiSegmentationCategory::EmojiPresentation) { + return EmojiPresentationStyle::Emoji; + } + + if !self.sequence().eq(EmojiSequence::Basic) { + return EmojiPresentationStyle::Emoji; + } + + // single emoji modifier; e.g. ๐Ÿป + if self.contains_category(EmojiSegmentationCategory::EmojiModifier) { + return EmojiPresentationStyle::Emoji; + } + + // single emoji modifier base; e.g โ˜ + if self.contains_category(EmojiSegmentationCategory::EmojiModifierBase) { + return EmojiPresentationStyle::Text; + } + + EmojiPresentationStyle::Default + } +} diff --git a/parley/src/emoji/mod.rs b/parley/src/emoji/mod.rs new file mode 100644 index 000000000..26e9ec779 --- /dev/null +++ b/parley/src/emoji/mod.rs @@ -0,0 +1,15 @@ +// Copyright 2026 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! This implementation is based on [emoji segmenter]'s Ragel grammar (Apache-2.0). +//! +//! And follow the [UTS51](Unicode Technical Standard #51). +//! +//! [emoji segmenter]: +//! [UTS51]: + +mod dfa; +mod types; + +pub(crate) use dfa::EmojiDFA; +pub(crate) use types::{EmojiFlags, EmojiPresentationStyle, EmojiSegmentationCategory}; diff --git a/parley/src/emoji/types.rs b/parley/src/emoji/types.rs new file mode 100644 index 000000000..9bdbc3de1 --- /dev/null +++ b/parley/src/emoji/types.rs @@ -0,0 +1,278 @@ +// Copyright 2026 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +/// Flags are used to identify [`EmojiSegmentationCategory`]. +#[derive(Clone, Copy, Default)] +pub(crate) struct EmojiFlags(u32); + +impl EmojiFlags { + const EMOJI_SHIFT: u32 = 0; + const EMOJI_PRESENTATION_SHIFT: u32 = 1; + const EMOJI_MODIFIER_SHIFT: u32 = 2; + const EMOJI_MODIFIER_BASE_SHIFT: u32 = 3; + const REGIONAL_INDICATOR_SHIFT: u32 = 4; + + const EMOJI_MASK: u32 = 1 << Self::EMOJI_SHIFT; + const EMOJI_PRESENTATION_MASK: u32 = 1 << Self::EMOJI_PRESENTATION_SHIFT; + const EMOJI_MODIFIER_MASK: u32 = 1 << Self::EMOJI_MODIFIER_SHIFT; + const EMOJI_MODIFIER_BASE_MASK: u32 = 1 << Self::EMOJI_MODIFIER_BASE_SHIFT; + const REGIONAL_INDICATOR_MASK: u32 = 1 << Self::REGIONAL_INDICATOR_SHIFT; + + #[inline] + pub(crate) const fn new( + is_emoji: bool, + is_emoji_presentation: bool, + is_emoji_modifier: bool, + is_emoji_modifier_base: bool, + is_regional_indicator: bool, + ) -> Self { + let flags = (is_emoji as u32) << Self::EMOJI_SHIFT + | (is_emoji_presentation as u32) << Self::EMOJI_PRESENTATION_SHIFT + | (is_emoji_modifier as u32) << Self::EMOJI_MODIFIER_SHIFT + | (is_emoji_modifier_base as u32) << Self::EMOJI_MODIFIER_BASE_SHIFT + | (is_regional_indicator as u32) << Self::REGIONAL_INDICATOR_SHIFT; + Self(flags) + } + + #[inline] + pub(crate) const fn is_emoji(self) -> bool { + self.0 & Self::EMOJI_MASK != 0 + } + + #[inline] + pub(crate) const fn is_emoji_presentation(self) -> bool { + self.0 & Self::EMOJI_PRESENTATION_MASK != 0 + } + + #[inline] + pub(crate) const fn is_emoji_modifier(self) -> bool { + self.0 & Self::EMOJI_MODIFIER_MASK != 0 + } + + #[inline] + pub(crate) const fn is_emoji_modifier_base(self) -> bool { + self.0 & Self::EMOJI_MODIFIER_BASE_MASK != 0 + } + + #[inline] + pub(crate) const fn is_regional_indicator(self) -> bool { + self.0 & Self::REGIONAL_INDICATOR_MASK != 0 + } +} + +#[repr(u8)] +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum EmojiState { + Reject = 0, + Start, + + Terminal, + Emoji, + EmojiModifierBase, + OptionalZwj, + KeycapVs, + TagBase, + /// `RegionalIndicator` + Ri, + + TagSpec, + TagEmpty, + KeycapBase, + Zwj, +} + +impl EmojiState { + #[inline] + pub(crate) const fn from_u8(value: u8) -> Self { + match value { + 1 => Self::Start, + 2 => Self::Terminal, + 3 => Self::Emoji, + 4 => Self::EmojiModifierBase, + 5 => Self::OptionalZwj, + 6 => Self::KeycapVs, + 7 => Self::TagBase, + 8 => Self::Ri, + 9 => Self::TagSpec, + 10 => Self::TagEmpty, + 11 => Self::KeycapBase, + 12 => Self::Zwj, + _ => Self::Reject, + } + } + + #[inline] + pub(crate) const fn as_usize(self) -> usize { + self as usize + } + + #[inline] + pub(crate) const fn as_u8(self) -> u8 { + self as u8 + } + + #[inline] + pub(crate) const fn eq(self, other: Self) -> bool { + self.as_u8() == other.as_u8() + } +} + +impl core::ops::Index for [T] { + type Output = T; + + #[inline] + fn index(&self, index: EmojiState) -> &T { + &self[index.as_usize()] + } +} + +impl core::ops::IndexMut for [T] { + #[inline] + fn index_mut(&mut self, index: EmojiState) -> &mut T { + &mut self[index.as_usize()] + } +} + +/// Represents the category of an emoji segmentation. +#[repr(u8)] +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum EmojiSegmentationCategory { + Emoji = 0, + EmojiPresentation, + EmojiModifier, + EmojiModifierBase, + KeycapBase, + KeycapEnd, + TagBase, + TagSpec, + TagEnd, + /// `RegionalIndicator` + Ri, + Vs15, + Vs16, + Zwj, + None, +} + +impl EmojiSegmentationCategory { + /// Returns the category of the given codepoint and flags. + /// + /// + #[inline] + pub(crate) fn from_codepoint(cp: u32, flags: EmojiFlags) -> Self { + match cp { + // '0'..'9', '#', '*' + 0x30..=0x39 | 0x23 | 0x2A => Self::KeycapBase, + 0x200D => Self::Zwj, + 0x20E3 => Self::KeycapEnd, + 0xFE0E => Self::Vs15, + 0xFE0F => Self::Vs16, + 0x1F3F4 => Self::TagBase, + 0xE0030..=0xE0039 | 0xE0061..=0xE007A => Self::TagSpec, + 0xE007F => Self::TagEnd, + _ => { + if flags.is_regional_indicator() { + return Self::Ri; + } + + if flags.is_emoji_modifier_base() { + return Self::EmojiModifierBase; + } + + if flags.is_emoji_modifier() { + return Self::EmojiModifier; + } + + if flags.is_emoji_presentation() { + return Self::EmojiPresentation; + } + + if flags.is_emoji() { + return Self::Emoji; + } + + Self::None + } + } + } + + #[inline] + pub(crate) const fn as_usize(self) -> usize { + self as usize + } + + #[inline] + pub(crate) const fn as_u8(self) -> u8 { + self as u8 + } + + #[inline] + pub(crate) const fn eq(self, other: Self) -> bool { + self.as_u8() == other.as_u8() + } +} + +impl core::ops::Index for [T] { + type Output = T; + + #[inline] + fn index(&self, index: EmojiSegmentationCategory) -> &T { + &self[index.as_usize()] + } +} + +impl core::ops::IndexMut for [T] { + #[inline] + fn index_mut(&mut self, index: EmojiSegmentationCategory) -> &mut T { + &mut self[index.as_usize()] + } +} + +#[repr(u8)] +#[derive(Clone, Copy, PartialEq, Debug)] +pub(crate) enum EmojiSequence { + Basic, + Keycap, + Modifier, + Flag, + Zwj, + Tag, +} + +impl EmojiSequence { + #[inline] + pub(crate) const fn as_u8(self) -> u8 { + self as u8 + } + + #[inline] + pub(crate) const fn eq(self, other: Self) -> bool { + self.as_u8() == other.as_u8() + } +} + +#[repr(u8)] +#[derive(Clone, Copy, PartialEq, Default, Debug)] +pub(crate) enum EmojiPresentationStyle { + Emoji, + Text, + #[default] + Default, +} + +impl EmojiPresentationStyle { + #[inline] + pub(crate) const fn is_emoji(self) -> bool { + self.eq(Self::Emoji) + } + + #[inline] + pub(crate) const fn as_u8(self) -> u8 { + self as u8 + } + + #[inline] + pub(crate) const fn eq(self, other: Self) -> bool { + self.as_u8() == other.as_u8() + } +} diff --git a/parley/src/lib.rs b/parley/src/lib.rs index ac8269ed1..70226720e 100644 --- a/parley/src/lib.rs +++ b/parley/src/lib.rs @@ -113,6 +113,7 @@ mod bidi; mod builder; mod context; mod convert; +mod emoji; mod font; mod inline_box; mod lru_cache; diff --git a/parley/src/shape/mod.rs b/parley/src/shape/mod.rs index cd451001e..6e4b520f3 100644 --- a/parley/src/shape/mod.rs +++ b/parley/src/shape/mod.rs @@ -14,6 +14,7 @@ use super::style::{Brush, FontFeature, FontVariation}; use crate::analysis::cluster::{Char, CharCluster, Status}; use crate::analysis::{AnalysisDataSources, CharInfo}; use crate::convert::script_to_harfrust; +use crate::emoji::{EmojiDFA, EmojiFlags, EmojiSegmentationCategory}; use crate::inline_box::InlineBox; use crate::lru_cache::LruCache; use crate::util::nearly_eq; @@ -229,41 +230,58 @@ fn fill_cluster_in_place( item_infos_iter: &mut core::slice::Iter<'_, (CharInfo, u16)>, code_unit_offset_in_string: &mut usize, char_cluster: &mut CharCluster, + analysis_data_sources: &AnalysisDataSources, ) { // Reset cluster but keep allocation char_cluster.clear(); let mut force_normalize = false; - let mut is_emoji_or_pictograph = false; let mut map_len: u8 = 0; let start = *code_unit_offset_in_string as u32; + let mut is_emoji = false; + let mut emoji_dfa = EmojiDFA::new(); + for ((_, ch), (info, style_index)) in segment_text.char_indices().zip(item_infos_iter.by_ref()) { + *code_unit_offset_in_string += ch.len_utf8(); force_normalize |= info.force_normalize(); + // TODO - make emoji detection more complete, as per (except using composite Trie tables as // much as possible: // https://github.com/conor-93/parley/blob/4637d826732a1a82bbb3c904c7f47a16a21cceec/parley/src/shape/mod.rs#L221-L269 - is_emoji_or_pictograph |= info.is_emoji_or_pictograph(); - *code_unit_offset_in_string += ch.len_utf8(); // TODO: Explore ignoring other modifiers in determining `contributes_to_shaping`: // regional indicators, subdivision flag tag sequences, skin tone modifiers // See also: https://github.com/google/emoji-segmenter - // If the color emoji has a non-printing variation selector, ignore the variation selector. - // Its presentation depends on the platform and font. - // - // e.g. - // - `U+270C + U+FE0F`: `โœŒ`, force basic presentation - // - `U+270C + U+FE0F`: `โœŒ๏ธ`, force emoji presentation - // - // - let is_emoji_with_non_printing_variation_selector = - is_emoji_or_pictograph && info.is_variation_selector(); - - let contributes_to_shaping = - info.contributes_to_shaping() && !is_emoji_with_non_printing_variation_selector; + is_emoji |= info.is_emoji_or_pictograph(); + + let mut is_emoji_presentation_selector = false; + + if is_emoji { + let emoji_modifier = analysis_data_sources.emoji_modifier(); + let emoji_modifier_base = analysis_data_sources.emoji_modifier_base(); + let emoji_presentation = analysis_data_sources.emoji_presentation(); + + let category = EmojiSegmentationCategory::from_codepoint( + ch as u32, + EmojiFlags::new( + is_emoji, + emoji_presentation.contains(ch), + emoji_modifier.contains(ch), + emoji_modifier_base.contains(ch), + info.is_region_indicator(), + ), + ); + + is_emoji_presentation_selector = category.eq(EmojiSegmentationCategory::Vs16) + || category.eq(EmojiSegmentationCategory::Vs15); + + emoji_dfa.step_record(category); + } + + let contributes_to_shaping = info.contributes_to_shaping(); if contributes_to_shaping { map_len += 1; } @@ -274,16 +292,20 @@ fn fill_cluster_in_place( glyph_id: 0, style_index: *style_index, is_control_character: info.is_control(), + is_emoji_presentation_selector, }); } // Finalize cluster metadata let end = *code_unit_offset_in_string as u32; - char_cluster.is_emoji = is_emoji_or_pictograph; char_cluster.map_len = map_len; char_cluster.start = start; char_cluster.end = end; char_cluster.force_normalize = force_normalize; + + if is_emoji { + char_cluster.emoji_presentation_style = emoji_dfa.presentation_style(); + } } fn shape_item<'a, B: Brush>( @@ -325,6 +347,7 @@ fn shape_item<'a, B: Brush>( &mut item_infos_iter, &mut code_unit_offset_in_string, char_cluster, + analysis_data_sources, ); let mut current_font = font_selector.select_font(char_cluster, analysis_data_sources); @@ -345,6 +368,7 @@ fn shape_item<'a, B: Brush>( &mut item_infos_iter, &mut code_unit_offset_in_string, char_cluster, + analysis_data_sources, ); if let Some(next_font) = font_selector.select_font(char_cluster, analysis_data_sources) @@ -570,7 +594,7 @@ impl<'a, 'b, B: Brush> FontSelector<'a, 'b, B> { analysis_data_sources: &AnalysisDataSources, ) -> Option { let style_index = cluster.style_index(); - let is_emoji = cluster.is_emoji; + let is_emoji = cluster.emoji_presentation_style.is_emoji(); if style_index != self.style_index || is_emoji || self.fonts_id.is_none() { self.style_index = style_index; let style = &self.styles[style_index as usize]; diff --git a/parley/src/tests/mod.rs b/parley/src/tests/mod.rs index 52b77f872..f6f46b89d 100644 --- a/parley/src/tests/mod.rs +++ b/parley/src/tests/mod.rs @@ -3,4 +3,5 @@ mod test_analysis; mod test_builders; +mod test_emoji_segmenters; mod utils; diff --git a/parley/src/tests/test_analysis.rs b/parley/src/tests/test_analysis.rs index 202e4f11a..1e80d97d4 100644 --- a/parley/src/tests/test_analysis.rs +++ b/parley/src/tests/test_analysis.rs @@ -1180,7 +1180,7 @@ fn test_whitespace_contiguous_interspersed_in_latin_mixed() { } #[test] -fn test_color_emoji_with_non_printing_variation_selector() { +fn test_color_emoji_with_presentation() { verify_analysis("\u{270c}\u{fe0f}", |_| {}) .expect_is_emoji_or_pictograph_list(vec![true, false]) .expect_is_variation_selector_list(vec![false, true]); diff --git a/parley/src/tests/test_emoji_segmenters.rs b/parley/src/tests/test_emoji_segmenters.rs new file mode 100644 index 000000000..14ea0635d --- /dev/null +++ b/parley/src/tests/test_emoji_segmenters.rs @@ -0,0 +1,631 @@ +// Copyright 2026 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Tests extracted from the [emoji segmenter]. +//! +//! [emoji segmenter]: + +use alloc::vec::Vec; +use core::char; + +use crate::{ + analysis::AnalysisDataSources, + emoji::{EmojiDFA, EmojiFlags, EmojiPresentationStyle, EmojiSegmentationCategory}, +}; + +struct TestEntity<'a> { + sequence: &'a [u32], + categories: &'a [EmojiSegmentationCategory], + style: EmojiPresentationStyle, +} + +fn assert_emoji_segmenters_produce_same_result(entity: TestEntity<'_>) { + let analysis = AnalysisDataSources::new(); + let emoji_presentation = analysis.emoji_presentation(); + let emoji_modifier = analysis.emoji_modifier(); + let emoji_modifier_base = analysis.emoji_modifier_base(); + + let mut emoji_dfa = EmojiDFA::new(); + + let result = entity + .sequence + .iter() + .copied() + .map(|cp| { + let props = analysis.properties(char::from_u32(cp).unwrap()); + + let is_emoji = props.is_emoji_or_pictograph(); + let is_emoji_presentation = emoji_presentation.contains32(cp); + let is_emoji_modifier = emoji_modifier.contains32(cp); + let is_emoji_modifier_base = emoji_modifier_base.contains32(cp); + let is_regional_indicator = props.is_region_indicator(); + + let emoji_flags = EmojiFlags::new( + is_emoji, + is_emoji_presentation, + is_emoji_modifier, + is_emoji_modifier_base, + is_regional_indicator, + ); + + let category = EmojiSegmentationCategory::from_codepoint(cp, emoji_flags); + + emoji_dfa.step_record(category); + + category + }) + .collect::>(); + + assert_eq!(result, entity.categories); + assert_eq!(emoji_dfa.presentation_style(), entity.style); +} + +// Emoji presentation default; Encoded: ๐Ÿ˜€ +#[test] +fn emoji_presentation_default() { + assert_emoji_segmenters_produce_same_result(TestEntity { + sequence: &[ + 0x1F600, // GRINNING FACE + ], + categories: &[EmojiSegmentationCategory::EmojiPresentation], + style: EmojiPresentationStyle::Emoji, + }); +} + +// Text presentation default (copyright); Encoded: ยฉ +#[test] +fn text_presentation_default() { + assert_emoji_segmenters_produce_same_result(TestEntity { + sequence: &[ + 0x00A9, // COPYRIGHT SIGN + ], + categories: &[EmojiSegmentationCategory::Emoji], + style: EmojiPresentationStyle::Default, + }); +} + +// Lone keycap base; Encoded: 1 +#[test] +fn long_keycap_base() { + assert_emoji_segmenters_produce_same_result(TestEntity { + sequence: &[0x0031], // DIGIT ONE + categories: &[EmojiSegmentationCategory::KeycapBase], + style: EmojiPresentationStyle::Default, + }); +} + +// Keycap base + VS-15 (no term); Encoded: 1๏ธŽ +#[test] +fn keycap_base_vs15() { + assert_emoji_segmenters_produce_same_result(TestEntity { + sequence: &[ + 0x0031, // DIGIT ONE + 0xFE0E, // VARIATION SELECTOR-15 + ], + categories: &[ + EmojiSegmentationCategory::KeycapBase, + EmojiSegmentationCategory::Vs15, + ], + style: EmojiPresentationStyle::Text, + }); +} + +// Keycap base + VS-16 (no term); Encoded: 1๏ธ +#[test] +fn keycap_base_vs16() { + assert_emoji_segmenters_produce_same_result(TestEntity { + sequence: &[ + 0x0031, // DIGIT ONE + 0xFE0F, // VARIATION SELECTOR-16 + ], + categories: &[ + EmojiSegmentationCategory::KeycapBase, + EmojiSegmentationCategory::Vs16, + ], + style: EmojiPresentationStyle::Emoji, + }); +} + +// Unqualified keycap; Encoded: #โƒฃ +#[test] +fn unqualified_keycap() { + assert_emoji_segmenters_produce_same_result(TestEntity { + sequence: &[ + 0x0023, // NUMBER SIGN + 0x20E3, // COMBINING ENCLOSING KEYCAP + ], + categories: &[ + EmojiSegmentationCategory::KeycapBase, + EmojiSegmentationCategory::KeycapEnd, + ], + style: EmojiPresentationStyle::Default, + }); +} + +// Keycap + VS-15 + term; Encoded: 1๏ธŽโƒฃ +#[test] +fn keycap_vs15_term() { + assert_emoji_segmenters_produce_same_result(TestEntity { + sequence: &[ + 0x0031, // DIGIT ONE + 0xFE0E, // VARIATION SELECTOR-15 + 0x20E3, // COMBINING ENCLOSING KEYCAP + ], + categories: &[ + EmojiSegmentationCategory::KeycapBase, + EmojiSegmentationCategory::Vs15, + EmojiSegmentationCategory::KeycapEnd, + ], + style: EmojiPresentationStyle::Text, + }); +} + +// Qualified keycap; Encoded: *๏ธโƒฃ +#[test] +fn qualified_keycap() { + assert_emoji_segmenters_produce_same_result(TestEntity { + sequence: &[ + 0x002A, // ASTERISK + 0xFE0F, // VARIATION SELECTOR-16 + 0x20E3, // COMBINING ENCLOSING KEYCAP + ], + categories: &[ + EmojiSegmentationCategory::KeycapBase, + EmojiSegmentationCategory::Vs16, + EmojiSegmentationCategory::KeycapEnd, + ], + style: EmojiPresentationStyle::Emoji, + }); +} + +// Lone emoji modifier (Fitzpatrick); Encoded: ๐Ÿป +#[test] +fn lone_emoji_modifier() { + assert_emoji_segmenters_produce_same_result(TestEntity { + sequence: &[ + 0x1F3FB, // EMOJI MODIFIER FITZPATRICK TYPE-1-2 + ], + categories: &[EmojiSegmentationCategory::EmojiModifier], + style: EmojiPresentationStyle::Emoji, + }); +} + +// Bare modifier base, text default; Encoded: โ˜ +#[test] +fn bare_modifier_base_text_default() { + assert_emoji_segmenters_produce_same_result(TestEntity { + sequence: &[ + 0x261D, // WHITE UP POINTING INDEX + ], + categories: &[EmojiSegmentationCategory::EmojiModifierBase], + style: EmojiPresentationStyle::Text, + }); +} + +// Modifier base (text default) + VS-16; Encoded: โ˜๏ธ +#[test] +fn modifier_base_text_default_vs16() { + assert_emoji_segmenters_produce_same_result(TestEntity { + sequence: &[ + 0x261D, // WHITE UP POINTING INDEX + 0xFE0F, // VARIATION SELECTOR-16 + ], + categories: &[ + EmojiSegmentationCategory::EmojiModifierBase, + EmojiSegmentationCategory::Vs16, + ], + style: EmojiPresentationStyle::Emoji, + }); +} + +// Modifier base (text default) + skin tone; Encoded: โ˜๐Ÿป +#[test] +fn modifier_base_text_default_skin_tone() { + assert_emoji_segmenters_produce_same_result(TestEntity { + sequence: &[ + 0x261D, // WHITE UP POINTING INDEX + 0x1F3FB, // EMOJI MODIFIER FITZPATRICK TYPE-1-2 + ], + categories: &[ + EmojiSegmentationCategory::EmojiModifierBase, + EmojiSegmentationCategory::EmojiModifier, + ], + style: EmojiPresentationStyle::Emoji, + }); +} + +// Modifier base (emoji default) + skin tone; Encoded: ๐Ÿ‘ฆ๐Ÿป +#[test] +fn modifier_base_emoji_default_skin_tone() { + assert_emoji_segmenters_produce_same_result(TestEntity { + sequence: &[ + 0x1F466, // BOY + 0x1F3FB, // EMOJI MODIFIER FITZPATRICK TYPE-1-2 + ], + categories: &[ + EmojiSegmentationCategory::EmojiModifierBase, + EmojiSegmentationCategory::EmojiModifier, + ], + style: EmojiPresentationStyle::Emoji, + }); +} + +// Lone regional indicator; Encoded: ๐Ÿ‡บ +#[test] +fn lone_regional_indicator() { + assert_emoji_segmenters_produce_same_result(TestEntity { + sequence: &[ + 0x1F1FA, // REGIONAL INDICATOR SYMBOL LETTER U + ], + categories: &[EmojiSegmentationCategory::Ri], + style: EmojiPresentationStyle::Default, + }); +} + +// Flag sequence (US); Encoded: ๐Ÿ‡บ๐Ÿ‡ธ +#[test] +fn flag_sequence_us() { + assert_emoji_segmenters_produce_same_result(TestEntity { + sequence: &[ + 0x1F1FA, // REGIONAL INDICATOR SYMBOL LETTER U + 0x1F1F8, // REGIONAL INDICATOR SYMBOL LETTER S + ], + categories: &[EmojiSegmentationCategory::Ri, EmojiSegmentationCategory::Ri], + style: EmojiPresentationStyle::Emoji, + }); +} + +// Double lone regional indicator + Flag sequence (US); Encoded: ๐Ÿ‡บ๐Ÿ‡บ๐Ÿ‡ธ +// +// FIXME: segmented clusters are incorrect +// โœ–๏ธ, [[0x1F1FA, 0x1F1FA], [0x1F1F8]] +// โœ”๏ธ, [[0x1F1FA], [0x1F1FA, 0x1F1F8]] +#[test] +#[ignore] +fn double_lone_regional_indicator_flag_sequence_us() { + assert_emoji_segmenters_produce_same_result(TestEntity { + sequence: &[ + 0x1F1FA, // REGIONAL INDICATOR SYMBOL LETTER U + 0x1F1FA, // REGIONAL INDICATOR SYMBOL LETTER U + 0x1F1F8, // REGIONAL INDICATOR SYMBOL LETTER S + ], + categories: &[ + EmojiSegmentationCategory::Ri, + EmojiSegmentationCategory::Ri, + EmojiSegmentationCategory::Ri, + ], + style: EmojiPresentationStyle::Emoji, + }); +} + +// Text-default emoji + VS-15; Encoded: โ˜บ๏ธŽ +#[test] +fn text_default_emoji_vs15() { + assert_emoji_segmenters_produce_same_result(TestEntity { + sequence: &[ + 0x263A, // WHITE SMILING FACE + 0xFE0E, // VARIATION SELECTOR-15 + ], + categories: &[ + EmojiSegmentationCategory::Emoji, + EmojiSegmentationCategory::Vs15, + ], + style: EmojiPresentationStyle::Text, + }); +} + +// Text-default emoji + VS-16; Encoded: โ˜บ๏ธ +#[test] +fn text_default_emoji_vs16() { + assert_emoji_segmenters_produce_same_result(TestEntity { + sequence: &[ + 0x263A, // WHITE SMILING FACE + 0xFE0F, // VARIATION SELECTOR-16 + ], + categories: &[ + EmojiSegmentationCategory::Emoji, + EmojiSegmentationCategory::Vs16, + ], + style: EmojiPresentationStyle::Emoji, + }); +} + +// Emoji-default emoji + VS-15; Encoded: ๐Ÿ˜€๏ธŽ +#[test] +fn emoji_default_emoji_vs15() { + assert_emoji_segmenters_produce_same_result(TestEntity { + sequence: &[ + 0x1F600, // GRINNING FACE + 0xFE0E, // VARIATION SELECTOR-15 + ], + categories: &[ + EmojiSegmentationCategory::EmojiPresentation, + EmojiSegmentationCategory::Vs15, + ], + style: EmojiPresentationStyle::Text, + }); +} + +// Emoji-default emoji + VS-16; Encoded: ๐Ÿ˜€๏ธ +#[test] +fn emoji_default_emoji_vs16() { + assert_emoji_segmenters_produce_same_result(TestEntity { + sequence: &[ + 0x1F600, // GRINNING FACE + 0xFE0F, // VARIATION SELECTOR-16 + ], + categories: &[ + EmojiSegmentationCategory::EmojiPresentation, + EmojiSegmentationCategory::Vs16, + ], + style: EmojiPresentationStyle::Emoji, + }); +} + +// ZWJ family; Encoded: ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ง +#[test] +fn zwj_family() { + assert_emoji_segmenters_produce_same_result(TestEntity { + sequence: &[ + 0x1F468, // MAN + 0x200D, // ZERO WIDTH JOINER + 0x1F469, // WOMAN + 0x200D, // ZERO WIDTH JOINER + 0x1F467, // GIRL + ], + categories: &[ + EmojiSegmentationCategory::EmojiModifierBase, + EmojiSegmentationCategory::Zwj, + EmojiSegmentationCategory::EmojiModifierBase, + EmojiSegmentationCategory::Zwj, + EmojiSegmentationCategory::EmojiModifierBase, + ], + style: EmojiPresentationStyle::Emoji, + }); +} + +// Long ZWJ family (4 members); Encoded: ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ +#[test] +fn long_zwj_family() { + assert_emoji_segmenters_produce_same_result(TestEntity { + sequence: &[ + 0x1F468, // MAN + 0x200D, // ZERO WIDTH JOINER + 0x1F469, // WOMAN + 0x200D, // ZERO WIDTH JOINER + 0x1F467, // GIRL + 0x200D, // ZERO WIDTH JOINER + 0x1F466, // BOY + ], + categories: &[ + EmojiSegmentationCategory::EmojiModifierBase, + EmojiSegmentationCategory::Zwj, + EmojiSegmentationCategory::EmojiModifierBase, + EmojiSegmentationCategory::Zwj, + EmojiSegmentationCategory::EmojiModifierBase, + EmojiSegmentationCategory::Zwj, + EmojiSegmentationCategory::EmojiModifierBase, + ], + style: EmojiPresentationStyle::Emoji, + }); +} + +// ZWJ couple; Encoded: ๐Ÿ‘จโ€โคโ€๐Ÿ‘จ +#[test] +fn zwj_couple() { + assert_emoji_segmenters_produce_same_result(TestEntity { + sequence: &[ + 0x1F468, // MAN + 0x200D, // ZERO WIDTH JOINER + 0x2764, // HEAVY BLACK HEART + 0x200D, // ZERO WIDTH JOINER + 0x1F468, // MAN + ], + categories: &[ + EmojiSegmentationCategory::EmojiModifierBase, + EmojiSegmentationCategory::Zwj, + EmojiSegmentationCategory::Emoji, + EmojiSegmentationCategory::Zwj, + EmojiSegmentationCategory::EmojiModifierBase, + ], + style: EmojiPresentationStyle::Emoji, + }); +} + +// ZWJ with VS-16 element; Encoded: ๐Ÿ‘จ๏ธโ€๐Ÿ‘ฉ +#[test] +fn zwj_with_vs16_element() { + assert_emoji_segmenters_produce_same_result(TestEntity { + sequence: &[ + 0x1F468, // MAN + 0xFE0F, // VARIATION SELECTOR-16 + 0x200D, // ZERO WIDTH JOINER + 0x1F469, // WOMAN + ], + categories: &[ + EmojiSegmentationCategory::EmojiModifierBase, + EmojiSegmentationCategory::Vs16, + EmojiSegmentationCategory::Zwj, + EmojiSegmentationCategory::EmojiModifierBase, + ], + style: EmojiPresentationStyle::Emoji, + }); +} + +// ZWJ with VS-16 on both elements; Encoded: ๐Ÿ‘จ๏ธโ€๐Ÿ‘ฉ๏ธ +#[test] +fn zwj_with_vs16_on_both_elements() { + assert_emoji_segmenters_produce_same_result(TestEntity { + sequence: &[ + 0x1F468, // MAN + 0xFE0F, // VARIATION SELECTOR-16 + 0x200D, // ZERO WIDTH JOINER + 0x1F469, // WOMAN + 0xFE0F, // VARIATION SELECTOR-16 + ], + categories: &[ + EmojiSegmentationCategory::EmojiModifierBase, + EmojiSegmentationCategory::Vs16, + EmojiSegmentationCategory::Zwj, + EmojiSegmentationCategory::EmojiModifierBase, + EmojiSegmentationCategory::Vs16, + ], + style: EmojiPresentationStyle::Emoji, + }); +} + +// ZWJ after modifier sequence; Encoded: ๐Ÿ‘ฆ๐Ÿปโ€๐Ÿ’ป +#[test] +fn zwj_after_modifier_sequence() { + assert_emoji_segmenters_produce_same_result(TestEntity { + sequence: &[ + 0x1F466, // BOY + 0x1F3FB, // EMOJI MODIFIER FITZPATRICK TYPE-1-2 + 0x200D, // ZERO WIDTH JOINER + 0x1F4BB, // PERSONAL COMPUTER + ], + categories: &[ + EmojiSegmentationCategory::EmojiModifierBase, + EmojiSegmentationCategory::EmojiModifier, + EmojiSegmentationCategory::Zwj, + EmojiSegmentationCategory::EmojiPresentation, + ], + style: EmojiPresentationStyle::Emoji, + }); +} + +// ZWJ technologist with skin tone; Encoded: ๐Ÿ‘จ๐Ÿปโ€๐Ÿ’ป +#[test] +fn zwj_technologist_with_skin_tone() { + assert_emoji_segmenters_produce_same_result(TestEntity { + sequence: &[ + 0x1F468, // MAN + 0x1F3FB, // EMOJI MODIFIER FITZPATRICK TYPE-1-2 + 0x200D, // ZERO WIDTH JOINER + 0x1F4BB, // PERSONAL COMPUTER + ], + categories: &[ + EmojiSegmentationCategory::EmojiModifierBase, + EmojiSegmentationCategory::EmojiModifier, + EmojiSegmentationCategory::Zwj, + EmojiSegmentationCategory::EmojiPresentation, + ], + style: EmojiPresentationStyle::Emoji, + }); +} + +// VS-16 enables ZWJ continuation; Encoded: โ˜บ๏ธโ€๐Ÿ‘ฉ +#[test] +fn vs16_enables_zwj_continuation() { + assert_emoji_segmenters_produce_same_result(TestEntity { + sequence: &[ + 0x263A, // WHITE SMILING FACE + 0xFE0F, // VARIATION SELECTOR-16 + 0x200D, // ZERO WIDTH JOINER + 0x1F469, // WOMAN + ], + categories: &[ + EmojiSegmentationCategory::Emoji, + EmojiSegmentationCategory::Vs16, + EmojiSegmentationCategory::Zwj, + EmojiSegmentationCategory::EmojiModifierBase, + ], + style: EmojiPresentationStyle::Emoji, + }); +} + +// Tag sequence (England); Encoded: ๐Ÿด๓ ง๓ ข๓ ฅ๓ ฎ๓ ง๓ ฟ +#[test] +fn tag_sequence_england() { + assert_emoji_segmenters_produce_same_result(TestEntity { + sequence: &[ + 0x1F3F4, // WAVING BLACK FLAG + 0xE0067, // TAG LATIN SMALL LETTER G + 0xE0062, // TAG LATIN SMALL LETTER B + 0xE0065, // TAG LATIN SMALL LETTER E + 0xE006E, // TAG LATIN SMALL LETTER N + 0xE0067, // TAG LATIN SMALL LETTER G + 0xE007F, // CANCEL TAG + ], + categories: &[ + EmojiSegmentationCategory::TagBase, + EmojiSegmentationCategory::TagSpec, + EmojiSegmentationCategory::TagSpec, + EmojiSegmentationCategory::TagSpec, + EmojiSegmentationCategory::TagSpec, + EmojiSegmentationCategory::TagSpec, + EmojiSegmentationCategory::TagEnd, + ], + style: EmojiPresentationStyle::Emoji, + }); +} + +// TAG_BASE as ZWJ element; Encoded: ๐Ÿดโ€๐Ÿ˜€" +#[test] +fn tag_base_as_zwj_element() { + assert_emoji_segmenters_produce_same_result(TestEntity { + sequence: &[ + 0x1F3F4, // WAVING BLACK FLAG + 0x200D, // ZERO WIDTH JOINER + 0x1F600, // GRINNING FACE + ], + categories: &[ + EmojiSegmentationCategory::TagBase, + EmojiSegmentationCategory::Zwj, + EmojiSegmentationCategory::EmojiPresentation, + ], + style: EmojiPresentationStyle::Emoji, + }); +} + +// TAG_BASE + VS-16 + ZWJ; Encoded: ๐Ÿด๏ธโ€๐Ÿ˜€", +#[test] +fn tag_base_vs16_as_zwj() { + assert_emoji_segmenters_produce_same_result(TestEntity { + sequence: &[ + 0x1F3F4, // WAVING BLACK FLAG + 0xFE0F, // VARIATION SELECTOR-16 + 0x200D, // ZERO WIDTH JOINER + 0x1F600, // GRINNING FACE + ], + categories: &[ + EmojiSegmentationCategory::TagBase, + EmojiSegmentationCategory::Vs16, + EmojiSegmentationCategory::Zwj, + EmojiSegmentationCategory::EmojiPresentation, + ], + style: EmojiPresentationStyle::Emoji, + }); +} + +// TAG_BASE + VS-15; Encoded: ๐Ÿด๏ธŽ +#[test] +fn tag_base_vs15() { + assert_emoji_segmenters_produce_same_result(TestEntity { + sequence: &[ + 0x1F3F4, // WAVING BLACK FLAG + 0xFE0E, // VARIATION SELECTOR-15 + ], + categories: &[ + EmojiSegmentationCategory::TagBase, + EmojiSegmentationCategory::Vs15, + ], + style: EmojiPresentationStyle::Text, + }); +} + +// TAG_BASE + VS-16; Encoded: ๐Ÿด๏ธ +#[test] +fn tag_base_vs16() { + assert_emoji_segmenters_produce_same_result(TestEntity { + sequence: &[ + 0x1F3F4, // WAVING BLACK FLAG + 0xFE0F, // VARIATION SELECTOR-16 + ], + categories: &[ + EmojiSegmentationCategory::TagBase, + EmojiSegmentationCategory::Vs16, + ], + style: EmojiPresentationStyle::Emoji, + }); +} diff --git a/parley_tests/snapshots/draw_colr_emoji_with_non_printing_variation_selector_16-2x_hint.png b/parley_tests/snapshots/draw_colr_emoji_with_presentation_style-2x_hint.png similarity index 100% rename from parley_tests/snapshots/draw_colr_emoji_with_non_printing_variation_selector_16-2x_hint.png rename to parley_tests/snapshots/draw_colr_emoji_with_presentation_style-2x_hint.png diff --git a/parley_tests/snapshots/draw_colr_emoji_with_non_printing_variation_selector_16-2x_hint_skew.png b/parley_tests/snapshots/draw_colr_emoji_with_presentation_style-2x_hint_skew.png similarity index 100% rename from parley_tests/snapshots/draw_colr_emoji_with_non_printing_variation_selector_16-2x_hint_skew.png rename to parley_tests/snapshots/draw_colr_emoji_with_presentation_style-2x_hint_skew.png diff --git a/parley_tests/snapshots/draw_colr_emoji_with_non_printing_variation_selector_16-2x_nohint.png b/parley_tests/snapshots/draw_colr_emoji_with_presentation_style-2x_nohint.png similarity index 100% rename from parley_tests/snapshots/draw_colr_emoji_with_non_printing_variation_selector_16-2x_nohint.png rename to parley_tests/snapshots/draw_colr_emoji_with_presentation_style-2x_nohint.png diff --git a/parley_tests/snapshots/draw_colr_emoji_with_non_printing_variation_selector_16-2x_nohint_skew.png b/parley_tests/snapshots/draw_colr_emoji_with_presentation_style-2x_nohint_skew.png similarity index 100% rename from parley_tests/snapshots/draw_colr_emoji_with_non_printing_variation_selector_16-2x_nohint_skew.png rename to parley_tests/snapshots/draw_colr_emoji_with_presentation_style-2x_nohint_skew.png diff --git a/parley_tests/snapshots/draw_colr_emoji_with_non_printing_variation_selector_16-hint.png b/parley_tests/snapshots/draw_colr_emoji_with_presentation_style-hint.png similarity index 100% rename from parley_tests/snapshots/draw_colr_emoji_with_non_printing_variation_selector_16-hint.png rename to parley_tests/snapshots/draw_colr_emoji_with_presentation_style-hint.png diff --git a/parley_tests/snapshots/draw_colr_emoji_with_non_printing_variation_selector_16-hint_skew.png b/parley_tests/snapshots/draw_colr_emoji_with_presentation_style-hint_skew.png similarity index 100% rename from parley_tests/snapshots/draw_colr_emoji_with_non_printing_variation_selector_16-hint_skew.png rename to parley_tests/snapshots/draw_colr_emoji_with_presentation_style-hint_skew.png diff --git a/parley_tests/snapshots/draw_colr_emoji_with_non_printing_variation_selector_16-nohint.png b/parley_tests/snapshots/draw_colr_emoji_with_presentation_style-nohint.png similarity index 100% rename from parley_tests/snapshots/draw_colr_emoji_with_non_printing_variation_selector_16-nohint.png rename to parley_tests/snapshots/draw_colr_emoji_with_presentation_style-nohint.png diff --git a/parley_tests/snapshots/draw_colr_emoji_with_non_printing_variation_selector_16-nohint_skew.png b/parley_tests/snapshots/draw_colr_emoji_with_presentation_style-nohint_skew.png similarity index 100% rename from parley_tests/snapshots/draw_colr_emoji_with_non_printing_variation_selector_16-nohint_skew.png rename to parley_tests/snapshots/draw_colr_emoji_with_presentation_style-nohint_skew.png diff --git a/parley_tests/snapshots/draw_colr_emoji_with_presentation_style_without_setting_default_font-2x_hint.png b/parley_tests/snapshots/draw_colr_emoji_with_presentation_style_without_setting_default_font-2x_hint.png new file mode 100644 index 000000000..89307d853 Binary files /dev/null and b/parley_tests/snapshots/draw_colr_emoji_with_presentation_style_without_setting_default_font-2x_hint.png differ diff --git a/parley_tests/snapshots/draw_colr_emoji_with_presentation_style_without_setting_default_font-2x_hint_skew.png b/parley_tests/snapshots/draw_colr_emoji_with_presentation_style_without_setting_default_font-2x_hint_skew.png new file mode 100644 index 000000000..f9f2da85b Binary files /dev/null and b/parley_tests/snapshots/draw_colr_emoji_with_presentation_style_without_setting_default_font-2x_hint_skew.png differ diff --git a/parley_tests/snapshots/draw_colr_emoji_with_presentation_style_without_setting_default_font-2x_nohint.png b/parley_tests/snapshots/draw_colr_emoji_with_presentation_style_without_setting_default_font-2x_nohint.png new file mode 100644 index 000000000..89307d853 Binary files /dev/null and b/parley_tests/snapshots/draw_colr_emoji_with_presentation_style_without_setting_default_font-2x_nohint.png differ diff --git a/parley_tests/snapshots/draw_colr_emoji_with_presentation_style_without_setting_default_font-2x_nohint_skew.png b/parley_tests/snapshots/draw_colr_emoji_with_presentation_style_without_setting_default_font-2x_nohint_skew.png new file mode 100644 index 000000000..f9f2da85b Binary files /dev/null and b/parley_tests/snapshots/draw_colr_emoji_with_presentation_style_without_setting_default_font-2x_nohint_skew.png differ diff --git a/parley_tests/snapshots/draw_colr_emoji_with_presentation_style_without_setting_default_font-hint.png b/parley_tests/snapshots/draw_colr_emoji_with_presentation_style_without_setting_default_font-hint.png new file mode 100644 index 000000000..bdcdbb76f Binary files /dev/null and b/parley_tests/snapshots/draw_colr_emoji_with_presentation_style_without_setting_default_font-hint.png differ diff --git a/parley_tests/snapshots/draw_colr_emoji_with_presentation_style_without_setting_default_font-hint_skew.png b/parley_tests/snapshots/draw_colr_emoji_with_presentation_style_without_setting_default_font-hint_skew.png new file mode 100644 index 000000000..9ba1318ef Binary files /dev/null and b/parley_tests/snapshots/draw_colr_emoji_with_presentation_style_without_setting_default_font-hint_skew.png differ diff --git a/parley_tests/snapshots/draw_colr_emoji_with_presentation_style_without_setting_default_font-nohint.png b/parley_tests/snapshots/draw_colr_emoji_with_presentation_style_without_setting_default_font-nohint.png new file mode 100644 index 000000000..bdcdbb76f Binary files /dev/null and b/parley_tests/snapshots/draw_colr_emoji_with_presentation_style_without_setting_default_font-nohint.png differ diff --git a/parley_tests/snapshots/draw_colr_emoji_with_presentation_style_without_setting_default_font-nohint_skew.png b/parley_tests/snapshots/draw_colr_emoji_with_presentation_style_without_setting_default_font-nohint_skew.png new file mode 100644 index 000000000..9ba1318ef Binary files /dev/null and b/parley_tests/snapshots/draw_colr_emoji_with_presentation_style_without_setting_default_font-nohint_skew.png differ diff --git a/parley_tests/tests/draw.rs b/parley_tests/tests/draw.rs index b32e9891c..da49dc45c 100644 --- a/parley_tests/tests/draw.rs +++ b/parley_tests/tests/draw.rs @@ -173,11 +173,50 @@ fn draw_colr_emoji() { }); } -/// Test COLR emoji with non printing variation selector 16 rendering across different hinting, -/// per-glyph transform, and scale configurations. +/// Test COLR emoji rendering across different hinting, per-glyph transform, and scale configurations. +/// +/// The COLR emoji with presentation style(VS16). +/// +/// The default color emoji is different for each system, so only macOS was added for testing. #[cfg(all(target_os = "macos", feature = "system"))] #[test] -fn draw_colr_emoji_with_non_printing_variation_selector_16() { +fn draw_colr_emoji_with_presentation_style() { + let mut env = TestEnv::new(test_name!(), None); + env.set_tolerance(5.0); + + let collection = &mut env.font_context().collection; + collection.load_system_fonts(); + + let text = "\u{270c}\u{fe0f}\u{2705}\u{270c}\u{fe0f}"; + + test_with_configs(&mut env, |env| { + let mut builder = env.ranged_builder(text); + builder.push_default(StyleProperty::FontSize(24.0)); + // Following + builder.push_default(StyleProperty::FontFamily( + parley::GenericFamily::Emoji.into(), + )); + builder.push( + StyleProperty::FontFamily(FontFamily::named("Noto Color Emoji")), + 0..9, + ); + + let mut layout = builder.build(text); + layout.break_all_lines(None); + layout.align(Alignment::Start, AlignmentOptions::default()); + layout + }); +} + +/// Test COLR emoji rendering across different hinting, per-glyph transform, and scale configurations. +/// +/// The COLR emoji with presentation style without setting the default font, +/// and should fallback to the system default color emoji font. +/// +/// The default color emoji is different for each system, so only macOS was added for testing. +#[cfg(all(target_os = "macos", feature = "system"))] +#[test] +fn draw_colr_emoji_with_presentation_style_without_setting_default_font() { let mut env = TestEnv::new(test_name!(), None); env.set_tolerance(5.0); @@ -189,9 +228,6 @@ fn draw_colr_emoji_with_non_printing_variation_selector_16() { test_with_configs(&mut env, |env| { let mut builder = env.ranged_builder(text); builder.push_default(StyleProperty::FontSize(24.0)); - builder.push_default(StyleProperty::FontFamily(FontFamily::named( - "Apple Color Emoji", - ))); builder.push( StyleProperty::FontFamily(FontFamily::named("Noto Color Emoji")), 0..9,