use std::rc::Rc;
use crate::render::dimension::Pt;
use crate::render::emoji::cluster::{self, EmojiCluster, InlineCluster};
use crate::render::emoji::resolve::{EmojiFamily, EmojiTypeface};
use crate::render::layout::measurer::TextMeasurer;
use crate::render::resolve::color::RgbColor;
use super::{FontProps, Fragment, FragmentBorder, TextMetrics};
pub(super) fn resolve_highlight_color(hl: crate::model::HighlightColor) -> RgbColor {
use crate::model::HighlightColor;
match hl {
HighlightColor::Black => RgbColor { r: 0, g: 0, b: 0 },
HighlightColor::Blue => RgbColor { r: 0, g: 0, b: 255 },
HighlightColor::Cyan => RgbColor {
r: 0,
g: 255,
b: 255,
},
HighlightColor::DarkBlue => RgbColor { r: 0, g: 0, b: 139 },
HighlightColor::DarkCyan => RgbColor {
r: 0,
g: 139,
b: 139,
},
HighlightColor::DarkGray => RgbColor {
r: 169,
g: 169,
b: 169,
},
HighlightColor::DarkGreen => RgbColor { r: 0, g: 100, b: 0 },
HighlightColor::DarkMagenta => RgbColor {
r: 139,
g: 0,
b: 139,
},
HighlightColor::DarkRed => RgbColor { r: 139, g: 0, b: 0 },
HighlightColor::DarkYellow => RgbColor {
r: 139,
g: 139,
b: 0,
},
HighlightColor::Green => RgbColor { r: 0, g: 255, b: 0 },
HighlightColor::LightGray => RgbColor {
r: 211,
g: 211,
b: 211,
},
HighlightColor::Magenta => RgbColor {
r: 255,
g: 0,
b: 255,
},
HighlightColor::Red => RgbColor { r: 255, g: 0, b: 0 },
HighlightColor::White => RgbColor {
r: 255,
g: 255,
b: 255,
},
HighlightColor::Yellow => RgbColor {
r: 255,
g: 255,
b: 0,
},
}
}
pub(super) struct TextRunStyle {
pub color: RgbColor,
pub shading: Option<RgbColor>,
pub border: Option<FragmentBorder>,
pub baseline_offset: Pt,
}
fn split_into_words(text: &str) -> Vec<&str> {
let mut words = Vec::new();
let mut start = 0;
for (i, ch) in text.char_indices() {
match ch {
' ' | '\t' => {
let end = i + ch.len_utf8();
if end > start {
words.push(&text[start..end]);
start = end;
}
}
'-' | '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}' => {
let end = i + ch.len_utf8();
if end > start {
words.push(&text[start..end]);
start = end;
}
}
_ => {}
}
}
if start < text.len() {
words.push(&text[start..]);
}
words
}
pub(super) fn emit_text_fragments<F>(
text: &str,
font: &FontProps,
style: &TextRunStyle,
hyperlink_url: Option<&str>,
measure_text: &F,
measurer: Option<&TextMeasurer<'_>>,
fragments: &mut Vec<Fragment>,
) where
F: Fn(&str, &FontProps) -> (Pt, TextMetrics),
{
let cleaned: String = text
.chars()
.filter(|&c| !c.is_control() || c == '\t')
.collect();
if cleaned.is_empty() {
return;
}
let Some(measurer) = measurer else {
emit_text_words(
&cleaned,
font,
style,
hyperlink_url,
measure_text,
fragments,
);
return;
};
for cluster in cluster::classify(&cleaned) {
match cluster {
InlineCluster::Text(span) => {
emit_text_words(span, font, style, hyperlink_url, measure_text, fragments);
}
InlineCluster::Emoji(emoji) => {
emit_emoji_or_fallback(
&emoji,
font,
style,
hyperlink_url,
measure_text,
measurer,
fragments,
);
}
}
}
}
pub(super) fn emit_text_words<F>(
text: &str,
font: &FontProps,
style: &TextRunStyle,
hyperlink_url: Option<&str>,
measure_text: &F,
fragments: &mut Vec<Fragment>,
) where
F: Fn(&str, &FontProps) -> (Pt, TextMetrics),
{
if text.is_empty() {
return;
}
for word in split_into_words(text) {
let (w, m) = measure_text(word, font);
let trimmed = word.trim_end();
let tw = if trimmed.len() < word.len() {
measure_text(trimmed, font).0
} else {
w
};
fragments.push(Fragment::Text {
text: Rc::from(word),
font: font.clone(),
color: style.color,
shading: style.shading,
border: style.border,
width: w,
trimmed_width: tw,
metrics: m,
hyperlink_url: hyperlink_url.map(String::from),
baseline_offset: style.baseline_offset,
text_offset: Pt::ZERO,
});
}
}
pub(super) fn emit_emoji_or_fallback<F>(
cluster: &EmojiCluster<'_>,
font: &FontProps,
style: &TextRunStyle,
hyperlink_url: Option<&str>,
measure_text: &F,
measurer: &TextMeasurer<'_>,
fragments: &mut Vec<Fragment>,
) where
F: Fn(&str, &FontProps) -> (Pt, TextMetrics),
{
let requested = EmojiFamily::from_name_ci(&font.family);
match measurer.resolve_emoji(requested) {
EmojiTypeface::Resolved {
entry: typeface, ..
} => {
let (advance, metrics) =
measurer.measure_with_typeface(cluster.text, &typeface, font.size);
let (_, line_metrics) = measure_text("X", font);
fragments.push(Fragment::Emoji {
text: cluster.text.to_string(),
typeface,
size: font.size,
presentation: cluster.presentation,
structure: cluster.structure,
advance,
metrics,
line_metrics,
baseline_offset: style.baseline_offset,
});
}
EmojiTypeface::Unavailable { attempted } => {
measurer.warn_emoji_unavailable_once(cluster.text, &attempted);
emit_text_words(
cluster.text,
font,
style,
hyperlink_url,
measure_text,
fragments,
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_single_word() {
assert_eq!(split_into_words("hello"), vec!["hello"]);
}
#[test]
fn split_two_words() {
assert_eq!(split_into_words("hello world"), vec!["hello ", "world"]);
}
#[test]
fn split_trailing_space() {
assert_eq!(split_into_words("hello "), vec!["hello "]);
}
use crate::render::emoji::cluster::EmojiPresentation;
use crate::render::fonts::FontRegistry;
use crate::render::layout::measurer::TextMeasurer;
use skia_safe::FontMgr;
use std::rc::Rc;
fn font(family: &str, size: f32) -> FontProps {
FontProps {
family: Rc::from(family),
size: Pt::new(size),
bold: false,
italic: false,
underline: false,
char_spacing: Pt::ZERO,
underline_position: Pt::ZERO,
underline_thickness: Pt::ZERO,
}
}
fn style() -> TextRunStyle {
TextRunStyle {
color: RgbColor::BLACK,
shading: None,
border: None,
baseline_offset: Pt::ZERO,
}
}
#[test]
fn l1_emoji_run_splits_into_text_and_emoji_fragments() {
let registry = FontRegistry::new(FontMgr::new());
let measurer = TextMeasurer::new(®istry);
use crate::render::emoji::resolve::EmojiTypeface;
if matches!(
measurer.resolve_emoji(None),
EmojiTypeface::Unavailable { .. }
) {
eprintln!("skipping L1: no color emoji typeface on this host");
return;
}
let mut fragments = Vec::new();
let measure = |text: &str, fp: &FontProps| measurer.measure(text, fp);
emit_text_fragments(
"hi \u{1F4DE}",
&font("Calibri", 12.0),
&style(),
None,
&measure,
Some(&measurer),
&mut fragments,
);
assert_eq!(
fragments.len(),
2,
"expected 2 fragments (Text + Emoji), got {fragments:#?}"
);
match &fragments[0] {
Fragment::Text { text, .. } => assert_eq!(&**text, "hi "),
other => panic!("first fragment must be Text, got {other:?}"),
}
match &fragments[1] {
Fragment::Emoji {
text,
presentation,
advance,
..
} => {
assert_eq!(text, "\u{1F4DE}");
assert_eq!(*presentation, EmojiPresentation::Emoji);
assert!(
advance.raw() > 0.0,
"advance must be positive, got {advance}"
);
}
other => panic!("second fragment must be Emoji, got {other:?}"),
}
}
#[test]
fn l4_no_measurer_routes_emoji_through_text_path() {
let mut fragments = Vec::new();
let measure = |text: &str, _fp: &FontProps| {
(
Pt::new(text.len() as f32 * 6.0),
TextMetrics {
ascent: Pt::new(10.0),
descent: Pt::new(2.0),
leading: Pt::ZERO,
},
)
};
emit_text_fragments(
"hi \u{1F4DE}",
&font("Calibri", 12.0),
&style(),
None,
&measure,
None,
&mut fragments,
);
for f in &fragments {
assert!(
!matches!(f, Fragment::Emoji { .. }),
"no emoji fragments must be produced when measurer is None"
);
}
let joined: String = fragments
.iter()
.filter_map(|f| match f {
Fragment::Text { text, .. } => Some(&**text),
_ => None,
})
.collect();
assert!(
joined.contains('\u{1F4DE}'),
"emoji codepoint must survive through the text path"
);
}
#[test]
fn split_multiple_words() {
assert_eq!(
split_into_words("the quick brown fox"),
vec!["the ", "quick ", "brown ", "fox"]
);
}
#[test]
fn split_empty() {
let result: Vec<&str> = split_into_words("");
assert!(result.is_empty());
}
}