Skip to main content

drawlang_render/
svg.rs

1//! Deterministic SVG writer. Same Geometry in, byte-identical SVG out:
2//! fixed-precision floats, ordered iteration, no timestamps or ids.
3
4use crate::theme::{Palette, palette};
5use drawlang_core::geom::*;
6use drawlang_core::model::*;
7use drawlang_core::text::FONT_FAMILY;
8
9/// Two-decimal fixed formatting with trailing zeros trimmed: `12`, `12.5`,
10/// `12.25`. Stable across platforms.
11pub fn fmt_f(v: f64) -> String {
12    let r = (v * 100.0).round() / 100.0;
13    if r == r.trunc() {
14        format!("{}", r as i64)
15    } else {
16        let s = format!("{r:.2}");
17        s.trim_end_matches('0').trim_end_matches('.').to_string()
18    }
19}
20
21fn esc(s: &str) -> String {
22    s.replace('&', "&")
23        .replace('<', "&lt;")
24        .replace('>', "&gt;")
25        .replace('"', "&quot;")
26}
27
28pub fn render_svg(doc: &Document, g: &Geometry) -> String {
29    let p = palette(doc.canvas.theme);
30    let mut out = String::with_capacity(16 * 1024);
31    let (w, h) = (fmt_f(g.width), fmt_f(g.height));
32    out.push_str(&format!(
33        r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" width="{w}" height="{h}" font-family="{FONT_FAMILY}, Helvetica, Arial, sans-serif">"#
34    ));
35    out.push('\n');
36    out.push_str(&format!(
37        r#"<rect width="{w}" height="{h}" fill="{}"/>"#,
38        p.bg
39    ));
40    out.push('\n');
41
42    if let Some(t) = &g.title {
43        draw_label(&mut out, t, p.ink);
44    }
45
46    // Boxes, parents before children (DFS order = stacking order).
47    for id in doc.walk() {
48        if id == doc.root {
49            continue;
50        }
51        draw_element(&mut out, doc, g, id, p);
52    }
53
54    // Edges, then their labels, then port sockets on top.
55    for route in &g.routes {
56        draw_edge(&mut out, doc, route, p);
57    }
58    for route in &g.routes {
59        if let Some(label) = &route.label {
60            draw_halo_label(&mut out, label, p);
61        }
62    }
63    draw_ports(&mut out, doc, g, p);
64
65    out.push_str("</svg>\n");
66    out
67}
68
69fn draw_element(out: &mut String, doc: &Document, g: &Geometry, id: ElementId, p: &Palette) {
70    let el = doc.el(id);
71    if el.kind.is_container() {
72        return; // containers are invisible
73    }
74    let r = g.rect(id);
75    let style = doc.resolved_style(id);
76
77    match el.kind {
78        ElementKind::Group => {
79            let stroke = p.resolve(style.color.as_ref(), p.group_border);
80            let sw = style.stroke.unwrap_or(1.2);
81            let corner = style.corner.unwrap_or(10.0);
82            let wash = p.resolve(style.fill.as_ref(), p.ink);
83            let wash_op = if style.fill.is_some() {
84                1.0
85            } else {
86                p.group_wash_opacity
87            };
88            out.push_str(&format!(
89                r#"<rect x="{}" y="{}" width="{}" height="{}" rx="{}" fill="{}" fill-opacity="{}" stroke="{}" stroke-width="{}"{}/>"#,
90                fmt_f(r.x), fmt_f(r.y), fmt_f(r.w), fmt_f(r.h), fmt_f(corner),
91                wash, fmt_f(wash_op), stroke, fmt_f(sw),
92                dash_attr(&style),
93            ));
94            out.push('\n');
95            if let Some(label) = g.labels.get(&id) {
96                let color = p.resolve(style.text_color.as_ref(), p.group_label);
97                draw_label(out, label, &color);
98            }
99        }
100        ElementKind::Node => {
101            let parent_is_node = el
102                .parent
103                .map(|q| doc.el(q).kind == ElementKind::Node)
104                .unwrap_or(false);
105            let default_fill = if parent_is_node { p.inner } else { p.surface };
106            let fill = p.resolve(style.fill.as_ref(), default_fill);
107            let stroke = p.resolve(style.color.as_ref(), p.node_border);
108            let sw = style.stroke.unwrap_or(1.4);
109            let shape = style.shape.unwrap_or(Shape::Rect);
110            match shape {
111                Shape::Rect | Shape::Pill => {
112                    let corner = if shape == Shape::Pill {
113                        r.h / 2.0
114                    } else {
115                        style.corner.unwrap_or(7.0)
116                    };
117                    out.push_str(&format!(
118                        r#"<rect x="{}" y="{}" width="{}" height="{}" rx="{}" fill="{}" stroke="{}" stroke-width="{}"{}/>"#,
119                        fmt_f(r.x), fmt_f(r.y), fmt_f(r.w), fmt_f(r.h), fmt_f(corner),
120                        fill, stroke, fmt_f(sw),
121                        dash_attr(&style),
122                    ));
123                }
124                Shape::Ellipse => {
125                    out.push_str(&format!(
126                        r#"<ellipse cx="{}" cy="{}" rx="{}" ry="{}" fill="{}" stroke="{}" stroke-width="{}"{}/>"#,
127                        fmt_f(r.cx()), fmt_f(r.cy()), fmt_f(r.w / 2.0), fmt_f(r.h / 2.0),
128                        fill, stroke, fmt_f(sw),
129                        dash_attr(&style),
130                    ));
131                }
132            }
133            out.push('\n');
134            if let Some(label) = g.labels.get(&id) {
135                let color = match &style.text_color {
136                    Some(c) => p.resolve(Some(c), p.ink),
137                    // Auto-contrast: explicit dark fills get light text.
138                    None if style.fill.is_some() && luminance(&fill) < 0.45 => {
139                        "#FAFBFC".to_string()
140                    }
141                    None if parent_is_node => p.edge_label.to_string(),
142                    None => p.ink.to_string(),
143                };
144                draw_label(out, label, &color);
145            }
146        }
147        _ => {}
148    }
149}
150
151/// Relative luminance of a `#rgb`/`#rrggbb`/`#rrggbbaa` color, 0..1.
152fn luminance(hex: &str) -> f64 {
153    let h = hex.trim_start_matches('#');
154    let (r, g, b) = match h.len() {
155        3 => (
156            u8::from_str_radix(&h[0..1].repeat(2), 16).unwrap_or(0),
157            u8::from_str_radix(&h[1..2].repeat(2), 16).unwrap_or(0),
158            u8::from_str_radix(&h[2..3].repeat(2), 16).unwrap_or(0),
159        ),
160        6 | 8 => (
161            u8::from_str_radix(&h[0..2], 16).unwrap_or(0),
162            u8::from_str_radix(&h[2..4], 16).unwrap_or(0),
163            u8::from_str_radix(&h[4..6], 16).unwrap_or(0),
164        ),
165        _ => return 1.0,
166    };
167    (0.2126 * r as f64 + 0.7152 * g as f64 + 0.0722 * b as f64) / 255.0
168}
169
170fn dash_attr(style: &Style) -> &'static str {
171    if style.dashed == Some(true) {
172        r#" stroke-dasharray="6 4""#
173    } else {
174        ""
175    }
176}
177
178fn draw_label(out: &mut String, l: &LabelBlock, color: &str) {
179    let weight = if l.bold { r#" font-weight="bold""# } else { "" };
180    for (i, line) in l.lines.iter().enumerate() {
181        if line.is_empty() {
182            continue;
183        }
184        let y = l.y + l.baseline + l.line_height * i as f64;
185        let (x, anchor) = match l.align {
186            TextAlign::Center => (l.x + l.width / 2.0, r#" text-anchor="middle""#),
187            TextAlign::Left => (l.x, ""),
188        };
189        out.push_str(&format!(
190            r#"<text x="{}" y="{}" font-size="{}"{}{} fill="{}">{}</text>"#,
191            fmt_f(x),
192            fmt_f(y),
193            fmt_f(l.size),
194            weight,
195            anchor,
196            color,
197            esc(line)
198        ));
199        out.push('\n');
200    }
201}
202
203fn draw_halo_label(out: &mut String, l: &LabelBlock, p: &Palette) {
204    let pad_x = 4.0;
205    let pad_y = 2.0;
206    out.push_str(&format!(
207        r#"<rect x="{}" y="{}" width="{}" height="{}" rx="3" fill="{}" fill-opacity="0.92"/>"#,
208        fmt_f(l.x - pad_x),
209        fmt_f(l.y - pad_y),
210        fmt_f(l.width + 2.0 * pad_x),
211        fmt_f(l.height + 2.0 * pad_y),
212        p.bg,
213    ));
214    out.push('\n');
215    draw_label(out, l, p.edge_label);
216}
217
218fn draw_edge(out: &mut String, doc: &Document, route: &EdgeRoute, p: &Palette) {
219    let edge = &doc.edges[route.edge];
220    let style = doc.edge_style(edge);
221    let color = p.resolve(style.color.as_ref(), p.edge);
222    let sw = style.stroke.unwrap_or(1.6);
223    let arrow_len = 7.0 + sw * 1.6;
224
225    // Trim the path so the line doesn't poke through the arrowhead.
226    let mut pts = route.points.clone();
227    let (end_apex, end_dir) = end_tangent(&pts, route.kind);
228    if route.arrow_end {
229        let trimmed = (
230            end_apex.0 - end_dir.0 * arrow_len * 0.8,
231            end_apex.1 - end_dir.1 * arrow_len * 0.8,
232        );
233        set_endpoint(&mut pts, route.kind, true, trimmed);
234    }
235    let (start_apex, start_dir) = start_tangent(&pts, route.kind);
236    if route.arrow_start {
237        let trimmed = (
238            start_apex.0 - start_dir.0 * arrow_len * 0.8,
239            start_apex.1 - start_dir.1 * arrow_len * 0.8,
240        );
241        set_endpoint(&mut pts, route.kind, false, trimmed);
242    }
243
244    let d = match route.kind {
245        RouteKind::Straight => {
246            format!(
247                "M {} {} L {} {}",
248                fmt_f(pts[0].0),
249                fmt_f(pts[0].1),
250                fmt_f(pts[1].0),
251                fmt_f(pts[1].1)
252            )
253        }
254        RouteKind::Cubic => format!(
255            "M {} {} C {} {}, {} {}, {} {}",
256            fmt_f(pts[0].0),
257            fmt_f(pts[0].1),
258            fmt_f(pts[1].0),
259            fmt_f(pts[1].1),
260            fmt_f(pts[2].0),
261            fmt_f(pts[2].1),
262            fmt_f(pts[3].0),
263            fmt_f(pts[3].1),
264        ),
265        RouteKind::Ortho => ortho_path(&pts),
266    };
267
268    out.push_str(&format!(
269        r#"<path d="{d}" fill="none" stroke="{color}" stroke-width="{}" stroke-linecap="round" stroke-linejoin="round"{}/>"#,
270        fmt_f(sw),
271        dash_attr(&style),
272    ));
273    out.push('\n');
274
275    if route.arrow_end {
276        draw_arrow(out, end_apex, end_dir, arrow_len, &color);
277    }
278    if route.arrow_start {
279        draw_arrow(out, start_apex, start_dir, arrow_len, &color);
280    }
281}
282
283/// Orthogonal path with rounded corners (quadratic shortcuts at each bend).
284fn ortho_path(pts: &[(f64, f64)]) -> String {
285    const R: f64 = 8.0;
286    if pts.len() < 3 {
287        return format!(
288            "M {} {} L {} {}",
289            fmt_f(pts[0].0),
290            fmt_f(pts[0].1),
291            fmt_f(pts[pts.len() - 1].0),
292            fmt_f(pts[pts.len() - 1].1)
293        );
294    }
295    let mut d = format!("M {} {}", fmt_f(pts[0].0), fmt_f(pts[0].1));
296    for i in 1..pts.len() - 1 {
297        let prev = pts[i - 1];
298        let cur = pts[i];
299        let next = pts[i + 1];
300        let in_len = ((cur.0 - prev.0).abs() + (cur.1 - prev.1).abs()).max(1e-6);
301        let out_len = ((next.0 - cur.0).abs() + (next.1 - cur.1).abs()).max(1e-6);
302        let r = R.min(in_len / 2.0).min(out_len / 2.0);
303        // NB: f64::signum(0.0) is 1.0, so derive axis directions explicitly.
304        let in_dir = axis_dir(prev, cur);
305        let out_dir = axis_dir(cur, next);
306        let before = (cur.0 - in_dir.0 * r, cur.1 - in_dir.1 * r);
307        let after = (cur.0 + out_dir.0 * r, cur.1 + out_dir.1 * r);
308        d.push_str(&format!(
309            " L {} {} Q {} {}, {} {}",
310            fmt_f(before.0),
311            fmt_f(before.1),
312            fmt_f(cur.0),
313            fmt_f(cur.1),
314            fmt_f(after.0),
315            fmt_f(after.1),
316        ));
317    }
318    let last = pts[pts.len() - 1];
319    d.push_str(&format!(" L {} {}", fmt_f(last.0), fmt_f(last.1)));
320    d
321}
322
323/// Unit direction of an axis-aligned segment (robust to tiny drift).
324fn axis_dir(a: (f64, f64), b: (f64, f64)) -> (f64, f64) {
325    let dx = b.0 - a.0;
326    let dy = b.1 - a.1;
327    if dx.abs() >= dy.abs() {
328        (if dx >= 0.0 { 1.0 } else { -1.0 }, 0.0)
329    } else {
330        (0.0, if dy >= 0.0 { 1.0 } else { -1.0 })
331    }
332}
333
334fn end_tangent(pts: &[(f64, f64)], kind: RouteKind) -> ((f64, f64), (f64, f64)) {
335    let (a, b) = match kind {
336        RouteKind::Cubic => (pts[2], pts[3]),
337        _ => (pts[pts.len() - 2], pts[pts.len() - 1]),
338    };
339    (b, norm((b.0 - a.0, b.1 - a.1)))
340}
341
342fn start_tangent(pts: &[(f64, f64)], kind: RouteKind) -> ((f64, f64), (f64, f64)) {
343    let (a, b) = match kind {
344        RouteKind::Cubic => (pts[1], pts[0]),
345        _ => (pts[1], pts[0]),
346    };
347    (b, norm((b.0 - a.0, b.1 - a.1)))
348}
349
350fn set_endpoint(pts: &mut [(f64, f64)], _kind: RouteKind, end: bool, p: (f64, f64)) {
351    if end {
352        let n = pts.len();
353        pts[n - 1] = p;
354    } else {
355        pts[0] = p;
356    }
357}
358
359fn norm(v: (f64, f64)) -> (f64, f64) {
360    let len = (v.0 * v.0 + v.1 * v.1).sqrt();
361    if len < 1e-9 {
362        (1.0, 0.0)
363    } else {
364        (v.0 / len, v.1 / len)
365    }
366}
367
368fn draw_arrow(out: &mut String, apex: (f64, f64), dir: (f64, f64), len: f64, color: &str) {
369    let half_w = len * 0.42;
370    let base = (apex.0 - dir.0 * len, apex.1 - dir.1 * len);
371    let perp = (-dir.1, dir.0);
372    let p1 = (base.0 + perp.0 * half_w, base.1 + perp.1 * half_w);
373    let p2 = (base.0 - perp.0 * half_w, base.1 - perp.1 * half_w);
374    out.push_str(&format!(
375        r#"<path d="M {} {} L {} {} L {} {} Z" fill="{color}"/>"#,
376        fmt_f(apex.0),
377        fmt_f(apex.1),
378        fmt_f(p1.0),
379        fmt_f(p1.1),
380        fmt_f(p2.0),
381        fmt_f(p2.1),
382    ));
383    out.push('\n');
384}
385
386fn draw_ports(out: &mut String, doc: &Document, g: &Geometry, p: &Palette) {
387    const PORT_LABEL_SIZE: f64 = 9.5;
388    for (&(eid, pi), &(x, y)) in &g.ports {
389        let el = doc.el(ElementId(eid));
390        let style = doc.resolved_style(el.id);
391        let stroke = p.resolve(style.color.as_ref(), p.node_border);
392        out.push_str(&format!(
393            r#"<circle cx="{}" cy="{}" r="3.2" fill="{}" stroke="{}" stroke-width="1.2"/>"#,
394            fmt_f(x),
395            fmt_f(y),
396            p.surface,
397            stroke
398        ));
399        out.push('\n');
400        // Optional tiny label, just outside the border, nudged along it so
401        // the socket stays visible.
402        let port = &el.ports[pi];
403        if let Some(text) = &port.label {
404            use drawlang_core::model::Side;
405            let (lx, ly, anchor) = match port.side {
406                Side::Top => (x + 6.0, y - 7.0, ""),
407                Side::Bottom => (x + 6.0, y + 7.0 + PORT_LABEL_SIZE * 0.8, ""),
408                Side::Left => (x - 7.0, y - 6.0, r#" text-anchor="end""#),
409                Side::Right => (x + 7.0, y - 6.0, ""),
410            };
411            out.push_str(&format!(
412                r#"<text x="{}" y="{}" font-size="{}"{} fill="{}">{}</text>"#,
413                fmt_f(lx),
414                fmt_f(ly),
415                fmt_f(PORT_LABEL_SIZE),
416                anchor,
417                p.muted,
418                esc(text)
419            ));
420            out.push('\n');
421        }
422    }
423}