Skip to main content

batuta/oracle/coursera/
banner.rs

1//! Banner PNG generation for Coursera readings
2//!
3//! Generates a 1200x400 SVG banner with title and concept bubbles,
4//! then rasterizes to PNG via resvg + tiny-skia (feature-gated).
5
6use super::key_concepts;
7use super::types::{BannerConfig, TranscriptInput};
8use crate::oracle::svg::builder::SvgBuilder;
9use crate::oracle::svg::palette::{Color, MaterialPalette};
10
11/// Generate a banner SVG string from a config.
12pub fn generate_banner_svg(config: &BannerConfig) -> String {
13    let palette = MaterialPalette::light();
14    let width = config.width as f32;
15    let height = config.height as f32;
16
17    let mut builder =
18        SvgBuilder::new().size(width, height).transparent().title(&config.course_title);
19
20    // Gradient background band (subtle)
21    builder = builder.rect_styled(
22        "bg-band",
23        0.0,
24        0.0,
25        width,
26        height,
27        palette.primary_container,
28        None,
29        0.0,
30    );
31
32    // Title text
33    let title_y = height * 0.35;
34    builder = builder.heading(width / 2.0, title_y, &config.course_title);
35
36    // Concept bubbles: arrange horizontally
37    let concepts = &config.concepts;
38    let max_bubbles = concepts.len().min(6);
39    if max_bubbles > 0 {
40        let bubble_y = height * 0.7;
41        let total_width = width * 0.8;
42        let spacing = total_width / max_bubbles as f32;
43        let start_x = (width - total_width) / 2.0 + spacing / 2.0;
44
45        let bubble_colors = [
46            palette.primary,
47            palette.secondary,
48            palette.tertiary,
49            Color::rgb(0, 150, 136), // Teal
50            Color::rgb(255, 109, 0), // Deep Orange
51            Color::rgb(41, 98, 255), // Blue
52        ];
53
54        for (i, concept) in concepts.iter().take(max_bubbles).enumerate() {
55            let cx = start_x + i as f32 * spacing;
56            let color = bubble_colors[i % bubble_colors.len()];
57
58            // Bubble background
59            let bubble_w = spacing * 0.85;
60            let bubble_h = 36.0;
61            builder = builder.rect_styled(
62                &format!("bubble-{i}"),
63                cx - bubble_w / 2.0,
64                bubble_y - bubble_h / 2.0,
65                bubble_w,
66                bubble_h,
67                color.lighten(0.7),
68                Some((color, 1.5)),
69                18.0,
70            );
71
72            // Bubble label
73            builder = builder.label(cx, bubble_y + 5.0, concept);
74        }
75    }
76
77    builder.build()
78}
79
80/// Generate a banner config from a transcript by extracting concepts.
81pub fn banner_config_from_transcript(
82    transcript: &TranscriptInput,
83    course_title: &str,
84) -> BannerConfig {
85    let reading = key_concepts::generate_key_concepts(transcript);
86    let concepts: Vec<String> = reading.concepts.iter().take(6).map(|c| c.term.clone()).collect();
87
88    BannerConfig { course_title: course_title.to_string(), concepts, ..Default::default() }
89}
90
91/// Rasterize an SVG string to PNG bytes.
92///
93/// Requires the `coursera-assets` feature (resvg + tiny-skia).
94#[cfg(feature = "coursera-assets")]
95pub fn svg_to_png(svg: &str, width: u32, height: u32) -> anyhow::Result<Vec<u8>> {
96    use anyhow::Context;
97
98    let tree = resvg::usvg::Tree::from_str(svg, &resvg::usvg::Options::default())
99        .context("Failed to parse SVG")?;
100
101    let mut pixmap =
102        resvg::tiny_skia::Pixmap::new(width, height).context("Failed to create pixmap")?;
103
104    resvg::render(&tree, resvg::usvg::Transform::default(), &mut pixmap.as_mut());
105
106    pixmap.encode_png().context("Failed to encode PNG")
107}
108
109/// Stub for when resvg is not available.
110#[cfg(not(feature = "coursera-assets"))]
111pub fn svg_to_png(_svg: &str, _width: u32, _height: u32) -> anyhow::Result<Vec<u8>> {
112    anyhow::bail!(
113        "PNG rasterization requires the 'coursera-assets' feature.\n\
114         Build with: cargo build --features coursera-assets\n\
115         SVG output is always available without this feature."
116    )
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn test_generate_banner_svg_basic() {
125        let config = BannerConfig {
126            course_title: "MLOps Fundamentals".to_string(),
127            concepts: vec!["MLOps".to_string(), "CI/CD".to_string(), "Docker".to_string()],
128            width: 1200,
129            height: 400,
130        };
131        let svg = generate_banner_svg(&config);
132        assert!(svg.contains("<svg"));
133        assert!(svg.contains("MLOps Fundamentals"));
134        assert!(svg.contains("MLOps"));
135        assert!(svg.contains("CI/CD"));
136        assert!(svg.contains("Docker"));
137    }
138
139    #[test]
140    fn test_generate_banner_svg_empty_concepts() {
141        let config = BannerConfig {
142            course_title: "Empty Course".to_string(),
143            concepts: vec![],
144            ..Default::default()
145        };
146        let svg = generate_banner_svg(&config);
147        assert!(svg.contains("<svg"));
148        assert!(svg.contains("Empty Course"));
149    }
150
151    #[test]
152    fn test_generate_banner_svg_max_concepts() {
153        let config = BannerConfig {
154            course_title: "Full Course".to_string(),
155            concepts: (0..10).map(|i| format!("Concept{i}")).collect(),
156            ..Default::default()
157        };
158        let svg = generate_banner_svg(&config);
159        assert!(svg.contains("Concept0"));
160        assert!(svg.contains("Concept5"));
161        // Should be limited to 6 bubbles
162        assert!(!svg.contains("bubble-6"));
163    }
164
165    #[test]
166    fn test_banner_config_from_transcript() {
167        let t = TranscriptInput {
168            text: "GPU acceleration speeds up ML inference. GPU computing enables parallel processing. \
169                   API endpoints serve predictions. API calls handle requests."
170                .to_string(),
171            language: "en".to_string(),
172            segments: vec![],
173            source_path: "test.txt".to_string(),
174        };
175        let config = banner_config_from_transcript(&t, "ML Course");
176        assert_eq!(config.course_title, "ML Course");
177        assert!(config.concepts.len() <= 6);
178    }
179
180    #[test]
181    fn test_banner_svg_no_background_rect() {
182        let config = BannerConfig {
183            course_title: "Test".to_string(),
184            concepts: vec![],
185            ..Default::default()
186        };
187        let svg = generate_banner_svg(&config);
188        // Should use transparent mode (no 100% background rect)
189        assert!(!svg.contains("width=\"100%\" height=\"100%\""));
190    }
191
192    #[cfg(not(feature = "coursera-assets"))]
193    #[test]
194    fn test_svg_to_png_without_feature() {
195        let result = svg_to_png("<svg></svg>", 100, 100);
196        assert!(result.is_err());
197        assert!(result.unwrap_err().to_string().contains("coursera-assets"));
198    }
199}