Skip to main content

blockworx_export/
svg.rs

1//! SVG export backend.
2//!
3//! [`SvgRenderer`] implements the [`Renderer`] trait, so the exact same render
4//! path that draws the diagram on screen can be pointed at it to produce a
5//! standalone `.svg`. Geometry has a single source of truth; this module only
6//! translates the primitive calls into SVG nodes (built with the `svg` crate).
7//! Like the on-screen painter it holds a [`Palette`] and resolves each
8//! [`Swatch`] to a color at draw time — the canvas never sees an app-level role.
9//!
10//! Text layout comes from the host's [`TextLayout`] — the very engine that lays
11//! the diagram out on screen — so the rows a run breaks into, and where each
12//! row stands, are the screen's. Each row is written as a `<text>` in the
13//! face's own family, with the face embedded, so the export's text is text: a
14//! viewer can select it, a PDF made from it carries it, a search finds it. The
15//! viewer shapes within a row; the row is ours.
16//!
17//! Coordinates are world space and written out directly (no zoom/pan); the
18//! final `<svg viewBox>` frames whatever was drawn.
19
20use std::cell::{Cell, RefCell};
21
22use blockworx_doc::block_model::Asset;
23use blockworx_geom::{Align2, Angle, Pos2, Rect, Vec2, WorldPx, grid::GRID_SIZE, vec2};
24use blockworx_paint::text::{Layout, TextLayout, TextRow};
25use blockworx_paint::{Color, Font};
26use svg::Document;
27use svg::node::element::{Circle, Image, Line, Polygon, Polyline, Rectangle, Style};
28use svg::node::{Blob, Node};
29
30use blockworx_paint::{Palette, PaletteStroke, Renderer, Swatch, extent::Bounds};
31
32pub struct SvgRenderer<L: TextLayout> {
33    palette: Palette,
34    /// The host's text engine — the one the canvas draws through.
35    layout: L,
36    /// The document under construction; draw calls `append` nodes to it.
37    doc: RefCell<Document>,
38    /// Running world-space bounding box of everything drawn so far.
39    bounds: Bounds,
40    /// Whether any text was written, and so whether the face is embedded.
41    wrote_text: Cell<bool>,
42}
43
44impl<L: TextLayout> SvgRenderer<L> {
45    pub fn new(palette: Palette, layout: L) -> Self {
46        Self {
47            palette,
48            layout,
49            doc: RefCell::new(Document::new()),
50            bounds: Bounds::default(),
51            wrote_text: Cell::new(false),
52        }
53    }
54
55    /// Resolve a [`Swatch`] to a concrete color through the palette.
56    fn color(&self, swatch: impl Into<Swatch>) -> Color {
57        self.palette.resolve(swatch.into())
58    }
59
60    /// Resolve a palette-based stroke to the width and color the SVG states.
61    fn resolve_stroke(&self, stroke: impl Into<PaletteStroke>) -> Ink {
62        let s = stroke.into();
63        Ink {
64            width: s.width,
65            color: self.color(s.color),
66        }
67    }
68
69    /// Finalize the `<svg>` root: set its `viewBox`/size to frame the content
70    /// (with a small margin) and serialize it, with the viewBox rect — the
71    /// exact world-space region the SVG depicts, for callers that place the
72    /// output back into world coordinates (the PDF export's page fit).
73    pub fn finish(self) -> (String, Rect) {
74        let view = framed(self.bounds.get());
75        let mut doc = self
76            .doc
77            .into_inner()
78            .set(
79                "viewBox",
80                (view.min.x, view.min.y, view.width(), view.height()),
81            )
82            .set("width", view.width())
83            .set("height", view.height());
84        if self.wrote_text.get() {
85            let typeface = self.layout.typeface();
86            let family = crate::fonts::family(typeface);
87            doc = doc
88                .set("font-family", family.as_str())
89                .set("xml:space", "preserve")
90                .add(Style::new(font_face(&family, typeface.bytes())));
91        }
92        (doc.to_string(), view)
93    }
94
95    fn append(&self, node: impl Into<Box<dyn Node>>) {
96        self.doc.borrow_mut().append(node);
97    }
98
99    /// Write each row of `laid`, anchored at world `pos` and turned by
100    /// `angle` about it, as a `<text>` at the row's pen. Returns the unturned
101    /// anchored rect.
102    // Mirrors `Renderer::text(pos, anchor, text, font, color)`, extended with
103    // the rotation this canvas needs. Grouping these into a struct would make
104    // every call site diverge from the API it wraps.
105    #[expect(clippy::too_many_arguments)]
106    fn draw_rows(
107        &self,
108        pos: Pos2,
109        anchor: Align2,
110        laid: &Layout,
111        font: &Font,
112        color: Color,
113        angle: Angle,
114    ) -> Rect {
115        let rows = laid.rows_at(pos, anchor, angle);
116        if !rows.is_empty() {
117            for row in &rows {
118                self.append(text_element(row, font, color, angle));
119            }
120            self.wrote_text.set(true);
121            self.bounds.rect(laid.bounds_at(pos, anchor, angle));
122        }
123        anchor.anchor_size(pos, laid.size)
124    }
125}
126
127impl<L: TextLayout> Renderer for SvgRenderer<L> {
128    fn rect(
129        &self,
130        rect: Rect,
131        rounding: WorldPx,
132        fill: impl Into<Swatch>,
133        stroke: impl Into<PaletteStroke>,
134    ) {
135        let stroke = stroke.into();
136        self.bounds.stroked(rect, &stroke);
137        let stroke = self.resolve_stroke(stroke);
138        let mut el = Rectangle::new()
139            .set("x", rect.min.x)
140            .set("y", rect.min.y)
141            .set("width", rect.width())
142            .set("height", rect.height())
143            .set("rx", rounding.get());
144        apply_fill(&mut el, self.color(fill));
145        apply_stroke(&mut el, stroke);
146        self.append(el);
147    }
148
149    fn line_segment(&self, points: [Pos2; 2], stroke: impl Into<PaletteStroke>) {
150        let stroke = self.resolve_stroke(stroke);
151        self.bounds.path(&points);
152        let mut el = Line::new()
153            .set("x1", points[0].x)
154            .set("y1", points[0].y)
155            .set("x2", points[1].x)
156            .set("y2", points[1].y)
157            .set("stroke-linecap", "round");
158        apply_stroke(&mut el, stroke);
159        self.append(el);
160    }
161
162    fn line(&self, points: Vec<Pos2>, stroke: impl Into<PaletteStroke>) {
163        let stroke = self.resolve_stroke(stroke);
164        self.bounds.path(&points);
165        let mut el = Polyline::new()
166            .set("points", point_list(&points))
167            .set("fill", "none")
168            .set("stroke-linecap", "round")
169            .set("stroke-linejoin", "round");
170        apply_stroke(&mut el, stroke);
171        self.append(el);
172    }
173
174    fn circle(
175        &self,
176        center: Pos2,
177        radius: WorldPx,
178        fill: impl Into<Swatch>,
179        stroke: impl Into<PaletteStroke>,
180    ) {
181        let stroke = stroke.into();
182        self.bounds.disc(center, radius, &stroke);
183        let stroke = self.resolve_stroke(stroke);
184        let mut el = Circle::new()
185            .set("cx", center.x)
186            .set("cy", center.y)
187            .set("r", radius.get());
188        apply_fill(&mut el, self.color(fill));
189        apply_stroke(&mut el, stroke);
190        self.append(el);
191    }
192
193    fn add_convex_polygon(
194        &self,
195        points: Vec<Pos2>,
196        fill: impl Into<Swatch>,
197        stroke: impl Into<PaletteStroke>,
198    ) {
199        let stroke = self.resolve_stroke(stroke);
200        self.bounds.path(&points);
201        let mut el = Polygon::new().set("points", point_list(&points));
202        apply_fill(&mut el, self.color(fill));
203        apply_stroke(&mut el, stroke);
204        self.append(el);
205    }
206
207    fn text(
208        &self,
209        pos: Pos2,
210        anchor: Align2,
211        text: impl ToString,
212        font: &Font,
213        color: impl Into<Swatch>,
214    ) -> Rect {
215        let color = self.color(color);
216        let laid = self
217            .layout
218            .layout(&text.to_string(), font, WorldPx::UNBOUNDED);
219        self.draw_rows(pos, anchor, &laid, font, color, Angle::ZERO)
220    }
221
222    fn rotated_text(
223        &self,
224        pos: Pos2,
225        anchor: Align2,
226        text: impl ToString,
227        font: &Font,
228        color: impl Into<Swatch>,
229        angle: Angle,
230    ) {
231        let color = self.color(color);
232        let laid = self
233            .layout
234            .layout(&text.to_string(), font, WorldPx::UNBOUNDED);
235        self.draw_rows(pos, anchor, &laid, font, color, angle);
236    }
237
238    fn text_wrapped(
239        &self,
240        pos: Pos2,
241        anchor: Align2,
242        text: impl ToString,
243        font: &Font,
244        color: impl Into<Swatch>,
245        max_width: WorldPx,
246    ) -> Rect {
247        let color = self.color(color);
248        let laid = self.layout.layout(&text.to_string(), font, max_width);
249        self.draw_rows(pos, anchor, &laid, font, color, Angle::ZERO)
250    }
251
252    fn text_size(&self, text: impl ToString, font: &Font) -> Vec2 {
253        self.layout
254            .layout(&text.to_string(), font, WorldPx::UNBOUNDED)
255            .size
256    }
257
258    fn text_size_wrapped(&self, text: impl ToString, font: &Font, max_width: WorldPx) -> Vec2 {
259        self.layout.layout(&text.to_string(), font, max_width).size
260    }
261
262    fn text_layout(&self, text: &str, font: &Font, max_width: WorldPx) -> Layout {
263        self.layout.layout(text, font, max_width)
264    }
265
266    fn draw_image(&self, rect: Rect, image: &Asset) {
267        use base64::Engine as _;
268        self.bounds.rect(rect);
269        // Embed the image as a base64 data URI so the export is self-contained,
270        // with the MIME type matching the payload. `rect` is already
271        // aspect-correct; `meet` keeps the image centered and undistorted within
272        // it.
273        let mime = match image {
274            Asset::Svg(_) => "image/svg+xml",
275            Asset::Png(_) => "image/png",
276        };
277        let href = format!(
278            "data:{mime};base64,{}",
279            base64::engine::general_purpose::STANDARD.encode(image.bytes())
280        );
281        let el = Image::new()
282            .set("x", rect.min.x)
283            .set("y", rect.min.y)
284            .set("width", rect.width())
285            .set("height", rect.height())
286            .set("preserveAspectRatio", "xMidYMid meet")
287            .set("href", href.as_str());
288        self.append(el);
289    }
290
291    fn image_intrinsic_size(&self, image: &Asset) -> Option<Vec2> {
292        blockworx_paint::image::image_intrinsic_size(image).ok()
293    }
294}
295
296/// The world rect an export frames: everything `drawn`, and a cell's margin
297/// around it — or a unit square for a level that drew nothing.
298pub(crate) fn framed(drawn: Option<Rect>) -> Rect {
299    drawn.map_or(Rect::from_min_size(Pos2::ZERO, vec2(1.0, 1.0)), |r| {
300        r.expand(GRID_SIZE)
301    })
302}
303
304/// The `@font-face` rule that embeds `bytes` under `family`, so a viewer
305/// without the face installed sets the text in it.
306fn font_face(family: &str, bytes: &[u8]) -> String {
307    use base64::Engine as _;
308    format!(
309        "@font-face {{ font-family: '{family}'; src: url(data:font/ttf;base64,{}); }}",
310        base64::engine::general_purpose::STANDARD.encode(bytes)
311    )
312}
313
314/// Every `<text>` of an exported SVG, in draw order: its characters, and the
315/// pen it starts at. Lives here so the exporter's own tests and the
316/// end-to-end render-path tests read the output the same way; the exporter
317/// never reads what it writes.
318#[cfg(test)]
319pub(crate) fn text_rows(svg: &str) -> Vec<(String, Pos2)> {
320    let attribute = |tag: &str, name: &str| -> f32 {
321        let at = tag
322            .find(&format!(" {name}=\""))
323            .expect("the attribute is written")
324            + name.len()
325            + 3;
326        tag[at..tag[at..].find('"').map(|end| at + end).expect("closed")]
327            .parse()
328            .expect("a number")
329    };
330    svg.match_indices("<text ")
331        .map(|(at, _)| {
332            let rest = &svg[at..];
333            let open = &rest[..rest.find('>').expect("the tag is closed")];
334            let body = &rest[open.len() + 1..rest.find("</text>").expect("the element is closed")];
335            let text = body
336                .replace("&lt;", "<")
337                .replace("&gt;", ">")
338                .replace("&quot;", "\"")
339                .replace("&#39;", "'")
340                .replace("&amp;", "&");
341            (text, Pos2::new(attribute(open, "x"), attribute(open, "y")))
342        })
343        .collect()
344}
345
346/// Build the `points` attribute value for a polyline/polygon. `Vec<(f32, f32)>`
347/// serializes to `"x y x y …"`, which SVG accepts.
348fn point_list(points: &[Pos2]) -> Vec<(f32, f32)> {
349    points.iter().map(|p| (p.x, p.y)).collect()
350}
351
352fn color_hex(c: Color) -> String {
353    let [r, g, b, _] = c.to_srgba_unmultiplied();
354    format!("#{r:02x}{g:02x}{b:02x}")
355}
356
357/// `fill`/`fill-opacity`, or `fill="none"` when fully transparent.
358fn fill(c: Color) -> Vec<(&'static str, String)> {
359    let [.., a] = c.to_srgba_unmultiplied();
360    match a {
361        0 => vec![("fill", "none".to_owned())],
362        255 => vec![("fill", color_hex(c))],
363        _ => vec![
364            ("fill", color_hex(c)),
365            ("fill-opacity", (f32::from(a) / 255.0).to_string()),
366        ],
367    }
368}
369
370fn apply_fill(node: &mut impl Node, c: Color) {
371    for (name, value) in fill(c) {
372        node.assign(name, value);
373    }
374}
375
376/// One row as a `<text>` at its pen, turned about it by `angle`. Written by
377/// hand because the `svg` crate breaks an element's content onto lines of
378/// its own, which `xml:space="preserve"` would set as spaces.
379fn text_element(row: &TextRow, font: &Font, color: Color, angle: Angle) -> Blob {
380    use std::fmt::Write as _;
381    let (x, y) = (row.pen.x, row.pen.y);
382    let mut tag = format!(r#"<text x="{x}" y="{y}" font-size="{}""#, font.size);
383    if angle != Angle::ZERO {
384        let _ = write!(tag, r#" transform="rotate({} {x} {y})""#, angle.degrees());
385    }
386    for (name, value) in fill(color) {
387        let _ = write!(tag, r#" {name}="{value}""#);
388    }
389    let text = row
390        .text
391        .replace('&', "&amp;")
392        .replace('<', "&lt;")
393        .replace('>', "&gt;");
394    Blob::new(format!("{tag}>{text}</text>"))
395}
396
397/// A palette stroke resolved to what the SVG states: a width and a color.
398#[derive(Clone, Copy)]
399struct Ink {
400    width: WorldPx,
401    color: Color,
402}
403
404/// Set `stroke`/`stroke-width`(+opacity); no-op when there's no visible stroke.
405fn apply_stroke(node: &mut impl Node, ink: Ink) {
406    let [.., a] = ink.color.to_srgba_unmultiplied();
407    if ink.width == WorldPx::ZERO || a == 0 {
408        return;
409    }
410    node.assign("stroke", color_hex(ink.color));
411    node.assign("stroke-width", ink.width.get());
412    if a != 255 {
413        node.assign("stroke-opacity", a as f32 / 255.0);
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420    use blockworx_paint::{Base, FontChoice};
421    use blockworx_text::Shaper;
422
423    fn px(v: f32) -> WorldPx {
424        WorldPx::new(v)
425    }
426
427    fn palette() -> Palette {
428        Palette::tokyo_night_moon()
429    }
430
431    fn renderer() -> SvgRenderer<Shaper> {
432        SvgRenderer::new(palette(), Shaper::new(FontChoice::Basic))
433    }
434
435    /// The family the canvas actually draws in.
436    fn font() -> Font {
437        Font::canvas(12.0)
438    }
439
440    /// A run is written as its characters, not as outlines, in the face the
441    /// layout measured — named on the root and embedded for a viewer that
442    /// lacks it — in the palette's resolved ink.
443    #[test]
444    fn a_run_is_written_as_text_in_the_embedded_face() {
445        let r = renderer();
446        let rect = r.text(Pos2::ZERO, Align2::LEFT_TOP, "Config", &font(), Base::B00);
447        let (svg, _) = r.finish();
448        let rows = text_rows(&svg);
449        assert_eq!(rows.len(), 1, "one row: {rows:?}");
450        assert_eq!(rows[0].0, "Config", "a ligature is the viewer's to shape");
451        assert!(
452            rows[0].1.y > 0.0 && rows[0].1.y < rect.height(),
453            "the pen is on the baseline, inside the run: {rows:?} in {rect:?}"
454        );
455        assert!(!svg.contains("<path"), "no glyph outlines:\n{svg}");
456        assert!(svg.contains(r#"font-family="Roboto""#), "{svg}");
457        assert!(svg.contains("@font-face { font-family: 'Roboto'"));
458        assert!(svg.contains(r##"fill="#222436""##), "resolved palette fill");
459    }
460
461    #[test]
462    fn a_wrapped_run_writes_one_text_per_row() {
463        let r = renderer();
464        let line = "the quick brown fox jumps over the lazy dog";
465        let max = r.text_size(line, &font()).x / 3.0;
466        r.text_wrapped(
467            Pos2::ZERO,
468            Align2::LEFT_TOP,
469            line,
470            &font(),
471            Base::B00,
472            px(max),
473        );
474        let (svg, _) = r.finish();
475        let rows = text_rows(&svg);
476        assert!(
477            rows.len() >= 3,
478            "a third of the width takes three rows: {rows:?}"
479        );
480        let joined: String = rows.iter().map(|(text, _)| text.as_str()).collect();
481        assert_eq!(
482            joined.split_whitespace().collect::<Vec<_>>().join(" "),
483            line
484        );
485        assert!(
486            rows.windows(2)
487                .all(|pair| pair[0].1.y < pair[1].1.y && pair[0].1.x == pair[1].1.x),
488            "rows stack down one left edge: {rows:?}"
489        );
490    }
491
492    #[test]
493    fn a_turned_run_turns_each_row_about_its_pen() {
494        let r = renderer();
495        r.rotated_text(
496            Pos2::new(50.0, 20.0),
497            Align2::LEFT_TOP,
498            "sig",
499            &font(),
500            Base::B00,
501            Angle::QUARTER_TURN,
502        );
503        let (svg, _) = r.finish();
504        let rows = text_rows(&svg);
505        assert_eq!(rows.len(), 1);
506        let (x, y) = (rows[0].1.x, rows[0].1.y);
507        assert!(
508            x < 50.0 && (y - 20.0).abs() < 1e-3,
509            "a quarter turn puts the baseline left of the anchor: {rows:?}"
510        );
511        assert!(svg.contains(&format!("rotate(90 {x} {y})")), "{svg}");
512    }
513
514    #[test]
515    fn markup_in_a_run_is_escaped() {
516        let r = renderer();
517        r.text(Pos2::ZERO, Align2::LEFT_TOP, "a<b & c", &font(), Base::B00);
518        let (svg, _) = r.finish();
519        assert!(svg.contains("a&lt;b &amp; c"), "{svg}");
520        assert_eq!(text_rows(&svg)[0].0, "a<b & c");
521    }
522
523    #[test]
524    fn multiline_text_stacks_lines() {
525        let r = renderer();
526        let font = font();
527        // Two `\n`-separated lines measure two rows tall, one row wide.
528        let one = r.text_size("aaaa", &font);
529        let two = r.text_size("aa\naa", &font);
530        assert!(
531            (two.y - 2.0 * one.y).abs() < 0.001,
532            "two lines should be twice as tall: {} vs {}",
533            two.y,
534            one.y
535        );
536        assert!(
537            two.x < one.x + 0.001,
538            "width is the widest line, not the sum: {} vs {}",
539            two.x,
540            one.x
541        );
542        // A trailing newline keeps an empty final row.
543        assert!((r.text_size("aa\n", &font).y - two.y).abs() < 0.001);
544    }
545
546    #[test]
547    fn wrapped_text_breaks_long_lines_within_the_width() {
548        let r = renderer();
549        let font = font();
550        let line = "the quick brown fox jumps over the lazy dog";
551        let unwrapped = r.text_size(line, &font);
552        // Wrap to a third of the unwrapped width: it must take more rows, and no
553        // row may exceed the wrap width.
554        let max = unwrapped.x / 3.0;
555        let wrapped = r.text_size_wrapped(line, &font, px(max));
556        assert!(
557            wrapped.y > unwrapped.y,
558            "wrapping a long line adds rows: {} vs {}",
559            wrapped.y,
560            unwrapped.y
561        );
562        assert!(
563            wrapped.x <= max + 0.001,
564            "no wrapped row exceeds the wrap width: {} vs {}",
565            wrapped.x,
566            max
567        );
568        // An infinite width is a no-op (matches the no-wrap default).
569        assert!(
570            (r.text_size_wrapped(line, &font, WorldPx::UNBOUNDED).x - unwrapped.x).abs() < 0.001
571        );
572    }
573
574    #[test]
575    fn primitives_and_viewbox() {
576        let r = renderer();
577        r.rect(
578            Rect::from_min_size(Pos2::new(10.0, 10.0), vec2(30.0, 20.0)),
579            px(3.0),
580            Base::B01,
581            (1.0, Base::B0D),
582        );
583        r.line(
584            vec![Pos2::new(0.0, 0.0), Pos2::new(40.0, 0.0)],
585            (1.7, Base::B0B),
586        );
587        let (svg, _) = r.finish();
588        assert!(svg.contains("<svg"));
589        assert!(svg.contains("viewBox="));
590        assert!(svg.contains("<rect"));
591        assert!(svg.contains("<polyline"));
592        assert!(svg.contains("</svg>"));
593        assert!(
594            !svg.contains("@font-face"),
595            "a picture with no text embeds no face"
596        );
597    }
598
599    #[test]
600    fn image_embeds_as_data_uri() {
601        let r = renderer();
602        r.draw_image(
603            Rect::from_min_size(Pos2::new(0.0, 0.0), vec2(10.0, 10.0)),
604            &Asset::Svg(r#"<svg viewBox="0 0 1 1"/>"#.as_bytes().into()),
605        );
606        let (svg, _) = r.finish();
607        assert!(
608            svg.contains("<image"),
609            "image exports as an <image>:\n{svg}"
610        );
611        assert!(
612            svg.contains("data:image/svg+xml;base64,"),
613            "image href is a base64 SVG data URI"
614        );
615        assert!(svg.contains(r#"preserveAspectRatio="xMidYMid meet""#));
616    }
617
618    #[test]
619    fn png_image_embeds_as_png_data_uri() {
620        let r = renderer();
621        r.draw_image(
622            Rect::from_min_size(Pos2::new(0.0, 0.0), vec2(10.0, 10.0)),
623            &Asset::Png(vec![0x89, b'P', b'N', b'G', 1, 2, 3].into()),
624        );
625        let (svg, _) = r.finish();
626        assert!(
627            svg.contains("data:image/png;base64,"),
628            "PNG image href is a base64 PNG data URI:\n{svg}"
629        );
630    }
631}