Skip to main content

batuta/oracle/svg/
typography.rs

1//! Typography Styles
2//!
3//! Material Design 3 typography system with Roboto font specifications.
4
5use super::palette::Color;
6use std::fmt;
7
8/// Font family options
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
10pub enum FontFamily {
11    /// Roboto (Material default)
12    #[default]
13    Roboto,
14    /// Roboto Mono (code)
15    RobotoMono,
16    /// System sans-serif fallback
17    SansSerif,
18    /// System monospace fallback
19    Monospace,
20    /// Segoe UI (video-optimized sans-serif)
21    SegoeUI,
22    /// Cascadia Code (video-optimized monospace)
23    CascadiaCode,
24}
25
26impl FontFamily {
27    /// Get the CSS font-family value
28    #[allow(clippy::wrong_self_convention)]
29    pub fn to_css(&self) -> &'static str {
30        match self {
31            Self::Roboto => "Roboto, sans-serif",
32            Self::RobotoMono => "'Roboto Mono', monospace",
33            Self::SansSerif => "system-ui, -apple-system, sans-serif",
34            Self::Monospace => "ui-monospace, 'Cascadia Code', monospace",
35            Self::SegoeUI => "'Segoe UI', 'Helvetica Neue', sans-serif",
36            Self::CascadiaCode => "'Cascadia Code', 'Fira Code', 'Consolas', monospace",
37        }
38    }
39}
40
41impl fmt::Display for FontFamily {
42    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43        write!(f, "{}", self.to_css())
44    }
45}
46
47/// Font weight options
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
49pub enum FontWeight {
50    /// Thin (100)
51    Thin,
52    /// Light (300)
53    Light,
54    /// Regular (400)
55    #[default]
56    Regular,
57    /// Medium (500)
58    Medium,
59    /// SemiBold (600)
60    SemiBold,
61    /// Bold (700)
62    Bold,
63    /// Black (900)
64    Black,
65}
66
67impl FontWeight {
68    /// Get the numeric weight value
69    pub fn value(&self) -> u16 {
70        match self {
71            Self::Thin => 100,
72            Self::Light => 300,
73            Self::Regular => 400,
74            Self::Medium => 500,
75            Self::SemiBold => 600,
76            Self::Bold => 700,
77            Self::Black => 900,
78        }
79    }
80}
81
82impl fmt::Display for FontWeight {
83    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84        write!(f, "{}", self.value())
85    }
86}
87
88/// Text alignment
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
90pub enum TextAlign {
91    #[default]
92    Start,
93    Middle,
94    End,
95}
96
97impl TextAlign {
98    /// Get the SVG text-anchor value
99    pub fn as_svg_anchor(self) -> &'static str {
100        match self {
101            Self::Start => "start",
102            Self::Middle => "middle",
103            Self::End => "end",
104        }
105    }
106}
107
108impl fmt::Display for TextAlign {
109    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110        write!(f, "{}", self.as_svg_anchor())
111    }
112}
113
114/// A typography style definition
115#[derive(Debug, Clone)]
116pub struct TextStyle {
117    /// Font family
118    pub family: FontFamily,
119    /// Font size in pixels
120    pub size: f32,
121    /// Font weight
122    pub weight: FontWeight,
123    /// Line height multiplier
124    pub line_height: f32,
125    /// Letter spacing in ems
126    pub letter_spacing: f32,
127    /// Text color
128    pub color: Color,
129    /// Text alignment
130    pub align: TextAlign,
131}
132
133impl TextStyle {
134    /// Create a new text style
135    pub fn new(size: f32, weight: FontWeight) -> Self {
136        Self {
137            family: FontFamily::default(),
138            size,
139            weight,
140            line_height: 1.5,
141            letter_spacing: 0.0,
142            color: Color::rgb(0, 0, 0),
143            align: TextAlign::default(),
144        }
145    }
146
147    /// Set the font family
148    pub fn with_family(mut self, family: FontFamily) -> Self {
149        self.family = family;
150        self
151    }
152
153    /// Set the line height
154    pub fn with_line_height(mut self, height: f32) -> Self {
155        self.line_height = height;
156        self
157    }
158
159    /// Set the letter spacing
160    pub fn with_letter_spacing(mut self, spacing: f32) -> Self {
161        self.letter_spacing = spacing;
162        self
163    }
164
165    /// Set the color
166    pub fn with_color(mut self, color: Color) -> Self {
167        self.color = color;
168        self
169    }
170
171    /// Set the alignment
172    pub fn with_align(mut self, align: TextAlign) -> Self {
173        self.align = align;
174        self
175    }
176
177    /// Generate SVG style attributes
178    pub fn to_svg_attrs(&self) -> String {
179        let mut attrs = format!(
180            "font-family=\"{}\" font-size=\"{}\" font-weight=\"{}\" fill=\"{}\"",
181            self.family,
182            self.size,
183            self.weight,
184            self.color.to_css_hex()
185        );
186
187        if self.letter_spacing != 0.0 {
188            attrs.push_str(&format!(" letter-spacing=\"{}em\"", self.letter_spacing));
189        }
190
191        if self.align != TextAlign::Start {
192            attrs.push_str(&format!(" text-anchor=\"{}\"", self.align));
193        }
194
195        attrs
196    }
197}
198
199impl Default for TextStyle {
200    fn default() -> Self {
201        Self::new(14.0, FontWeight::Regular)
202    }
203}
204
205/// Material Design 3 typography scale
206#[derive(Debug, Clone)]
207pub struct MaterialTypography {
208    /// Display Large - 57px
209    pub display_large: TextStyle,
210    /// Display Medium - 45px
211    pub display_medium: TextStyle,
212    /// Display Small - 36px
213    pub display_small: TextStyle,
214
215    /// Headline Large - 32px
216    pub headline_large: TextStyle,
217    /// Headline Medium - 28px
218    pub headline_medium: TextStyle,
219    /// Headline Small - 24px
220    pub headline_small: TextStyle,
221
222    /// Title Large - 22px
223    pub title_large: TextStyle,
224    /// Title Medium - 16px
225    pub title_medium: TextStyle,
226    /// Title Small - 14px
227    pub title_small: TextStyle,
228
229    /// Body Large - 16px
230    pub body_large: TextStyle,
231    /// Body Medium - 14px
232    pub body_medium: TextStyle,
233    /// Body Small - 12px
234    pub body_small: TextStyle,
235
236    /// Label Large - 14px
237    pub label_large: TextStyle,
238    /// Label Medium - 12px
239    pub label_medium: TextStyle,
240    /// Label Small - 11px
241    pub label_small: TextStyle,
242
243    /// Code (monospace) - 14px
244    pub code: TextStyle,
245}
246
247impl MaterialTypography {
248    /// Create the Material Design 3 typography scale with a text color
249    pub fn with_color(color: Color) -> Self {
250        Self {
251            // Display
252            display_large: TextStyle::new(57.0, FontWeight::Regular)
253                .with_line_height(1.12)
254                .with_letter_spacing(-0.014)
255                .with_color(color),
256            display_medium: TextStyle::new(45.0, FontWeight::Regular)
257                .with_line_height(1.16)
258                .with_color(color),
259            display_small: TextStyle::new(36.0, FontWeight::Regular)
260                .with_line_height(1.22)
261                .with_color(color),
262
263            // Headline
264            headline_large: TextStyle::new(32.0, FontWeight::Regular)
265                .with_line_height(1.25)
266                .with_color(color),
267            headline_medium: TextStyle::new(28.0, FontWeight::Regular)
268                .with_line_height(1.29)
269                .with_color(color),
270            headline_small: TextStyle::new(24.0, FontWeight::Regular)
271                .with_line_height(1.33)
272                .with_color(color),
273
274            // Title
275            title_large: TextStyle::new(22.0, FontWeight::Regular)
276                .with_line_height(1.27)
277                .with_color(color),
278            title_medium: TextStyle::new(16.0, FontWeight::Medium)
279                .with_line_height(1.5)
280                .with_letter_spacing(0.009)
281                .with_color(color),
282            title_small: TextStyle::new(14.0, FontWeight::Medium)
283                .with_line_height(1.43)
284                .with_letter_spacing(0.007)
285                .with_color(color),
286
287            // Body
288            body_large: TextStyle::new(16.0, FontWeight::Regular)
289                .with_line_height(1.5)
290                .with_letter_spacing(0.031)
291                .with_color(color),
292            body_medium: TextStyle::new(14.0, FontWeight::Regular)
293                .with_line_height(1.43)
294                .with_letter_spacing(0.018)
295                .with_color(color),
296            body_small: TextStyle::new(12.0, FontWeight::Regular)
297                .with_line_height(1.33)
298                .with_letter_spacing(0.033)
299                .with_color(color),
300
301            // Label
302            label_large: TextStyle::new(14.0, FontWeight::Medium)
303                .with_line_height(1.43)
304                .with_letter_spacing(0.007)
305                .with_color(color),
306            label_medium: TextStyle::new(12.0, FontWeight::Medium)
307                .with_line_height(1.33)
308                .with_letter_spacing(0.042)
309                .with_color(color),
310            label_small: TextStyle::new(11.0, FontWeight::Medium)
311                .with_line_height(1.45)
312                .with_letter_spacing(0.045)
313                .with_color(color),
314
315            // Code
316            code: TextStyle::new(14.0, FontWeight::Regular)
317                .with_family(FontFamily::RobotoMono)
318                .with_line_height(1.5)
319                .with_color(color),
320        }
321    }
322}
323
324impl Default for MaterialTypography {
325    fn default() -> Self {
326        Self::with_color(Color::rgb(28, 27, 31))
327    }
328}
329
330/// Video-optimized typography for 1080p presentation SVGs.
331///
332/// All sizes >= 18px (hard minimum for readability at 1080p).
333/// Uses Segoe UI for body text and Cascadia Code for code.
334#[derive(Debug, Clone)]
335pub struct VideoTypography {
336    /// Slide title — 56px, Bold (700), Segoe UI
337    pub slide_title: TextStyle,
338    /// Section header — 36px, SemiBold (600), Segoe UI
339    pub section_header: TextStyle,
340    /// Body text — 24px, Regular (400), Segoe UI
341    pub body: TextStyle,
342    /// Labels — 18px, Regular (400), Segoe UI
343    pub label: TextStyle,
344    /// Code — 22px, Regular (400), Cascadia Code
345    pub code: TextStyle,
346    /// Icon text — 18px, Bold (700), Segoe UI
347    pub icon_text: TextStyle,
348}
349
350impl VideoTypography {
351    /// Minimum font size for video mode.
352    pub const MIN_FONT_SIZE: f32 = 18.0;
353
354    /// Video typography with colors for dark backgrounds.
355    pub fn dark() -> Self {
356        let heading = Color::rgb(241, 245, 249); // #f1f5f9
357        let body_color = Color::rgb(148, 163, 184); // #94a3b8
358        let accent = Color::rgb(96, 165, 250); // #60a5fa
359
360        Self {
361            slide_title: TextStyle::new(56.0, FontWeight::Bold)
362                .with_family(FontFamily::SegoeUI)
363                .with_color(heading)
364                .with_line_height(1.15),
365            section_header: TextStyle::new(36.0, FontWeight::SemiBold)
366                .with_family(FontFamily::SegoeUI)
367                .with_color(heading)
368                .with_line_height(1.2),
369            body: TextStyle::new(24.0, FontWeight::Regular)
370                .with_family(FontFamily::SegoeUI)
371                .with_color(body_color)
372                .with_line_height(1.4),
373            label: TextStyle::new(18.0, FontWeight::Regular)
374                .with_family(FontFamily::SegoeUI)
375                .with_color(body_color)
376                .with_line_height(1.4),
377            code: TextStyle::new(22.0, FontWeight::Regular)
378                .with_family(FontFamily::CascadiaCode)
379                .with_color(accent)
380                .with_line_height(1.5),
381            icon_text: TextStyle::new(18.0, FontWeight::Bold)
382                .with_family(FontFamily::SegoeUI)
383                .with_color(heading)
384                .with_line_height(1.4),
385        }
386    }
387
388    /// Video typography with colors for light backgrounds.
389    pub fn light() -> Self {
390        let heading = Color::rgb(15, 23, 42); // #0f172a
391        let body_color = Color::rgb(71, 85, 105); // #475569
392        let accent = Color::rgb(37, 99, 235); // #2563eb
393
394        Self {
395            slide_title: TextStyle::new(56.0, FontWeight::Bold)
396                .with_family(FontFamily::SegoeUI)
397                .with_color(heading)
398                .with_line_height(1.15),
399            section_header: TextStyle::new(36.0, FontWeight::SemiBold)
400                .with_family(FontFamily::SegoeUI)
401                .with_color(heading)
402                .with_line_height(1.2),
403            body: TextStyle::new(24.0, FontWeight::Regular)
404                .with_family(FontFamily::SegoeUI)
405                .with_color(body_color)
406                .with_line_height(1.4),
407            label: TextStyle::new(18.0, FontWeight::Regular)
408                .with_family(FontFamily::SegoeUI)
409                .with_color(body_color)
410                .with_line_height(1.4),
411            code: TextStyle::new(22.0, FontWeight::Regular)
412                .with_family(FontFamily::CascadiaCode)
413                .with_color(accent)
414                .with_line_height(1.5),
415            icon_text: TextStyle::new(18.0, FontWeight::Bold)
416                .with_family(FontFamily::SegoeUI)
417                .with_color(heading)
418                .with_line_height(1.4),
419        }
420    }
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    #[test]
428    fn test_font_family_css() {
429        assert_eq!(FontFamily::Roboto.to_css(), "Roboto, sans-serif");
430        assert_eq!(FontFamily::RobotoMono.to_css(), "'Roboto Mono', monospace");
431        assert_eq!(FontFamily::SegoeUI.to_css(), "'Segoe UI', 'Helvetica Neue', sans-serif");
432        assert_eq!(
433            FontFamily::CascadiaCode.to_css(),
434            "'Cascadia Code', 'Fira Code', 'Consolas', monospace"
435        );
436    }
437
438    #[test]
439    fn test_font_weight_value() {
440        assert_eq!(FontWeight::Regular.value(), 400);
441        assert_eq!(FontWeight::Bold.value(), 700);
442    }
443
444    #[test]
445    fn test_text_align_svg() {
446        assert_eq!(TextAlign::Start.as_svg_anchor(), "start");
447        assert_eq!(TextAlign::Middle.as_svg_anchor(), "middle");
448        assert_eq!(TextAlign::End.as_svg_anchor(), "end");
449    }
450
451    #[test]
452    fn test_text_style_creation() {
453        let style = TextStyle::new(16.0, FontWeight::Bold);
454        assert_eq!(style.size, 16.0);
455        assert_eq!(style.weight, FontWeight::Bold);
456    }
457
458    #[test]
459    fn test_text_style_builder() {
460        let style = TextStyle::new(14.0, FontWeight::Regular)
461            .with_family(FontFamily::RobotoMono)
462            .with_color(Color::rgb(255, 0, 0))
463            .with_align(TextAlign::Middle);
464
465        assert_eq!(style.family, FontFamily::RobotoMono);
466        assert_eq!(style.color, Color::rgb(255, 0, 0));
467        assert_eq!(style.align, TextAlign::Middle);
468    }
469
470    #[test]
471    fn test_text_style_to_svg_attrs() {
472        let style = TextStyle::new(16.0, FontWeight::Bold).with_color(Color::rgb(0, 0, 0));
473
474        let attrs = style.to_svg_attrs();
475        assert!(attrs.contains("font-size=\"16\""));
476        assert!(attrs.contains("font-weight=\"700\""));
477        assert!(attrs.contains("fill=\"#000000\""));
478    }
479
480    #[test]
481    fn test_material_typography_scale() {
482        let typo = MaterialTypography::default();
483
484        assert_eq!(typo.display_large.size, 57.0);
485        assert_eq!(typo.headline_large.size, 32.0);
486        assert_eq!(typo.body_medium.size, 14.0);
487        assert_eq!(typo.label_small.size, 11.0);
488        assert_eq!(typo.code.family, FontFamily::RobotoMono);
489    }
490
491    #[test]
492    fn test_material_typography_with_color() {
493        let color = Color::rgb(255, 255, 255);
494        let typo = MaterialTypography::with_color(color);
495
496        assert_eq!(typo.body_medium.color, color);
497        assert_eq!(typo.headline_large.color, color);
498    }
499
500    #[test]
501    fn test_font_family_display() {
502        assert_eq!(format!("{}", FontFamily::Roboto), "Roboto, sans-serif");
503        assert_eq!(format!("{}", FontFamily::SansSerif), "system-ui, -apple-system, sans-serif");
504        assert_eq!(
505            format!("{}", FontFamily::Monospace),
506            "ui-monospace, 'Cascadia Code', monospace"
507        );
508    }
509
510    #[test]
511    fn test_font_family_default() {
512        assert_eq!(FontFamily::default(), FontFamily::Roboto);
513    }
514
515    #[test]
516    fn test_font_weight_display() {
517        assert_eq!(format!("{}", FontWeight::Thin), "100");
518        assert_eq!(format!("{}", FontWeight::Light), "300");
519        assert_eq!(format!("{}", FontWeight::Regular), "400");
520        assert_eq!(format!("{}", FontWeight::Medium), "500");
521        assert_eq!(format!("{}", FontWeight::SemiBold), "600");
522        assert_eq!(format!("{}", FontWeight::Bold), "700");
523        assert_eq!(format!("{}", FontWeight::Black), "900");
524    }
525
526    #[test]
527    fn test_font_weight_default() {
528        assert_eq!(FontWeight::default(), FontWeight::Regular);
529    }
530
531    #[test]
532    fn test_text_align_display() {
533        assert_eq!(format!("{}", TextAlign::Start), "start");
534        assert_eq!(format!("{}", TextAlign::Middle), "middle");
535        assert_eq!(format!("{}", TextAlign::End), "end");
536    }
537
538    #[test]
539    fn test_text_align_default() {
540        assert_eq!(TextAlign::default(), TextAlign::Start);
541    }
542
543    #[test]
544    fn test_text_style_with_line_height() {
545        let style = TextStyle::new(14.0, FontWeight::Regular).with_line_height(2.0);
546        assert_eq!(style.line_height, 2.0);
547    }
548
549    #[test]
550    fn test_text_style_with_letter_spacing() {
551        let style = TextStyle::new(14.0, FontWeight::Regular).with_letter_spacing(0.05);
552        assert_eq!(style.letter_spacing, 0.05);
553    }
554
555    #[test]
556    fn test_text_style_default() {
557        let style = TextStyle::default();
558        assert_eq!(style.size, 14.0);
559        assert_eq!(style.weight, FontWeight::Regular);
560        assert_eq!(style.family, FontFamily::Roboto);
561        assert_eq!(style.line_height, 1.5);
562        assert_eq!(style.letter_spacing, 0.0);
563        assert_eq!(style.align, TextAlign::Start);
564    }
565
566    #[test]
567    fn test_text_style_svg_attrs_with_letter_spacing() {
568        let style = TextStyle::new(14.0, FontWeight::Regular).with_letter_spacing(0.05);
569        let attrs = style.to_svg_attrs();
570        assert!(attrs.contains("letter-spacing=\"0.05em\""));
571    }
572
573    #[test]
574    fn test_text_style_svg_attrs_with_alignment() {
575        let style = TextStyle::new(14.0, FontWeight::Regular).with_align(TextAlign::End);
576        let attrs = style.to_svg_attrs();
577        assert!(attrs.contains("text-anchor=\"end\""));
578    }
579
580    #[test]
581    fn test_text_style_svg_attrs_no_optional() {
582        let style = TextStyle::new(14.0, FontWeight::Regular);
583        let attrs = style.to_svg_attrs();
584        assert!(!attrs.contains("letter-spacing"));
585        assert!(!attrs.contains("text-anchor"));
586    }
587
588    #[test]
589    fn test_font_weight_all_values() {
590        assert_eq!(FontWeight::Thin.value(), 100);
591        assert_eq!(FontWeight::Light.value(), 300);
592        assert_eq!(FontWeight::Medium.value(), 500);
593        assert_eq!(FontWeight::SemiBold.value(), 600);
594        assert_eq!(FontWeight::Black.value(), 900);
595    }
596
597    #[test]
598    fn test_font_family_segoe_ui_display() {
599        let display = format!("{}", FontFamily::SegoeUI);
600        assert!(display.contains("Segoe UI"));
601    }
602
603    #[test]
604    fn test_font_family_cascadia_code_display() {
605        let display = format!("{}", FontFamily::CascadiaCode);
606        assert!(display.contains("Cascadia Code"));
607    }
608
609    #[test]
610    fn test_video_typography_dark() {
611        let vt = VideoTypography::dark();
612        assert_eq!(vt.slide_title.size, 56.0);
613        assert_eq!(vt.slide_title.weight, FontWeight::Bold);
614        assert_eq!(vt.slide_title.family, FontFamily::SegoeUI);
615
616        assert_eq!(vt.section_header.size, 36.0);
617        assert_eq!(vt.section_header.weight, FontWeight::SemiBold);
618
619        assert_eq!(vt.body.size, 24.0);
620        assert_eq!(vt.body.weight, FontWeight::Regular);
621
622        assert_eq!(vt.label.size, 18.0);
623        assert!(vt.label.size >= VideoTypography::MIN_FONT_SIZE);
624
625        assert_eq!(vt.code.size, 22.0);
626        assert_eq!(vt.code.family, FontFamily::CascadiaCode);
627
628        assert_eq!(vt.icon_text.size, 18.0);
629        assert_eq!(vt.icon_text.weight, FontWeight::Bold);
630    }
631
632    #[test]
633    fn test_video_typography_light() {
634        let vt = VideoTypography::light();
635        assert_eq!(vt.slide_title.size, 56.0);
636        assert_eq!(vt.body.size, 24.0);
637        assert_eq!(vt.code.family, FontFamily::CascadiaCode);
638    }
639
640    #[test]
641    fn test_video_typography_all_sizes_meet_minimum() {
642        for vt in &[VideoTypography::dark(), VideoTypography::light()] {
643            assert!(vt.slide_title.size >= VideoTypography::MIN_FONT_SIZE);
644            assert!(vt.section_header.size >= VideoTypography::MIN_FONT_SIZE);
645            assert!(vt.body.size >= VideoTypography::MIN_FONT_SIZE);
646            assert!(vt.label.size >= VideoTypography::MIN_FONT_SIZE);
647            assert!(vt.code.size >= VideoTypography::MIN_FONT_SIZE);
648            assert!(vt.icon_text.size >= VideoTypography::MIN_FONT_SIZE);
649        }
650    }
651
652    #[test]
653    fn test_video_typography_min_font_size_constant() {
654        assert_eq!(VideoTypography::MIN_FONT_SIZE, 18.0);
655    }
656}