aboutsummaryrefslogtreecommitdiffstats
path: root/subprojects/frontend/src/graph/exportDiagram.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'subprojects/frontend/src/graph/exportDiagram.tsx')
-rw-r--r--subprojects/frontend/src/graph/exportDiagram.tsx363
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
7import createCache from '@emotion/cache';
8import { serializeStyles } from '@emotion/serialize';
9import type { StyleSheet } from '@emotion/utils';
10import italicFontURL from '@fontsource/open-sans/files/open-sans-latin-400-italic.woff2?url';
11import normalFontURL from '@fontsource/open-sans/files/open-sans-latin-400-normal.woff2?url';
12import boldFontURL from '@fontsource/open-sans/files/open-sans-latin-700-normal.woff2?url';
13import variableItalicFontURL from '@fontsource-variable/open-sans/files/open-sans-latin-wght-italic.woff2?url';
14import variableFontURL from '@fontsource-variable/open-sans/files/open-sans-latin-wght-normal.woff2?url';
15import cancelSVG from '@material-icons/svg/svg/cancel/baseline.svg?raw';
16import labelSVG from '@material-icons/svg/svg/label/baseline.svg?raw';
17import labelOutlinedSVG from '@material-icons/svg/svg/label/outline.svg?raw';
18import type { Theme } from '@mui/material/styles';
19
20import { darkTheme, lightTheme } from '../theme/ThemeProvider';
21
22import type ExportSettingsStore from './ExportSettingsStore';
23import type GraphStore from './GraphStore';
24import { createGraphTheme } from './GraphTheme';
25import { SVG_NS } from './postProcessSVG';
26
27const PROLOG = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>';
28const SVG_CONTENT_TYPE = 'image/svg+xml';
29
30const ICONS: Map<string, Element> = new Map();
31
32function 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
44importSVG(labelSVG, 'icon-TRUE');
45importSVG(labelOutlinedSVG, 'icon-UNKNOWN');
46importSVG(cancelSVG, 'icon-ERROR');
47
48function 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
63async 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
79let fontCSS: string | undefined;
80let variableFontCSS: string | undefined;
81
82async 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
116async 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
142function 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
173function 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
208function 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
216function 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
231async 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
242async 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
309export 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}