aboutsummaryrefslogtreecommitdiffstats
path: root/subprojects/frontend/src/graph/export/exportDiagram.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'subprojects/frontend/src/graph/export/exportDiagram.tsx')
-rw-r--r--subprojects/frontend/src/graph/export/exportDiagram.tsx359
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
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';
21import { copyBlob, saveBlob } from '../../utils/fileIO';
22import type GraphStore from '../GraphStore';
23import { createGraphTheme } from '../GraphTheme';
24import { SVG_NS } from '../postProcessSVG';
25
26import type ExportSettingsStore from './ExportSettingsStore';
27
28const PROLOG = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>';
29const PNG_CONTENT_TYPE = 'image/png';
30const SVG_CONTENT_TYPE = 'image/svg+xml';
31const EXPORT_ID = 'export-image';
32
33const ICONS: Map<string, Element> = new Map();
34
35function 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
47importSVG(labelSVG, 'icon-TRUE');
48importSVG(labelOutlinedSVG, 'icon-UNKNOWN');
49importSVG(cancelSVG, 'icon-ERROR');
50
51function 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
66async 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
82let fontCSS: string | undefined;
83let variableFontCSS: string | undefined;
84
85async 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
119async 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
145function 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
176function 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
211function 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
219async 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
286let serializePDFCached:
287 | ((svg: SVGSVGElement, embedFonts: boolean) => Promise<Blob>)
288 | undefined;
289
290async 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
300export 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}