1use crate::color::Color;
2
3#[derive(Debug, Clone, PartialEq)]
5#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
6pub struct RichText {
7 pub segments: Vec<RichSegment>,
8 pub color: Color,
9 pub font_size: f32,
10 pub font: String,
16 #[cfg_attr(
21 feature = "serde",
22 serde(default, skip_serializing_if = "TextAlign::is_left")
23 )]
24 pub align: TextAlign,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
37pub enum TextAlign {
38 #[default]
41 Left,
42 Center,
44 Right,
46}
47
48impl TextAlign {
49 #[cfg(feature = "serde")]
51 fn is_left(&self) -> bool {
52 *self == TextAlign::Left
53 }
54
55 pub fn line_offset(self, block_width: f32, line_width: f32) -> f32 {
59 match self {
60 TextAlign::Left => 0.0,
61 TextAlign::Center => (block_width - line_width) * 0.5,
62 TextAlign::Right => block_width - line_width,
63 }
64 }
65}
66
67#[derive(Debug, Clone, PartialEq)]
68#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
69pub struct RichSegment {
70 pub text: char,
71 pub bold: bool,
72 pub italic: bool,
73 pub underline: bool,
74 pub superscript: bool,
75 pub subscript: bool,
76 pub greek: bool,
77 #[cfg_attr(
79 feature = "serde",
80 serde(default, skip_serializing_if = "Option::is_none")
81 )]
82 pub color: Option<Color>,
83 #[cfg_attr(
86 feature = "serde",
87 serde(default, skip_serializing_if = "Option::is_none")
88 )]
89 pub font_size: Option<f32>,
90 #[cfg_attr(
95 feature = "serde",
96 serde(default, skip_serializing_if = "Option::is_none")
97 )]
98 pub field_em: Option<f32>,
99 #[cfg_attr(
103 feature = "serde",
104 serde(default, skip_serializing_if = "std::ops::Not::not")
105 )]
106 pub rule: bool,
107 #[cfg_attr(
110 feature = "serde",
111 serde(default, skip_serializing_if = "Option::is_none")
112 )]
113 pub rule_dash: Option<Vec<f32>>,
114}
115
116impl RichSegment {
117 pub fn plain(text: char) -> Self {
120 Self {
121 text,
122 bold: false,
123 italic: false,
124 underline: false,
125 superscript: false,
126 subscript: false,
127 greek: false,
128 color: None,
129 font_size: None,
130 field_em: None,
131 rule: false,
132 rule_dash: None,
133 }
134 }
135
136 pub fn rule(width_em: f32, color: Option<Color>) -> Self {
140 Self {
141 field_em: Some(width_em),
142 rule: true,
143 color,
144 ..Self::plain('—')
145 }
146 }
147
148 pub fn dashed_rule(width_em: f32, color: Option<Color>, dash_em: Vec<f32>) -> Self {
151 Self {
152 rule_dash: (!dash_em.is_empty()).then_some(dash_em),
153 ..Self::rule(width_em, color)
154 }
155 }
156
157 pub fn fielded(text: char, width_em: f32, color: Option<Color>) -> Self {
159 Self {
160 field_em: Some(width_em),
161 color,
162 ..Self::plain(text)
163 }
164 }
165}
166
167impl RichText {
168 pub fn plain(text: &str, color: Color, font_size: f32, font: impl Into<String>) -> Self {
169 Self {
170 segments: rich_segments_from_text(text),
171 color,
172 font_size,
173 font: font.into(),
174 align: TextAlign::Left,
175 }
176 }
177}
178
179#[derive(Debug, Clone, Copy, PartialEq)]
184pub struct TextExtents {
185 pub width: f32,
186 pub ascent: f32,
187 pub descent: f32,
188}
189
190impl TextExtents {
191 pub fn height(&self) -> f32 {
192 self.ascent + self.descent
193 }
194}
195
196pub trait MeasureText {
202 fn measure_rich(&self, rt: &RichText) -> TextExtents;
203}
204
205pub fn rich_segments_from_text(text: &str) -> Vec<RichSegment> {
209 text.chars().map(rich_segment_from_char).collect()
210}
211
212fn rich_segment_from_char(c: char) -> RichSegment {
213 let (text, superscript, subscript) = match c {
214 '⁰' => ('0', true, false),
215 '¹' => ('1', true, false),
216 '²' => ('2', true, false),
217 '³' => ('3', true, false),
218 '⁴' => ('4', true, false),
219 '⁵' => ('5', true, false),
220 '⁶' => ('6', true, false),
221 '⁷' => ('7', true, false),
222 '⁸' => ('8', true, false),
223 '⁹' => ('9', true, false),
224 '⁺' => ('+', true, false),
225 '⁻' => ('-', true, false),
226 '⁽' => ('(', true, false),
227 '⁾' => (')', true, false),
228 'ⁿ' => ('n', true, false),
229 'ᵗ' => ('t', true, false),
230 '₀' => ('0', false, true),
231 '₁' => ('1', false, true),
232 '₂' => ('2', false, true),
233 '₃' => ('3', false, true),
234 '₄' => ('4', false, true),
235 '₅' => ('5', false, true),
236 '₆' => ('6', false, true),
237 '₇' => ('7', false, true),
238 '₈' => ('8', false, true),
239 '₉' => ('9', false, true),
240 '₊' => ('+', false, true),
241 '₋' => ('-', false, true),
242 '₍' => ('(', false, true),
243 '₎' => (')', false, true),
244 other => (other, false, false),
245 };
246 RichSegment {
247 superscript,
248 subscript,
249 ..RichSegment::plain(text)
250 }
251}
252
253pub fn greek_char(c: char) -> char {
256 match c {
257 'a' => 'α',
258 'b' => 'β',
259 'c' => 'χ',
260 'd' => 'δ',
261 'e' => 'ε',
262 'f' => 'φ',
263 'g' => 'γ',
264 'h' => 'η',
265 'i' => 'ι',
266 'j' => 'ϕ',
267 'k' => 'κ',
268 'l' => 'λ',
269 'm' => 'μ',
270 'n' => 'ν',
271 'o' => 'ο',
272 'p' => 'π',
273 'q' => 'θ',
274 'r' => 'ρ',
275 's' => 'σ',
276 't' => 'τ',
277 'u' => 'υ',
278 'v' => 'ϖ',
279 'w' => 'ω',
280 'x' => 'ξ',
281 'y' => 'ψ',
282 'z' => 'ζ',
283 'A' => 'Α',
284 'B' => 'Β',
285 'C' => 'Χ',
286 'D' => 'Δ',
287 'E' => 'Ε',
288 'F' => 'Φ',
289 'G' => 'Γ',
290 'H' => 'Η',
291 'I' => 'Ι',
292 'J' => 'ϑ',
293 'K' => 'Κ',
294 'L' => 'Λ',
295 'M' => 'Μ',
296 'N' => 'Ν',
297 'O' => 'Ο',
298 'P' => 'Π',
299 'Q' => 'Θ',
300 'R' => 'Ρ',
301 'S' => 'Σ',
302 'T' => 'Τ',
303 'U' => 'Υ',
304 'V' => 'ς',
305 'W' => 'Ω',
306 'X' => 'Ξ',
307 'Y' => 'Ψ',
308 'Z' => 'Ζ',
309 other => other,
310 }
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316
317 #[test]
318 fn lowercase_full_table() {
319 let pairs: &[(char, char)] = &[
320 ('a', 'α'),
321 ('b', 'β'),
322 ('c', 'χ'),
323 ('d', 'δ'),
324 ('e', 'ε'),
325 ('f', 'φ'),
326 ('g', 'γ'),
327 ('h', 'η'),
328 ('i', 'ι'),
329 ('j', 'ϕ'),
330 ('k', 'κ'),
331 ('l', 'λ'),
332 ('m', 'μ'),
333 ('n', 'ν'),
334 ('o', 'ο'),
335 ('p', 'π'),
336 ('q', 'θ'),
337 ('r', 'ρ'),
338 ('s', 'σ'),
339 ('t', 'τ'),
340 ('u', 'υ'),
341 ('v', 'ϖ'),
342 ('w', 'ω'),
343 ('x', 'ξ'),
344 ('y', 'ψ'),
345 ('z', 'ζ'),
346 ];
347 for &(l, g) in pairs {
348 assert_eq!(greek_char(l), g, "lower '{}' -> '{}'", l, g);
349 }
350 }
351
352 #[test]
353 fn uppercase_full_table() {
354 let pairs: &[(char, char)] = &[
355 ('A', 'Α'),
356 ('B', 'Β'),
357 ('C', 'Χ'),
358 ('D', 'Δ'),
359 ('E', 'Ε'),
360 ('F', 'Φ'),
361 ('G', 'Γ'),
362 ('H', 'Η'),
363 ('I', 'Ι'),
364 ('J', 'ϑ'),
365 ('K', 'Κ'),
366 ('L', 'Λ'),
367 ('M', 'Μ'),
368 ('N', 'Ν'),
369 ('O', 'Ο'),
370 ('P', 'Π'),
371 ('Q', 'Θ'),
372 ('R', 'Ρ'),
373 ('S', 'Σ'),
374 ('T', 'Τ'),
375 ('U', 'Υ'),
376 ('V', 'ς'),
377 ('W', 'Ω'),
378 ('X', 'Ξ'),
379 ('Y', 'Ψ'),
380 ('Z', 'Ζ'),
381 ];
382 for &(l, g) in pairs {
383 assert_eq!(greek_char(l), g, "upper '{}' -> '{}'", l, g);
384 }
385 }
386
387 #[test]
388 fn non_latin_passthrough() {
389 for c in ['0', '9', ' ', '+', '=', '.', '-', '_', 'α', 'Ω', '漢'] {
390 assert_eq!(greek_char(c), c);
391 }
392 }
393
394 #[cfg(feature = "serde")]
397 #[test]
398 fn segment_overrides_stay_backward_compatible_and_compact() {
399 let old = r#"{"text":"a","bold":false,"italic":false,"underline":false,"superscript":false,"subscript":false,"greek":false}"#;
400 let seg: RichSegment = serde_json::from_str(old).expect("pre-override document parses");
401 assert_eq!(seg, RichSegment::plain('a'));
402
403 let json = serde_json::to_string(&seg).unwrap();
404 assert!(
405 !json.contains("color"),
406 "None color must be skipped: {json}"
407 );
408 assert!(
409 !json.contains("font_size"),
410 "None font_size must be skipped: {json}"
411 );
412 }
413
414 #[cfg(feature = "serde")]
418 #[test]
419 fn align_defaults_to_left_and_round_trips() {
420 let old = r#"{"segments":[],"color":{"r":0.0,"g":0.0,"b":0.0,"a":1.0},"font_size":12.0,"font":""}"#;
421 let rt: RichText = serde_json::from_str(old).expect("pre-align document parses");
422 assert_eq!(rt.align, TextAlign::Left);
423 assert_eq!(rt, RichText::plain("", Color::BLACK, 12.0, ""));
424 assert_eq!(
425 serde_json::to_string(&rt).unwrap(),
426 old,
427 "a left-aligned document must serialize exactly as before"
428 );
429
430 for align in [TextAlign::Center, TextAlign::Right] {
431 let rt = RichText {
432 align,
433 ..RichText::plain("a\nbc", Color::BLACK, 12.0, "")
434 };
435 let json = serde_json::to_string(&rt).unwrap();
436 assert!(json.contains(&format!(r#""align":"{align:?}""#)), "{json}");
437 let back: RichText = serde_json::from_str(&json).unwrap();
438 assert_eq!(back, rt);
439 }
440 }
441
442 #[test]
443 fn align_spends_none_half_or_all_of_the_slack() {
444 assert_eq!(TextAlign::default(), TextAlign::Left);
445 assert_eq!(TextAlign::Left.line_offset(100.0, 40.0), 0.0);
446 assert_eq!(TextAlign::Center.line_offset(100.0, 40.0), 30.0);
447 assert_eq!(TextAlign::Right.line_offset(100.0, 40.0), 60.0);
448 for align in [TextAlign::Left, TextAlign::Center, TextAlign::Right] {
450 assert_eq!(align.line_offset(100.0, 100.0), 0.0);
451 }
452 assert_eq!(TextAlign::Left.line_offset(f32::NAN, 1.0), 0.0);
454 }
455}