Skip to main content

dioxus_mind_elixir/plugins/export/
web.rs

1use super::{ExportFile, ImageFormat, ImageOptions, escape, failure};
2use crate::MindMapError;
3use base64::{Engine, engine::general_purpose::STANDARD};
4use dioxus::prelude::MountedData;
5use wasm_bindgen::{JsCast, JsValue};
6use wasm_bindgen_futures::JsFuture;
7use web_sys::{Element, HtmlCanvasElement, HtmlImageElement};
8
9fn js(error: impl Into<JsValue>) -> MindMapError {
10    failure(format!("{:?}", error.into()))
11}
12fn window() -> Result<web_sys::Window, MindMapError> {
13    web_sys::window().ok_or_else(|| failure("No browser window"))
14}
15fn document() -> Result<web_sys::Document, MindMapError> {
16    window()?
17        .document()
18        .ok_or_else(|| failure("No browser document"))
19}
20fn computed(element: &Element) -> Result<web_sys::CssStyleDeclaration, MindMapError> {
21    window()?
22        .get_computed_style(element)
23        .map_err(js)?
24        .ok_or_else(|| failure("No computed styles"))
25}
26// Layout has already been resolved by the browser. Inlining every computed
27// property preserves inherited variables without shipping the host stylesheet.
28fn inline(live: &Element, clone: &Element) -> Result<(), MindMapError> {
29    let style = computed(live)?;
30    let mut css = String::new();
31    for i in 0..style.length() {
32        let name = style.item(i);
33        let value = style.get_property_value(&name).map_err(js)?;
34        // URLs are not silently left pointing at the original application's origin.
35        if value.contains("url(") && !value.contains("url(\"data:") && !value.contains("url(data:")
36        {
37            return Err(failure(format!(
38                "External CSS resource in {name}; embed it as a data URL before export"
39            )));
40        }
41        css.push_str(&format!("{name}:{value};"));
42    }
43    clone.set_attribute("style", &css).map_err(js)?;
44    let a = live.children();
45    let b = clone.children();
46    if a.length() != b.length() {
47        return Err(failure("View changed during capture"));
48    }
49    for i in 0..a.length() {
50        if let (Some(a), Some(b)) = (a.item(i), b.item(i)) {
51            inline(&a, &b)?;
52        }
53    }
54    Ok(())
55}
56async fn font_css() -> Result<String, MindMapError> {
57    let sheets = document()?.style_sheets();
58    let mut rules = Vec::new();
59    for i in 0..sheets.length() {
60        let Some(sheet) = sheets.item(i) else {
61            continue;
62        };
63        let sheet = sheet.dyn_into::<web_sys::CssStyleSheet>().map_err(js)?;
64        let list=sheet.css_rules().map_err(|_|failure("Cannot read cross-origin font stylesheet; serve fonts and styles with CORS on the same origin"))?;
65        for i in 0..list.length() {
66            if let Some(rule) = list.item(i) {
67                let css = rule.css_text();
68                if css.starts_with("@font-face") {
69                    rules.push(css);
70                }
71            }
72        }
73    }
74    let mut output = String::new();
75    for mut css in rules {
76        while let Some(start) = css.find("url(") {
77            let rest = css
78                .get(start + 4..)
79                .ok_or_else(|| failure("Invalid font URL"))?;
80            let end = rest.find(')').ok_or_else(|| failure("Invalid font URL"))?;
81            let url = rest
82                .get(..end)
83                .ok_or_else(|| failure("Invalid font URL"))?
84                .trim()
85                .trim_matches(['\'', '"']);
86            if url.starts_with("data:") {
87                // Preserve inline fonts; advance by processing the remaining rule.
88                output.push_str(
89                    css.get(..start + 4 + end + 1)
90                        .ok_or_else(|| failure("Invalid font rule"))?,
91                );
92                css = css
93                    .get(start + 4 + end + 1..)
94                    .ok_or_else(|| failure("Invalid font rule"))?
95                    .into();
96                continue;
97            }
98            let response = JsFuture::from(window()?.fetch_with_str(url))
99                .await
100                .map_err(js)?
101                .dyn_into::<web_sys::Response>()
102                .map_err(js)?;
103            if !response.ok() {
104                return Err(failure(format!(
105                    "Font request failed: {}",
106                    response.status()
107                )));
108            }
109            let bytes = JsFuture::from(response.array_buffer().map_err(js)?)
110                .await
111                .map_err(js)?;
112            let encoded = STANDARD.encode(js_sys::Uint8Array::new(&bytes).to_vec());
113            let replacement = format!("url(\"data:font/woff2;base64,{encoded}\")");
114            let before = css
115                .get(..start)
116                .ok_or_else(|| failure("Invalid font rule"))?;
117            let after = css
118                .get(start + 4 + end + 1..)
119                .ok_or_else(|| failure("Invalid font rule"))?;
120            // Consume the replacement now so its data URL is not processed again.
121            output.push_str(before);
122            output.push_str(&replacement);
123            css = after.into();
124        }
125        output.push_str(&css);
126    }
127    Ok(output)
128}
129/// Capture actual HTML layout into a self-contained SVG foreignObject image.
130/// Full canvas is exported; viewport pan and zoom are removed, editing and
131/// selection affordances are omitted. Loaded custom fonts are embedded.
132pub async fn capture_svg(
133    mounted: &MountedData,
134    options: &ImageOptions,
135) -> Result<ExportFile, MindMapError> {
136    options.validate()?;
137    let element = mounted
138        .downcast::<Element>()
139        .ok_or_else(|| failure("Mounted element is not a Web DOM element"))?;
140    if !element.is_connected() {
141        return Err(failure("Map has been unmounted"));
142    }
143    JsFuture::from(document()?.fonts().ready().map_err(js)?)
144        .await
145        .map_err(js)?;
146    let map = if element.class_list().contains("dme-map") {
147        element.clone()
148    } else {
149        element
150            .query_selector(".dme-map")
151            .map_err(js)?
152            .ok_or_else(|| failure("Expected .dme-map"))?
153    };
154    let canvas = map
155        .query_selector(".dme-canvas")
156        .map_err(js)?
157        .ok_or_else(|| failure("Expected a mounted .dme-map"))?;
158    if element.query_selector("textarea").map_err(js)?.is_some() {
159        return Err(failure("Finish editing before export"));
160    }
161    let before = canvas.inner_html();
162    let width = f64::from(
163        canvas
164            .dyn_ref::<web_sys::HtmlElement>()
165            .ok_or_else(|| failure("No canvas dimensions"))?
166            .offset_width(),
167    );
168    let height = f64::from(
169        canvas
170            .dyn_ref::<web_sys::HtmlElement>()
171            .ok_or_else(|| failure("No canvas dimensions"))?
172            .offset_height(),
173    );
174    if width <= 0.0 || height <= 0.0 || width * height * options.scale.powi(2) > 64_000_000.0 {
175        return Err(failure(
176            "Invalid or oversized image (64 million pixel limit)",
177        ));
178    }
179    let clone = canvas
180        .clone_node_with_deep(true)
181        .map_err(js)?
182        .dyn_into::<Element>()
183        .map_err(js)?;
184    inline(&canvas, &clone)?;
185    let clone_style = clone
186        .get_attribute("style")
187        .ok_or_else(|| failure("Missing inline style"))?;
188    clone.set_attribute("style",&format!("{clone_style};transform:none;min-width:0;min-height:0;width:{width}px;height:{height}px;position:relative;")).map_err(js)?;
189    let remove = clone
190        .query_selector_all(".dme-collapse,.dme-focus-marker,.dme-scale-probe")
191        .map_err(js)?;
192    for i in 0..remove.length() {
193        if let Some(node) = remove.item(i) {
194            if let Some(parent) = node.parent_node() {
195                parent.remove_child(&node).map_err(js)?;
196            }
197        }
198    }
199    let topics = clone.query_selector_all(".dme-topic").map_err(js)?;
200    for i in 0..topics.length() {
201        if let Some(node) = topics.item(i) {
202            if let Some(element) = node.dyn_ref::<Element>() {
203                let css = element
204                    .get_attribute("style")
205                    .ok_or_else(|| failure("Missing node style"))?;
206                element
207                    .set_attribute("style", &format!("{css};outline:none;"))
208                    .map_err(js)?;
209            }
210        }
211    }
212    let fonts = font_css().await?;
213    if !element.is_connected() || canvas.inner_html() != before {
214        return Err(failure("Map changed or unmounted while capturing; retry"));
215    }
216    let background = computed(&map)?
217        .get_property_value("background-color")
218        .map_err(js)?;
219    let background = if background == "rgba(0, 0, 0, 0)" || background == "transparent" {
220        options.background.clone()
221    } else {
222        background
223    };
224    let xml = web_sys::XmlSerializer::new()
225        .map_err(js)?
226        .serialize_to_string(&clone)
227        .map_err(js)?;
228    let svg = format!(
229        "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{width}\" height=\"{height}\" viewBox=\"0 0 {width} {height}\"><foreignObject width=\"100%\" height=\"100%\"><div xmlns=\"http://www.w3.org/1999/xhtml\" style=\"width:{width}px;height:{height}px;background:{}\"><style>{}</style>{xml}</div></foreignObject></svg>",
230        escape(&background),
231        escape(&fonts)
232    );
233    Ok(ExportFile {
234        name: "mind-map.svg".into(),
235        mime: "image/svg+xml".into(),
236        bytes: svg.into_bytes(),
237    })
238}
239/// Rasterize the captured SVG with the browser's native image/canvas codec.
240/// Detects unsupported codecs instead of returning PNG bytes with a WebP suffix.
241pub async fn rasterize(
242    svg: &ExportFile,
243    format: ImageFormat,
244    options: &ImageOptions,
245) -> Result<ExportFile, MindMapError> {
246    options.validate()?;
247    if svg.mime != "image/svg+xml" {
248        return Err(failure("Expected an SVG capture"));
249    }
250    let image = HtmlImageElement::new().map_err(js)?;
251    image.set_src(&format!(
252        "data:image/svg+xml;base64,{}",
253        STANDARD.encode(&svg.bytes)
254    ));
255    JsFuture::from(image.decode()).await.map_err(js)?;
256    let width = (f64::from(image.natural_width()) * options.scale).round();
257    let height = (f64::from(image.natural_height()) * options.scale).round();
258    if width <= 0.0 || height <= 0.0 || width * height > 64_000_000.0 {
259        return Err(failure("Invalid raster dimensions"));
260    }
261    let canvas = document()?
262        .create_element("canvas")
263        .map_err(js)?
264        .dyn_into::<HtmlCanvasElement>()
265        .map_err(js)?;
266    canvas.set_width(width as u32);
267    canvas.set_height(height as u32);
268    let context = canvas
269        .get_context("2d")
270        .map_err(js)?
271        .ok_or_else(|| failure("No 2D canvas"))?
272        .dyn_into::<web_sys::CanvasRenderingContext2d>()
273        .map_err(js)?;
274    context.set_fill_style_str(&options.background);
275    context.fill_rect(0.0, 0.0, width, height);
276    context
277        .draw_image_with_html_image_element_and_dw_and_dh(&image, 0.0, 0.0, width, height)
278        .map_err(js)?;
279    let (extension, mime) = match format {
280        ImageFormat::Png => ("png", "image/png"),
281        ImageFormat::Jpeg => ("jpg", "image/jpeg"),
282        ImageFormat::WebP => ("webp", "image/webp"),
283    };
284    let url = canvas
285        .to_data_url_with_type_and_encoder_options(mime, &JsValue::from_f64(options.quality))
286        .map_err(js)?;
287    let expected = format!("data:{mime};base64,");
288    let encoded = url
289        .strip_prefix(&expected)
290        .ok_or_else(|| failure(format!("Browser does not support {mime}")))?;
291    Ok(ExportFile {
292        name: format!("mind-map.{extension}"),
293        mime: mime.into(),
294        bytes: STANDARD.decode(encoded).map_err(failure)?,
295    })
296}
297pub(super) fn download(file: &ExportFile) -> Result<(), MindMapError> {
298    let parts = js_sys::Array::new();
299    parts.push(&js_sys::Uint8Array::from(file.bytes.as_slice()));
300    let options = web_sys::BlobPropertyBag::new();
301    options.set_type(&file.mime);
302    let blob =
303        web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &options).map_err(js)?;
304    let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?;
305    let result = (|| {
306        let anchor = document()?
307            .create_element("a")
308            .map_err(js)?
309            .dyn_into::<web_sys::HtmlAnchorElement>()
310            .map_err(js)?;
311        anchor.set_href(&url);
312        anchor.set_download(&file.name);
313        anchor.click();
314        Ok(())
315    })();
316    web_sys::Url::revoke_object_url(&url).map_err(js)?;
317    result
318}