1use super::icons::Icon;
2use super::{
3 Draw, Fill, Glow, Group, IDENTITY, IconShape, Matrix, Shadow, Shape, Srgba, Stroke, multiply,
4 rotation, scaling, translation,
5};
6use std::collections::HashMap;
7
8#[derive(Clone, Copy, Debug, PartialEq)]
9pub enum Filter {
10 Elevation(f32),
11 Glow { sigma: f32 },
12}
13
14#[derive(Clone, Debug, Default)]
15pub struct Board {
16 pub size: [f32; 2],
17 pub draws: Vec<Draw>,
18 pub groups: Vec<Group>,
19 pub icons: Vec<IconShape>,
20}
21
22#[derive(Clone, Debug)]
23struct Tag {
24 name: String,
25 attributes: HashMap<String, String>,
26 closing: bool,
27}
28
29impl Tag {
30 fn get(&self, key: &str) -> Option<&str> {
31 self.attributes.get(key).map(String::as_str)
32 }
33
34 fn need(&self, key: &str) -> Result<&str, String> {
35 self.get(key)
36 .ok_or_else(|| format!("<{}> has no {key}", self.name))
37 }
38}
39
40fn tags(source: &str) -> Result<Vec<Tag>, String> {
41 let mut out = Vec::new();
42 let mut rest = source;
43 while let Some(open) = rest.find('<') {
44 let close = rest[open..].find('>').ok_or("a tag is not closed")? + open;
45 let body = &rest[open + 1..close];
46 rest = &rest[close + 1..];
47 if body.starts_with('?') || body.starts_with('!') {
48 continue;
49 }
50 let closing = body.starts_with('/');
51 let body = body.trim_start_matches('/').trim_end_matches('/');
52 let name_end = body.find(char::is_whitespace).unwrap_or(body.len());
53 let name = body[..name_end].to_string();
54 let mut attributes = HashMap::new();
55 let mut text = &body[name_end..];
56 while let Some(eq) = text.find("=\"") {
57 let key = text[..eq].trim().to_string();
58 let value_end = text[eq + 2..]
59 .find('"')
60 .ok_or("an attribute is not closed")?
61 + eq
62 + 2;
63 attributes.insert(key, text[eq + 2..value_end].to_string());
64 text = &text[value_end + 1..];
65 }
66 out.push(Tag {
67 name,
68 attributes,
69 closing,
70 });
71 }
72 Ok(out)
73}
74
75fn number(value: &str) -> Result<f32, String> {
76 value
77 .trim()
78 .parse()
79 .map_err(|_| format!("{value:?} is not a number"))
80}
81
82fn numbers(value: &str) -> Result<Vec<f32>, String> {
83 value
84 .split(|c: char| c == ',' || c.is_whitespace())
85 .filter(|part| !part.is_empty())
86 .map(number)
87 .collect()
88}
89
90fn colour(value: &str) -> Result<Srgba, String> {
91 let hex = value
92 .strip_prefix('#')
93 .ok_or_else(|| format!("{value:?} is not a #rgb colour"))?;
94 let hex = if hex.len() == 3 {
95 hex.chars().flat_map(|c| [c, c]).collect::<String>()
96 } else {
97 hex.to_string()
98 };
99 u32::from_str_radix(&hex, 16)
100 .map(Srgba::hex)
101 .map_err(|_| format!("{value:?} is not a #rgb colour"))
102}
103
104fn transform(value: &str) -> Result<Matrix, String> {
105 let mut m = IDENTITY;
106 let mut rest = value;
107 while let Some(open) = rest.find('(') {
108 let name = rest[..open].trim();
109 let close = rest.find(')').ok_or("a transform is not closed")?;
110 let args = numbers(&rest[open + 1..close])?;
111 rest = &rest[close + 1..];
112 let first = *args.first().ok_or("a transform has no arguments")?;
113 let step = match name {
114 "translate" => translation(first, args.get(1).copied().unwrap_or(0.0)),
115 "scale" => scaling(first, args.get(1).copied().unwrap_or(first)),
116 "rotate" => {
117 let turn = rotation(first.to_radians());
118 if args.len() == 3 {
119 multiply(
120 translation(args[1], args[2]),
121 multiply(turn, translation(-args[1], -args[2])),
122 )
123 } else {
124 turn
125 }
126 }
127 other => return Err(format!("the {other} transform is unsupported")),
128 };
129 m = multiply(m, step);
130 }
131 Ok(m)
132}
133
134fn arc_points(
135 from: [f32; 2],
136 radius: f32,
137 large: bool,
138 sweep: bool,
139 to: [f32; 2],
140) -> Vec<[f32; 2]> {
141 let mid = [(from[0] - to[0]) * 0.5, (from[1] - to[1]) * 0.5];
142 let d2 = mid[0] * mid[0] + mid[1] * mid[1];
143 let r = radius.max(d2.sqrt());
144 let factor =
145 ((r * r - d2).max(0.0) / d2.max(1e-12)).sqrt() * if large == sweep { -1.0 } else { 1.0 };
146 let centre = [
147 factor * mid[1] + (from[0] + to[0]) * 0.5,
148 -factor * mid[0] + (from[1] + to[1]) * 0.5,
149 ];
150 let start = (from[1] - centre[1]).atan2(from[0] - centre[0]);
151 let mut end = (to[1] - centre[1]).atan2(to[0] - centre[0]);
152 if sweep && end < start {
153 end += std::f32::consts::TAU;
154 }
155 if !sweep && end > start {
156 end -= std::f32::consts::TAU;
157 }
158 (1..=24)
159 .map(|i| {
160 let a = start + (end - start) * i as f32 / 24.0;
161 [centre[0] + r * a.cos(), centre[1] + r * a.sin()]
162 })
163 .collect()
164}
165
166fn path(d: &str) -> Result<Vec<Vec<[f32; 2]>>, String> {
167 let mut tokens = Vec::new();
168 let mut current = String::new();
169 for c in d.chars() {
170 if c.is_ascii_alphabetic() && c != 'e' {
171 if !current.is_empty() {
172 tokens.push(std::mem::take(&mut current));
173 }
174 tokens.push(c.to_string());
175 } else if c == '-' && !current.is_empty() && !current.ends_with('e') {
176 tokens.push(std::mem::take(&mut current));
177 current.push('-');
178 } else if c == ',' || c.is_whitespace() {
179 if !current.is_empty() {
180 tokens.push(std::mem::take(&mut current));
181 }
182 } else {
183 current.push(c);
184 }
185 }
186 if !current.is_empty() {
187 tokens.push(current);
188 }
189 let mut subpaths: Vec<Vec<[f32; 2]>> = Vec::new();
190 let mut at = [0.0f32, 0.0];
191 let mut command = 'M';
192 let mut index = 0;
193 let take = |count: usize, index: &mut usize| -> Result<Vec<f32>, String> {
194 let values = tokens
195 .get(*index..*index + count)
196 .ok_or("a path command is missing its numbers")?
197 .iter()
198 .map(|token| number(token))
199 .collect::<Result<Vec<_>, _>>()?;
200 *index += count;
201 Ok(values)
202 };
203 while index < tokens.len() {
204 if tokens[index].chars().all(|c| c.is_ascii_alphabetic()) {
205 command = tokens[index].chars().next().unwrap_or('M');
206 index += 1;
207 if command == 'Z' || command == 'z' {
208 let start = subpaths.last().ok_or("Z before a move")?[0];
209 at = start;
210 continue;
211 }
212 }
213 let relative = command.is_ascii_lowercase();
214 let base = if relative { at } else { [0.0, 0.0] };
215 match command.to_ascii_uppercase() {
216 'M' => {
217 let v = take(2, &mut index)?;
218 at = [base[0] + v[0], base[1] + v[1]];
219 subpaths.push(vec![at]);
220 command = if relative { 'l' } else { 'L' };
221 continue;
222 }
223 'L' => {
224 let v = take(2, &mut index)?;
225 at = [base[0] + v[0], base[1] + v[1]];
226 }
227 'H' => at[0] = base[0] + take(1, &mut index)?[0],
228 'V' => at[1] = base[1] + take(1, &mut index)?[0],
229 'A' => {
230 let v = take(7, &mut index)?;
231 let to = [base[0] + v[5], base[1] + v[6]];
232 let points = arc_points(at, v[0], v[3] != 0.0, v[4] != 0.0, to);
233 let line = subpaths.last_mut().ok_or("an arc before a move")?;
234 line.extend_from_slice(&points[..points.len() - 1]);
235 at = to;
236 }
237 other => return Err(format!("the {other} path command is unsupported")),
238 }
239 subpaths.last_mut().ok_or("a line before a move")?.push(at);
240 }
241 Ok(subpaths)
242}
243
244#[derive(Clone)]
245struct Context {
246 transform: Matrix,
247 filter: Option<String>,
248 group: Option<u16>,
249 first: bool,
250 rotated: bool,
251}
252
253fn filter_of<'a>(filters: &'a [(&str, Filter)], value: &str) -> Result<&'a Filter, String> {
254 let id = value
255 .strip_prefix("url(#")
256 .and_then(|rest| rest.strip_suffix(')'))
257 .ok_or_else(|| format!("{value:?} is not a filter reference"))?;
258 filters
259 .iter()
260 .find(|(name, _)| *name == id)
261 .map(|(_, filter)| filter)
262 .ok_or_else(|| format!("the filter {id} has no elevation"))
263}
264
265pub fn read(source: &str, filters: &[(&str, Filter)]) -> Result<Board, String> {
266 let all = tags(source)?;
267 let mut gradients: HashMap<String, Vec<Srgba>> = HashMap::new();
268 let mut symbols: HashMap<String, Tag> = HashMap::new();
269 let mut size = [0.0, 0.0];
270 let mut body = Vec::new();
271 let mut in_defs = false;
272 let mut in_text = 0usize;
273 let mut gradient: Option<String> = None;
274 let mut symbol: Option<String> = None;
275 for tag in all {
276 match (tag.name.as_str(), tag.closing) {
277 ("svg", false) => {
278 size = [number(tag.need("width")?)?, number(tag.need("height")?)?];
279 }
280 ("svg", true) => {}
281 ("defs", closing) => in_defs = !closing,
282 ("text", false) => in_text += 1,
283 ("text", true) => in_text = in_text.saturating_sub(1),
284 _ if in_text > 0 => {}
285 ("linearGradient", false) if in_defs => gradient = Some(tag.need("id")?.to_string()),
286 ("stop", _) if in_defs => {
287 let mut c = colour(tag.need("stop-color")?)?;
288 if let Some(alpha) = tag.get("stop-opacity") {
289 c = c.alpha(number(alpha)?);
290 }
291 gradients
292 .entry(gradient.clone().ok_or("a stop outside a gradient")?)
293 .or_default()
294 .push(c);
295 }
296 ("g", false) if in_defs => symbol = tag.get("id").map(str::to_string),
297 ("path", _) if in_defs => {
298 if let Some(id) = symbol.clone() {
299 symbols.insert(id, tag.clone());
300 }
301 }
302 _ if in_defs => {}
303 _ => body.push(tag),
304 }
305 }
306 let mut board = Board {
307 size,
308 ..Board::default()
309 };
310 let mut icon_keys: Vec<(String, u32)> = Vec::new();
311 let mut level = 0.0f32;
312 let mut stack = vec![Context {
313 transform: IDENTITY,
314 filter: None,
315 group: None,
316 first: false,
317 rotated: false,
318 }];
319 for tag in body {
320 let top = stack.last().ok_or("a group closed twice")?.clone();
321 let rotates = tag.get("transform").is_some_and(|t| t.contains("rotate"));
322 if tag.name == "g" {
323 if tag.closing {
324 stack.pop();
325 continue;
326 }
327 let own = tag.get("transform").map_or(Ok(IDENTITY), transform)?;
328 let opacity = tag.get("opacity").map_or(Ok(1.0), number)?;
329 let group = if opacity < 1.0 {
330 board.groups.push(Group { opacity });
331 Some(u16::try_from(board.groups.len() - 1).map_err(|_| "too many groups")?)
332 } else {
333 top.group
334 };
335 stack.push(Context {
336 transform: multiply(top.transform, own),
337 filter: tag.get("filter").map(str::to_string).or(top.filter.clone()),
338 group,
339 first: tag.get("filter").is_some(),
340 rotated: top.rotated || rotates,
341 });
342 continue;
343 }
344 if tag.closing {
345 continue;
346 }
347 let own = tag.get("transform").map_or(Ok(IDENTITY), transform)?;
348 let element = if tag.name == "use" {
349 let href = tag
350 .get("href")
351 .or(tag.get("xlink:href"))
352 .ok_or("<use> has no href")?;
353 symbols
354 .get(href.trim_start_matches('#'))
355 .ok_or_else(|| format!("{href} is not a defined path"))?
356 .clone()
357 } else {
358 tag.clone()
359 };
360 let base = multiply(top.transform, own);
361 let rotated = top.rotated || rotates;
362 let paint = |key: &str| -> Result<Option<Fill>, String> {
363 let Some(value) = element.get(key) else {
364 return Ok(None);
365 };
366 if value == "none" {
367 return Ok(None);
368 }
369 if let Some(id) = value.strip_prefix("url(#") {
370 let stops = gradients
371 .get(id.trim_end_matches(')'))
372 .filter(|stops| stops.len() == 2)
373 .ok_or_else(|| format!("{value} is not a two-stop gradient"))?;
374 return Ok(Some(Fill::Vertical(stops[0], stops[1])));
375 }
376 Ok(Some(Fill::Solid(colour(value)?)))
377 };
378 let fill = match (element.name.as_str(), element.get("fill")) {
379 ("path", None) => None,
380 (_, None) => Some(Fill::Solid(Srgba::hex(0))),
381 _ => paint("fill")?,
382 };
383 let stroke_width = element.get("stroke-width").map_or(Ok(1.0), number)?;
384 let stroke = match paint("stroke")? {
385 None => None,
386 Some(Fill::Solid(mut c)) => {
387 if let Some(alpha) = element.get("stroke-opacity") {
388 c = c.alpha(number(alpha)?);
389 }
390 Some(
391 match element.get("stroke-dasharray").map(numbers).transpose()? {
392 Some(d) if d.len() >= 2 => Stroke::dashed(c, stroke_width, d[0], d[1]),
393 _ => Stroke::solid(c, stroke_width),
394 },
395 )
396 }
397 Some(_) => return Err("gradient strokes are unsupported".into()),
398 };
399 let opacity = element.get("opacity").map_or(Ok(1.0), number)?;
400 let filter = tag.get("filter").map(str::to_string).or(top.filter.clone());
401 let casts = tag.get("filter").is_some() || top.first;
402 let mut draw = match element.name.as_str() {
403 "rect" => {
404 let x = element.get("x").map_or(Ok(0.0), number)?;
405 let y = element.get("y").map_or(Ok(0.0), number)?;
406 let w = number(element.need("width")?)?;
407 let h = number(element.need("height")?)?;
408 let r = element.get("rx").map_or(Ok(0.0), number)?;
409 Draw::new(Shape::Rect {
410 half: [w * 0.5, h * 0.5],
411 radii: [r; 4],
412 })
413 .transform(multiply(base, translation(x + w * 0.5, y + h * 0.5)))
414 }
415 "circle" => {
416 let cx = number(element.need("cx")?)?;
417 let cy = number(element.need("cy")?)?;
418 let r = number(element.need("r")?)?;
419 let placed = multiply(base, translation(cx, cy));
420 match (fill, stroke) {
421 (None, Some(s)) => {
422 let shape = match s.dash {
423 Some(dash) => Shape::Arc {
424 radius: r,
425 width: s.width,
426 start: 0.0,
427 sweep: dash.on / r,
428 },
429 None => Shape::Ring {
430 radius: r,
431 width: s.width,
432 },
433 };
434 Draw::new(shape).transform(placed).fill(s.colour)
435 }
436 _ => Draw::new(Shape::Circle { radius: r }).transform(placed),
437 }
438 }
439 "path" => {
440 let d = element.need("d")?;
441 let subpaths = path(d)?;
442 let reach = stroke.map_or(0.0, |s| s.width * 0.5) + 4.0;
443 let shape = if fill.is_some() {
444 IconShape {
445 polygons: subpaths,
446 reach,
447 ..IconShape::default()
448 }
449 } else {
450 IconShape {
451 lines: subpaths,
452 reach,
453 ..IconShape::default()
454 }
455 };
456 let key = (d.to_string(), reach.to_bits());
457 let index = match icon_keys.iter().position(|k| *k == key) {
458 Some(index) => index,
459 None => {
460 icon_keys.push(key);
461 board.icons.push(shape);
462 icon_keys.len() - 1
463 }
464 };
465 Draw::new(Shape::Icon(Icon(index as u32))).transform(base)
466 }
467 other => return Err(format!("<{other}> is unsupported")),
468 };
469 if matches!(draw.shape, Shape::Ring { .. } | Shape::Arc { .. }) {
470 draw.opacity = opacity;
471 } else {
472 draw.fill = fill.unwrap_or(Fill::None);
473 draw.stroke = stroke;
474 draw.opacity = opacity;
475 }
476 draw.group = top.group;
477 draw.shadow = Shadow::None;
478 if let Some(filter) = filter {
479 match *filter_of(filters, &filter)? {
480 Filter::Elevation(elevation) => {
481 level = level.max(elevation);
482 if casts {
483 draw.shadow = if rotated {
484 Shadow::Turned
485 } else {
486 Shadow::Cast
487 };
488 }
489 }
490 Filter::Glow { sigma } => {
491 if casts {
492 let Fill::Solid(c) = draw.fill else {
493 return Err("a glow needs a solid fill".into());
494 };
495 draw.fill = Fill::None;
496 draw.shadow = Shadow::Glow(Glow {
497 colour: c.alpha(c.0[3] * draw.opacity),
498 sigma,
499 });
500 draw.opacity = 1.0;
501 }
502 }
503 }
504 }
505 draw.elevation = level;
506 if let Some(context) = stack.last_mut() {
507 context.first = false;
508 }
509 board.draws.push(draw);
510 }
511 Ok(board)
512}
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517
518 const FILTERS: [(&str, Filter); 2] = [
519 ("lift1", Filter::Elevation(1.0)),
520 ("halo", Filter::Glow { sigma: 8.0 }),
521 ];
522
523 #[test]
524 fn paths_read_relative_moves_lines_and_arcs() {
525 let lines = path("M10 20h5v-5l-5 5ZM0 0a14 14 0 0 1 28 0").unwrap();
526 assert_eq!(
527 lines[0],
528 vec![[10.0, 20.0], [15.0, 20.0], [15.0, 15.0], [10.0, 20.0]]
529 );
530 let arc = &lines[1];
531 assert_eq!(arc[0], [0.0, 0.0]);
532 let end = arc.last().unwrap();
533 assert!((end[0] - 28.0).abs() < 1e-4 && end[1].abs() < 1e-4);
534 assert!(
535 arc.iter()
536 .all(|p| ((p[0] - 14.0).hypot(p[1]) - 14.0).abs() < 1e-3)
537 );
538 assert!(arc.iter().any(|p| p[1] < -13.0));
539 }
540
541 #[test]
542 fn unsupported_input_is_refused_by_name() {
543 assert!(read("<svg width=\"1\" height=\"1\"><ellipse/></svg>", &FILTERS).is_err());
544 assert!(read("<svg width=\"1\" height=\"1\"><rect width=\"1\" height=\"1\" filter=\"url(#x)\"/></svg>", &FILTERS).is_err());
545 let board = read(
546 "<svg width=\"4\" height=\"2\"><rect width=\"4\" height=\"2\" fill=\"#fff\"/><text x=\"1\">hi<tspan>x</tspan></text></svg>",
547 &FILTERS,
548 )
549 .unwrap();
550 assert_eq!(board.size, [4.0, 2.0]);
551 assert_eq!(board.draws.len(), 1);
552 }
553}