Skip to main content

asbuilt_core/
svg.rs

1//! A rendered view inlined into a page, so the page's colors reach it.
2//!
3//! The SVG `dot -Tsvg` wrote is rewritten: the prolog, comments and
4//! tooltips go; `width` and `height` give way to the page's layout; ids
5//! are prefixed with the view's name, so several views share a page; and
6//! classes name what `theme.css` colors: each node's and group box's
7//! element kind (read from the `.dot` LikeC4 wrote beside it), a node's
8//! secondary text, and an edge label's backing. Only a LikeC4 render is
9//! inlined. Any other SVG is left for an `<img>`, where a script in it
10//! cannot run, and the rewrite drops scripts, `foreignObject` and event
11//! handlers besides. Pure string work over Graphviz's own output.
12
13use std::collections::BTreeMap;
14
15use crate::docs::escape;
16
17/// A rendered view as `render` left it: the SVG, and the `.dot` beside it.
18#[derive(Debug, Clone, PartialEq, Eq, Default)]
19pub struct ViewSource {
20    pub svg: String,
21    pub dot: Option<String>,
22}
23
24/// Whether `dot` was written by `likec4 gen dot`, which names the view in
25/// a `likec4_viewId` graph attribute.
26pub fn is_likec4_dot(dot: &str) -> bool {
27    dot.contains("likec4_viewId=")
28}
29
30fn is_dot_id(text: &str) -> bool {
31    !text.is_empty() && text.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
32}
33
34/// An attribute value at the start of `text`: quoted, or up to the next
35/// separator.
36fn attr_value(text: &str) -> String {
37    match text.strip_prefix('"') {
38        Some(quoted) => quoted.split('"').next().unwrap_or_default().to_string(),
39        None => text
40            .split(|c: char| matches!(c, ',' | ']' | ';') || c.is_whitespace())
41            .next()
42            .unwrap_or_default()
43            .to_string(),
44    }
45}
46
47/// The LikeC4 id of each node and group box in a `.dot` LikeC4 wrote,
48/// keyed by the Graphviz name the SVG's `<title>` carries. LikeC4 writes
49/// one attribute per line, so a statement's name opens a line (`server
50/// [`, `subgraph cluster_app {`) and its `likec4_id` follows; an edge
51/// carries no element, and a cluster's own `graph [` line keeps its name.
52pub fn likec4_ids(dot: &str) -> BTreeMap<String, String> {
53    let mut ids = BTreeMap::new();
54    let mut current: Option<String> = None;
55    for line in dot.lines().map(str::trim) {
56        if let Some(rest) = line.strip_prefix("subgraph ") {
57            current = rest
58                .split_whitespace()
59                .next()
60                .filter(|name| is_dot_id(name))
61                .map(str::to_string);
62        } else if let Some((head, _)) = line.split_once('[') {
63            let head = head.trim();
64            if head.contains("->") {
65                current = None;
66            } else if head != "graph" && is_dot_id(head) {
67                current = Some(head.to_string());
68            }
69        }
70        if let Some(at) = line.find("likec4_id=")
71            && let Some(name) = current.take()
72        {
73            ids.insert(name, attr_value(&line[at + "likec4_id=".len()..]));
74        }
75    }
76    ids
77}
78
79/// The index of the `>` that closes the tag opening `text`, outside
80/// any quoted attribute value.
81fn tag_end(text: &str) -> Option<usize> {
82    let mut quoted = false;
83    for (i, c) in text.char_indices() {
84        match c {
85            '"' => quoted = !quoted,
86            '>' if !quoted => return Some(i),
87            _ => {}
88        }
89    }
90    None
91}
92
93#[derive(Debug)]
94struct Tag {
95    name: String,
96    attrs: Vec<(String, String)>,
97    self_closing: bool,
98}
99
100impl Tag {
101    /// A start tag's name and double-quoted attributes, values kept as
102    /// written; `None` for anything else.
103    fn parse(inner: &str) -> Option<Tag> {
104        let inner = inner.trim_end();
105        let (inner, self_closing) = match inner.strip_suffix('/') {
106            Some(inner) => (inner, true),
107            None => (inner, false),
108        };
109        let name_end = inner.find(char::is_whitespace).unwrap_or(inner.len());
110        let name = inner[..name_end].to_string();
111        let mut attrs = Vec::new();
112        let mut rest = inner[name_end..].trim_start();
113        while !rest.is_empty() {
114            let (key, after) = rest.split_once('=')?;
115            let quoted = after.trim_start().strip_prefix('"')?;
116            let (value, tail) = quoted.split_once('"')?;
117            attrs.push((key.trim().to_string(), value.to_string()));
118            rest = tail.trim_start();
119        }
120        Some(Tag {
121            name,
122            attrs,
123            self_closing,
124        })
125    }
126
127    fn get(&self, key: &str) -> Option<&str> {
128        self.attrs
129            .iter()
130            .find(|(k, _)| k == key)
131            .map(|(_, v)| v.as_str())
132    }
133
134    /// Set `key` to `value`, which the caller has escaped.
135    fn set(&mut self, key: &str, value: String) {
136        match self.attrs.iter_mut().find(|(k, _)| k == key) {
137            Some((_, v)) => *v = value,
138            None => self.attrs.push((key.to_string(), value)),
139        }
140    }
141
142    fn remove(&mut self, key: &str) {
143        self.attrs.retain(|(k, _)| k != key);
144    }
145
146    fn add_class(&mut self, class: &str) {
147        let classes = match self.get("class") {
148            Some(existing) => format!("{existing} {class}"),
149            None => class.to_string(),
150        };
151        self.set("class", classes);
152    }
153
154    /// Write the tag; returns where its `class` value ends in `out`, so
155    /// a class learned later can be inserted.
156    fn write(&self, out: &mut String) -> Option<usize> {
157        out.push('<');
158        out.push_str(&self.name);
159        let mut class_end = None;
160        for (key, value) in &self.attrs {
161            out.push(' ');
162            out.push_str(key);
163            out.push_str("=\"");
164            out.push_str(value);
165            if key == "class" {
166                class_end = Some(out.len());
167            }
168            out.push('"');
169        }
170        out.push_str(if self.self_closing { "/>" } else { ">" });
171        class_end
172    }
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176enum Role {
177    Node,
178    Cluster,
179    Edge,
180}
181
182#[derive(Debug)]
183struct Group {
184    role: Option<Role>,
185    /// Where the group's `class` value ends in the output, until its
186    /// `<title>` names the element and the kind class goes in there.
187    class_end: Option<usize>,
188    /// The fill of a node's first text line, its title.
189    title_fill: Option<String>,
190}
191
192/// An attribute that could run a script: an event handler, or a link to
193/// a `javascript:` URL.
194fn is_active(key: &str, value: &str) -> bool {
195    key.to_ascii_lowercase().starts_with("on")
196        || (key.ends_with("href")
197            && value
198                .trim_start()
199                .to_ascii_lowercase()
200                .starts_with("javascript:"))
201}
202
203/// `source.svg` rewritten for a page, or `None` when it is not a LikeC4
204/// render (no `.dot` beside it, or one LikeC4 did not write) or not SVG
205/// in the shape Graphviz writes. `kinds` maps a LikeC4 id to its element
206/// kind; `label` is the diagram's accessible name.
207pub fn inline(
208    source: &ViewSource,
209    view: &str,
210    label: &str,
211    kinds: &BTreeMap<String, String>,
212) -> Option<String> {
213    let dot = source.dot.as_deref().filter(|dot| is_likec4_dot(dot))?;
214    let ids = likec4_ids(dot);
215    let mut rest = &source.svg[source.svg.find("<svg")?..];
216    let mut out = String::with_capacity(rest.len());
217    let mut groups: Vec<Group> = Vec::new();
218    while let Some(lt) = rest.find('<') {
219        out.push_str(&rest[..lt]);
220        rest = &rest[lt..];
221        if let Some(comment) = rest.strip_prefix("<!--") {
222            rest = &comment[comment.find("-->")? + "-->".len()..];
223            continue;
224        }
225        let end = tag_end(rest)?;
226        let inner = &rest[1..end];
227        rest = &rest[end + 1..];
228        if inner.starts_with('?') || inner.starts_with('!') {
229            continue;
230        }
231        if let Some(closing) = inner.strip_prefix('/') {
232            if closing.trim() == "g" {
233                groups.pop();
234            }
235            out.push('<');
236            out.push_str(inner);
237            out.push('>');
238            continue;
239        }
240        let mut tag = Tag::parse(inner)?;
241        if tag.name == "title" && !tag.self_closing {
242            let close = rest.find("</title>")?;
243            let name = &rest[..close];
244            rest = &rest[close + "</title>".len()..];
245            if let Some(group) = groups.last_mut()
246                && let Some(at) = group.class_end.take()
247                && let Some(kind) = ids.get(name).and_then(|id| kinds.get(id))
248            {
249                out.insert_str(at, &format!(" c4-k-{kind}"));
250            }
251            continue;
252        }
253        if matches!(tag.name.as_str(), "script" | "foreignObject") {
254            if !tag.self_closing {
255                let close = format!("</{}>", tag.name);
256                rest = &rest[rest.find(&close)? + close.len()..];
257            }
258            continue;
259        }
260        tag.attrs.retain(|(key, value)| !is_active(key, value));
261        if let Some((_, id)) = tag.attrs.iter_mut().find(|(key, _)| key == "id") {
262            *id = format!("{}-{id}", escape(view));
263        }
264        let enclosing = groups.iter().rev().find_map(|g| g.role);
265        match tag.name.as_str() {
266            "svg" => {
267                tag.remove("width");
268                tag.remove("height");
269                tag.set("class", "c4".to_string());
270                tag.set("data-view", escape(view));
271                tag.set("role", "img".to_string());
272                tag.set("aria-label", escape(label));
273            }
274            "g" => {
275                let role = match tag.get("class") {
276                    Some("node") => Some(Role::Node),
277                    Some("cluster") => Some(Role::Cluster),
278                    Some("edge") => Some(Role::Edge),
279                    _ => None,
280                };
281                let class_end = tag.write(&mut out);
282                groups.push(Group {
283                    role,
284                    class_end: class_end
285                        .filter(|_| matches!(role, Some(Role::Node | Role::Cluster))),
286                    title_fill: None,
287                });
288                continue;
289            }
290            "text" if enclosing == Some(Role::Node) => {
291                let fill = tag.get("fill").unwrap_or_default().to_string();
292                if let Some(node) = groups.iter_mut().rev().find(|g| g.role.is_some()) {
293                    match &node.title_fill {
294                        None => node.title_fill = Some(fill),
295                        Some(title) if *title != fill => tag.add_class("c4-muted"),
296                        Some(_) => {}
297                    }
298                }
299            }
300            "polygon" if enclosing == Some(Role::Edge) && tag.get("fill-opacity").is_some() => {
301                tag.add_class("c4-label-bg");
302            }
303            _ => {}
304        }
305        tag.write(&mut out);
306    }
307    out.push_str(rest);
308    Some(out)
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    const DOT: &str = r##"digraph {
316    graph [likec4_viewId=view_app,
317        bgcolor=transparent
318    ];
319    node [color="#2563eb",
320        fillcolor="#3b82f6"
321    ];
322    subgraph cluster_app {
323        graph [color="#57130f",
324            label=<<FONT POINT-SIZE="11">APP [x]</FONT>>,
325            likec4_id=app
326        ];
327        server [color="#2563eb",
328            likec4_id="app.server"];
329    }
330    driver_1 [likec4_id=node_driver];
331    server -> driver_1 [likec4_id="1ab"];
332}
333"##;
334
335    const SVG: &str = r##"<?xml version="1.0" encoding="UTF-8" standalone="no"?>
336<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
337 "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
338<!-- Generated by graphviz -->
339<svg width="300pt" height="200pt"
340 viewBox="0.00 0.00 300.00 200.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
341<g id="graph0" class="graph" transform="scale(1 1)">
342<title>G</title>
343<g id="clust1" class="cluster">
344<title>cluster_app</title>
345<polygon fill="#722f24" stroke="#57130f" points="0,0 1,1"/>
346<text x="1" y="1" fill="#ffffe5" fill-opacity="0.7">APP</text>
347</g>
348<!-- server -->
349<g id="node1" class="node">
350<title>server</title>
351<polygon fill="#3b82f6" stroke="#2563eb" points="0,0 1,1"/>
352<text x="1" y="1" fill="#eff6ff">server</text>
353<text x="1" y="2" fill="#bfdbfe">Where it runs</text>
354</g>
355<g id="node2" class="node">
356<title>driver_1</title>
357<polygon fill="#3b82f6" stroke="#2563eb" points="0,0 1,1"/>
358<text x="1" y="1" fill="#eff6ff">node</text>
359<text x="1" y="2" fill="#eff6ff">driver</text>
360</g>
361<g id="node3" class="node">
362<title>stranger</title>
363<polygon fill="#3b82f6" stroke="#2563eb" points="0,0 1,1"/>
364</g>
365<g id="edge1" class="edge">
366<title>server&#45;&gt;driver_1</title>
367<path fill="none" stroke="#8d8d8d" d="M0,0"/>
368<polygon fill="#8d8d8d" stroke="#8d8d8d" points="0,0 1,1"/>
369<polygon fill="#18191b" fill-opacity="0.627451" stroke="none" points="0,0 1,1"/>
370<text x="1" y="1" fill="#c9c9c9">spawns</text>
371<text x="1" y="2" fill="#aaaaaa">stdio</text>
372</g>
373</g>
374</svg>
375"##;
376
377    fn kinds() -> BTreeMap<String, String> {
378        [
379            ("app", "container"),
380            ("app.server", "component"),
381            ("node_driver", "process"),
382        ]
383        .iter()
384        .map(|(id, kind)| (id.to_string(), kind.to_string()))
385        .collect()
386    }
387
388    fn source() -> ViewSource {
389        ViewSource {
390            svg: SVG.into(),
391            dot: Some(DOT.into()),
392        }
393    }
394
395    fn inlined() -> String {
396        inline(&source(), "view_app", "app", &kinds()).expect("inlined")
397    }
398
399    #[test]
400    fn likec4_ids_maps_nodes_and_group_boxes_but_not_edges() {
401        let ids = likec4_ids(DOT);
402        let expected: BTreeMap<String, String> = [
403            ("cluster_app", "app"),
404            ("driver_1", "node_driver"),
405            ("server", "app.server"),
406        ]
407        .iter()
408        .map(|(n, id)| (n.to_string(), id.to_string()))
409        .collect();
410        assert_eq!(ids, expected);
411    }
412
413    #[test]
414    fn a_view_without_a_dot_is_not_inlined() {
415        let source = ViewSource {
416            svg: SVG.into(),
417            dot: None,
418        };
419        assert_eq!(inline(&source, "view_app", "app", &kinds()), None);
420    }
421
422    #[test]
423    fn a_view_whose_dot_likec4_did_not_write_is_not_inlined() {
424        let source = ViewSource {
425            svg: SVG.into(),
426            dot: Some("digraph { a -> b }".into()),
427        };
428        assert_eq!(inline(&source, "view_app", "app", &kinds()), None);
429    }
430
431    #[test]
432    fn the_prolog_comments_and_tooltips_are_dropped() {
433        let svg = inlined();
434        assert!(svg.starts_with("<svg "), "{svg}");
435        for gone in ["<?xml", "<!DOCTYPE", "<!--", "-->", "<title>", "graphviz"] {
436            assert!(!svg.contains(gone), "{gone} in {svg}");
437        }
438    }
439
440    #[test]
441    fn the_root_gives_way_to_the_page_and_names_the_view() {
442        let svg = inline(&source(), "view_app", "a<b", &kinds()).unwrap();
443        let root = &svg[..svg.find('>').unwrap()];
444        assert_eq!(
445            root,
446            "<svg viewBox=\"0.00 0.00 300.00 200.00\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" class=\"c4\" data-view=\"view_app\" role=\"img\" aria-label=\"a&lt;b\""
447        );
448    }
449
450    #[test]
451    fn ids_carry_the_view_name() {
452        let svg = inlined();
453        assert!(svg.contains("<g id=\"view_app-node1\""), "{svg}");
454        assert!(!svg.contains("id=\"node1\""), "{svg}");
455    }
456
457    #[test]
458    fn nodes_and_group_boxes_are_classed_by_element_kind() {
459        let svg = inlined();
460        assert!(
461            svg.contains("<g id=\"view_app-clust1\" class=\"cluster c4-k-container\">"),
462            "{svg}"
463        );
464        assert!(
465            svg.contains("<g id=\"view_app-node1\" class=\"node c4-k-component\">"),
466            "{svg}"
467        );
468        assert!(
469            svg.contains("<g id=\"view_app-node2\" class=\"node c4-k-process\">"),
470            "{svg}"
471        );
472    }
473
474    #[test]
475    fn a_node_the_dot_does_not_name_keeps_its_plain_class() {
476        let svg = inlined();
477        assert!(
478            svg.contains("<g id=\"view_app-node3\" class=\"node\">"),
479            "{svg}"
480        );
481    }
482
483    #[test]
484    fn a_node_s_title_keeps_its_class_and_its_other_lines_are_muted() {
485        let svg = inlined();
486        assert!(
487            svg.contains("<text x=\"1\" y=\"1\" fill=\"#eff6ff\">server</text>"),
488            "{svg}"
489        );
490        assert!(
491            svg.contains(
492                "<text x=\"1\" y=\"2\" fill=\"#bfdbfe\" class=\"c4-muted\">Where it runs</text>"
493            ),
494            "{svg}"
495        );
496    }
497
498    #[test]
499    fn a_title_that_wraps_keeps_every_line_as_title() {
500        let svg = inlined();
501        assert!(
502            svg.contains("<text x=\"1\" y=\"2\" fill=\"#eff6ff\">driver</text>"),
503            "{svg}"
504        );
505    }
506
507    #[test]
508    fn an_edge_label_s_lines_are_never_muted() {
509        let svg = inlined();
510        assert!(
511            svg.contains("<text x=\"1\" y=\"2\" fill=\"#aaaaaa\">stdio</text>"),
512            "{svg}"
513        );
514    }
515
516    #[test]
517    fn an_edge_s_id_is_not_given_to_an_unnamed_node_before_it() {
518        let ids = likec4_ids(
519            "digraph {\n    graph [likec4_viewId=v];\n    helper [shape=point];\n    helper -> server [likec4_id=\"1ab\"];\n}\n",
520        );
521        assert_eq!(ids, BTreeMap::new());
522    }
523
524    #[test]
525    fn a_greater_than_sign_inside_an_attribute_value_does_not_end_the_tag() {
526        let source = ViewSource {
527            svg: SVG.replace(
528                "<text x=\"1\" y=\"2\" fill=\"#bfdbfe\">",
529                "<text x=\"1\" y=\"2\" data-note=\"a>b\" fill=\"#bfdbfe\">",
530            ),
531            dot: Some(DOT.into()),
532        };
533        let svg = inline(&source, "view_app", "app", &kinds()).unwrap();
534        assert!(
535            svg.contains("data-note=\"a>b\" fill=\"#bfdbfe\" class=\"c4-muted\">"),
536            "{svg}"
537        );
538    }
539
540    #[test]
541    fn a_processing_instruction_inside_the_drawing_is_dropped() {
542        let source = ViewSource {
543            svg: SVG.replace("<g id=\"graph0\"", "<?render x?><g id=\"graph0\""),
544            dot: Some(DOT.into()),
545        };
546        let svg = inline(&source, "view_app", "app", &kinds()).unwrap();
547        assert!(!svg.contains("<?render"), "{svg}");
548    }
549
550    #[test]
551    fn an_edge_label_s_backing_is_classed_and_its_arrowhead_is_not() {
552        let svg = inlined();
553        assert!(
554            svg.contains("<polygon fill=\"#18191b\" fill-opacity=\"0.627451\" stroke=\"none\" points=\"0,0 1,1\" class=\"c4-label-bg\"/>"),
555            "{svg}"
556        );
557        assert!(
558            svg.contains("<polygon fill=\"#8d8d8d\" stroke=\"#8d8d8d\" points=\"0,0 1,1\"/>"),
559            "{svg}"
560        );
561    }
562
563    #[test]
564    fn scripts_foreign_objects_and_event_handlers_are_dropped() {
565        let svg = SVG.replace(
566            "<g id=\"edge1\" class=\"edge\">",
567            "<script>alert(1)</script><foreignObject><div>x</div></foreignObject><a xlink:href=\"javascript:alert(1)\" onclick=\"alert(1)\" href=\"#ok\"></a><g id=\"edge1\" class=\"edge\">",
568        );
569        let source = ViewSource {
570            svg,
571            dot: Some(DOT.into()),
572        };
573        let out = inline(&source, "view_app", "app", &kinds()).unwrap();
574        for gone in ["script", "alert", "foreignObject", "<div>", "onclick"] {
575            assert!(!out.contains(gone), "{gone} in {out}");
576        }
577        assert!(out.contains("<a href=\"#ok\"></a>"), "{out}");
578    }
579
580    #[test]
581    fn an_svg_that_does_not_parse_is_not_inlined() {
582        let source = ViewSource {
583            svg: "<svg viewBox=\"0 0 1 1><g>".into(),
584            dot: Some(DOT.into()),
585        };
586        assert_eq!(inline(&source, "view_app", "app", &kinds()), None);
587    }
588}