Skip to main content

timetable_core/
renderer.rs

1//! SVG timetable rendering with embedded maps.
2//!
3//! This module generates A4-sized SVG documents containing a formatted weekly
4//! timetable grid with color-coded cells and an embedded school map.
5
6use 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/// Errors that can occur during SVG rendering.
17#[derive(Error, Debug)]
18pub enum RenderError {
19    /// SVG file writing error
20    #[error("SVG generation error: {0}")]
21    Svg(#[from] std::io::Error),
22}
23
24/// Render a timetable week to an SVG file.
25///
26/// Generates an A4-sized (210mm × 297mm) SVG document containing:
27/// - A formatted timetable grid with student name, week identifier, and lessons
28/// - Color-coded cells based on room-to-department mappings
29/// - Break and lunch period rows
30/// - An embedded school map with highlighted departments
31///
32/// # Arguments
33///
34/// * `week` - The week data to render
35/// * `config` - Configuration for room mappings and styling
36/// * `map_content` - Processed SVG map content (from [`process_map`](crate::processor::process_map))
37/// * `output_path` - Path where the SVG file will be written
38///
39/// # Returns
40///
41/// `Ok(())` if the SVG was successfully generated and written.
42///
43/// # Errors
44///
45/// Returns [`RenderError`] if:
46/// - The output file cannot be created or written
47/// - The output directory doesn't exist
48///
49/// # Example
50///
51/// ```no_run
52/// use timetable_core::{config::Config, parser::{parse_pdf, Week}, renderer::render_timetable};
53/// use std::path::Path;
54///
55/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
56/// let config = Config::load(Path::new("config.toml"))?;
57/// let weeks = parse_pdf(Path::new("input/timetable.pdf"))?;
58/// let map_svg = "<svg></svg>"; // Processed map content
59///
60/// for (i, week) in weeks.iter().enumerate() {
61///     let output = format!("output/week_{}.svg", i + 1);
62///     render_timetable(week, &config, map_svg, Path::new(&output))?;
63/// }
64/// # Ok(())
65/// # }
66/// ```
67pub fn render_timetable(
68    week: &Week,
69    config: &Config,
70    map_content: &str,
71    output_path: &Path,
72) -> Result<(), RenderError> {
73    // A4 @ 96 DPI ~= 794 x 1123
74    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    // Add white background rectangle for the entire page
86    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    // Inject Styles matching the diagram
95    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    // 1. Draw Timetable
161    let timetable_group = draw_timetable_grid(week, config, width, timetable_height);
162    document = document.add(timetable_group);
163
164    // 2. Embed Map
165    // We wrap the map content in a nested <svg> to handle positioning
166    // The map_content is a full <svg> string. We need to strip the xml declaration if present,
167    // and maybe wrap it in a <g> with transform.
168    // Or better: use <svg x="..." y="..." width="..." height="..."> ... </svg>
169    // But we have the content as a string.
170
171    // We can't easily add a raw string to `svg::Document`.
172    // So we will serialize the document so far, and then inject the map string.
173
174    let mut svg_string = document.to_string();
175
176    // Remove the closing </svg>
177    if svg_string.ends_with("</svg>") {
178        svg_string.truncate(svg_string.len() - 6);
179    }
180
181    // Inject the map if provided (map_content non-empty). If empty, skip embedding.
182    if !map_content.trim().is_empty() {
183        // We place it at the bottom.
184        let map_y = timetable_height + 20;
185        let map_area_height = height - map_y - 20; // Leave 20px margin at bottom
186
187        svg_string.push_str(&format!(
188            "<svg x=\"0\" y=\"{}\" width=\"{}\" height=\"{}\">",
189            map_y, width, map_area_height
190        ));
191
192        // Strip <?xml ... ?> if exists
193        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    // Close the root svg
209    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    // Grid dimensions
220    let cols = 5; // Mon-Fri
221    let periods = 6; // PD + L1-L5
222
223    let left_margin = 60; // Space for period labels
224    let top_margin = 80; // Space for student name and week
225    let right_margin = 30;
226    let bottom_margin = 40; // Space for update date
227
228    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    // Add student name and form at top left
239    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    // Add week label at top center
254    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    // Draw day headers (Monday-Friday)
262    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    // Period labels and rows
275    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        // Adjust for breaks - break comes after L2 (index 2)
281        if period_idx > 2 {
282            y += break_height;
283        }
284        // Lunch comes after L4 (index 4)
285        if period_idx > 4 {
286            y += lunch_height;
287        }
288
289        // Draw period label on left
290        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        // Draw break after L2 (period_idx 2)
298        if period_idx == 2 {
299            let cell_padding = 3;
300            let break_y = y + row_height + cell_padding;
301            // Calculate actual content width (5 columns worth of cells)
302            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        // Draw lunch after L4 (period_idx 4)
323        if period_idx == 4 {
324            let cell_padding = 3;
325            let lunch_y = y + row_height + cell_padding;
326            // Calculate actual content width (5 columns worth of cells)
327            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    // Draw lessons
349    for lesson in &week.lessons {
350        let x = left_margin + (lesson.day_index as i32 * col_width);
351
352        // Calculate Y based on period and gaps
353        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        // Handle Unknown room - use dark grey
362        let is_unknown_room = lesson.room == "Unknown" || lesson.room == "DEFAULT";
363
364        // Get color mapping from config
365        let (bg_color, fg_color) = if is_unknown_room {
366            ("#e0e0e0", "#4a4a4a") // Light grey bg, dark grey fg for unknown
367        } 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; // Space between cells
375        let label_width = 30; // Width of the vertical label section on right
376
377        // Main cell area (white background)
378        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        // Right label area (colored background)
390        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        // Text: Subject (top left, bold)
402        // Split long subjects into multiple lines if needed
403        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            // Multi-line subject
408            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            // Render each line
427            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            // Single line subject
437            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        // Text: Room code (above teacher) - only if not Unknown
446        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        // Text: Teacher (bottom, smaller text) - only if not Unknown
455        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        // Text: Room code as vertical label on right (rotated 90°, saturated color).
465        // The group/class code is distinct from the physical room and must not be
466        // shown in this room label (for example, `10T7/Pd` vs `MA7`).
467        let label_text = if is_unknown_room {
468            &lesson.subject // No room is available for this cell.
469        } 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    // Add update date footer
493    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        // basic checks: student name, one subject, and a room label
573        assert!(content.contains("Test Student"));
574        assert!(content.contains("Maths"));
575        assert!(content.contains("MA3"));
576        assert!(!content.contains("10A1/Ma"));
577
578        // cleanup
579        remove_project_file(&out_path).expect("remove test output");
580    }
581}