dioxus_mind_elixir/plugins/export/
web.rs1use 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}
26fn 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 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 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 output.push_str(before);
122 output.push_str(&replacement);
123 css = after.into();
124 }
125 output.push_str(&css);
126 }
127 Ok(output)
128}
129pub 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}
239pub 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}