Skip to main content

oxidize_pdf/layout/
rich_text.rs

1use crate::text::{measure_text, Font};
2use crate::Color;
3use std::collections::{HashMap, HashSet};
4use std::fmt::Write;
5
6/// A styled text segment with its own font, size, and color.
7#[derive(Debug, Clone)]
8pub struct TextSpan {
9    pub text: String,
10    pub font: Font,
11    pub font_size: f64,
12    pub color: Color,
13}
14
15impl TextSpan {
16    /// Create a new text span.
17    pub fn new(text: &str, font: Font, font_size: f64, color: Color) -> Self {
18        Self {
19            text: text.to_string(),
20            font,
21            font_size,
22            color,
23        }
24    }
25
26    /// Measure the width of this span in points.
27    pub fn measure_width(&self) -> f64 {
28        measure_text(&self.text, &self.font, self.font_size)
29    }
30}
31
32/// A line of mixed-style text composed of multiple [`TextSpan`]s.
33///
34/// Each span can have a different font, size, and color. The entire
35/// RichText renders as a single line (no word-wrapping).
36///
37/// # Example
38///
39/// ```rust
40/// use oxidize_pdf::layout::{RichText, TextSpan};
41/// use oxidize_pdf::{Color, Font};
42///
43/// let rich = RichText::new(vec![
44///     TextSpan::new("Total: ", Font::HelveticaBold, 14.0, Color::black()),
45///     TextSpan::new("$1,234.56", Font::Helvetica, 14.0, Color::gray(0.3)),
46/// ]);
47/// assert_eq!(rich.spans().len(), 2);
48/// assert!(rich.total_width() > 0.0);
49/// ```
50#[derive(Debug)]
51pub struct RichText {
52    spans: Vec<TextSpan>,
53}
54
55impl RichText {
56    /// Create a RichText from a list of spans.
57    pub fn new(spans: Vec<TextSpan>) -> Self {
58        Self { spans }
59    }
60
61    /// Total width of all spans combined.
62    pub fn total_width(&self) -> f64 {
63        self.spans.iter().map(|s| s.measure_width()).sum()
64    }
65
66    /// Maximum font size across all spans (determines line height).
67    pub fn max_font_size(&self) -> f64 {
68        self.spans
69            .iter()
70            .map(|s| s.font_size)
71            .fold(0.0_f64, f64::max)
72    }
73
74    /// Access the spans.
75    pub fn spans(&self) -> &[TextSpan] {
76        &self.spans
77    }
78
79    /// Generate PDF operators to render this rich text at position (x, y).
80    ///
81    /// Produces a single BT/ET block with per-span font/color/text changes.
82    /// Render this rich-text block to a content-stream fragment plus a
83    /// per-font character usage map (issue #204).
84    ///
85    /// The caller is responsible for splicing `ops` into the target
86    /// page's content stream and reporting `font_usage` via
87    /// [`crate::Page::append_raw_content`] — both go together so the
88    /// writer knows which fonts this fragment referenced and what
89    /// characters it drew with each. Returning the usage map is the
90    /// type-gated replacement for scattering `record_used_chars` calls
91    /// through every content builder; future builders cannot forget
92    /// tracking because `append_raw_content` won't compile without it.
93    pub(crate) fn render_operations(
94        &self,
95        x: f64,
96        y: f64,
97    ) -> (String, HashMap<String, HashSet<char>>) {
98        let mut font_usage: HashMap<String, HashSet<char>> = HashMap::new();
99        if self.spans.is_empty() {
100            return (String::new(), font_usage);
101        }
102
103        let mut ops = String::new();
104        ops.push_str("BT\n");
105        writeln!(&mut ops, "{x:.2} {y:.2} Td").expect("write to String");
106
107        for span in &self.spans {
108            // Set color via the shared NaN-sanitising helper (issues #220, #221).
109            crate::graphics::color::write_fill_color(&mut ops, span.color);
110
111            // Set font
112            let font_name = span.font.pdf_name();
113            writeln!(&mut ops, "/{} {:.2} Tf", font_name, span.font_size).expect("write to String");
114
115            // Show text with escaping
116            ops.push('(');
117            for ch in span.text.chars() {
118                match ch {
119                    '(' => ops.push_str("\\("),
120                    ')' => ops.push_str("\\)"),
121                    '\\' => ops.push_str("\\\\"),
122                    '\n' => ops.push_str("\\n"),
123                    '\r' => ops.push_str("\\r"),
124                    '\t' => ops.push_str("\\t"),
125                    _ => ops.push(ch),
126                }
127            }
128            ops.push_str(") Tj\n");
129
130            // Report the characters drawn with this span's font so the
131            // writer can subset the font accurately (issue #204).
132            font_usage
133                .entry(font_name)
134                .or_default()
135                .extend(span.text.chars());
136        }
137
138        ops.push_str("ET\n");
139        (ops, font_usage)
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn test_empty_rich_text() {
149        let rt = RichText::new(vec![]);
150        assert_eq!(rt.total_width(), 0.0);
151        assert_eq!(rt.max_font_size(), 0.0);
152        let (ops, font_usage) = rt.render_operations(0.0, 0.0);
153        assert!(ops.is_empty());
154        assert!(font_usage.is_empty(), "no spans → no font usage reported");
155    }
156
157    #[test]
158    fn test_render_operations_contains_bt_et() {
159        let rt = RichText::new(vec![TextSpan::new(
160            "Hello",
161            Font::Helvetica,
162            12.0,
163            Color::black(),
164        )]);
165        let (ops, font_usage) = rt.render_operations(50.0, 700.0);
166        assert!(ops.starts_with("BT\n"));
167        assert!(ops.ends_with("ET\n"));
168        assert!(ops.contains("(Hello) Tj"));
169        assert!(ops.contains("/Helvetica 12.00 Tf"));
170
171        // PR for issue #204: render_operations must also report per-font
172        // char usage so the caller can feed it into the page tracker
173        // via `Page::append_raw_content`.
174        let chars = font_usage
175            .get("Helvetica")
176            .expect("Helvetica span must produce a bucket");
177        assert!(chars.contains(&'H'));
178        assert!(chars.contains(&'e'));
179        assert!(chars.contains(&'l'));
180        assert!(chars.contains(&'o'));
181    }
182}