From 3cdbc76fff140cc60cfa6796c3a2c24f9a5fbc05 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 31 Mar 2025 18:36:57 +0300 Subject: [PATCH 1/3] feat(mermaid): display an error when PNG export could not occur --- src/public/app/services/utils.ts | 75 +++++++++++-------- .../abstract_svg_split_type_widget.ts | 11 ++- src/public/translations/en/translation.json | 3 + 3 files changed, 56 insertions(+), 33 deletions(-) diff --git a/src/public/app/services/utils.ts b/src/public/app/services/utils.ts index a4b0aa750..9d39bb279 100644 --- a/src/public/app/services/utils.ts +++ b/src/public/app/services/utils.ts @@ -650,47 +650,58 @@ function triggerDownload(fileName: string, dataUrl: string) { * * @param nameWithoutExtension the name of the file. The .png suffix is automatically added to it. * @param svgContent the content of the SVG file download. - * @returns `true` if the operation succeeded (width/height present), or `false` if the download was not triggered. + * @returns a promise which resolves if the operation was successful, or rejects if it failed (permissions issue or some other issue). */ function downloadSvgAsPng(nameWithoutExtension: string, svgContent: string) { - const mime = "image/svg+xml"; + return new Promise((resolve, reject) => { + const mime = "image/svg+xml"; - // First, we need to determine the width and the height from the input SVG. - const svgDocument = (new DOMParser()).parseFromString(svgContent, mime); - const width = svgDocument.documentElement?.getAttribute("width"); - const height = svgDocument.documentElement?.getAttribute("height"); + // First, we need to determine the width and the height from the input SVG. + const svgDocument = (new DOMParser()).parseFromString(svgContent, mime); + const width = svgDocument.documentElement?.getAttribute("width"); + const height = svgDocument.documentElement?.getAttribute("height"); - if (!width || !height) { - return false; - } + if (!width || !height) { + reject(); + return; + } - // Convert the image to a blob. - const svgBlob = new Blob([ svgContent ], { - type: mime - }) + // Convert the image to a blob. + const svgBlob = new Blob([ svgContent ], { + type: mime + }) - // Create an image element and load the SVG. - const imageEl = new Image(); - imageEl.width = parseFloat(width); - imageEl.height = parseFloat(height); - imageEl.src = URL.createObjectURL(svgBlob); - imageEl.onload = () => { - // Draw the image with a canvas. - const canvasEl = document.createElement("canvas"); - canvasEl.width = imageEl.width; - canvasEl.height = imageEl.height; - document.body.appendChild(canvasEl); + // Create an image element and load the SVG. + const imageEl = new Image(); + imageEl.width = parseFloat(width); + imageEl.height = parseFloat(height); + imageEl.onload = () => { + try { + // Draw the image with a canvas. + const canvasEl = document.createElement("canvas"); + canvasEl.width = imageEl.width; + canvasEl.height = imageEl.height; + document.body.appendChild(canvasEl); - const ctx = canvasEl.getContext("2d"); - ctx?.drawImage(imageEl, 0, 0); - URL.revokeObjectURL(imageEl.src); + const ctx = canvasEl.getContext("2d"); + if (!ctx) { + reject(); + } - const imgUri = canvasEl.toDataURL("image/png") - triggerDownload(`${nameWithoutExtension}.png`, imgUri); - document.body.removeChild(canvasEl); - }; + ctx?.drawImage(imageEl, 0, 0); + URL.revokeObjectURL(imageEl.src); - return true; + const imgUri = canvasEl.toDataURL("image/png") + triggerDownload(`${nameWithoutExtension}.png`, imgUri); + document.body.removeChild(canvasEl); + resolve(); + } catch (e) { + console.warn(e); + reject(); + } + }; + imageEl.src = URL.createObjectURL(svgBlob); + }); } /** diff --git a/src/public/app/widgets/type_widgets/abstract_svg_split_type_widget.ts b/src/public/app/widgets/type_widgets/abstract_svg_split_type_widget.ts index aeea96089..061694349 100644 --- a/src/public/app/widgets/type_widgets/abstract_svg_split_type_widget.ts +++ b/src/public/app/widgets/type_widgets/abstract_svg_split_type_widget.ts @@ -2,7 +2,9 @@ import type { EventData } from "../../components/app_context.js"; import type FNote from "../../entities/fnote.js"; import { t } from "../../services/i18n.js"; import server from "../../services/server.js"; +import toast from "../../services/toast.js"; import utils from "../../services/utils.js"; +import ws from "../../services/ws.js"; import OnClickButtonWidget from "../buttons/onclick_button.js"; import AbstractSplitTypeWidget from "./abstract_split_type_widget.js"; @@ -218,11 +220,18 @@ export default abstract class AbstractSvgSplitTypeWidget extends AbstractSplitTy } async exportPngEvent({ ntxId }: EventData<"exportPng">) { + console.log("Export to PNG", this.noteContext?.noteId, ntxId, this.svg); if (!this.isNoteContext(ntxId) || this.note?.type !== "mermaid" || !this.svg) { + console.log("Return"); return; } - utils.downloadSvgAsPng(this.note.title, this.svg); + try { + await utils.downloadSvgAsPng(this.note.title, this.svg); + } catch (e) { + console.warn(e); + toast.showError(t("svg.export_to_png")); + } } } diff --git a/src/public/translations/en/translation.json b/src/public/translations/en/translation.json index 13bb74dfd..e985ffc4d 100644 --- a/src/public/translations/en/translation.json +++ b/src/public/translations/en/translation.json @@ -1746,5 +1746,8 @@ }, "png_export_button": { "button_title": "Export diagram as PNG" + }, + "svg": { + "export_to_png": "The diagram could not be exported to PNG." } } From 3d0ec27038c104ce1738e2d02f5e4e874ea6b09d Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 31 Mar 2025 18:54:57 +0300 Subject: [PATCH 2/3] fix(mermaid): fix export to PNG for some diagram types --- src/public/app/services/utils.spec.ts | 22 +++++++++++++ src/public/app/services/utils.ts | 46 +++++++++++++++++++++------ 2 files changed, 58 insertions(+), 10 deletions(-) create mode 100644 src/public/app/services/utils.spec.ts diff --git a/src/public/app/services/utils.spec.ts b/src/public/app/services/utils.spec.ts new file mode 100644 index 000000000..2885fa270 --- /dev/null +++ b/src/public/app/services/utils.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { getSizeFromSvg } from "./utils.js"; + +describe("getSizeFromSvg", () => { + it("parses width & height attribute", () => { + const svg = ``; + const result = getSizeFromSvg(svg); + expect(result).toMatchObject({ + width: 714, + height: 574, + }); + }); + + it("parses viewbox", () => { + const svg = ``; + const result = getSizeFromSvg(svg); + expect(result).toMatchObject({ + width: 872.2750244140625, + height: 655 + }); + }); +}); diff --git a/src/public/app/services/utils.ts b/src/public/app/services/utils.ts index 9d39bb279..bb85729ba 100644 --- a/src/public/app/services/utils.ts +++ b/src/public/app/services/utils.ts @@ -2,6 +2,8 @@ import dayjs from "dayjs"; import { Modal } from "bootstrap"; import type { ViewScope } from "./link.js"; +const SVG_MIME = "image/svg+xml"; + function reloadFrontendApp(reason?: string) { if (reason) { logInfo(`Frontend app reload: ${reason}`); @@ -654,27 +656,23 @@ function triggerDownload(fileName: string, dataUrl: string) { */ function downloadSvgAsPng(nameWithoutExtension: string, svgContent: string) { return new Promise((resolve, reject) => { - const mime = "image/svg+xml"; - // First, we need to determine the width and the height from the input SVG. - const svgDocument = (new DOMParser()).parseFromString(svgContent, mime); - const width = svgDocument.documentElement?.getAttribute("width"); - const height = svgDocument.documentElement?.getAttribute("height"); - - if (!width || !height) { + const result = getSizeFromSvg(svgContent); + if (!result) { reject(); return; } // Convert the image to a blob. + const { width, height } = result; const svgBlob = new Blob([ svgContent ], { - type: mime + type: SVG_MIME }) // Create an image element and load the SVG. const imageEl = new Image(); - imageEl.width = parseFloat(width); - imageEl.height = parseFloat(height); + imageEl.width = width; + imageEl.height = height; imageEl.onload = () => { try { // Draw the image with a canvas. @@ -704,6 +702,34 @@ function downloadSvgAsPng(nameWithoutExtension: string, svgContent: string) { }); } +export function getSizeFromSvg(svgContent: string) { + const svgDocument = (new DOMParser()).parseFromString(svgContent, SVG_MIME); + + // Try to use width & height attributes if available. + let width = svgDocument.documentElement?.getAttribute("width"); + let height = svgDocument.documentElement?.getAttribute("height"); + + // If not, use the viewbox. + if (!width || !height) { + const viewBox = svgDocument.documentElement?.getAttribute("viewBox"); + if (viewBox) { + const viewBoxParts = viewBox.split(" "); + width = viewBoxParts[2]; + height = viewBoxParts[3]; + } + } + + if (width && height) { + return { + width: parseFloat(width), + height: parseFloat(height) + } + } else { + console.warn("SVG export error", svgDocument.documentElement); + return null; + } +} + /** * Compares two semantic version strings. * Returns: From 6976c9555ee2b0061edaeab0968e4a1c1aa1c6e0 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 31 Mar 2025 21:18:40 +0300 Subject: [PATCH 3/3] fix(mermaid): bypass security issue when generating PNG --- src/public/app/services/utils.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/public/app/services/utils.ts b/src/public/app/services/utils.ts index bb85729ba..004b38762 100644 --- a/src/public/app/services/utils.ts +++ b/src/public/app/services/utils.ts @@ -665,14 +665,12 @@ function downloadSvgAsPng(nameWithoutExtension: string, svgContent: string) { // Convert the image to a blob. const { width, height } = result; - const svgBlob = new Blob([ svgContent ], { - type: SVG_MIME - }) // Create an image element and load the SVG. const imageEl = new Image(); imageEl.width = width; imageEl.height = height; + imageEl.crossOrigin = "anonymous"; imageEl.onload = () => { try { // Draw the image with a canvas. @@ -687,7 +685,6 @@ function downloadSvgAsPng(nameWithoutExtension: string, svgContent: string) { } ctx?.drawImage(imageEl, 0, 0); - URL.revokeObjectURL(imageEl.src); const imgUri = canvasEl.toDataURL("image/png") triggerDownload(`${nameWithoutExtension}.png`, imgUri); @@ -698,7 +695,8 @@ function downloadSvgAsPng(nameWithoutExtension: string, svgContent: string) { reject(); } }; - imageEl.src = URL.createObjectURL(svgBlob); + imageEl.onerror = (e) => reject(e); + imageEl.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgContent)}`; }); }