From d90be20a72df3ba7ed903ff7bf7795a5c95a66c1 Mon Sep 17 00:00:00 2001 From: Amit Haridas Date: Wed, 30 Sep 2026 22:17:15 +0530 Subject: [PATCH] feat(flowchart): export to editable Visio .vsdx (C14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New 'Export Visio' toolbar button sends the current graph to the main process, which generates an OOXML .vsdx zip with JSZip and writes it to a user-chosen path. The result is fully editable in Microsoft Visio, draw.io, Lucidchart, and any other tool that accepts the format — not an embedded image. Architecture follows the existing pure-module pattern: - src/flowchart/flowchart-vsdx-export.js: pure translator that produces (a) page1.xml describing each node as a Visio Shape with correct geometry (PinX/PinY/Width/Height in inches, with the Y-axis flipped to match Visio's bottom-up coords), each edge as a Connect entry, and (b) the seven static boilerplate XML files that make a .vsdx zip valid ([Content_Types].xml, _rels/.rels, visio/document.xml, visio/pages/pages.xml, etc.). - src/main.js: new 'export-vsdx' IPC handler requires the pure module + JSZip, builds the zip, writes to disk, returns { canceled, path } | { canceled: false, error }. - src/preload.js: flowchart.exportVsdx(graph) bridge. - src/renderer/flowchart-bundle.js: btnExportVsdx handler, status feedback for cancel / save / error paths. - src/flowchart-generator.html: button next to Export JPG. Tests: 7 new (1362 total) cover graphBounds (empty / single / multi-node), XML escaping of label text, Connect emission, the 5 node kinds mapping to 5 distinct master names, and that all boilerplate files start with the XML declaration. Amit Haridas --- src/flowchart-generator.html | 7 + src/flowchart/flowchart-vsdx-export.js | 264 +++++++++++++++++++++++++ src/main.js | 34 ++++ src/preload.js | 3 + src/renderer/flowchart-bundle.js | 24 +++ tests/flowchart-vsdx-export.test.js | 98 +++++++++ 6 files changed, 430 insertions(+) create mode 100644 src/flowchart/flowchart-vsdx-export.js create mode 100644 tests/flowchart-vsdx-export.test.js 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` + + ` \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('