diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0404c45..62adf63 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
# Changelog
+## [0.1.3] - (2026-03-15)
+
+### New Features
+
+- **Upload button**: Toolbar button to load `.xml` or `.archimate` files directly from the browser — no Python code needed to switch models
+- **Export to SVG**: Download the current diagram as a self-contained SVG file with inlined fonts
+- **Export to PNG**: Download the current diagram as a high-resolution (2x) PNG with correct light/dark background
+
## [0.1.2] - (2026-03-15)
### New Features
diff --git a/pyproject.toml b/pyproject.toml
index ba6e52b..45f78fe 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "anywidget-archimate"
-version = "0.1.2"
+version = "0.1.3"
description = "Interactive ArchiMate model viewer widget for Jupyter, Marimo, and VS Code notebooks"
readme = "README.md"
license = "Apache-2.0"
diff --git a/src/anywidget_archimate/parser.py b/src/anywidget_archimate/parser.py
index 38ef229..8b59250 100644
--- a/src/anywidget_archimate/parser.py
+++ b/src/anywidget_archimate/parser.py
@@ -63,12 +63,16 @@ def parse_xml(source: str | Path) -> tuple[list[dict], list[dict]]:
Returns:
Tuple of (elements, relationships) as lists of dicts.
"""
- source_path = Path(source) if not isinstance(source, Path) else source
- if source_path.exists():
- tree = ET.parse(source_path)
- root = tree.getroot()
+ source_str = str(source)
+ if not source_str.lstrip().startswith("<"):
+ source_path = Path(source) if not isinstance(source, Path) else source
+ if source_path.exists():
+ tree = ET.parse(source_path)
+ root = tree.getroot()
+ else:
+ root = ET.fromstring(source_str)
else:
- root = ET.fromstring(str(source))
+ root = ET.fromstring(source_str)
ns = _detect_namespace(root)
diff --git a/src/anywidget_archimate/ui/index.js b/src/anywidget_archimate/ui/index.js
index f2de3dd..e3bbc0f 100644
--- a/src/anywidget_archimate/ui/index.js
+++ b/src/anywidget_archimate/ui/index.js
@@ -1021,6 +1021,15 @@ function svgPoint(svg, event) {
// Utilities
// ============================================================================
+function downloadBlob(blob, filename) {
+ const a = document.createElement("a");
+ a.href = URL.createObjectURL(blob);
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 100);
+}
+
function truncate(s, n) { return !s ? "" : s.length > n ? s.slice(0, n - 1) + "\u2026" : s; }
function formatType(type) {
@@ -1055,6 +1064,10 @@ function render({ model, el }) {
Application
Technology
+
+
+
+
`;
@@ -1111,6 +1124,69 @@ function render({ model, el }) {
});
toolbar.querySelector(".aam-btn-dark").addEventListener("click", () => { model.set("dark_mode", !model.get("dark_mode")); model.save_changes(); });
+ // Upload
+ const fileInput = toolbar.querySelector(".aam-file-input");
+ toolbar.querySelector(".aam-btn-upload").addEventListener("click", () => { fileInput.value = ""; fileInput.click(); });
+ fileInput.addEventListener("change", () => {
+ const file = fileInput.files[0];
+ if (!file) return;
+ const reader = new FileReader();
+ reader.onload = () => { model.set("_upload_xml", reader.result); model.save_changes(); };
+ reader.readAsText(file);
+ });
+
+ // Export SVG
+ toolbar.querySelector(".aam-btn-export-svg").addEventListener("click", () => {
+ const svg = graphContainer.querySelector("svg");
+ if (!svg) return;
+ const clone = svg.cloneNode(true);
+ // Inline key styles so the SVG is self-contained
+ const style = document.createElementNS("http://www.w3.org/2000/svg", "style");
+ style.textContent = `
+ .aam-node-label { font: 600 11px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
+ .aam-node-type { font: 8px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
+ .aam-layer-label { font: 700 12px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; text-transform: uppercase; letter-spacing: 0.5px; opacity: 0.7; }
+ `;
+ clone.insertBefore(style, clone.firstChild);
+ // Set explicit dimensions from viewBox
+ const vb = clone.getAttribute("viewBox");
+ if (vb) { const [,,w,h] = vb.split(" "); clone.setAttribute("width", w); clone.setAttribute("height", h); }
+ const blob = new Blob([new XMLSerializer().serializeToString(clone)], { type: "image/svg+xml" });
+ downloadBlob(blob, "archimate-diagram.svg");
+ });
+
+ // Export PNG
+ toolbar.querySelector(".aam-btn-export-png").addEventListener("click", () => {
+ const svg = graphContainer.querySelector("svg");
+ if (!svg) return;
+ const clone = svg.cloneNode(true);
+ const style = document.createElementNS("http://www.w3.org/2000/svg", "style");
+ style.textContent = `
+ .aam-node-label { font: 600 11px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
+ .aam-node-type { font: 8px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
+ .aam-layer-label { font: 700 12px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; text-transform: uppercase; letter-spacing: 0.5px; opacity: 0.7; }
+ `;
+ clone.insertBefore(style, clone.firstChild);
+ const vb = clone.getAttribute("viewBox");
+ let w = 1200, h = 800;
+ if (vb) { const parts = vb.split(" "); w = Math.ceil(parts[2] * 2); h = Math.ceil(parts[3] * 2); }
+ clone.setAttribute("width", w); clone.setAttribute("height", h);
+ const blob = new Blob([new XMLSerializer().serializeToString(clone)], { type: "image/svg+xml" });
+ const url = URL.createObjectURL(blob);
+ const img = new Image();
+ img.onload = () => {
+ const canvas = document.createElement("canvas");
+ canvas.width = w; canvas.height = h;
+ const ctx = canvas.getContext("2d");
+ ctx.fillStyle = model.get("dark_mode") ? "#1e1e3a" : "#fdfdfd";
+ ctx.fillRect(0, 0, w, h);
+ ctx.drawImage(img, 0, 0, w, h);
+ URL.revokeObjectURL(url);
+ canvas.toBlob((b) => { if (b) downloadBlob(b, "archimate-diagram.png"); }, "image/png");
+ };
+ img.src = url;
+ });
+
model.on("change:elements", rebuildDiagram);
model.on("change:relationships", rebuildDiagram);
model.on("change:dark_mode", rebuildDiagram);
diff --git a/src/anywidget_archimate/widget.py b/src/anywidget_archimate/widget.py
index 5a2305b..840a803 100644
--- a/src/anywidget_archimate/widget.py
+++ b/src/anywidget_archimate/widget.py
@@ -36,6 +36,18 @@ class ArchiMate(anywidget.AnyWidget):
# Interaction state
selected_element = traitlets.Dict(allow_none=True, default_value=None).tag(sync=True)
+ # File upload (browser → Python)
+ _upload_xml = traitlets.Unicode(default_value="").tag(sync=True)
+
+ @traitlets.observe("_upload_xml")
+ def _on_upload_xml(self, change):
+ xml = change["new"]
+ if xml:
+ elements, relationships = parse_xml(xml)
+ self.elements = elements
+ self.relationships = relationships
+ self._upload_xml = "" # reset
+
@classmethod
def from_xml(cls, source: str | Path, **kwargs) -> ArchiMate:
"""Create widget from ArchiMate XML file or string.