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