1pub mod level;
21pub mod pdf;
22#[cfg(test)]
23mod render_path_tests;
24#[cfg(test)]
25mod style_tests;
26pub mod svg;
27
28pub use blockworx_tools::commands::{ExportFormat, ExportScope};
32
33#[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#[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
55pub 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 #[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
89pub struct ExportPayload {
93 pub name: String,
94 pub content: ExportContent,
95}
96
97#[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#[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#[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
175fn 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
189fn 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 #[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}