diff options
Diffstat (limited to 'subprojects/frontend/src/graph/export/exportDiagram.tsx')
-rw-r--r-- | subprojects/frontend/src/graph/export/exportDiagram.tsx | 359 |
1 files changed, 359 insertions, 0 deletions
diff --git a/subprojects/frontend/src/graph/export/exportDiagram.tsx b/subprojects/frontend/src/graph/export/exportDiagram.tsx new file mode 100644 index 00000000..685b6ace --- /dev/null +++ b/subprojects/frontend/src/graph/export/exportDiagram.tsx | |||
@@ -0,0 +1,359 @@ | |||
1 | /* | ||
2 | * SPDX-FileCopyrightText: 2024 The Refinery Authors <https://refinery.tools/> | ||
3 | * | ||
4 | * SPDX-License-Identifier: EPL-2.0 | ||
5 | */ | ||
6 | |||
7 | import createCache from '@emotion/cache'; | ||
8 | import { serializeStyles } from '@emotion/serialize'; | ||
9 | import type { StyleSheet } from '@emotion/utils'; | ||
10 | import italicFontURL from '@fontsource/open-sans/files/open-sans-latin-400-italic.woff2?url'; | ||
11 | import normalFontURL from '@fontsource/open-sans/files/open-sans-latin-400-normal.woff2?url'; | ||
12 | import boldFontURL from '@fontsource/open-sans/files/open-sans-latin-700-normal.woff2?url'; | ||
13 | import variableItalicFontURL from '@fontsource-variable/open-sans/files/open-sans-latin-wght-italic.woff2?url'; | ||
14 | import variableFontURL from '@fontsource-variable/open-sans/files/open-sans-latin-wght-normal.woff2?url'; | ||
15 | import cancelSVG from '@material-icons/svg/svg/cancel/baseline.svg?raw'; | ||
16 | import labelSVG from '@material-icons/svg/svg/label/baseline.svg?raw'; | ||
17 | import labelOutlinedSVG from '@material-icons/svg/svg/label/outline.svg?raw'; | ||
18 | import type { Theme } from '@mui/material/styles'; | ||
19 | |||
20 | import { darkTheme, lightTheme } from '../../theme/ThemeProvider'; | ||
21 | import { copyBlob, saveBlob } from '../../utils/fileIO'; | ||
22 | import type GraphStore from '../GraphStore'; | ||
23 | import { createGraphTheme } from '../GraphTheme'; | ||
24 | import { SVG_NS } from '../postProcessSVG'; | ||
25 | |||
26 | import type ExportSettingsStore from './ExportSettingsStore'; | ||
27 | |||
28 | const PROLOG = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>'; | ||
29 | const PNG_CONTENT_TYPE = 'image/png'; | ||
30 | const SVG_CONTENT_TYPE = 'image/svg+xml'; | ||
31 | const EXPORT_ID = 'export-image'; | ||
32 | |||
33 | const ICONS: Map<string, Element> = new Map(); | ||
34 | |||
35 | function importSVG(svgSource: string, className: string): void { | ||
36 | const parser = new DOMParser(); | ||
37 | const svgDocument = parser.parseFromString(svgSource, SVG_CONTENT_TYPE); | ||
38 | const root = svgDocument.children[0]; | ||
39 | if (root === undefined) { | ||
40 | return; | ||
41 | } | ||
42 | root.id = className; | ||
43 | root.classList.add(className); | ||
44 | ICONS.set(className, root); | ||
45 | } | ||
46 | |||
47 | importSVG(labelSVG, 'icon-TRUE'); | ||
48 | importSVG(labelOutlinedSVG, 'icon-UNKNOWN'); | ||
49 | importSVG(cancelSVG, 'icon-ERROR'); | ||
50 | |||
51 | function addBackground( | ||
52 | svgDocument: XMLDocument, | ||
53 | svg: SVGSVGElement, | ||
54 | theme: Theme, | ||
55 | ): void { | ||
56 | const viewBox = svg.getAttribute('viewBox')?.split(' '); | ||
57 | const rect = svgDocument.createElementNS(SVG_NS, 'rect'); | ||
58 | rect.setAttribute('x', viewBox?.[0] ?? '0'); | ||
59 | rect.setAttribute('y', viewBox?.[1] ?? '0'); | ||
60 | rect.setAttribute('width', viewBox?.[2] ?? '0'); | ||
61 | rect.setAttribute('height', viewBox?.[3] ?? '0'); | ||
62 | rect.setAttribute('fill', theme.palette.background.default); | ||
63 | svg.prepend(rect); | ||
64 | } | ||
65 | |||
66 | async function fetchAsFontURL(url: string): Promise<string> { | ||
67 | const fetchResult = await fetch(url); | ||
68 | const buffer = await fetchResult.arrayBuffer(); | ||
69 | const blob = new Blob([buffer], { type: 'font/woff2' }); | ||
70 | return new Promise((resolve, reject) => { | ||
71 | const fileReader = new FileReader(); | ||
72 | fileReader.addEventListener('load', () => { | ||
73 | resolve(fileReader.result as string); | ||
74 | }); | ||
75 | fileReader.addEventListener('error', () => { | ||
76 | reject(fileReader.error); | ||
77 | }); | ||
78 | fileReader.readAsDataURL(blob); | ||
79 | }); | ||
80 | } | ||
81 | |||
82 | let fontCSS: string | undefined; | ||
83 | let variableFontCSS: string | undefined; | ||
84 | |||
85 | async function fetchFontCSS(): Promise<string> { | ||
86 | if (fontCSS !== undefined) { | ||
87 | return fontCSS; | ||
88 | } | ||
89 | const [normalDataURL, boldDataURL, italicDataURL] = await Promise.all([ | ||
90 | fetchAsFontURL(normalFontURL), | ||
91 | fetchAsFontURL(boldFontURL), | ||
92 | fetchAsFontURL(italicFontURL), | ||
93 | ]); | ||
94 | fontCSS = ` | ||
95 | @font-face { | ||
96 | font-family: 'Open Sans'; | ||
97 | font-style: normal; | ||
98 | font-display: swap; | ||
99 | font-weight: 400; | ||
100 | src: url(${normalDataURL}) format('woff2'); | ||
101 | } | ||
102 | @font-face { | ||
103 | font-family: 'Open Sans'; | ||
104 | font-style: normal; | ||
105 | font-display: swap; | ||
106 | font-weight: 700; | ||
107 | src: url(${boldDataURL}) format('woff2'); | ||
108 | } | ||
109 | @font-face { | ||
110 | font-family: 'Open Sans'; | ||
111 | font-style: italic; | ||
112 | font-display: swap; | ||
113 | font-weight: 400; | ||
114 | src: url(${italicDataURL}) format('woff2'); | ||
115 | }`; | ||
116 | return fontCSS; | ||
117 | } | ||
118 | |||
119 | async function fetchVariableFontCSS(): Promise<string> { | ||
120 | if (variableFontCSS !== undefined) { | ||
121 | return variableFontCSS; | ||
122 | } | ||
123 | const [variableDataURL, variableItalicDataURL] = await Promise.all([ | ||
124 | fetchAsFontURL(variableFontURL), | ||
125 | fetchAsFontURL(variableItalicFontURL), | ||
126 | ]); | ||
127 | variableFontCSS = ` | ||
128 | @font-face { | ||
129 | font-family: 'Open Sans Variable'; | ||
130 | font-style: normal; | ||
131 | font-display: swap; | ||
132 | font-weight: 300 800; | ||
133 | src: url(${variableDataURL}) format('woff2-variations'); | ||
134 | } | ||
135 | @font-face { | ||
136 | font-family: 'Open Sans Variable'; | ||
137 | font-style: normal; | ||
138 | font-display: swap; | ||
139 | font-weight: 300 800; | ||
140 | src: url(${variableItalicDataURL}) format('woff2-variations'); | ||
141 | }`; | ||
142 | return variableFontCSS; | ||
143 | } | ||
144 | |||
145 | function appendStyles( | ||
146 | svgDocument: XMLDocument, | ||
147 | svg: SVGSVGElement, | ||
148 | theme: Theme, | ||
149 | colorNodes: boolean, | ||
150 | fontsCSS: string, | ||
151 | ): void { | ||
152 | const cache = createCache({ | ||
153 | key: 'refinery', | ||
154 | container: svg, | ||
155 | prepend: true, | ||
156 | }); | ||
157 | // @ts-expect-error `CSSObject` types don't match up between `@mui/material` and | ||
158 | // `@emotion/serialize`, but they are compatible in practice. | ||
159 | const styles = serializeStyles([createGraphTheme], cache.registered, { | ||
160 | theme, | ||
161 | colorNodes, | ||
162 | noEmbedIcons: true, | ||
163 | }); | ||
164 | const rules: string[] = [fontsCSS]; | ||
165 | const sheet = { | ||
166 | insert(rule) { | ||
167 | rules.push(rule); | ||
168 | }, | ||
169 | } as StyleSheet; | ||
170 | cache.insert('', styles, sheet, false); | ||
171 | const styleElement = svgDocument.createElementNS(SVG_NS, 'style'); | ||
172 | svg.prepend(styleElement); | ||
173 | styleElement.innerHTML = rules.join(''); | ||
174 | } | ||
175 | |||
176 | function fixForeignObjects(svgDocument: XMLDocument, svg: SVGSVGElement): void { | ||
177 | const foreignObjects: SVGForeignObjectElement[] = []; | ||
178 | svg | ||
179 | .querySelectorAll('foreignObject') | ||
180 | .forEach((object) => foreignObjects.push(object)); | ||
181 | foreignObjects.forEach((object) => { | ||
182 | const useElement = svgDocument.createElementNS(SVG_NS, 'use'); | ||
183 | let x = Number(object.getAttribute('x') ?? '0'); | ||
184 | let y = Number(object.getAttribute('y') ?? '0'); | ||
185 | const width = Number(object.getAttribute('width') ?? '0'); | ||
186 | const height = Number(object.getAttribute('height') ?? '0'); | ||
187 | const size = Math.min(width, height); | ||
188 | x += (width - size) / 2; | ||
189 | y += (height - size) / 2; | ||
190 | useElement.setAttribute('x', String(x)); | ||
191 | useElement.setAttribute('y', String(y)); | ||
192 | useElement.setAttribute('width', String(size)); | ||
193 | useElement.setAttribute('height', String(size)); | ||
194 | useElement.id = object.id; | ||
195 | object.children[0]?.classList?.forEach((className) => { | ||
196 | useElement.classList.add(className); | ||
197 | if (ICONS.has(className)) { | ||
198 | useElement.setAttribute('href', `#${className}`); | ||
199 | } | ||
200 | }); | ||
201 | object.replaceWith(useElement); | ||
202 | }); | ||
203 | const defs = svgDocument.createElementNS(SVG_NS, 'defs'); | ||
204 | svg.prepend(defs); | ||
205 | ICONS.forEach((value) => { | ||
206 | const importedValue = svgDocument.importNode(value, true); | ||
207 | defs.appendChild(importedValue); | ||
208 | }); | ||
209 | } | ||
210 | |||
211 | function serializeSVG(svgDocument: XMLDocument): Blob { | ||
212 | const serializer = new XMLSerializer(); | ||
213 | const svgText = `${PROLOG}\n${serializer.serializeToString(svgDocument)}`; | ||
214 | return new Blob([svgText], { | ||
215 | type: SVG_CONTENT_TYPE, | ||
216 | }); | ||
217 | } | ||
218 | |||
219 | async function serializePNG( | ||
220 | serializedSVG: Blob, | ||
221 | svg: SVGSVGElement, | ||
222 | settings: ExportSettingsStore, | ||
223 | theme: Theme, | ||
224 | ): Promise<Blob> { | ||
225 | const scale = settings.scale / 100; | ||
226 | const baseWidth = svg.width.baseVal.value; | ||
227 | const baseHeight = svg.height.baseVal.value; | ||
228 | const exactWidth = baseWidth * scale; | ||
229 | const exactHeight = baseHeight * scale; | ||
230 | const width = Math.round(exactWidth); | ||
231 | const height = Math.round(exactHeight); | ||
232 | |||
233 | const canvas = document.createElement('canvas'); | ||
234 | canvas.width = width; | ||
235 | canvas.height = height; | ||
236 | |||
237 | const image = document.createElement('img'); | ||
238 | const url = window.URL.createObjectURL(serializedSVG); | ||
239 | try { | ||
240 | await new Promise((resolve, reject) => { | ||
241 | image.addEventListener('load', () => resolve(undefined)); | ||
242 | image.addEventListener('error', ({ error }) => | ||
243 | reject( | ||
244 | error instanceof Error | ||
245 | ? error | ||
246 | : new Error(`Failed to load image: ${error}`), | ||
247 | ), | ||
248 | ); | ||
249 | image.src = url; | ||
250 | }); | ||
251 | } finally { | ||
252 | window.URL.revokeObjectURL(url); | ||
253 | } | ||
254 | |||
255 | const context = canvas.getContext('2d'); | ||
256 | if (context === null) { | ||
257 | throw new Error('Failed to get canvas 2D context'); | ||
258 | } | ||
259 | if (!settings.transparent) { | ||
260 | context.fillStyle = theme.palette.background.default; | ||
261 | context.fillRect(0, 0, width, height); | ||
262 | } | ||
263 | context.drawImage( | ||
264 | image, | ||
265 | 0, | ||
266 | 0, | ||
267 | baseWidth, | ||
268 | baseHeight, | ||
269 | 0, | ||
270 | 0, | ||
271 | exactWidth, | ||
272 | exactHeight, | ||
273 | ); | ||
274 | |||
275 | return new Promise<Blob>((resolve, reject) => { | ||
276 | canvas.toBlob((exportedBlob) => { | ||
277 | if (exportedBlob === null) { | ||
278 | reject(new Error('Failed to export PNG blob')); | ||
279 | } else { | ||
280 | resolve(exportedBlob); | ||
281 | } | ||
282 | }, PNG_CONTENT_TYPE); | ||
283 | }); | ||
284 | } | ||
285 | |||
286 | let serializePDFCached: | ||
287 | | ((svg: SVGSVGElement, embedFonts: boolean) => Promise<Blob>) | ||
288 | | undefined; | ||
289 | |||
290 | async function serializePDF( | ||
291 | svg: SVGSVGElement, | ||
292 | settings: ExportSettingsStore, | ||
293 | ): Promise<Blob> { | ||
294 | if (serializePDFCached === undefined) { | ||
295 | serializePDFCached = (await import('./serializePDF')).default; | ||
296 | } | ||
297 | return serializePDFCached(svg, settings.embedFonts); | ||
298 | } | ||
299 | |||
300 | export default async function exportDiagram( | ||
301 | svgContainer: HTMLElement | undefined, | ||
302 | graph: GraphStore, | ||
303 | settings: ExportSettingsStore, | ||
304 | mode: 'download' | 'copy', | ||
305 | ): Promise<void> { | ||
306 | const svg = svgContainer?.querySelector('svg'); | ||
307 | if (!svg) { | ||
308 | return; | ||
309 | } | ||
310 | const svgDocument = document.implementation.createDocument( | ||
311 | SVG_NS, | ||
312 | 'svg', | ||
313 | null, | ||
314 | ); | ||
315 | const copyOfSVG = svgDocument.importNode(svg, true); | ||
316 | const originalRoot = svgDocument.childNodes[0]; | ||
317 | if (originalRoot === undefined) { | ||
318 | svgDocument.appendChild(copyOfSVG); | ||
319 | } else { | ||
320 | svgDocument.replaceChild(copyOfSVG, originalRoot); | ||
321 | } | ||
322 | |||
323 | const theme = settings.theme === 'light' ? lightTheme : darkTheme; | ||
324 | if (!settings.transparent) { | ||
325 | addBackground(svgDocument, copyOfSVG, theme); | ||
326 | } | ||
327 | |||
328 | fixForeignObjects(svgDocument, copyOfSVG); | ||
329 | |||
330 | const { colorNodes } = graph; | ||
331 | let fontsCSS = ''; | ||
332 | if (settings.format === 'png') { | ||
333 | // If we are creating a PNG, font file size doesn't matter, | ||
334 | // and we can reuse fonts the browser has already downloaded. | ||
335 | fontsCSS = await fetchVariableFontCSS(); | ||
336 | } else if (settings.format === 'svg' && settings.embedFonts) { | ||
337 | fontsCSS = await fetchFontCSS(); | ||
338 | } | ||
339 | appendStyles(svgDocument, copyOfSVG, theme, colorNodes, fontsCSS); | ||
340 | |||
341 | if (settings.format === 'pdf') { | ||
342 | const pdf = await serializePDF(copyOfSVG, settings); | ||
343 | await saveBlob(pdf, 'graph.pdf', 'application/pdf', EXPORT_ID); | ||
344 | return; | ||
345 | } | ||
346 | const serializedSVG = serializeSVG(svgDocument); | ||
347 | if (settings.format === 'png') { | ||
348 | const png = await serializePNG(serializedSVG, svg, settings, theme); | ||
349 | if (mode === 'copy') { | ||
350 | await copyBlob(png); | ||
351 | } else { | ||
352 | await saveBlob(png, 'graph.png', PNG_CONTENT_TYPE, EXPORT_ID); | ||
353 | } | ||
354 | } else if (mode === 'copy') { | ||
355 | await copyBlob(serializedSVG); | ||
356 | } else { | ||
357 | await saveBlob(serializedSVG, 'graph.svg', SVG_CONTENT_TYPE, EXPORT_ID); | ||
358 | } | ||
359 | } | ||