Skip to main content

model/
text.rs

1use crate::color::Color;
2
3/// A rich text value composed of styled segments.
4#[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    /// Font family name (e.g. "Noto Sans"). Resolved by the renderer's text
11    /// stack: runtime-registered fonts → system fonts (native only) →
12    /// bundled Liberation Sans. Empty string = bundled font.
13    pub font: String,
14}
15
16#[derive(Debug, Clone, PartialEq)]
17#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
18pub struct RichSegment {
19    pub text: char,
20    pub bold: bool,
21    pub italic: bool,
22    pub underline: bool,
23    pub superscript: bool,
24    pub subscript: bool,
25    pub greek: bool,
26    /// Per-segment ink color. `None` inherits `RichText.color`.
27    #[cfg_attr(
28        feature = "serde",
29        serde(default, skip_serializing_if = "Option::is_none")
30    )]
31    pub color: Option<Color>,
32    /// Per-segment base font size. `None` inherits `RichText.font_size`;
33    /// the sub/superscript ratio applies on top of the effective size.
34    #[cfg_attr(
35        feature = "serde",
36        serde(default, skip_serializing_if = "Option::is_none")
37    )]
38    pub font_size: Option<f32>,
39    /// Fixed advance in em (× effective font size). The glyph ink is drawn
40    /// horizontally centered inside the field; the advance itself ignores
41    /// glyph metrics entirely. This is what gives legend marks an exact,
42    /// font-size-relative width no glyph combination could guarantee.
43    #[cfg_attr(
44        feature = "serde",
45        serde(default, skip_serializing_if = "Option::is_none")
46    )]
47    pub field_em: Option<f32>,
48    /// Render as a horizontal rule (drawn bar) spanning the full advance
49    /// instead of a glyph — the line part of legend marks. `text` stays as
50    /// a fallback character for consumers that ignore the flag.
51    #[cfg_attr(
52        feature = "serde",
53        serde(default, skip_serializing_if = "std::ops::Not::not")
54    )]
55    pub rule: bool,
56    /// Optional dash pattern for a rule segment, in em units. Values are
57    /// multiplied by the effective segment font size at draw time.
58    #[cfg_attr(
59        feature = "serde",
60        serde(default, skip_serializing_if = "Option::is_none")
61    )]
62    pub rule_dash: Option<Vec<f32>>,
63}
64
65impl RichSegment {
66    /// One plain character: all style flags off, no per-segment overrides —
67    /// color and font size inherit from the parent `RichText`.
68    pub fn plain(text: char) -> Self {
69        Self {
70            text,
71            bold: false,
72            italic: false,
73            underline: false,
74            superscript: false,
75            subscript: false,
76            greek: false,
77            color: None,
78            font_size: None,
79            field_em: None,
80            rule: false,
81            rule_dash: None,
82        }
83    }
84
85    /// A drawn horizontal rule filling a fixed `width_em × font_size`
86    /// advance — the line stroke of legend marks. `'—'` remains as the
87    /// fallback text form.
88    pub fn rule(width_em: f32, color: Option<Color>) -> Self {
89        Self {
90            field_em: Some(width_em),
91            rule: true,
92            color,
93            ..Self::plain('—')
94        }
95    }
96
97    /// A drawn horizontal rule with an optional dash pattern. Dash entries are
98    /// expressed in em units so the pattern follows the legend font size.
99    pub fn dashed_rule(width_em: f32, color: Option<Color>, dash_em: Vec<f32>) -> Self {
100        Self {
101            rule_dash: (!dash_em.is_empty()).then_some(dash_em),
102            ..Self::rule(width_em, color)
103        }
104    }
105
106    /// A glyph centered inside a fixed `width_em × font_size` advance.
107    pub fn fielded(text: char, width_em: f32, color: Option<Color>) -> Self {
108        Self {
109            field_em: Some(width_em),
110            color,
111            ..Self::plain(text)
112        }
113    }
114}
115
116impl RichText {
117    pub fn plain(text: &str, color: Color, font_size: f32, font: impl Into<String>) -> Self {
118        Self {
119            segments: rich_segments_from_text(text),
120            color,
121            font_size,
122            font: font.into(),
123        }
124    }
125}
126
127/// Measured pixel extents of a laid-out `RichText`.
128///
129/// Mirrors the renderer's skia `TextMetrics` shape so geometry formulas can be
130/// shared verbatim between drawing (renderer) and bounds policies (model).
131#[derive(Debug, Clone, Copy, PartialEq)]
132pub struct TextExtents {
133    pub width: f32,
134    pub ascent: f32,
135    pub descent: f32,
136}
137
138impl TextExtents {
139    pub fn height(&self) -> f32 {
140        self.ascent + self.descent
141    }
142}
143
144/// Text measurement contract.
145///
146/// The model crate computes element bounds (selection boxes, hit areas) from
147/// text extents but cannot measure glyphs itself — the renderer implements
148/// this with its skia text stack and injects it; tests use fixed stubs.
149pub trait MeasureText {
150    fn measure_rich(&self, rt: &RichText) -> TextExtents;
151}
152
153/// One plain `RichSegment` per char, mapping Unicode super/subscript chars to
154/// styled segments. Public because the renderer's `Chart` text builders
155/// (`with_title`, …) construct plain titles through this.
156pub fn rich_segments_from_text(text: &str) -> Vec<RichSegment> {
157    text.chars().map(rich_segment_from_char).collect()
158}
159
160fn rich_segment_from_char(c: char) -> RichSegment {
161    let (text, superscript, subscript) = match c {
162        '⁰' => ('0', true, false),
163        '¹' => ('1', true, false),
164        '²' => ('2', true, false),
165        '³' => ('3', true, false),
166        '⁴' => ('4', true, false),
167        '⁵' => ('5', true, false),
168        '⁶' => ('6', true, false),
169        '⁷' => ('7', true, false),
170        '⁸' => ('8', true, false),
171        '⁹' => ('9', true, false),
172        '⁺' => ('+', true, false),
173        '⁻' => ('-', true, false),
174        '⁽' => ('(', true, false),
175        '⁾' => (')', true, false),
176        'ⁿ' => ('n', true, false),
177        'ᵗ' => ('t', true, false),
178        '₀' => ('0', false, true),
179        '₁' => ('1', false, true),
180        '₂' => ('2', false, true),
181        '₃' => ('3', false, true),
182        '₄' => ('4', false, true),
183        '₅' => ('5', false, true),
184        '₆' => ('6', false, true),
185        '₇' => ('7', false, true),
186        '₈' => ('8', false, true),
187        '₉' => ('9', false, true),
188        '₊' => ('+', false, true),
189        '₋' => ('-', false, true),
190        '₍' => ('(', false, true),
191        '₎' => (')', false, true),
192        other => (other, false, false),
193    };
194    RichSegment {
195        superscript,
196        subscript,
197        ..RichSegment::plain(text)
198    }
199}
200
201/// Map Latin a-z/A-Z to the matching Greek letter following the Adobe Symbol
202/// encoding. Other input (digits, whitespace, already-Greek, etc.) passes through unchanged.
203pub fn greek_char(c: char) -> char {
204    match c {
205        'a' => 'α',
206        'b' => 'β',
207        'c' => 'χ',
208        'd' => 'δ',
209        'e' => 'ε',
210        'f' => 'φ',
211        'g' => 'γ',
212        'h' => 'η',
213        'i' => 'ι',
214        'j' => 'ϕ',
215        'k' => 'κ',
216        'l' => 'λ',
217        'm' => 'μ',
218        'n' => 'ν',
219        'o' => 'ο',
220        'p' => 'π',
221        'q' => 'θ',
222        'r' => 'ρ',
223        's' => 'σ',
224        't' => 'τ',
225        'u' => 'υ',
226        'v' => 'ϖ',
227        'w' => 'ω',
228        'x' => 'ξ',
229        'y' => 'ψ',
230        'z' => 'ζ',
231        'A' => 'Α',
232        'B' => 'Β',
233        'C' => 'Χ',
234        'D' => 'Δ',
235        'E' => 'Ε',
236        'F' => 'Φ',
237        'G' => 'Γ',
238        'H' => 'Η',
239        'I' => 'Ι',
240        'J' => 'ϑ',
241        'K' => 'Κ',
242        'L' => 'Λ',
243        'M' => 'Μ',
244        'N' => 'Ν',
245        'O' => 'Ο',
246        'P' => 'Π',
247        'Q' => 'Θ',
248        'R' => 'Ρ',
249        'S' => 'Σ',
250        'T' => 'Τ',
251        'U' => 'Υ',
252        'V' => 'ς',
253        'W' => 'Ω',
254        'X' => 'Ξ',
255        'Y' => 'Ψ',
256        'Z' => 'Ζ',
257        other => other,
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn lowercase_full_table() {
267        let pairs: &[(char, char)] = &[
268            ('a', 'α'),
269            ('b', 'β'),
270            ('c', 'χ'),
271            ('d', 'δ'),
272            ('e', 'ε'),
273            ('f', 'φ'),
274            ('g', 'γ'),
275            ('h', 'η'),
276            ('i', 'ι'),
277            ('j', 'ϕ'),
278            ('k', 'κ'),
279            ('l', 'λ'),
280            ('m', 'μ'),
281            ('n', 'ν'),
282            ('o', 'ο'),
283            ('p', 'π'),
284            ('q', 'θ'),
285            ('r', 'ρ'),
286            ('s', 'σ'),
287            ('t', 'τ'),
288            ('u', 'υ'),
289            ('v', 'ϖ'),
290            ('w', 'ω'),
291            ('x', 'ξ'),
292            ('y', 'ψ'),
293            ('z', 'ζ'),
294        ];
295        for &(l, g) in pairs {
296            assert_eq!(greek_char(l), g, "lower '{}' -> '{}'", l, g);
297        }
298    }
299
300    #[test]
301    fn uppercase_full_table() {
302        let pairs: &[(char, char)] = &[
303            ('A', 'Α'),
304            ('B', 'Β'),
305            ('C', 'Χ'),
306            ('D', 'Δ'),
307            ('E', 'Ε'),
308            ('F', 'Φ'),
309            ('G', 'Γ'),
310            ('H', 'Η'),
311            ('I', 'Ι'),
312            ('J', 'ϑ'),
313            ('K', 'Κ'),
314            ('L', 'Λ'),
315            ('M', 'Μ'),
316            ('N', 'Ν'),
317            ('O', 'Ο'),
318            ('P', 'Π'),
319            ('Q', 'Θ'),
320            ('R', 'Ρ'),
321            ('S', 'Σ'),
322            ('T', 'Τ'),
323            ('U', 'Υ'),
324            ('V', 'ς'),
325            ('W', 'Ω'),
326            ('X', 'Ξ'),
327            ('Y', 'Ψ'),
328            ('Z', 'Ζ'),
329        ];
330        for &(l, g) in pairs {
331            assert_eq!(greek_char(l), g, "upper '{}' -> '{}'", l, g);
332        }
333    }
334
335    #[test]
336    fn non_latin_passthrough() {
337        for c in ['0', '9', ' ', '+', '=', '.', '-', '_', 'α', 'Ω', '漢'] {
338            assert_eq!(greek_char(c), c);
339        }
340    }
341
342    // Per-segment overrides are additive schema: documents serialized before
343    // they existed must still parse (→ None), and None must not serialize.
344    #[cfg(feature = "serde")]
345    #[test]
346    fn segment_overrides_stay_backward_compatible_and_compact() {
347        let old = r#"{"text":"a","bold":false,"italic":false,"underline":false,"superscript":false,"subscript":false,"greek":false}"#;
348        let seg: RichSegment = serde_json::from_str(old).expect("pre-override document parses");
349        assert_eq!(seg, RichSegment::plain('a'));
350
351        let json = serde_json::to_string(&seg).unwrap();
352        assert!(
353            !json.contains("color"),
354            "None color must be skipped: {json}"
355        );
356        assert!(
357            !json.contains("font_size"),
358            "None font_size must be skipped: {json}"
359        );
360    }
361}