oxidize_pdf/layout/
rich_text.rs1use crate::text::{measure_text, Font};
2use crate::Color;
3use std::collections::{HashMap, HashSet};
4use std::fmt::Write;
5
6#[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 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 pub fn measure_width(&self) -> f64 {
28 measure_text(&self.text, &self.font, self.font_size)
29 }
30}
31
32#[derive(Debug)]
51pub struct RichText {
52 spans: Vec<TextSpan>,
53}
54
55impl RichText {
56 pub fn new(spans: Vec<TextSpan>) -> Self {
58 Self { spans }
59 }
60
61 pub fn total_width(&self) -> f64 {
63 self.spans.iter().map(|s| s.measure_width()).sum()
64 }
65
66 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 pub fn spans(&self) -> &[TextSpan] {
76 &self.spans
77 }
78
79 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 crate::graphics::color::write_fill_color(&mut ops, span.color);
110
111 let font_name = span.font.pdf_name();
113 writeln!(&mut ops, "/{} {:.2} Tf", font_name, span.font_size).expect("write to String");
114
115 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 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 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}