/* * SPDX-FileCopyrightText: 2024 The Refinery Authors * * SPDX-License-Identifier: EPL-2.0 */ import createCache from '@emotion/cache'; import { serializeStyles } from '@emotion/serialize'; import type { StyleSheet } from '@emotion/utils'; import italicFontURL from '@fontsource/open-sans/files/open-sans-latin-400-italic.woff2?url'; import normalFontURL from '@fontsource/open-sans/files/open-sans-latin-400-normal.woff2?url'; import boldFontURL from '@fontsource/open-sans/files/open-sans-latin-700-normal.woff2?url'; import variableItalicFontURL from '@fontsource-variable/open-sans/files/open-sans-latin-wght-italic.woff2?url'; import variableFontURL from '@fontsource-variable/open-sans/files/open-sans-latin-wght-normal.woff2?url'; import cancelSVG from '@material-icons/svg/svg/cancel/baseline.svg?raw'; import labelSVG from '@material-icons/svg/svg/label/baseline.svg?raw'; import labelOutlinedSVG from '@material-icons/svg/svg/label/outline.svg?raw'; import type { Theme } from '@mui/material/styles'; import { nanoid } from 'nanoid'; import { darkTheme, lightTheme } from '../../theme/ThemeProvider'; import { copyBlob, saveBlob } from '../../utils/fileIO'; import type GraphStore from '../GraphStore'; import { createGraphTheme } from '../GraphTheme'; import { SVG_NS } from '../postProcessSVG'; import type ExportSettingsStore from './ExportSettingsStore'; const PROLOG = ''; const PNG_CONTENT_TYPE = 'image/png'; const SVG_CONTENT_TYPE = 'image/svg+xml'; const EXPORT_ID = 'export-image'; const ICONS: Map = new Map(); function importSVG(svgSource: string, className: string): void { const parser = new DOMParser(); const svgDocument = parser.parseFromString(svgSource, SVG_CONTENT_TYPE); const root = svgDocument.children[0]; if (root === undefined) { return; } root.id = className; root.classList.add(className); ICONS.set(className, root); } importSVG(labelSVG, 'icon-TRUE'); importSVG(labelOutlinedSVG, 'icon-UNKNOWN'); importSVG(cancelSVG, 'icon-ERROR'); function fixIDs(id: string, svgDocument: XMLDocument) { const idMap = new Map(); let i = 0; svgDocument.querySelectorAll('[id]').forEach((node) => { const oldId = node.getAttribute('id'); if (oldId === null) { return; } if (oldId.endsWith(',clip')) { const newId = `refinery-${id}-clip-${i}`; i += 1; idMap.set(`url(#${oldId})`, `url(#${newId})`); node.setAttribute('id', newId); } else { node.setAttribute('id', ''); } }); svgDocument.querySelectorAll('[clip-path]').forEach((node) => { const oldPath = node.getAttribute('clip-path'); if (oldPath === null) { return; } const newPath = idMap.get(oldPath); if (newPath === undefined) { return; } node.setAttribute('clip-path', newPath); }); } function addBackground( svgDocument: XMLDocument, svg: SVGSVGElement, theme: Theme, ): void { const viewBox = svg.getAttribute('viewBox')?.split(' '); const rect = svgDocument.createElementNS(SVG_NS, 'rect'); rect.setAttribute('x', viewBox?.[0] ?? '0'); rect.setAttribute('y', viewBox?.[1] ?? '0'); rect.setAttribute('width', viewBox?.[2] ?? '0'); rect.setAttribute('height', viewBox?.[3] ?? '0'); rect.setAttribute('fill', theme.palette.background.default); svg.prepend(rect); } async function fetchAsFontURL(url: string): Promise { const fetchResult = await fetch(url); const buffer = await fetchResult.arrayBuffer(); const blob = new Blob([buffer], { type: 'font/woff2' }); return new Promise((resolve, reject) => { const fileReader = new FileReader(); fileReader.addEventListener('load', () => { resolve(fileReader.result as string); }); fileReader.addEventListener('error', () => { reject(fileReader.error); }); fileReader.readAsDataURL(blob); }); } let fontCSS: string | undefined; let variableFontCSS: string | undefined; async function fetchFontCSS(): Promise { if (fontCSS !== undefined) { return fontCSS; } const [normalDataURL, boldDataURL, italicDataURL] = await Promise.all([ fetchAsFontURL(normalFontURL), fetchAsFontURL(boldFontURL), fetchAsFontURL(italicFontURL), ]); fontCSS = ` @font-face { font-family: 'Open Sans'; font-style: normal; font-display: swap; font-weight: 400; src: url(${normalDataURL}) format('woff2'); } @font-face { font-family: 'Open Sans'; font-style: normal; font-display: swap; font-weight: 700; src: url(${boldDataURL}) format('woff2'); } @font-face { font-family: 'Open Sans'; font-style: italic; font-display: swap; font-weight: 400; src: url(${italicDataURL}) format('woff2'); }`; return fontCSS; } async function fetchVariableFontCSS(): Promise { if (variableFontCSS !== undefined) { return variableFontCSS; } const [variableDataURL, variableItalicDataURL] = await Promise.all([ fetchAsFontURL(variableFontURL), fetchAsFontURL(variableItalicFontURL), ]); variableFontCSS = ` @font-face { font-family: 'Open Sans Variable'; font-style: normal; font-display: swap; font-weight: 300 800; src: url(${variableDataURL}) format('woff2-variations'); } @font-face { font-family: 'Open Sans Variable'; font-style: italic; font-display: swap; font-weight: 300 800; src: url(${variableItalicDataURL}) format('woff2-variations'); }`; return variableFontCSS; } interface ThemeVariant { selector: string; theme: Theme; } function appendStyles( id: string, svgDocument: XMLDocument, svg: SVGSVGElement, themes: ThemeVariant[], colorNodes: boolean, hexTypeHashes: string[], fontsCSS: string, ): void { const className = `refinery-${id}`; svg.classList.add(className); const rules: string[] = [fontsCSS]; themes.forEach(({ selector, theme }) => { const cache = createCache({ key: 'refinery', container: svg, prepend: true, }); // @ts-expect-error `CSSObject` types don't match up between `@mui/material` and // `@emotion/serialize`, but they are compatible in practice. const styles = serializeStyles([createGraphTheme], cache.registered, { theme, colorNodes, hexTypeHashes, noEmbedIcons: true, }); const sheet = { insert(rule) { rules.push(rule); }, } as StyleSheet; cache.insert(`${selector} .${className}`, styles, sheet, false); }); const styleElement = svgDocument.createElementNS(SVG_NS, 'style'); svg.prepend(styleElement); styleElement.innerHTML = rules.join(''); } function fixForeignObjects( id: string, svgDocument: XMLDocument, svg: SVGSVGElement, ): void { const foreignObjects: SVGForeignObjectElement[] = []; svg .querySelectorAll('foreignObject') .forEach((object) => foreignObjects.push(object)); foreignObjects.forEach((object) => { const useElement = svgDocument.createElementNS(SVG_NS, 'use'); let x = Number(object.getAttribute('x') ?? '0'); let y = Number(object.getAttribute('y') ?? '0'); const width = Number(object.getAttribute('width') ?? '0'); const height = Number(object.getAttribute('height') ?? '0'); const size = Math.min(width, height); x += (width - size) / 2; y += (height - size) / 2; useElement.setAttribute('x', String(x)); useElement.setAttribute('y', String(y)); useElement.setAttribute('width', String(size)); useElement.setAttribute('height', String(size)); useElement.id = object.id; object.children[0]?.classList?.forEach((className) => { useElement.classList.add(className); if (ICONS.has(className)) { useElement.setAttribute('href', `#refinery-${id}-${className}`); } }); object.replaceWith(useElement); }); const defs = svgDocument.createElementNS(SVG_NS, 'defs'); svg.prepend(defs); ICONS.forEach((value) => { const importedValue = svgDocument.importNode(value, true); importedValue.id = `refinery-${id}-${importedValue.id}`; defs.appendChild(importedValue); }); } function serializeSVG(svgDocument: XMLDocument): Blob { const serializer = new XMLSerializer(); const svgText = `${PROLOG}\n${serializer.serializeToString(svgDocument)}`; return new Blob([svgText], { type: SVG_CONTENT_TYPE, }); } async function serializePNG( serializedSVG: Blob, svg: SVGSVGElement, settings: ExportSettingsStore, theme: Theme, ): Promise { const scale = settings.scale / 100; const baseWidth = svg.width.baseVal.value; const baseHeight = svg.height.baseVal.value; const exactWidth = baseWidth * scale; const exactHeight = baseHeight * scale; const width = Math.round(exactWidth); const height = Math.round(exactHeight); const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const image = document.createElement('img'); const url = window.URL.createObjectURL(serializedSVG); try { await new Promise((resolve, reject) => { image.addEventListener('load', () => resolve(undefined)); image.addEventListener('error', ({ error }) => reject( error instanceof Error ? error : new Error(`Failed to load image: ${error}`), ), ); image.src = url; }); } finally { window.URL.revokeObjectURL(url); } const context = canvas.getContext('2d'); if (context === null) { throw new Error('Failed to get canvas 2D context'); } if (!settings.transparent) { context.fillStyle = theme.palette.background.default; context.fillRect(0, 0, width, height); } context.drawImage( image, 0, 0, baseWidth, baseHeight, 0, 0, exactWidth, exactHeight, ); return new Promise((resolve, reject) => { canvas.toBlob((exportedBlob) => { if (exportedBlob === null) { reject(new Error('Failed to export PNG blob')); } else { resolve(exportedBlob); } }, PNG_CONTENT_TYPE); }); } let serializePDFCached: | ((svg: SVGSVGElement, embedFonts: boolean) => Promise) | undefined; async function serializePDF( svg: SVGSVGElement, settings: ExportSettingsStore, ): Promise { if (serializePDFCached === undefined) { serializePDFCached = (await import('./serializePDF')).default; } return serializePDFCached(svg, settings.embedFonts); } export default async function exportDiagram( svgContainer: HTMLElement | undefined, graph: GraphStore, settings: ExportSettingsStore, mode: 'download' | 'copy', ): Promise { const svg = svgContainer?.querySelector('svg'); if (!svg) { return; } const svgDocument = document.implementation.createDocument( SVG_NS, 'svg', null, ); const copyOfSVG = svgDocument.importNode(svg, true); const originalRoot = svgDocument.childNodes[0]; if (originalRoot === undefined) { svgDocument.appendChild(copyOfSVG); } else { svgDocument.replaceChild(copyOfSVG, originalRoot); } const id = nanoid(); fixIDs(id, svgDocument); let theme: Theme; let themes: ThemeVariant[]; if (settings.theme === 'dynamic') { theme = lightTheme; themes = [ { selector: '', theme: lightTheme, }, { selector: '[data-theme="dark"]', theme: darkTheme, }, ]; } else { theme = settings.theme === 'light' ? lightTheme : darkTheme; themes = [ { selector: '', theme, }, ]; } if (!settings.transparent) { addBackground(svgDocument, copyOfSVG, theme); } fixForeignObjects(id, svgDocument, copyOfSVG); const { colorNodes } = graph; let fontsCSS = ''; if (settings.format === 'png') { // If we are creating a PNG, font file size doesn't matter, // and we can reuse fonts the browser has already downloaded. fontsCSS = await fetchVariableFontCSS(); } else if (settings.format === 'svg' && settings.embedFonts) { fontsCSS = await fetchFontCSS(); } appendStyles( id, svgDocument, copyOfSVG, themes, colorNodes, graph.hexTypeHashes, fontsCSS, ); if (settings.format === 'pdf') { const pdf = await serializePDF(copyOfSVG, settings); await saveBlob(pdf, `${graph.name}.pdf`, { id: EXPORT_ID, types: [ { description: 'PDF files', accept: { 'application/pdf': ['.pdf', '.PDF'], }, }, ], }); return; } const serializedSVG = serializeSVG(svgDocument); if (settings.format === 'png') { const png = await serializePNG(serializedSVG, svg, settings, theme); if (mode === 'copy') { await copyBlob(png); } else { await saveBlob(png, `${graph.name}.png`, { id: EXPORT_ID, types: [ { description: 'PNG graphics', accept: { [PNG_CONTENT_TYPE]: ['.png', '.PNG'], }, }, ], }); } } else if (mode === 'copy') { await copyBlob(serializedSVG); } else { await saveBlob(serializedSVG, `${graph.name}.svg`, { id: EXPORT_ID, types: [ { description: 'SVG graphics', accept: { [SVG_CONTENT_TYPE]: ['.svg', '.SVG'], }, }, ], }); } }