Skip to main content

dioxus_mind_elixir/plugins/
export.rs

1//! Rust file encoders. No upstream scripts or runtime are embedded.
2use crate::{MindMapData, MindMapError, Node};
3use serde_json::{Value, json};
4use std::io::{Cursor, Write};
5
6fn failure(error: impl std::fmt::Display) -> MindMapError {
7    MindMapError::InvalidOperation(format!("Export failed: {error}"))
8}
9/// A complete file, passed to an application-owned save/download sink.
10#[derive(Clone, Debug, PartialEq, Eq)]
11pub struct ExportFile {
12    /// Suggested filename; hosts may replace it.
13    pub name: String,
14    /// MIME content type.
15    pub mime: String,
16    /// File payload.
17    pub bytes: Vec<u8>,
18}
19impl ExportFile {
20    /// Save using a replaceable host transport (native filesystem, dialog, download).
21    pub fn save_with(
22        &self,
23        sink: impl FnOnce(&Self) -> Result<(), MindMapError>,
24    ) -> Result<(), MindMapError> {
25        sink(self)
26    }
27    /// Download with an object URL, released immediately after the click.
28    #[cfg(all(feature = "web", target_arch = "wasm32"))]
29    pub fn download(&self) -> Result<(), MindMapError> {
30        web::download(self)
31    }
32}
33/// Encode a Zen/modern XMind ZIP with content.json, metadata.json and manifest.json.
34/// Exports the entire plain-text tree, including collapsed descendants. Arbitrary
35/// Mind Elixir metadata and CSS are not XMind fields and are not converted.
36pub fn xmind(data: &MindMapData) -> Result<ExportFile, MindMapError> {
37    data.validate()?;
38    fn topic(node: &Node, root: bool, direction: crate::LayoutDirection) -> Value {
39        let mut result = json!({"id":node.id,"class":"topic","title":node.topic});
40        if !node.children.is_empty() {
41            result["children"] = json!({"attached":node.children.iter().map(|n|topic(n,false,direction)).collect::<Vec<_>>()});
42        }
43        if !node.expanded {
44            result["branch"] = json!("folded");
45        }
46        if let Some(note) = &node.note {
47            result["notes"] = json!({"plain":{"content":note}});
48        }
49        if root {
50            result["structureClass"] = json!(match direction {
51                crate::LayoutDirection::Down => "org.xmind.ui.logic.down",
52                crate::LayoutDirection::Left => "org.xmind.ui.logic.left",
53                crate::LayoutDirection::Right => "org.xmind.ui.logic.right",
54                crate::LayoutDirection::Side => "org.xmind.ui.map.unbalanced",
55            });
56        }
57        if let Some(style) = &node.style {
58            let mut properties = serde_json::Map::new();
59            for (name, value) in [
60                ("fo:font-size", &style.font_size),
61                ("fo:font-family", &style.font_family),
62                ("fo:font-weight", &style.font_weight),
63                ("fo:color", &style.color),
64                ("svg:fill", &style.background),
65            ] {
66                if let Some(value) = value {
67                    properties.insert(name.into(), json!(value));
68                }
69            }
70            result["style"] = json!({"properties":properties});
71        }
72        result
73    }
74    let content = json!([{"id":"dme-sheet","class":"sheet","title":data.node_data.topic,"rootTopic":topic(&data.node_data,true,data.direction)}]);
75    let metadata = json!({"creator":{"name":"dioxus-mind-elixir","version":env!("CARGO_PKG_VERSION")},"activeSheetId":"dme-sheet"});
76    let manifest = json!({"file-entries":{"content.json":{},"metadata.json":{}}});
77    let mut archive = zip::ZipWriter::new(Cursor::new(Vec::new()));
78    for (name, value) in [
79        ("content.json", content),
80        ("metadata.json", metadata),
81        ("manifest.json", manifest),
82    ] {
83        archive
84            .start_file(
85                name,
86                zip::write::SimpleFileOptions::default()
87                    .compression_method(zip::CompressionMethod::Stored),
88            )
89            .map_err(failure)?;
90        archive
91            .write_all(&serde_json::to_vec(&value).map_err(failure)?)
92            .map_err(failure)?;
93    }
94    Ok(ExportFile {
95        name: "mind-map.xmind".into(),
96        mime: "application/x-xmind".into(),
97        bytes: archive.finish().map_err(failure)?.into_inner(),
98    })
99}
100/// Wrap a captured standalone SVG in a static, offline HTML document.
101/// No editor or scripts are included. SVG must come from a trusted capture source.
102pub fn static_html(title: &str, svg: &ExportFile) -> Result<ExportFile, MindMapError> {
103    if svg.mime != "image/svg+xml" {
104        return Err(failure("Expected an SVG capture"));
105    }
106    let body = std::str::from_utf8(&svg.bytes).map_err(failure)?;
107    Ok(ExportFile{name:"mind-map.html".into(),mime:"text/html".into(),bytes:format!("<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width\"><meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:;\"><title>{}</title><style>body{{margin:0;overflow:auto}}svg{{display:block;max-width:none}}</style></head><body>{body}</body></html>",escape(title)).into_bytes()})
108}
109pub(crate) fn escape(value: &str) -> String {
110    value
111        .replace('&', "&amp;")
112        .replace('<', "&lt;")
113        .replace('>', "&gt;")
114        .replace('"', "&quot;")
115        .replace('\'', "&#39;")
116}
117/// Raster output encoding.
118#[derive(Clone, Copy, Debug, PartialEq, Eq)]
119pub enum ImageFormat {
120    /// Lossless PNG.
121    Png,
122    /// JPEG with caller-defined quality.
123    Jpeg,
124    /// WebP if supported by the browser's encoder.
125    WebP,
126}
127/// Capture and raster settings. Dimensions are measured from the live view.
128#[derive(Clone, Debug, PartialEq)]
129pub struct ImageOptions {
130    /// Device-independent raster scale; finite, in 0.1..=4.
131    pub scale: f64,
132    /// JPEG/WebP quality, in 0..=1.
133    pub quality: f64,
134    /// Opaque canvas fallback when the live map background is transparent.
135    pub background: String,
136}
137impl Default for ImageOptions {
138    fn default() -> Self {
139        Self {
140            scale: 1.0,
141            quality: 0.9,
142            background: "#ffffff".into(),
143        }
144    }
145}
146impl ImageOptions {
147    /// Validate bounds before allocating a canvas.
148    pub fn validate(&self) -> Result<(), MindMapError> {
149        if !self.scale.is_finite()
150            || !(0.1..=4.0).contains(&self.scale)
151            || !self.quality.is_finite()
152            || !(0.0..=1.0).contains(&self.quality)
153            || self.background.trim().is_empty()
154        {
155            return Err(failure("Invalid image scale, quality or background"));
156        }
157        Ok(())
158    }
159}
160/// Capture a mounted Web map, resolving inherited CSS and loaded fonts.
161/// Other renderers receive a typed error and may provide their own capture sink.
162/// Call while the map is mounted and not being edited or animated.
163#[cfg(not(all(feature = "web", target_arch = "wasm32")))]
164pub async fn capture_svg(
165    _element: &dioxus::prelude::MountedData,
166    options: &ImageOptions,
167) -> Result<ExportFile, MindMapError> {
168    options.validate()?;
169    Err(failure(
170        "Live CSS capture requires the web feature and a Web renderer; use a host capture adapter on Desktop",
171    ))
172}
173#[cfg(all(feature = "web", target_arch = "wasm32"))]
174pub use web::{capture_svg, rasterize};
175#[cfg(all(feature = "web", target_arch = "wasm32"))]
176mod web;