use crate::text::{measure_text, Font};
use crate::Color;
use std::collections::{HashMap, HashSet};
use std::fmt::Write;
#[derive(Debug, Clone)]
pub struct TextSpan {
pub text: String,
pub font: Font,
pub font_size: f64,
pub color: Color,
}
impl TextSpan {
pub fn new(text: &str, font: Font, font_size: f64, color: Color) -> Self {
Self {
text: text.to_string(),
font,
font_size,
color,
}
}
pub fn measure_width(&self) -> f64 {
measure_text(&self.text, &self.font, self.font_size)
}
}
#[derive(Debug)]
pub struct RichText {
spans: Vec<TextSpan>,
}
impl RichText {
pub fn new(spans: Vec<TextSpan>) -> Self {
Self { spans }
}
pub fn total_width(&self) -> f64 {
self.spans.iter().map(|s| s.measure_width()).sum()
}
pub fn max_font_size(&self) -> f64 {
self.spans
.iter()
.map(|s| s.font_size)
.fold(0.0_f64, f64::max)
}
pub fn spans(&self) -> &[TextSpan] {
&self.spans
}
pub(crate) fn render_operations(
&self,
x: f64,
y: f64,
) -> (String, HashMap<String, HashSet<char>>) {
let mut font_usage: HashMap<String, HashSet<char>> = HashMap::new();
if self.spans.is_empty() {
return (String::new(), font_usage);
}
let mut ops = String::new();
ops.push_str("BT\n");
writeln!(&mut ops, "{x:.2} {y:.2} Td").expect("write to String");
for span in &self.spans {
match span.color {
Color::Rgb(r, g, b) => {
writeln!(&mut ops, "{r:.3} {g:.3} {b:.3} rg").expect("write to String");
}
Color::Gray(gray) => {
writeln!(&mut ops, "{gray:.3} g").expect("write to String");
}
Color::Cmyk(c, m, y, k) => {
writeln!(&mut ops, "{c:.3} {m:.3} {y:.3} {k:.3} k").expect("write to String");
}
}
let font_name = span.font.pdf_name();
writeln!(&mut ops, "/{} {:.2} Tf", font_name, span.font_size).expect("write to String");
ops.push('(');
for ch in span.text.chars() {
match ch {
'(' => ops.push_str("\\("),
')' => ops.push_str("\\)"),
'\\' => ops.push_str("\\\\"),
'\n' => ops.push_str("\\n"),
'\r' => ops.push_str("\\r"),
'\t' => ops.push_str("\\t"),
_ => ops.push(ch),
}
}
ops.push_str(") Tj\n");
font_usage
.entry(font_name)
.or_default()
.extend(span.text.chars());
}
ops.push_str("ET\n");
(ops, font_usage)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_rich_text() {
let rt = RichText::new(vec![]);
assert_eq!(rt.total_width(), 0.0);
assert_eq!(rt.max_font_size(), 0.0);
let (ops, font_usage) = rt.render_operations(0.0, 0.0);
assert!(ops.is_empty());
assert!(font_usage.is_empty(), "no spans → no font usage reported");
}
#[test]
fn test_render_operations_contains_bt_et() {
let rt = RichText::new(vec![TextSpan::new(
"Hello",
Font::Helvetica,
12.0,
Color::black(),
)]);
let (ops, font_usage) = rt.render_operations(50.0, 700.0);
assert!(ops.starts_with("BT\n"));
assert!(ops.ends_with("ET\n"));
assert!(ops.contains("(Hello) Tj"));
assert!(ops.contains("/Helvetica 12.00 Tf"));
let chars = font_usage
.get("Helvetica")
.expect("Helvetica span must produce a bucket");
assert!(chars.contains(&'H'));
assert!(chars.contains(&'e'));
assert!(chars.contains(&'l'));
assert!(chars.contains(&'o'));
}
}