From 0b6335560a2f3553750a548ea1c3dd3865983d3e Mon Sep 17 00:00:00 2001 From: MAEDA Go Date: Tue, 13 Oct 2026 19:01:20 +0900 Subject: [PATCH] Add a link to download a rendered Mermaid diagram as a PNG image A download link is shown next to the copy button when hovering over a rendered diagram. When the link is clicked, the diagram is drawn on a canvas in the browser and saved as a PNG image at twice its size. The background is left transparent, as in the diagram generated by Mermaid.js. To avoid using up memory, the image is limited to 4,096 pixels per side and 8,000,000 pixels in total. A larger diagram is scaled down with its aspect ratio preserved. The file name consists of the diagram type and the @2x suffix, such as mermaid-flowchart@2x.png. The suffix is omitted when the image has been scaled down. The diagram is not saved as SVG because the SVG generated by Mermaid.js relies on HTML () and CSS, and it is not displayed properly in applications other than web browsers. Co-Authored-By: Claude Opus 5.5 --- app/assets/stylesheets/application.css | 11 +- app/helpers/application_helper.rb | 2 +- .../controllers/mermaid_controller.js | 103 +++++++++++++++++- test/system/mermaid_rendering_test.rb | 19 ++++ 4 files changed, 131 insertions(+), 4 deletions(-) diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 302cdba14..dedd5a951 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -1994,7 +1994,8 @@ div.wiki *:not(pre)>code, div.wiki>code { white-space: pre-wrap; } -div.pre-wrapper a.copy-pre-content-link { +div.pre-wrapper a.copy-pre-content-link, +div.pre-wrapper a.mermaid-download-link { position: absolute; inset-block-start: 3px; inset-inline-end: calc(1em + 3px); @@ -2007,7 +2008,13 @@ div.pre-wrapper a.copy-pre-content-link { padding-inline: 3px; } -div.pre-wrapper:hover a.copy-pre-content-link { +div.pre-wrapper a.mermaid-download-link { + /* To the left of the copy button: its width (18px icon + padding + border) plus a 3px gap */ + inset-inline-end: calc(1em + 3px + 26px + 3px); +} + +div.pre-wrapper:hover a.copy-pre-content-link, +div.pre-wrapper:hover a.mermaid-download-link { display: block; } diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index ea533c776..806909c80 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1941,7 +1941,7 @@ module ApplicationHelper javascript_tag( "rm = window.rm || {};" \ "rm.I18n = rm.I18n || {};" \ - "rm.I18n = Object.freeze({buttonCopy: '#{l(:button_copy)}'});" + "rm.I18n = Object.freeze({buttonCopy: '#{l(:button_copy)}', buttonDownload: '#{l(:button_download)}'});" ) end diff --git a/app/javascript/controllers/mermaid_controller.js b/app/javascript/controllers/mermaid_controller.js index 1b95f6c7e..61d09f8e5 100644 --- a/app/javascript/controllers/mermaid_controller.js +++ b/app/javascript/controllers/mermaid_controller.js @@ -5,6 +5,12 @@ */ import { Controller } from "@hotwired/stimulus" +// Diagrams are saved larger than their original size so that they stay sharp when enlarged. +const PNG_SCALE = 2; +// Larger diagrams are scaled down so that saving them does not use up memory. +const MAX_PNG_PIXELS_PER_SIDE = 4096; +const MAX_PNG_TOTAL_PIXELS = 8_000_000; + let mermaidInitialized = false; let mermaidLoadPromise = null; @@ -63,7 +69,8 @@ export default class extends Controller { // Undo everything render() added so that a reconnect starts clean. this.container?.remove(); this.errorElement?.remove(); - this.container = this.errorElement = null; + this.downloadLink?.remove(); + this.container = this.errorElement = this.downloadLink = null; const pre = this.element.closest('pre'); if (pre) pre.style.display = ''; } @@ -87,6 +94,7 @@ export default class extends Controller { // visible, so that the invalid diagram can be inspected and copied. mermaid.run({ nodes: [container], suppressErrors: false }).then(() => { pre.style.display = 'none'; + if (this.container === container) this.addDownloadLink(pre, container); }).catch((error) => { container.remove(); if (this.container === container) this.container = null; @@ -105,4 +113,97 @@ export default class extends Controller { this.errorElement = errorElement; }); } + + addDownloadLink(pre, container) { + const link = document.createElement('a'); + link.className = 'mermaid-download-link icon-only'; + link.title = rm.I18n.buttonDownload; + // A link without href cannot be operated with the keyboard. + link.href = '#'; + link.append(createSVGIcon('download')); + link.addEventListener('click', (event) => { + event.preventDefault(); + this.download(); + }); + + // Put the link next to the copy button that CopypreScrubber adds to div.pre-wrapper. + const wrapper = pre.closest('.pre-wrapper'); + if (wrapper) { + wrapper.append(link); + } else { + container.insertAdjacentElement('beforebegin', link); + } + this.downloadLink = link; + } + + // The diagram is saved as a PNG image because the SVG generated by Mermaid.js + // relies on HTML and CSS and is not displayed properly outside web browsers. + async download() { + try { + const svg = this.container.querySelector('svg'); + const scale = this.pngScaleFor(svg); + const canvas = await this.drawOnCanvas(svg, scale); + canvas.toBlob((png) => { + if (png) this.saveAs(png, this.filenameFor(svg, scale)); + }, 'image/png'); + } catch (error) { + console.error('Failed to save Mermaid diagram:', error); + } + } + + pngScaleFor(svg) { + const { width, height } = svg.viewBox.baseVal; + return Math.min( + PNG_SCALE, + MAX_PNG_PIXELS_PER_SIDE / Math.max(width, height), + Math.sqrt(MAX_PNG_TOTAL_PIXELS / (width * height)) + ); + } + + // Returns a name like mermaid-flowchart@2x.png, or mermaid-flowchart.png + // when the image has been scaled down. + filenameFor(svg, scale) { + // Mermaid.js sets the diagram type here, such as "sequence" or "flowchart-v2". + const diagramType = (svg.getAttribute('aria-roledescription') || '').replace(/-v\d+$/, ''); + const name = /^[a-z0-9]+$/i.test(diagramType) ? diagramType : 'diagram'; + const suffix = scale === PNG_SCALE ? `@${PNG_SCALE}x` : ''; + return `mermaid-${name}${suffix}.png`; + } + + async drawOnCanvas(svg, scale) { + const { width, height } = svg.viewBox.baseVal; + // Rounded down so that the limits are never exceeded. + const pngWidth = Math.floor(width * scale); + const pngHeight = Math.floor(height * scale); + + // Unlike cloneNode(), this copy does not fetch elements in labels again. + const standaloneSvg = document.implementation.createHTMLDocument('').importNode(svg, true); + // Gives the SVG the size of the PNG so that it is rendered at that size from + // the start, without the max-width that Mermaid.js sets to the original width. + standaloneSvg.setAttribute('width', pngWidth); + standaloneSvg.setAttribute('height', pngHeight); + standaloneSvg.style.removeProperty('max-width'); + const source = new XMLSerializer().serializeToString(standaloneSvg); + + const image = new Image(); + // Not a blob: URL, with which Chrome refuses to export a canvas once an + // SVG containing has been drawn on it. + image.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(source); + await image.decode(); + + const canvas = document.createElement('canvas'); + canvas.width = pngWidth; + canvas.height = pngHeight; + canvas.getContext('2d').drawImage(image, 0, 0); + return canvas; + } + + saveAs(blob, filename) { + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = filename; + link.click(); + // Give the browser time to start the download before releasing the blob. + setTimeout(() => URL.revokeObjectURL(link.href), 1000); + } } diff --git a/test/system/mermaid_rendering_test.rb b/test/system/mermaid_rendering_test.rb index 2119d7c8d..ccd30add9 100644 --- a/test/system/mermaid_rendering_test.rb +++ b/test/system/mermaid_rendering_test.rb @@ -69,9 +69,28 @@ class MermaidRenderingTest < ApplicationSystemTestCase assert_selector '.flash.error', text: 'Failed to render mermaid diagram' assert_selector 'pre' assert_no_selector 'div.mermaid svg' + assert_no_selector 'a.mermaid-download-link', visible: :all end end + def test_should_download_mermaid_diagram_as_png + skip unless mermaid_installed? + + issue = Issue.find(1) + issue.update_column(:description, "```mermaid\ngraph TD;\nA-->B;\n```") + + log_user('jsmith', 'jsmith') + visit "/issues/#{issue.id}" + + within('div.description') do + find('div.mermaid svg').hover + find('a.mermaid-download-link').click + end + + png_signature = "\x89PNG\r\n\x1A\n".b + assert_equal png_signature, File.binread(downloaded_file('mermaid-flowchart@2x.png'))[0, 8] + end + def test_should_render_mermaid_in_description_preview skip unless mermaid_installed? -- 2.55.0