Skip to main content

pfx_live/flat/
svg.rs

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}