timetable_core/
processor.rs

1//! SVG map processing and department highlighting.
2//!
3//! This module manipulates school map SVG files by finding elements matching
4//! department IDs and applying color fills to highlight them.
5
6use regex::Regex;
7use roxmltree::Document;
8use std::fs;
9use std::path::Path;
10use thiserror::Error;
11
12/// Errors that can occur during map processing.
13#[derive(Error, Debug)]
14pub enum ProcessorError {
15    /// XML/SVG parsing error
16    #[error("XML parsing error: {0}")]
17    Xml(#[from] roxmltree::Error),
18    /// I/O error reading map file
19    #[error("IO error: {0}")]
20    Io(#[from] std::io::Error),
21    /// Regex compilation error
22    #[error("Regex error: {0}")]
23    Regex(#[from] regex::Error),
24}
25
26/// Represents a department to highlight on the map.
27#[derive(Clone)]
28pub struct MapHighlight {
29    /// SVG element ID or data-name attribute to match
30    pub id: String,
31    /// Hex color code to apply (e.g., "#fcdcd8")
32    pub color: String,
33}
34
35/// Process a school map SVG file and apply department highlights.
36///
37/// Loads an SVG map file, finds elements matching the provided highlight IDs,
38/// and injects fill attributes with the specified colors.
39///
40/// # Arguments
41///
42/// * `path` - Path to the school map SVG file
43/// * `highlights` - Vector of department highlights to apply
44///
45/// # Returns
46///
47/// The modified SVG content as a string with highlights applied.
48///
49/// # Errors
50///
51/// Returns [`ProcessorError`] if:
52/// - The map file cannot be read
53/// - The SVG XML is malformed
54/// - Regex patterns are invalid
55///
56/// # Example
57///
58/// ```no_run
59/// use timetable_core::processor::{process_map, MapHighlight};
60/// use std::path::Path;
61///
62/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
63/// let highlights = vec![
64///     MapHighlight {
65///         id: "Maths_Rooms".to_string(),
66///         color: "#fcdcd8".to_string(),
67///     },
68///     MapHighlight {
69///         id: "Science_Rooms".to_string(),
70///         color: "#fad7e6".to_string(),
71///     },
72/// ];
73///
74/// let map_svg = process_map(Path::new("resources/map.svg"), &highlights)?;
75/// println!("Processed map: {} bytes", map_svg.len());
76/// # Ok(())
77/// # }
78/// ```
79pub fn process_map(path: &Path, highlights: &[MapHighlight]) -> Result<String, ProcessorError> {
80    let content = fs::read_to_string(path)?;
81    let doc = Document::parse(&content)?;
82
83    // We will collect replacements: (start_index, end_index, new_text)
84    let mut replacements: Vec<(usize, usize, String)> = Vec::new();
85    let fill_re = Regex::new(r#"fill\s*=\s*(?:"[^"]*"|'[^']*')"#)?;
86
87    for highlight in highlights {
88        // Find the node by id or data-name
89        let node = doc.descendants().find(|n| {
90            n.attribute("id") == Some(&highlight.id)
91                || n.attribute("data-name") == Some(&highlight.id)
92        });
93
94        if let Some(group_node) = node {
95            // Iterate over all descendants to find shapes with fill attributes
96            for child in group_node.descendants() {
97                // We only care about elements that have a 'fill' attribute
98                if child.has_attribute("fill") {
99                    let range = child.range();
100                    // Find the end of the start tag.
101                    if let Some(start_tag_end) = content[range.start..].find('>') {
102                        let start_tag_str = &content[range.start..range.start + start_tag_end + 1];
103
104                        if let Some(mat) = fill_re.find(start_tag_str) {
105                            let absolute_start = range.start + mat.start();
106                            let absolute_end = range.start + mat.end();
107                            replacements.push((
108                                absolute_start,
109                                absolute_end,
110                                format!("fill=\"{}\"", highlight.color),
111                            ));
112                        }
113                    }
114                }
115            }
116        }
117    }
118
119    // Apply replacements in reverse order
120    replacements.sort_by(|a, b| b.0.cmp(&a.0));
121
122    // Deduplicate based on start index to avoid conflicting writes if regions overlap
123    replacements.dedup_by_key(|k| k.0);
124
125    let mut result = content;
126    for (start, end, text) in replacements {
127        // Ensure we don't panic if indices are out of bounds
128        if start <= end && end <= result.len() {
129            result.replace_range(start..end, &text);
130        }
131    }
132
133    Ok(result)
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use std::env;
140
141    #[test]
142    fn process_map_replaces_fill() {
143        let temp_dir = env::temp_dir();
144        let file = temp_dir.join("test_map.svg");
145        let content = r###"<?xml version="1.0"?>
146<svg>
147    <g id="Maths_Rooms">
148        <path fill="#000000" d="M0" />
149    </g>
150    <g id="Other">
151        <rect fill="#ffffff" />
152    </g>
153</svg>"###;
154
155        std::fs::write(&file, content).unwrap();
156
157        let highlights = vec![MapHighlight {
158            id: "Maths_Rooms".into(),
159            color: "#ff0000".into(),
160        }];
161        let out = process_map(&file, &highlights).unwrap();
162        assert!(out.contains("fill=\"#ff0000\""));
163    }
164}