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'); + }); +});