Skip to main content

blockworx_export/
lib.rs

1//! What the app writes out: the diagram as a picture, and the whole thing as
2//! a navigable PDF.
3//!
4//! The image formats capture one level as the editor renders it — the same
5//! render path, pointed at [`svg::SvgRenderer`] instead of at a screen — so an
6//! export cannot drift from what the user was looking at. PDF is the whole
7//! document, one page per scope, with the navigation
8//! hierarchy as its outline ([`pdf`]).
9//!
10//! A picture carries no embedded diagram for import to recover: the document
11//! travels as its container, not steganography.
12//!
13//! Text layout is the one thing here that needs a host: an exporter borrows the
14//! toolkit's own engine through [`blockworx_paint::TextLayout`], because a
15//! diagram exported with different line breaks than the canvas showed is a
16//! wrong export.
17
18pub mod level;
19pub mod pdf;
20#[cfg(test)]
21mod render_path_tests;
22#[cfg(test)]
23mod style_tests;
24pub mod svg;
25
26/// What an export rendered, captured on the UI thread. The variant carries
27/// what its format is written from, so a format can never be paired with
28/// content of the wrong kind: PNG holds the SVG source it rasterizes from on
29/// save, and PDF is already serialized because it needs the whole document.
30#[derive(PartialEq, serde::Serialize, serde::Deserialize)]
31pub enum ExportContent {
32    Svg(String),
33    Png(String),
34    Pdf(Vec<u8>),
35}
36
37impl ExportContent {
38    /// The text an export carries where it is text at all — what a test reads
39    /// the written bytes back as.
40    pub fn text(&self) -> Option<&str> {
41        match self {
42            ExportContent::Svg(text) | ExportContent::Png(text) => Some(text),
43            ExportContent::Pdf(_) => None,
44        }
45    }
46
47    /// The media type [`bytes_for`] answers in. Stated beside the rasterizer
48    /// rather than at the host that saves the file: PNG content is SVG source
49    /// until it is written, so only this crate knows what the bytes will be.
50    pub fn mime(&self) -> &'static str {
51        match self {
52            ExportContent::Svg(_) => "image/svg+xml",
53            ExportContent::Png(_) => "image/png",
54            ExportContent::Pdf(_) => "application/pdf",
55        }
56    }
57
58    /// And the extension the saved file carries.
59    pub fn extension(&self) -> &'static str {
60        match self {
61            ExportContent::Svg(_) => "svg",
62            ExportContent::Png(_) => "png",
63            ExportContent::Pdf(_) => "pdf",
64        }
65    }
66}
67
68/// The bytes to write for `content`: the SVG or PDF source verbatim, or
69/// — for PNG — rasterized. A failed rasterization falls back to nothing so the
70/// export produces an empty file rather than crashing the worker.
71#[must_use]
72pub fn bytes_for(content: &ExportContent) -> Vec<u8> {
73    match content {
74        ExportContent::Svg(text) => text.clone().into_bytes(),
75        ExportContent::Pdf(bytes) => bytes.clone(),
76        ExportContent::Png(svg) => render_png(svg).unwrap_or_else(|e| {
77            tracing::error!("Failed to rasterize PNG: {e}");
78            Vec::new()
79        }),
80    }
81}
82
83/// Rasterize the captured SVG to PNG bytes, upscaled for a crisp image.
84fn render_png(svg: &str) -> anyhow::Result<Vec<u8>> {
85    use resvg::{tiny_skia, usvg};
86    const SCALE: f32 = 2.0;
87    let tree = usvg::Tree::from_str(svg, &usvg::Options::default())?;
88    let size = tree.size();
89    let w = ((size.width() * SCALE).ceil() as u32).max(1);
90    let h = ((size.height() * SCALE).ceil() as u32).max(1);
91    let mut pixmap =
92        tiny_skia::Pixmap::new(w, h).ok_or_else(|| anyhow::anyhow!("empty export pixmap"))?;
93    resvg::render(
94        &tree,
95        tiny_skia::Transform::from_scale(SCALE, SCALE),
96        &mut pixmap.as_mut(),
97    );
98    Ok(pixmap.encode_png()?)
99}
100
101#[cfg(test)]
102mod content_tests {
103    use super::*;
104
105    /// A PNG export is SVG source until it is written, so the media type and
106    /// the extension have to be read off the variant rather than off the
107    /// bytes — and each variant must name its own.
108    #[test]
109    fn every_content_names_its_own_media_type_and_extension() {
110        let contents = [
111            ExportContent::Svg(String::new()),
112            ExportContent::Png(String::new()),
113            ExportContent::Pdf(Vec::new()),
114        ];
115        let mut named: Vec<(&str, &str)> = contents
116            .iter()
117            .map(|content| (content.mime(), content.extension()))
118            .collect();
119        let count = named.len();
120        named.sort_unstable();
121        named.dedup();
122        assert_eq!(named.len(), count, "two kinds are saved alike: {named:?}");
123        assert_eq!(ExportContent::Png(String::new()).mime(), "image/png");
124    }
125}