use crate::{TextError, TextPipeline, TextStyle};
use oxiui_core::UiError;
pub fn is_emoji_codepoint(ch: char) -> bool {
let cp = ch as u32;
matches!(cp,
0x2194..=0x2BFF |
0x1F000..=0x1FFFF |
0xE0000..=0xE01EF |
0xFE00..=0xFE0F
)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RunKind {
Plain,
Emoji,
}
#[derive(Clone, Debug)]
pub struct EmojiRun<'a> {
pub text: &'a str,
pub kind: RunKind,
pub byte_start: usize,
}
pub struct EmojiSegmenter<'a> {
source: &'a str,
byte_pos: usize,
}
impl<'a> EmojiSegmenter<'a> {
pub fn new(source: &'a str) -> Self {
EmojiSegmenter {
source,
byte_pos: 0,
}
}
}
impl<'a> Iterator for EmojiSegmenter<'a> {
type Item = EmojiRun<'a>;
fn next(&mut self) -> Option<Self::Item> {
if self.byte_pos >= self.source.len() {
return None;
}
let remaining = &self.source[self.byte_pos..];
let first_char = remaining.chars().next()?;
let start_kind = if is_emoji_codepoint(first_char) {
RunKind::Emoji
} else {
RunKind::Plain
};
let mut end_byte = 0;
for ch in remaining.chars() {
let ch_kind = if is_emoji_codepoint(ch) {
RunKind::Emoji
} else {
RunKind::Plain
};
if ch_kind != start_kind {
break;
}
end_byte += ch.len_utf8();
}
let run_text = &remaining[..end_byte];
let byte_start = self.byte_pos;
self.byte_pos += end_byte;
if run_text.is_empty() {
return None;
}
Some(EmojiRun {
text: run_text,
kind: start_kind,
byte_start,
})
}
}
#[derive(Clone, Debug)]
pub struct EmojiGlyph {
pub rgba: Vec<u8>,
pub width: u32,
pub height: u32,
pub advance_x: f32,
pub bearing_y: f32,
}
fn scale_rgba_nearest(src: &[u8], src_w: u32, src_h: u32, dst_w: u32, dst_h: u32) -> Vec<u8> {
let mut out = vec![0u8; (dst_w * dst_h * 4) as usize];
let x_ratio = src_w as f32 / dst_w as f32;
let y_ratio = src_h as f32 / dst_h as f32;
for dy in 0..dst_h {
for dx in 0..dst_w {
let sx = (dx as f32 * x_ratio) as u32;
let sy = (dy as f32 * y_ratio) as u32;
let src_idx = ((sy * src_w + sx) * 4) as usize;
let dst_idx = ((dy * dst_w + dx) * 4) as usize;
if src_idx + 3 < src.len() && dst_idx + 3 < out.len() {
out[dst_idx..dst_idx + 4].copy_from_slice(&src[src_idx..src_idx + 4]);
}
}
}
out
}
pub struct EmojiRenderer {
pipeline: TextPipeline,
emoji_px: u32,
}
impl EmojiRenderer {
pub fn new(pipeline: TextPipeline) -> Self {
EmojiRenderer {
pipeline,
emoji_px: 16,
}
}
pub fn from_bytes(font_bytes: &[u8]) -> Result<Self, TextError> {
let pipeline = TextPipeline::from_bytes(font_bytes)?;
Ok(Self::new(pipeline))
}
pub fn render_with_emoji(
&mut self,
text: &str,
style: &TextStyle,
target_px: u32,
) -> Result<Vec<EmojiGlyph>, UiError> {
self.emoji_px = target_px.max(1);
let mut out: Vec<EmojiGlyph> = Vec::new();
for run in EmojiSegmenter::new(text) {
if run.kind == RunKind::Plain {
continue;
}
for ch in run.text.chars() {
if ch == '\u{FE0F}' || ch == '\u{200D}' {
continue;
}
let ch_str = ch.to_string();
let render_result = self.pipeline.render(&ch_str, style);
let glyph = self.make_emoji_glyph(render_result, style, target_px);
out.push(glyph);
}
}
Ok(out)
}
fn make_emoji_glyph(
&self,
result: Result<oxitext::RenderResult, UiError>,
style: &TextStyle,
target_px: u32,
) -> EmojiGlyph {
match result {
Ok(rr) if !rr.bitmaps.is_empty() => {
let bm = &rr.bitmaps[0];
if rr.glyphs.is_empty() {
return self.placeholder_emoji(target_px, style);
};
let src_w = bm.width;
let src_h = bm.height;
if src_w == 0 || src_h == 0 {
return self.placeholder_emoji(target_px, style);
}
let rgba_raw: Vec<u8> = bm.pixels.iter().flat_map(|&v| [v, v, v, v]).collect();
let scaled = if src_w != target_px || src_h != target_px {
scale_rgba_nearest(&rgba_raw, src_w, src_h, target_px, target_px)
} else {
rgba_raw
};
EmojiGlyph {
rgba: scaled,
width: target_px,
height: target_px,
advance_x: target_px as f32,
bearing_y: style.font_size,
}
}
_ => self.placeholder_emoji(target_px, style),
}
}
fn placeholder_emoji(&self, size_px: u32, style: &TextStyle) -> EmojiGlyph {
let pixel_count = (size_px * size_px * 4) as usize;
EmojiGlyph {
rgba: vec![0u8; pixel_count],
width: size_px,
height: size_px,
advance_x: size_px as f32,
bearing_y: style.font_size,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_emoji_codepoint_ascii_is_false() {
for ch in 'a'..='z' {
assert!(!is_emoji_codepoint(ch), "'{ch}' should not be emoji");
}
for ch in '0'..='9' {
assert!(!is_emoji_codepoint(ch), "'{ch}' should not be emoji");
}
}
#[test]
fn is_emoji_codepoint_common_emoji() {
assert!(is_emoji_codepoint('\u{1F30D}'), "U+1F30D should be emoji");
assert!(is_emoji_codepoint('\u{1F389}'), "U+1F389 should be emoji");
assert!(is_emoji_codepoint('\u{1F600}'), "U+1F600 should be emoji");
}
#[test]
fn is_emoji_variation_selector() {
assert!(is_emoji_codepoint('\u{FE0F}'));
}
#[test]
fn emoji_segmenter_plain_only() {
let runs: Vec<_> = EmojiSegmenter::new("hello").collect();
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].kind, RunKind::Plain);
assert_eq!(runs[0].text, "hello");
assert_eq!(runs[0].byte_start, 0);
}
#[test]
fn emoji_segmenter_emoji_only() {
let s = "\u{1F600}\u{1F389}"; let runs: Vec<_> = EmojiSegmenter::new(s).collect();
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].kind, RunKind::Emoji);
assert_eq!(runs[0].text, s);
}
#[test]
fn emoji_segmenter_mixed() {
let s = "hello \u{1F600} world";
let runs: Vec<_> = EmojiSegmenter::new(s).collect();
assert_eq!(runs.len(), 3, "expected 3 runs, got {runs:?}");
assert_eq!(runs[0].kind, RunKind::Plain);
assert_eq!(runs[0].text, "hello ");
assert_eq!(runs[1].kind, RunKind::Emoji);
assert_eq!(runs[2].kind, RunKind::Plain);
assert_eq!(runs[2].text, " world");
}
#[test]
fn emoji_segmenter_empty_string() {
let runs: Vec<_> = EmojiSegmenter::new("").collect();
assert!(runs.is_empty());
}
#[test]
fn emoji_segmenter_byte_start_tracks() {
let s = "hi \u{1F600}";
let runs: Vec<_> = EmojiSegmenter::new(s).collect();
assert_eq!(runs[0].byte_start, 0);
assert_eq!(runs[1].byte_start, 3); }
#[test]
fn scale_rgba_nearest_identity() {
let src: Vec<u8> = (0..16).map(|i| i as u8).collect(); let scaled = scale_rgba_nearest(&src, 2, 2, 2, 2);
assert_eq!(scaled, src);
}
#[test]
fn scale_rgba_nearest_upscale() {
let src: Vec<u8> = vec![
255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 128, 128, 128, 255,
];
let scaled = scale_rgba_nearest(&src, 2, 2, 4, 4);
assert_eq!(scaled.len(), 4 * 4 * 4);
}
#[test]
fn run_kind_equality() {
assert_eq!(RunKind::Plain, RunKind::Plain);
assert_eq!(RunKind::Emoji, RunKind::Emoji);
assert_ne!(RunKind::Plain, RunKind::Emoji);
}
#[test]
fn emoji_renderer_from_invalid_bytes_fails() {
let r = EmojiRenderer::from_bytes(&[]);
assert!(r.is_err());
}
#[test]
fn placeholder_emoji_is_correct_size() {
let pipeline_result = TextPipeline::from_bytes(&[]);
if let Ok(pipeline) = pipeline_result {
let renderer = EmojiRenderer::new(pipeline);
let style = TextStyle::new(16.0);
let glyph = renderer.placeholder_emoji(32, &style);
assert_eq!(glyph.width, 32);
assert_eq!(glyph.height, 32);
assert_eq!(glyph.rgba.len(), (32 * 32 * 4) as usize);
assert!(glyph.rgba.iter().all(|&b| b == 0)); }
}
}