diff --git a/src/flowchart-generator.html b/src/flowchart-generator.html
index 3fa7ab4..1aaef30 100644
--- a/src/flowchart-generator.html
+++ b/src/flowchart-generator.html
@@ -395,6 +395,18 @@
Reset All
+
Export Image
+
+
+
+
+
diff --git a/src/main.js b/src/main.js
index 87e2f5e..515f1e6 100644
--- a/src/main.js
+++ b/src/main.js
@@ -5634,6 +5634,35 @@ ipcMain.handle('open-text-file-dialog', async (event, _opts = {}) => {
}
});
+// v4.13.0 — Save a flowchart as a binary blob (PNG / JPG / Visio / any
+// non-text format). The renderer rasterises the SVG canvas and sends the
+// bytes as a base64 data URL; this handler shows the Save dialog, then
+// writes the decoded bytes. SVG goes through save-text-file instead
+// because it stays as text.
+ipcMain.handle('save-binary-file', async (event, { data, defaultName, filters } = {}) => {
+ const { dialog } = require('electron');
+ const win = BrowserWindow.fromWebContents(event.sender);
+ const filterList =
+ Array.isArray(filters) && filters.length ? filters : [{ name: 'All Files', extensions: ['*'] }];
+ const result = await dialog.showSaveDialog(win || undefined, {
+ title: 'Save Flowchart',
+ defaultPath: typeof defaultName === 'string' && defaultName ? defaultName : 'flowchart.png',
+ filters: filterList,
+ });
+ if (result.canceled || !result.filePath) return { canceled: true };
+ try {
+ const raw = typeof data === 'string' ? data : '';
+ const commaIdx = raw.indexOf(',');
+ const b64 = commaIdx >= 0 ? raw.slice(commaIdx + 1) : raw;
+ const bytes = Buffer.from(b64, 'base64');
+ await require('fs').promises.writeFile(result.filePath, bytes);
+ return { canceled: false, path: result.filePath };
+ } catch (err) {
+ console.warn(`save-binary-file: write 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 011da45..ac1f7ca 100644
--- a/src/preload.js
+++ b/src/preload.js
@@ -568,6 +568,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
writeFile: (p, content) => ipcRenderer.invoke('write-text-file', { path: p, content }),
saveFile: (content, defaultName) =>
ipcRenderer.invoke('save-text-file', { content, defaultName }),
+ // v4.13.0 — binary export (PNG / JPG / Visio / etc.). `data` is a
+ // base64 data URL produced by the renderer's SVG rasteriser; the main
+ // process strips the `data:...;base64,` prefix and writes the raw bytes.
+ saveBinary: (data, defaultName, filters) =>
+ ipcRenderer.invoke('save-binary-file', { data, defaultName, filters }),
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 8b8814d..e0151a8 100644
--- a/src/renderer/flowchart-bundle.js
+++ b/src/renderer/flowchart-bundle.js
@@ -1,3 +1,4 @@
+/* global XMLSerializer */
/**
* v4.12.0 — Bundled single-file Flowchart Generator loader.
*
@@ -934,6 +935,12 @@
btnSave: document.getElementById('fc-btn-save'),
btnOpen: document.getElementById('fc-btn-open'),
btnReset: document.getElementById('fc-btn-reset'),
+ // v4.13.0 — image export buttons (SVG / PNG / JPG). All three use the
+ // same SVG→string serializer; PNG/JPG additionally rasterise through
+ // Image + canvas via the binary IPC bridge.
+ btnExportSvg: document.getElementById('fc-btn-export-svg'),
+ btnExportPng: document.getElementById('fc-btn-export-png'),
+ btnExportJpg: document.getElementById('fc-btn-export-jpg'),
btnUndo: document.getElementById('fc-btn-undo'),
btnRedo: document.getElementById('fc-btn-redo'),
historyCount: document.getElementById('fc-history-count'),
@@ -985,6 +992,25 @@
const PERSIST_DEBOUNCE_MS = 500;
const PERSISTENCE_FILENAME = 'flowchart-session.json';
+ // v4.13.0 — minimal CSS inlined into exported SVG/PNG/JPG so the file
+ // renders the same way without the editor stylesheet. Kept terse on
+ // purpose: only the rules that affect visible geometry (fills, strokes,
+ // label positions). Font fallbacks are conservative — the user-agent
+ // default sans-serif is the most portable choice for a portable diagram.
+ const EXPORT_CSS = [
+ '.flowchart-canvas { background: #ffffff; }',
+ '.flowchart-node-shape { stroke: #1f2937; stroke-width: 1.5; }',
+ '.flowchart-node-label { font: 14px sans-serif; fill: #111827; text-anchor: middle; dominant-baseline: middle; }',
+ '.flowchart-edge-line { stroke: #1f2937; fill: none; }',
+ '.flowchart-edge-label-bg { fill: #ffffff; stroke: none; }',
+ '.flowchart-edge-label { font: 12px sans-serif; fill: #111827; text-anchor: middle; dominant-baseline: middle; }',
+ '.flowchart-connect-preview { stroke: #2563eb; stroke-dasharray: 6 4; stroke-width: 2; fill: none; }',
+ '.flowchart-selection-ring { stroke: #2563eb; stroke-width: 2; fill: none; }',
+ ].join('\n');
+ const EXPORT_PADDING = 24;
+ const EXPORT_DEFAULT_WIDTH = 1000;
+ const EXPORT_DEFAULT_HEIGHT = 700;
+
let _userDataPath = null;
let _persistenceFile = null;
let _previewTimer = null;
@@ -1555,6 +1581,114 @@
});
}
+ // v4.13.0 — Export the canvas as SVG / PNG / JPG. SVG is a direct
+ // clone-and-serialise; PNG / JPG rasterise through Image + Canvas
+ // to produce a data URL the main process can write as bytes.
+ function getSvgStringForExport() {
+ if (!_canvas) return '';
+ const svg = _canvas.getSvg();
+ if (!svg) return '';
+ const clone = svg.cloneNode(true);
+ clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
+ clone.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
+ const styleEl = document.createElementNS('http://www.w3.org/2000/svg', 'style');
+ styleEl.textContent = EXPORT_CSS;
+ clone.insertBefore(styleEl, clone.firstChild);
+ return (
+ '\n' + new XMLSerializer().serializeToString(clone)
+ );
+ }
+
+ function rasterizeSvg(svgString, mime, quality) {
+ const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
+ const url = URL.createObjectURL(blob);
+ return new Promise((resolve, reject) => {
+ const img = new Image();
+ img.onload = () => {
+ const w = EXPORT_DEFAULT_WIDTH + EXPORT_PADDING * 2;
+ const h = EXPORT_DEFAULT_HEIGHT + EXPORT_PADDING * 2;
+ const canvas = document.createElement('canvas');
+ canvas.width = w;
+ canvas.height = h;
+ const ctx = canvas.getContext('2d');
+ if (mime === 'image/jpeg') {
+ ctx.fillStyle = '#ffffff';
+ ctx.fillRect(0, 0, w, h);
+ }
+ ctx.drawImage(
+ img,
+ EXPORT_PADDING,
+ EXPORT_PADDING,
+ EXPORT_DEFAULT_WIDTH,
+ EXPORT_DEFAULT_HEIGHT
+ );
+ try {
+ resolve(canvas.toDataURL(mime, quality));
+ } catch (err) {
+ reject(err);
+ }
+ };
+ img.onerror = () => reject(new Error('failed to load SVG for rasterisation'));
+ img.src = url;
+ }).finally(() => URL.revokeObjectURL(url));
+ }
+
+ async function handleSvgExport() {
+ if (!_canvas || !api.saveFile) return;
+ try {
+ const svgString = getSvgStringForExport();
+ const result = await api.saveFile(svgString, 'flowchart.svg');
+ if (!result || result.canceled) {
+ setStatus('Export cancelled');
+ } else if (result.path) {
+ setStatus('Saved SVG to ' + result.path);
+ } else {
+ setStatus('Saved SVG');
+ }
+ } catch (err) {
+ setStatus('SVG export failed: ' + (err && err.message ? err.message : err));
+ }
+ }
+
+ async function handleRasterExport(mime, ext, label) {
+ if (!_canvas || !api.saveBinary) return;
+ try {
+ const svgString = getSvgStringForExport();
+ const dataUrl = await rasterizeSvg(
+ svgString,
+ mime,
+ mime === 'image/jpeg' ? 0.92 : undefined
+ );
+ const result = await api.saveBinary(dataUrl, 'flowchart.' + ext, [
+ { name: label, extensions: [ext] },
+ { name: 'All Files', extensions: ['*'] },
+ ]);
+ if (!result || result.canceled) {
+ setStatus('Export cancelled');
+ } else if (result.path) {
+ setStatus('Saved ' + label + ' to ' + result.path);
+ } else if (result.error) {
+ setStatus('Export failed: ' + result.error);
+ } else {
+ setStatus('Saved ' + label);
+ }
+ } catch (err) {
+ setStatus(label + ' export failed: ' + (err && err.message ? err.message : err));
+ }
+ }
+
+ if (els.btnExportSvg) els.btnExportSvg.addEventListener('click', handleSvgExport);
+ if (els.btnExportPng) {
+ els.btnExportPng.addEventListener('click', () =>
+ handleRasterExport('image/png', 'png', 'PNG')
+ );
+ }
+ if (els.btnExportJpg) {
+ els.btnExportJpg.addEventListener('click', () =>
+ handleRasterExport('image/jpeg', 'jpg', 'JPEG')
+ );
+ }
+
// 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-controller.test.js b/tests/flowchart-controller.test.js
index fa9f0cc..fc01ad7 100644
--- a/tests/flowchart-controller.test.js
+++ b/tests/flowchart-controller.test.js
@@ -685,3 +685,63 @@ describe('flowchart-bundle: Save to File button (v4.12.0)', () => {
expect(document.getElementById('fc-status').textContent).toBe('Save failed: disk full');
});
});
+
+// v4.13.0 — Export Image buttons (SVG / PNG / JPG). The bundle wires each
+// button to the same export pipeline: SVG is a direct clone-and-serialise
+// of the canvas SVG; PNG / JPG rasterise through Image + Canvas. The
+// SVG-only path is verified here; raster tests live in the canvas suite.
+describe('flowchart-bundle: Export Image buttons (v4.13.0)', () => {
+ const BUNDLE_PATH = path.join(__dirname, '..', 'src', 'renderer', 'flowchart-bundle.js');
+ const HTML_PATH_BUNDLE = path.join(__dirname, '..', 'src', 'flowchart-generator.html');
+
+ async function loadBundle(apiOverrides = {}) {
+ const html = fs.readFileSync(HTML_PATH_BUNDLE, 'utf-8');
+ const bodyMatch = html.match(/]*>([\s\S]*)<\/body>/i);
+ document.body.innerHTML = bodyMatch ? bodyMatch[1] : html;
+ const apiMock = {
+ getUserDataPath: jest.fn(async () => '/userdata'),
+ readFile: jest.fn(async () => null),
+ writeFile: jest.fn(async () => undefined),
+ saveFile: jest.fn(async () => ({ canceled: true })),
+ saveBinary: jest.fn(async () => ({ canceled: true })),
+ openFile: jest.fn(async () => null),
+ insertAtCursor: jest.fn(),
+ ...apiOverrides,
+ };
+ window.electronAPI = { flowchart: apiMock };
+ const bundleSrc = fs.readFileSync(BUNDLE_PATH, 'utf-8');
+ // eslint-disable-next-line no-new-func
+ new Function('window', 'document', bundleSrc)(window, document);
+ for (let i = 0; i < 5; i += 1) {
+ await Promise.resolve();
+ }
+ return { apiMock };
+ }
+
+ test('Export SVG calls api.saveFile with the SVG string and flowchart.svg', async () => {
+ const { apiMock } = await loadBundle({
+ saveFile: jest.fn(async () => ({ canceled: false, path: '/tmp/flow.svg' })),
+ });
+ document.getElementById('fc-btn-export-svg').click();
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(apiMock.saveFile).toHaveBeenCalledTimes(1);
+ const [content, defaultName] = apiMock.saveFile.mock.calls[0];
+ expect(typeof content).toBe('string');
+ expect(content.startsWith(' {
+ const { apiMock } = await loadBundle({
+ saveFile: jest.fn(async () => ({ canceled: true })),
+ });
+ document.getElementById('fc-btn-export-svg').click();
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(apiMock.saveFile).toHaveBeenCalledTimes(1);
+ expect(document.getElementById('fc-status').textContent).toBe('Export cancelled');
+ });
+});