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}