batuta/oracle/coursera/
banner.rs1use super::key_concepts;
7use super::types::{BannerConfig, TranscriptInput};
8use crate::oracle::svg::builder::SvgBuilder;
9use crate::oracle::svg::palette::{Color, MaterialPalette};
10
11pub 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 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 let title_y = height * 0.35;
34 builder = builder.heading(width / 2.0, title_y, &config.course_title);
35
36 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), Color::rgb(255, 109, 0), Color::rgb(41, 98, 255), ];
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 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 builder = builder.label(cx, bubble_y + 5.0, concept);
74 }
75 }
76
77 builder.build()
78}
79
80pub 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#[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#[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 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 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}