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