Skip to main content

oxidize_pdf/layout/
rich_text.rs

1use crate::text::{measure_text, Font};
2use crate::Color;
3use std::fmt::Write;
4
5/// A styled text segment with its own font, size, and color.
6#[derive(Debug, Clone)]
7pub struct TextSpan {
8    pub text: String,
9    pub font: Font,
10    pub font_size: f64,
11    pub color: Color,
12}
13
14impl TextSpan {
15    /// Create a new text span.
16    pub fn new(text: &str, font: Font, font_size: f64, color: Color) -> Self {
17        Self {
18            text: text.to_string(),
19            font,
20            font_size,
21            color,
22        }
23    }
24
25    /// Measure the width of this span in points.
26    pub fn measure_width(&self) -> f64 {
27        measure_text(&self.text, &self.font, self.font_size)
28    }
29}
30
31/// A line of mixed-style text composed of multiple [`TextSpan`]s.
32///
33/// Each span can have a different font, size, and color. The entire
34/// RichText renders as a single line (no word-wrapping).
35///
36/// # Example
37///
38/// ```rust
39/// use oxidize_pdf::layout::{RichText, TextSpan};
40/// use oxidize_pdf::{Color, Font};
41///
42/// let rich = RichText::new(vec![
43///     TextSpan::new("Total: ", Font::HelveticaBold, 14.0, Color::black()),
44///     TextSpan::new("$1,234.56", Font::Helvetica, 14.0, Color::gray(0.3)),
45/// ]);
46/// assert_eq!(rich.spans().len(), 2);
47/// assert!(rich.total_width() > 0.0);
48/// ```
49#[derive(Debug)]
50pub struct RichText {
51    spans: Vec<TextSpan>,
52}
53
54impl RichText {
55    /// Create a RichText from a list of spans.
56    pub fn new(spans: Vec<TextSpan>) -> Self {
57        Self { spans }
58    }
59
60    /// Total width of all spans combined.
61    pub fn total_width(&self) -> f64 {
62        self.spans.iter().map(|s| s.measure_width()).sum()
63    }
64
65    /// Maximum font size across all spans (determines line height).
66    pub fn max_font_size(&self) -> f64 {
67        self.spans
68            .iter()
69            .map(|s| s.font_size)
70            .fold(0.0_f64, f64::max)
71    }
72
73    /// Access the spans.
74    pub fn spans(&self) -> &[TextSpan] {
75        &self.spans
76    }
77
78    /// Generate PDF operators to render this rich text at position (x, y).
79    ///
80    /// Produces a single BT/ET block with per-span font/color/text changes.
81    pub(crate) fn render_operations(&self, x: f64, y: f64) -> String {
82        if self.spans.is_empty() {
83            return String::new();
84        }
85
86        let mut ops = String::new();
87        ops.push_str("BT\n");
88        writeln!(&mut ops, "{x:.2} {y:.2} Td").expect("write to String");
89
90        for span in &self.spans {
91            // Set color
92            match span.color {
93                Color::Rgb(r, g, b) => {
94                    writeln!(&mut ops, "{r:.3} {g:.3} {b:.3} rg").expect("write to String");
95                }
96                Color::Gray(gray) => {
97                    writeln!(&mut ops, "{gray:.3} g").expect("write to String");
98                }
99                Color::Cmyk(c, m, y, k) => {
100                    writeln!(&mut ops, "{c:.3} {m:.3} {y:.3} {k:.3} k").expect("write to String");
101                }
102            }
103
104            // Set font
105            writeln!(
106                &mut ops,
107                "/{} {:.2} Tf",
108                span.font.pdf_name(),
109                span.font_size
110            )
111            .expect("write to String");
112
113            // Show text with escaping
114            ops.push('(');
115            for ch in span.text.chars() {
116                match ch {
117                    '(' => ops.push_str("\\("),
118                    ')' => ops.push_str("\\)"),
119                    '\\' => ops.push_str("\\\\"),
120                    '\n' => ops.push_str("\\n"),
121                    '\r' => ops.push_str("\\r"),
122                    '\t' => ops.push_str("\\t"),
123                    _ => ops.push(ch),
124                }
125            }
126            ops.push_str(") Tj\n");
127        }
128
129        ops.push_str("ET\n");
130        ops
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn test_empty_rich_text() {
140        let rt = RichText::new(vec![]);
141        assert_eq!(rt.total_width(), 0.0);
142        assert_eq!(rt.max_font_size(), 0.0);
143        assert!(rt.render_operations(0.0, 0.0).is_empty());
144    }
145
146    #[test]
147    fn test_render_operations_contains_bt_et() {
148        let rt = RichText::new(vec![TextSpan::new(
149            "Hello",
150            Font::Helvetica,
151            12.0,
152            Color::black(),
153        )]);
154        let ops = rt.render_operations(50.0, 700.0);
155        assert!(ops.starts_with("BT\n"));
156        assert!(ops.ends_with("ET\n"));
157        assert!(ops.contains("(Hello) Tj"));
158        assert!(ops.contains("/Helvetica 12.00 Tf"));
159    }
160}