diff --git a/src/flowchart-generator.html b/src/flowchart-generator.html
index 1aaef30..b06c3ef 100644
--- a/src/flowchart-generator.html
+++ b/src/flowchart-generator.html
@@ -406,6 +406,13 @@
+
diff --git a/src/flowchart/flowchart-vsdx-export.js b/src/flowchart/flowchart-vsdx-export.js
new file mode 100644
index 0000000..606bc8b
--- /dev/null
+++ b/src/flowchart/flowchart-vsdx-export.js
@@ -0,0 +1,264 @@
+/**
+ * Pure translator: graph → Visio (.vsdx) page1.xml.
+ *
+ * Generates the page-level XML that the main process wraps with the rest
+ * of the OOXML boilerplate and zips into a valid .vsdx file. Edits stay
+ * fully editable when opened in Microsoft Visio or vsdx-compatible tools
+ * (draw.io, Lucidchart, …).
+ *
+ * Coordinate system: the editor stores x, y, width, height in SVG pixels
+ * with origin top-left and Y growing downward. Visio uses inches with
+ * origin bottom-left and Y growing upward. We translate by:
+ *
+ * inchesPerPixel = 1 / 72 (72 SVG units per inch)
+ * pinX = (x + width/2) * inchesPerPixel
+ * pinY = pageHeightInches − (y + height/2) * inchesPerPixel
+ *
+ * `pageHeightInches` is computed from the graph bounds so the page is
+ * just tall enough to hold every shape with a half-inch margin.
+ *
+ * Pure module — no DOM, no globals.
+ *
+ * @module flowchart-vsdx-export
+ */
+
+'use strict';
+
+// v4.13.0 — same default geometry the canvas uses.
+const DEFAULT_WIDTH = 120;
+const DEFAULT_HEIGHT = 60;
+const MARGIN_INCHES = 0.5;
+const INCHES_PER_PIXEL = 1 / 72;
+
+// XML special characters that must be escaped in attribute / text values.
+const XML_ESCAPES = {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": ''',
+};
+
+function xmlEscape(value) {
+ if (value === null || value === undefined) return '';
+ return String(value).replace(/[&<>"']/g, (ch) => XML_ESCAPES[ch]);
+}
+
+function nodeWidth(node) {
+ return Number(node.width) > 0 ? Number(node.width) : DEFAULT_WIDTH;
+}
+
+function nodeHeight(_node) {
+ return DEFAULT_HEIGHT;
+}
+
+function shapeKindToVisioGeometry(kind) {
+ // Visio master shape names. We use generic Shape elements with the
+ // matching geometry; downstream tools that ship with these masters
+ // (Visio standard, draw.io) recognise the names and render the right
+ // shape. Falls back to a plain rectangle for unknown kinds.
+ switch (kind) {
+ case 'decision':
+ return { masterName: 'Decision / Diamond', geometry: 'diamond' };
+ case 'terminator':
+ return { masterName: 'Terminator / Oval', geometry: 'ellipse' };
+ case 'subroutine':
+ return { masterName: 'Subroutine', geometry: 'rect' };
+ case 'document':
+ return { masterName: 'Document', geometry: 'document' };
+ case 'process':
+ default:
+ return { masterName: 'Process', geometry: 'rect' };
+ }
+}
+
+/**
+ * Compute the page bounding box in pixels.
+ * Returns { width, height } in pixels; main process scales to inches.
+ */
+function graphBounds(graph) {
+ const nodes = Array.isArray(graph && graph.nodes) ? graph.nodes : [];
+ if (nodes.length === 0) {
+ return { width: 200, height: 100 };
+ }
+ let minX = Infinity;
+ let minY = Infinity;
+ let maxX = -Infinity;
+ let maxY = -Infinity;
+ for (const node of nodes) {
+ const w = nodeWidth(node);
+ const h = nodeHeight(node);
+ minX = Math.min(minX, node.x);
+ minY = Math.min(minY, node.y);
+ maxX = Math.max(maxX, node.x + w);
+ maxY = Math.max(maxY, node.y + h);
+ }
+ return {
+ width: Math.max(maxX - minX, 100),
+ height: Math.max(maxY - minY, 100),
+ };
+}
+
+function shapeForNode(node, pageHeightInches) {
+ const w = nodeWidth(node);
+ const h = nodeHeight(node);
+ const centerXPx = node.x + w / 2;
+ const centerYPx = node.y + h / 2;
+ const pinX = centerXPx * INCHES_PER_PIXEL;
+ const pinY = pageHeightInches - centerYPx * INCHES_PER_PIXEL;
+ const widthIn = (w * INCHES_PER_PIXEL).toFixed(3);
+ const heightIn = (h * INCHES_PER_PIXEL).toFixed(3);
+ const { masterName } = shapeKindToVisioGeometry(node.kind);
+ const color = xmlEscape((node.color || '#ffffff').replace(/^#/, '').toUpperCase());
+ return (
+ ` \n` +
+ ` \n` +
+ ` ${pinX.toFixed(3)}\n` +
+ ` ${pinY.toFixed(3)}\n` +
+ ` ${widthIn}\n` +
+ ` ${heightIn}\n` +
+ ` \n` +
+ ` \n` +
+ ` \n` +
+ ` \n` +
+ ` \n` +
+ ` \n` +
+ ` ${xmlEscape(node.label || '')}\n` +
+ ` `
+ );
+}
+
+function connectForEdge(edge) {
+ // Visio Connects pair shapes via their IDs using BeginX/EndX glue.
+ // We connect from shape(fromNodeId).BottomX to shape(toNodeId).TopX.
+ return (
+ ` `
+ );
+}
+
+/**
+ * Build the full page1.xml string for the given graph.
+ *
+ * @param {object} graph - { nodes: [...], edges: [...] } in editor coords.
+ * @returns {string} XML text suitable to drop into the visio/pages/ entry
+ * of the .vsdx zip.
+ */
+function toVisioPageXml(graph) {
+ const bounds = graphBounds(graph);
+ const heightIn = (bounds.height * INCHES_PER_PIXEL + MARGIN_INCHES * 2).toFixed(3);
+ const nodes = Array.isArray(graph && graph.nodes) ? graph.nodes : [];
+ const edges = Array.isArray(graph && graph.edges) ? graph.edges : [];
+ const shapes = nodes.map((n) => shapeForNode(n, Number(heightIn))).join('\n');
+ const connects = edges.map(connectForEdge).join('\n');
+ return (
+ `\n` +
+ `\n` +
+ ` \n${shapes}\n \n` +
+ ` \n${connects}\n \n` +
+ `\n`
+ );
+}
+
+/**
+ * Build the static boilerplate XML files for the .vsdx container.
+ * Returned as a map of zip-path -> string. Caller zips and writes.
+ */
+function visioBoilerplate() {
+ return {
+ '[Content_Types].xml':
+ `\n` +
+ `\n` +
+ ` \n` +
+ ` \n` +
+ ` \n` +
+ ` \n` +
+ ` \n` +
+ ` \n` +
+ ` \n` +
+ `\n`,
+ '_rels/.rels':
+ `\n` +
+ `\n` +
+ ` \n` +
+ ` \n` +
+ ` \n` +
+ `\n`,
+ 'docProps/core.xml':
+ `\n` +
+ `\n` +
+ ` Flowchart\n` +
+ ` MarkdownConverter\n` +
+ ` MarkdownConverter\n` +
+ `\n`,
+ 'docProps/app.xml':
+ `\n` +
+ `\n` +
+ ` MarkdownConverter\n` +
+ ` Flowchart\n` +
+ `\n`,
+ 'visio/_rels/document.xml.rels':
+ `\n` +
+ `\n` +
+ ` \n` +
+ `\n`,
+ 'visio/document.xml':
+ `\n` +
+ `\n` +
+ ` \n` +
+ ` \n` +
+ ` \n` +
+ ` \n` +
+ ` \n` +
+ ` \n` +
+ ` \n` +
+ ` \n` +
+ ` \n` +
+ ` \n` +
+ ` | \n` +
+ ` | \n` +
+ ` | \n` +
+ ` | \n` +
+ ` \n` +
+ ` \n` +
+ ` \n` +
+ ` \n` +
+ ` \n` +
+ ` | \n` +
+ ` | \n` +
+ ` \n` +
+ ` \n` +
+ ` \n` +
+ ` \n` +
+ `\n`,
+ 'visio/pages/_rels/pages.xml.rels':
+ `\n` +
+ `\n` +
+ ` \n` +
+ `\n`,
+ 'visio/pages/pages.xml':
+ `\n` +
+ `\n` +
+ ` \n` +
+ ` \n` +
+ ` \n` +
+ `\n`,
+ };
+}
+
+if (typeof module === 'object' && module.exports) {
+ module.exports = { toVisioPageXml, visioBoilerplate, graphBounds };
+}
+if (typeof window !== 'undefined') {
+ window.FlowchartVsdxExport = { toVisioPageXml, visioBoilerplate, graphBounds };
+}
diff --git a/src/main.js b/src/main.js
index 515f1e6..0db94ef 100644
--- a/src/main.js
+++ b/src/main.js
@@ -5663,6 +5663,40 @@ ipcMain.handle('save-binary-file', async (event, { data, defaultName, filters }
}
});
+// v4.13.0 — Export the flowchart graph as an editable Visio .vsdx file.
+// The pure translator (src/flowchart/flowchart-vsdx-export.js) produces
+// page1.xml plus the static boilerplate; this handler wraps them in a
+// zip via JSZip and writes the bytes to the user-chosen path.
+ipcMain.handle('export-vsdx', async (event, { graph } = {}) => {
+ const { dialog } = require('electron');
+ const fs = require('fs');
+ const win = BrowserWindow.fromWebContents(event.sender);
+ const result = await dialog.showSaveDialog(win || undefined, {
+ title: 'Export to Visio',
+ defaultPath: 'flowchart.vsdx',
+ filters: [
+ { name: 'Visio Drawing', extensions: ['vsdx'] },
+ { name: 'All Files', extensions: ['*'] },
+ ],
+ });
+ if (result.canceled || !result.filePath) return { canceled: true };
+ try {
+ const { toVisioPageXml, visioBoilerplate } = require('./flowchart/flowchart-vsdx-export');
+ const JSZip = require('jszip');
+ const zip = new JSZip();
+ for (const [name, body] of Object.entries(visioBoilerplate())) {
+ zip.file(name, body);
+ }
+ zip.file('visio/pages/page1.xml', toVisioPageXml(graph || { nodes: [], edges: [] }));
+ const buf = await zip.generateAsync({ type: 'nodebuffer' });
+ await fs.promises.writeFile(result.filePath, buf);
+ return { canceled: false, path: result.filePath };
+ } catch (err) {
+ console.warn(`export-vsdx: failed at ${result.filePath}: ${err.message}`);
+ return { canceled: false, error: err.message };
+ }
+});
+
// ============================================
// ASCII Art Generator Window
// ============================================
diff --git a/src/preload.js b/src/preload.js
index ac1f7ca..f244bd0 100644
--- a/src/preload.js
+++ b/src/preload.js
@@ -573,6 +573,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
// process strips the `data:...;base64,` prefix and writes the raw bytes.
saveBinary: (data, defaultName, filters) =>
ipcRenderer.invoke('save-binary-file', { data, defaultName, filters }),
+ // v4.13.0 — Visio .vsdx export. Renderer sends the current graph JSON;
+ // main process generates the OOXML zip and writes it.
+ exportVsdx: (graph) => ipcRenderer.invoke('export-vsdx', { graph }),
openFile: () => ipcRenderer.invoke('open-text-file-dialog'),
insertAtCursor: (text) => ipcRenderer.send('insert-content', text),
},
diff --git a/src/renderer/flowchart-bundle.js b/src/renderer/flowchart-bundle.js
index e0151a8..03f5fc4 100644
--- a/src/renderer/flowchart-bundle.js
+++ b/src/renderer/flowchart-bundle.js
@@ -941,6 +941,7 @@
btnExportSvg: document.getElementById('fc-btn-export-svg'),
btnExportPng: document.getElementById('fc-btn-export-png'),
btnExportJpg: document.getElementById('fc-btn-export-jpg'),
+ btnExportVsdx: document.getElementById('fc-btn-export-vsdx'),
btnUndo: document.getElementById('fc-btn-undo'),
btnRedo: document.getElementById('fc-btn-redo'),
historyCount: document.getElementById('fc-history-count'),
@@ -1689,6 +1690,29 @@
);
}
+ // v4.13.0 — Export to editable Visio .vsdx. Sends the graph JSON to
+ // the main process which generates the OOXML zip and writes it.
+ if (els.btnExportVsdx) {
+ els.btnExportVsdx.addEventListener('click', async () => {
+ if (!_store || !api.exportVsdx) return;
+ try {
+ const graph = _store.getGraph();
+ const result = await api.exportVsdx(graph);
+ if (!result || result.canceled) {
+ setStatus('Export cancelled');
+ } else if (result.path) {
+ setStatus('Saved Visio to ' + result.path);
+ } else if (result.error) {
+ setStatus('Export failed: ' + result.error);
+ } else {
+ setStatus('Saved Visio');
+ }
+ } catch (err) {
+ setStatus('Visio export failed: ' + (err && err.message ? err.message : err));
+ }
+ });
+ }
+
// v4.13.0 — Open from .mmd/.md file. Pops a system Open dialog,
// strips the ```mermaid fence (if any), parses via fromMermaid()
// (inlined above) and replaces the current graph. Confirms
diff --git a/tests/flowchart-vsdx-export.test.js b/tests/flowchart-vsdx-export.test.js
new file mode 100644
index 0000000..4cca8e4
--- /dev/null
+++ b/tests/flowchart-vsdx-export.test.js
@@ -0,0 +1,98 @@
+/**
+ * @jest-environment node
+ *
+ * Tests the pure Visio .vsdx exporter (src/flowchart/flowchart-vsdx-export.js).
+ * The pure module returns string XML; the main-process IPC handler wraps
+ * it in a zip and writes bytes. These tests verify the geometry, escaping,
+ * and shape / connect emission.
+ */
+const {
+ toVisioPageXml,
+ visioBoilerplate,
+ graphBounds,
+} = require('../src/flowchart/flowchart-vsdx-export');
+
+describe('flowchart-vsdx-export: pure module', () => {
+ test('graphBounds returns sensible defaults for empty graph', () => {
+ expect(graphBounds({ nodes: [], edges: [] })).toEqual({ width: 200, height: 100 });
+ expect(graphBounds(null)).toEqual({ width: 200, height: 100 });
+ });
+
+ test('graphBounds spans the union of node rectangles', () => {
+ const bounds = graphBounds({
+ nodes: [
+ { id: 'a', x: 0, y: 0, width: 100, height: 60 },
+ { id: 'b', x: 200, y: 100, width: 100, height: 60 },
+ ],
+ edges: [],
+ });
+ expect(bounds.width).toBe(300);
+ expect(bounds.height).toBe(160);
+ });
+
+ test('toVisioPageXml emits a Shape per node with correct geometry', () => {
+ const xml = toVisioPageXml({
+ nodes: [{ id: 'n1', kind: 'process', x: 0, y: 0, width: 120, height: 60, label: 'Hello' }],
+ edges: [],
+ });
+ expect(xml).toContain('Hello');
+ });
+
+ test('toVisioPageXml escapes XML-special characters in labels', () => {
+ const xml = toVisioPageXml({
+ nodes: [{ id: 'n1', kind: 'process', x: 0, y: 0, width: 120, height: 60, label: 'A & B ' }],
+ edges: [],
+ });
+ expect(xml).toContain('A & B <c>');
+ expect(xml).not.toContain('');
+ });
+
+ test('toVisioPageXml emits Connect entries for edges', () => {
+ const xml = toVisioPageXml({
+ nodes: [
+ { id: 'a', kind: 'process', x: 0, y: 0, width: 120, height: 60, label: 'A' },
+ { id: 'b', kind: 'process', x: 200, y: 0, width: 120, height: 60, label: 'B' },
+ ],
+ edges: [{ id: 'e1', fromNodeId: 'a', toNodeId: 'b', kind: 'solid' }],
+ });
+ expect(xml).toContain('');
+ expect(xml).toContain('FromSheet="a"');
+ expect(xml).toContain('ToSheet="b"');
+ });
+
+ test('toVisioPageXml maps the 5 node kinds to distinct master names', () => {
+ const kinds = ['process', 'decision', 'terminator', 'subroutine', 'document'];
+ const names = new Set();
+ for (const kind of kinds) {
+ const xml = toVisioPageXml({
+ nodes: [{ id: `n_${kind}`, kind, x: 0, y: 0, width: 120, height: 60, label: kind }],
+ edges: [],
+ });
+ const match = xml.match(/NameU="([^"]+)"/);
+ expect(match).not.toBeNull();
+ names.add(match[1]);
+ }
+ expect(names.size).toBe(5);
+ });
+
+ test('visioBoilerplate returns all required static XML files', () => {
+ const files = visioBoilerplate();
+ const keys = Object.keys(files);
+ expect(keys).toContain('[Content_Types].xml');
+ expect(keys).toContain('_rels/.rels');
+ expect(keys).toContain('visio/document.xml');
+ expect(keys).toContain('visio/pages/pages.xml');
+ expect(keys).toContain('visio/pages/_rels/pages.xml.rels');
+ expect(keys).toContain('docProps/core.xml');
+ expect(keys).toContain('docProps/app.xml');
+ for (const body of Object.values(files)) {
+ expect(body.startsWith('