1use crate::config::Config;
7use crate::parser::Week;
8use std::fs;
9use std::path::Path;
10use svg::node::element::{Group, Rectangle, Text};
11use svg::Document;
12use thiserror::Error;
13
14#[derive(Error, Debug)]
16pub enum RenderError {
17 #[error("SVG generation error: {0}")]
19 Svg(#[from] std::io::Error),
20}
21
22pub fn render_timetable(
66 week: &Week,
67 config: &Config,
68 map_content: &str,
69 output_path: &Path,
70) -> Result<(), RenderError> {
71 let width = 794;
73 let height = 1123;
74
75 let timetable_height = 650;
76 let _map_height = height - timetable_height;
77
78 let mut document = Document::new()
79 .set("viewBox", (0, 0, width, height))
80 .set("width", "210mm")
81 .set("height", "297mm");
82
83 let background = Rectangle::new()
85 .set("x", 0)
86 .set("y", 0)
87 .set("width", width)
88 .set("height", height)
89 .set("fill", "#ffffff");
90 document = document.add(background);
91
92 let styles = r#"
94 .detail {
95 font-family: 'Bahnschrift Light', Bahnschrift, Arial, sans-serif;
96 font-size: 11px;
97 font-weight: 300;
98 fill: #231f20;
99 }
100
101 .subject {
102 font-family: Bahnschrift, Arial, sans-serif;
103 font-size: 11px;
104 font-weight: 400;
105 fill: #231f20;
106 }
107
108 .room {
109 font-family: 'Bahnschrift SemiBold', Bahnschrift, Arial, sans-serif;
110 font-size: 18px;
111 font-weight: 600;
112 fill: #231f20;
113 text-anchor: middle;
114 dominant-baseline: middle;
115 }
116
117 .label {
118 font-family: 'Bahnschrift SemiBold', Bahnschrift, Arial, sans-serif;
119 font-size: 11px;
120 font-weight: 600;
121 fill: #231f20;
122 }
123
124 .box {
125 fill: none;
126 stroke: #231f20;
127 stroke-width: 1;
128 stroke-miterlimit: 10;
129 }
130
131 .period-label {
132 font-family: 'Bahnschrift SemiBold', Bahnschrift, Arial, sans-serif;
133 font-size: 12px;
134 font-weight: 600;
135 fill: #231f20;
136 text-anchor: middle;
137 }
138
139 .header-text {
140 font-family: Bahnschrift, Arial, sans-serif;
141 font-size: 14px;
142 font-weight: 400;
143 fill: #231f20;
144 }
145
146 .week-label {
147 font-family: 'Bahnschrift SemiBold', Bahnschrift, Arial, sans-serif;
148 font-size: 16px;
149 font-weight: 600;
150 fill: #231f20;
151 }
152 "#;
153
154 let style_element = svg::node::element::Style::new(styles);
155 let defs = svg::node::element::Definitions::new().add(style_element);
156 document = document.add(defs);
157
158 let timetable_group = draw_timetable_grid(week, config, width, timetable_height);
160 document = document.add(timetable_group);
161
162 let mut svg_string = document.to_string();
173
174 if svg_string.ends_with("</svg>") {
176 svg_string.truncate(svg_string.len() - 6);
177 }
178
179 if !map_content.trim().is_empty() {
181 let map_y = timetable_height + 20;
183 let map_area_height = height - map_y - 20; svg_string.push_str(&format!(
186 "<svg x=\"0\" y=\"{}\" width=\"{}\" height=\"{}\">",
187 map_y, width, map_area_height
188 ));
189
190 let clean_map = map_content.trim_start_matches(|c| c != '<');
192 let clean_map = if clean_map.starts_with("<?xml") {
193 if let Some(idx) = clean_map.find("?>") {
194 &clean_map[idx + 2..]
195 } else {
196 clean_map
197 }
198 } else {
199 clean_map
200 };
201
202 svg_string.push_str(clean_map);
203 svg_string.push_str("</svg>");
204 }
205
206 svg_string.push_str("</svg>");
208
209 fs::write(output_path, svg_string)?;
210
211 Ok(())
212}
213
214fn draw_timetable_grid(week: &Week, config: &Config, width: i32, height: i32) -> Group {
215 let mut group = Group::new().set("id", "timetable");
216
217 let cols = 5; let periods = 6; let left_margin = 60; let top_margin = 80; let right_margin = 30;
224 let bottom_margin = 40; let grid_width = width - left_margin - right_margin;
227 let grid_height = height - top_margin - bottom_margin;
228
229 let break_height = 24;
230 let lunch_height = 24;
231
232 let total_gap_height = break_height + lunch_height;
233 let row_height = (grid_height - total_gap_height) / periods;
234 let col_width = grid_width / cols;
235
236 let student_info = if let (Some(name), Some(form)) = (&week.student_name, &week.form) {
238 format!("{} ({})", name, form)
239 } else if let Some(name) = &week.student_name {
240 name.clone()
241 } else {
242 String::from("Student Timetable")
243 };
244
245 let text_student = Text::new(student_info.as_str())
246 .set("x", left_margin)
247 .set("y", 30)
248 .set("class", "header-text");
249 group = group.add(text_student);
250
251 let text_week = Text::new(week.week_name.as_str())
253 .set("x", width / 2)
254 .set("y", 30)
255 .set("text-anchor", "middle")
256 .set("class", "week-label");
257 group = group.add(text_week);
258
259 let days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"];
261 for (i, day) in days.iter().enumerate() {
262 let x = left_margin + (i as i32 * col_width) + (col_width / 2);
263 let y = top_margin - 15;
264 let text = Text::new(*day)
265 .set("x", x)
266 .set("y", y)
267 .set("text-anchor", "middle")
268 .set("class", "header-text");
269 group = group.add(text);
270 }
271
272 let period_labels = ["PD", "L1", "L2", "L3", "L4", "L5"];
274
275 for (period_idx, label) in period_labels.iter().enumerate() {
276 let mut y = top_margin + (period_idx as i32 * row_height);
277
278 if period_idx > 2 {
280 y += break_height;
281 }
282 if period_idx > 4 {
284 y += lunch_height;
285 }
286
287 let text_period = Text::new(*label)
289 .set("x", 30)
290 .set("y", y + (row_height / 2))
291 .set("dominant-baseline", "middle")
292 .set("class", "period-label");
293 group = group.add(text_period);
294
295 if period_idx == 2 {
297 let cell_padding = 3;
298 let break_y = y + row_height + cell_padding;
299 let total_content_width = col_width * cols;
301 let rect_break = Rectangle::new()
302 .set("x", left_margin + cell_padding)
303 .set("y", break_y)
304 .set("width", total_content_width - (cell_padding * 2))
305 .set("height", break_height - (cell_padding * 2))
306 .set("fill", "#eeeeee")
307 .set("stroke", "#231f20")
308 .set("stroke-width", 1);
309 group = group.add(rect_break);
310
311 let text_break = Text::new("Break (11:00 - 11:30)")
312 .set("x", left_margin + (total_content_width / 2))
313 .set("y", break_y + ((break_height - (cell_padding * 2)) / 2) + 1)
314 .set("text-anchor", "middle")
315 .set("dominant-baseline", "middle")
316 .set("class", "detail");
317 group = group.add(text_break);
318 }
319
320 if period_idx == 4 {
322 let cell_padding = 3;
323 let lunch_y = y + row_height + cell_padding;
324 let total_content_width = col_width * cols;
326 let rect_lunch = Rectangle::new()
327 .set("x", left_margin + cell_padding)
328 .set("y", lunch_y)
329 .set("width", total_content_width - (cell_padding * 2))
330 .set("height", lunch_height - (cell_padding * 2))
331 .set("fill", "#eeeeee")
332 .set("stroke", "#231f20")
333 .set("stroke-width", 1);
334 group = group.add(rect_lunch);
335
336 let text_lunch = Text::new("Lunch (13:30 - 14:10)")
337 .set("x", left_margin + (total_content_width / 2))
338 .set("y", lunch_y + (lunch_height / 2) - 2)
339 .set("text-anchor", "middle")
340 .set("dominant-baseline", "middle")
341 .set("class", "detail");
342 group = group.add(text_lunch);
343 }
344 }
345
346 for lesson in &week.lessons {
348 let x = left_margin + (lesson.day_index as i32 * col_width);
349
350 let mut y = top_margin + (lesson.period_index as i32 * row_height);
352 if lesson.period_index > 2 {
353 y += break_height;
354 }
355 if lesson.period_index > 4 {
356 y += lunch_height;
357 }
358
359 let is_unknown_room = lesson.room == "Unknown" || lesson.room == "DEFAULT";
361
362 let (bg_color, fg_color) = if is_unknown_room {
364 ("#e0e0e0", "#4a4a4a") } else {
366 config
367 .get_style_for_room(&lesson.room)
368 .map(|m| (m.bg_color.as_str(), m.fg_color.as_str()))
369 .unwrap_or(("#ffffff", "#231f20"))
370 };
371
372 let cell_padding = 3; let label_width = 30; let main_width = col_width - label_width - (cell_padding * 2);
377 let rect_main = Rectangle::new()
378 .set("x", x + cell_padding)
379 .set("y", y + cell_padding)
380 .set("width", main_width)
381 .set("height", row_height - (cell_padding * 2))
382 .set("fill", "#ffffff")
383 .set("stroke", "#231f20")
384 .set("stroke-width", 1);
385 group = group.add(rect_main);
386
387 let label_x = x + col_width - label_width - cell_padding;
389 let rect_label = Rectangle::new()
390 .set("x", label_x)
391 .set("y", y + cell_padding)
392 .set("width", label_width)
393 .set("height", row_height - (cell_padding * 2))
394 .set("fill", bg_color)
395 .set("stroke", "#231f20")
396 .set("stroke-width", 1);
397 group = group.add(rect_label);
398
399 let subject_words: Vec<&str> = lesson.subject.split_whitespace().collect();
402 let max_chars_per_line = 18;
403
404 if lesson.subject.len() > max_chars_per_line && subject_words.len() > 1 {
405 let mut lines = Vec::new();
407 let mut current_line = String::new();
408
409 for word in subject_words {
410 if current_line.is_empty() {
411 current_line = word.to_string();
412 } else if current_line.len() + word.len() < max_chars_per_line {
413 current_line.push(' ');
414 current_line.push_str(word);
415 } else {
416 lines.push(current_line.clone());
417 current_line = word.to_string();
418 }
419 }
420 if !current_line.is_empty() {
421 lines.push(current_line);
422 }
423
424 for (line_idx, line) in lines.iter().enumerate() {
426 let text_subject_line = Text::new(line.as_str())
427 .set("x", x + cell_padding + 5)
428 .set("y", y + cell_padding + 12 + (line_idx as i32 * 11))
429 .set("class", "subject")
430 .set("font-weight", "bold");
431 group = group.add(text_subject_line);
432 }
433 } else {
434 let text_subject = Text::new(lesson.subject.as_str())
436 .set("x", x + cell_padding + 5)
437 .set("y", y + cell_padding + 14)
438 .set("class", "subject")
439 .set("font-weight", "bold");
440 group = group.add(text_subject);
441 }
442
443 if lesson.room != "Unknown" {
445 let text_room = Text::new(lesson.room.as_str())
446 .set("x", x + cell_padding + 5)
447 .set("y", y + row_height - cell_padding - 22)
448 .set("class", "detail");
449 group = group.add(text_room);
450 }
451
452 if lesson.teacher != "Unknown" {
454 let text_teacher = Text::new(lesson.teacher.as_str())
455 .set("x", x + cell_padding + 5)
456 .set("y", y + row_height - cell_padding - 8)
457 .set("class", "detail")
458 .set("font-size", "9px");
459 group = group.add(text_teacher);
460 }
461
462 let label_text = if !lesson.class_code.is_empty() {
465 &lesson.class_code
466 } else if is_unknown_room {
467 &lesson.subject } else {
469 &lesson.room
470 };
471
472 let class_x = label_x + (label_width / 2) - 2;
473 let class_y = y + (row_height / 2);
474
475 let text_class = Text::new(label_text)
476 .set("x", class_x)
477 .set("y", class_y)
478 .set("transform", format!("rotate(90 {} {})", class_x, class_y))
479 .set("text-anchor", "middle")
480 .set("dominant-baseline", "middle")
481 .set(
482 "font-family",
483 "'Bahnschrift SemiBold', Bahnschrift, Arial, sans-serif",
484 )
485 .set("font-size", "20px")
486 .set("font-weight", "600")
487 .set("fill", fg_color);
488 group = group.add(text_class);
489 }
490
491 let update_date = chrono::Local::now().format("%d %B %Y").to_string();
493 let text_update = Text::new(format!("Updated: {}", update_date).as_str())
494 .set("x", width - right_margin)
495 .set("y", height - 10)
496 .set("text-anchor", "end")
497 .set("class", "detail");
498 group = group.add(text_update);
499
500 group
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506 use crate::config::{Config, Mapping};
507 use crate::parser::Lesson;
508 use std::env;
509
510 fn sample_week() -> Week {
511 let lessons = vec![
512 Lesson {
513 subject: "Maths".into(),
514 room: "MA3".into(),
515 teacher: "Ms Test A".into(),
516 class_code: "MA3".into(),
517 day_index: 0,
518 period_index: 1,
519 },
520 Lesson {
521 subject: "Science".into(),
522 room: "SC8".into(),
523 teacher: "Mr Test B".into(),
524 class_code: "SC8".into(),
525 day_index: 1,
526 period_index: 2,
527 },
528 ];
529
530 Week {
531 lessons,
532 week_name: "Week Test".into(),
533 student_name: Some("Test Student".into()),
534 form: Some("9X1".into()),
535 }
536 }
537
538 #[test]
539 fn render_timetable_generates_svg_with_expected_content() {
540 let cfg = Config {
541 mappings: vec![
542 Mapping {
543 prefix: "MA".into(),
544 bg_color: "#fcdcd8".into(),
545 fg_color: "#e8a490".into(),
546 map_id: "Maths_Rooms".into(),
547 label: Some("Maths".into()),
548 },
549 Mapping {
550 prefix: "SC".into(),
551 bg_color: "#fad7e6".into(),
552 fg_color: "#e68cb8".into(),
553 map_id: "Science_Rooms".into(),
554 label: Some("Science".into()),
555 },
556 ],
557 overrides: vec![],
558 };
559
560 let map_svg = "<svg><g id=\"Maths_Rooms\"><path d=\"M0\"/></g><g id=\"Science_Rooms\"><path d=\"M0\"/></g></svg>";
561 let week = sample_week();
562
563 let mut out_path = env::temp_dir();
564 out_path.push("timetable_test_output.svg");
565
566 let res = render_timetable(&week, &cfg, map_svg, &out_path);
567 assert!(res.is_ok());
568
569 let content = std::fs::read_to_string(&out_path).expect("output svg exists");
570 assert!(content.contains("Test Student"));
572 assert!(content.contains("Maths"));
573 assert!(content.contains("MA3"));
574
575 let _ = std::fs::remove_file(&out_path);
577 }
578}