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