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}