Skip to main content

blockworx/export/
mod.rs

1//! "Export…" file save. The user picks a format from the toolbar's Export
2//! submenu; the current diagram is written in that format — to a path chosen in
3//! a native save dialog, or straight to the browser's download picker on the
4//! web. The image formats capture the visible level as rendered; JSON writes
5//! the document itself (the round trip's write direction —
6//! [`blockworx_store::document_file`]), which import and the courtesy load
7//! read back;
8//! PDF writes the whole document, one page per scope, with the navigation
9//! hierarchy as its outline ([`pdf`], D21).
10//!
11//! A picture carries no embedded diagram for import to recover: the document
12//! travels as JSON, not steganography.
13//!
14//! The format is chosen up front (rather than inferred from the dialog's
15//! filename) because the web save dialog offers no format selection — it just
16//! downloads the default-named file — so a dialog-driven choice would always
17//! yield SVG there. PNG is rasterized from the SVG with `resvg` (pure Rust, so
18//! it works on native and web alike).
19
20pub mod level;
21pub mod pdf;
22#[cfg(test)]
23mod render_path_tests;
24#[cfg(test)]
25mod style_tests;
26pub mod svg;
27
28// The formats and the surfaces that offer them are command vocabulary — the
29// registry names an export by its format — so they live with the commands and
30// are reached here under their old names.
31pub use blockworx_tools::commands::{ExportFormat, ExportScope};
32
33/// Native save-dialog filter label.
34#[cfg(not(target_arch = "wasm32"))]
35fn filter_name(format: ExportFormat) -> &'static str {
36    match format {
37        ExportFormat::Svg => "SVG image",
38        ExportFormat::Png => "PNG image",
39        ExportFormat::Json => "BlockWorx diagram",
40        ExportFormat::Pdf => "PDF document",
41    }
42}
43
44/// MIME type for the web download's Blob.
45#[cfg(target_arch = "wasm32")]
46fn mime(format: ExportFormat) -> &'static str {
47    match format {
48        ExportFormat::Svg => "image/svg+xml",
49        ExportFormat::Png => "image/png",
50        ExportFormat::Json => "application/json",
51        ExportFormat::Pdf => "application/pdf",
52    }
53}
54
55/// What an export rendered, captured on the UI thread. The variant carries
56/// what its format is written from, so a format can never be paired with
57/// content of the wrong kind: PNG holds the SVG source it rasterizes from on
58/// save, and PDF is already serialized because it needs the whole document.
59pub enum ExportContent {
60    Svg(String),
61    Png(String),
62    Json(String),
63    Pdf(Vec<u8>),
64}
65
66impl ExportContent {
67    pub fn format(&self) -> ExportFormat {
68        match self {
69            ExportContent::Svg(_) => ExportFormat::Svg,
70            ExportContent::Png(_) => ExportFormat::Png,
71            ExportContent::Json(_) => ExportFormat::Json,
72            ExportContent::Pdf(_) => ExportFormat::Pdf,
73        }
74    }
75
76    /// The text an export carries where it is text at all — what a test reads
77    /// the written bytes back as.
78    #[cfg(test)]
79    pub fn text(&self) -> Option<&str> {
80        match self {
81            ExportContent::Svg(text) | ExportContent::Png(text) | ExportContent::Json(text) => {
82                Some(text)
83            }
84            ExportContent::Pdf(_) => None,
85        }
86    }
87}
88
89/// The rendered content for the chosen format, with the name the save dialog
90/// suggests before that format's extension — the document's own name, so a rev
91/// saved out of the history arrives called something a reader can place.
92pub struct ExportPayload {
93    pub name: String,
94    pub content: ExportContent,
95}
96
97/// Open the save dialog off the UI thread (so the canvas keeps repainting) and
98/// write `payload` in its format.
99///
100/// What became of it reaches the user through the toast (R38): an export is a
101/// file operation, so a write that failed is said out loud rather than only in
102/// the console. A cancelled dialog says nothing — the user already knows.
103#[cfg(not(target_arch = "wasm32"))]
104pub fn spawn_export(ctx: &egui::Context, payload: ExportPayload) {
105    let thread_ctx = ctx.clone();
106    std::thread::spawn(move || {
107        let format = payload.content.format();
108        let ext = format.extension();
109        let picked = rfd::FileDialog::new()
110            .set_file_name(format!("{}.{ext}", payload.name))
111            .add_filter(filter_name(format), &[ext])
112            .save_file();
113        if let Some(path) = picked {
114            let named = crate::file::container_name(&path);
115            match blockworx_store::atomic::write_atomically(&path, &bytes_for(&payload.content)) {
116                Ok(()) => crate::shell::status_line::say(&thread_ctx, format!("Exported {named}")),
117                Err(e) => {
118                    tracing::error!("Failed to export {}: {e}", path.display());
119                    crate::shell::toast::say(&thread_ctx, format!("Could not export {named}: {e}"));
120                }
121            }
122        }
123        thread_ctx.request_repaint();
124    });
125    ctx.request_repaint();
126}
127
128/// Web variant: the browser can't silently write to a path, so instead of a save
129/// dialog (rfd's shows a "click to download" link) we build a Blob for the bytes
130/// and click a synthetic `<a download>`, and the file saves straight away under
131/// a name carrying the chosen format's extension.
132// The signature must match the native variant, which moves the payload onto a
133// worker thread.
134#[expect(clippy::needless_pass_by_value)]
135#[cfg(target_arch = "wasm32")]
136pub fn spawn_export(ctx: &egui::Context, payload: ExportPayload) {
137    let format = payload.content.format();
138    let name = format!("{}.{}", payload.name, format.extension());
139    match download(&name, mime(format), &bytes_for(&payload.content)) {
140        Ok(()) => crate::shell::status_line::say(ctx, format!("Exported {name}")),
141        Err(e) => {
142            web_sys::console::error_1(&format!("Failed to export {name}: {e:?}").into());
143            crate::shell::toast::say(ctx, format!("Could not export {name}"));
144        }
145    }
146}
147
148/// Trigger a browser download of `bytes` as `file_name`: wrap them in a Blob,
149/// mint an object URL, and click a detached `<a download>`.
150#[cfg(target_arch = "wasm32")]
151fn download(file_name: &str, mime: &str, bytes: &[u8]) -> Result<(), wasm_bindgen::JsValue> {
152    use wasm_bindgen::JsCast;
153
154    let parts = js_sys::Array::new();
155    parts.push(&js_sys::Uint8Array::from(bytes));
156    let options = web_sys::BlobPropertyBag::new();
157    options.set_type(mime);
158    let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &options)?;
159    let url = web_sys::Url::create_object_url_with_blob(&blob)?;
160
161    let document = web_sys::window()
162        .and_then(|w| w.document())
163        .ok_or_else(|| wasm_bindgen::JsValue::from_str("no document"))?;
164    let anchor = document
165        .create_element("a")?
166        .dyn_into::<web_sys::HtmlAnchorElement>()?;
167    anchor.set_href(&url);
168    anchor.set_download(file_name);
169    anchor.click();
170
171    web_sys::Url::revoke_object_url(&url)?;
172    Ok(())
173}
174
175/// The bytes to write for `content`: the SVG, JSON or PDF source verbatim, or
176/// — for PNG — rasterized. A failed rasterization falls back to nothing so the
177/// export produces an empty file rather than crashing the worker.
178fn bytes_for(content: &ExportContent) -> Vec<u8> {
179    match content {
180        ExportContent::Svg(text) | ExportContent::Json(text) => text.clone().into_bytes(),
181        ExportContent::Pdf(bytes) => bytes.clone(),
182        ExportContent::Png(svg) => render_png(svg).unwrap_or_else(|e| {
183            tracing::error!("Failed to rasterize PNG: {e}");
184            Vec::new()
185        }),
186    }
187}
188
189/// Rasterize the captured SVG to PNG bytes, upscaled for a crisp image.
190fn render_png(svg: &str) -> anyhow::Result<Vec<u8>> {
191    use resvg::{tiny_skia, usvg};
192    const SCALE: f32 = 2.0;
193    let tree = usvg::Tree::from_str(svg, &usvg::Options::default())?;
194    let size = tree.size();
195    let w = ((size.width() * SCALE).ceil() as u32).max(1);
196    let h = ((size.height() * SCALE).ceil() as u32).max(1);
197    let mut pixmap =
198        tiny_skia::Pixmap::new(w, h).ok_or_else(|| anyhow::anyhow!("empty export pixmap"))?;
199    resvg::render(
200        &tree,
201        tiny_skia::Transform::from_scale(SCALE, SCALE),
202        &mut pixmap.as_mut(),
203    );
204    Ok(pixmap.encode_png()?)
205}
206
207#[cfg(test)]
208mod tests {
209    use super::{ExportFormat, ExportScope};
210
211    /// The two surfaces differ in exactly one format, and PDF is the one the
212    /// selection cannot offer: an excerpt has no scope hierarchy to navigate.
213    #[test]
214    fn only_the_view_offers_pdf() {
215        assert!(ExportScope::View.formats().contains(&ExportFormat::Pdf));
216        assert!(
217            !ExportScope::Selection
218                .formats()
219                .contains(&ExportFormat::Pdf)
220        );
221        for format in ExportScope::Selection.formats() {
222            assert!(
223                ExportScope::View.formats().contains(format),
224                "{format:?} is offered on a selection but not on the view",
225            );
226        }
227    }
228}