aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2024-02-23 00:57:33 +0100
committerLibravatar Kristóf Marussy <kristof@marussy.com>2024-02-23 19:13:12 +0100
commitee8ed85fe7b2212c3133692a990d80aca52ceef9 (patch)
treeed94a3515f9bd80a7b97509cf18f0036f292022c
parentrefactor(frontend): cleaner SVG export (diff)
downloadrefinery-ee8ed85fe7b2212c3133692a990d80aca52ceef9.tar.gz
refinery-ee8ed85fe7b2212c3133692a990d80aca52ceef9.tar.zst
refinery-ee8ed85fe7b2212c3133692a990d80aca52ceef9.zip
feat(frontend): optional SVG font embedding
Unfortunately, Pango does not support user-defined fonts, so the embedded font won't work in Inkscape (see https://wiki.inkscape.org/wiki/@font-face_Support) but it can be used in <img> tags on the web (see https://vecta.io/blog/how-to-use-fonts-in-svg).
-rw-r--r--subprojects/frontend/package.json1
-rw-r--r--subprojects/frontend/src/graph/ExportButton.tsx149
-rw-r--r--yarn.lock8
3 files changed, 135 insertions, 23 deletions
diff --git a/subprojects/frontend/package.json b/subprojects/frontend/package.json
index 685f7cc5..a8d6baff 100644
--- a/subprojects/frontend/package.json
+++ b/subprojects/frontend/package.json
@@ -42,6 +42,7 @@
42 "@emotion/utils": "^1.2.1", 42 "@emotion/utils": "^1.2.1",
43 "@fontsource-variable/jetbrains-mono": "^5.0.19", 43 "@fontsource-variable/jetbrains-mono": "^5.0.19",
44 "@fontsource-variable/open-sans": "^5.0.25", 44 "@fontsource-variable/open-sans": "^5.0.25",
45 "@fontsource/open-sans": "^5.0.24",
45 "@hpcc-js/wasm": "^2.16.0", 46 "@hpcc-js/wasm": "^2.16.0",
46 "@lezer/common": "^1.2.1", 47 "@lezer/common": "^1.2.1",
47 "@lezer/highlight": "^1.2.0", 48 "@lezer/highlight": "^1.2.0",
diff --git a/subprojects/frontend/src/graph/ExportButton.tsx b/subprojects/frontend/src/graph/ExportButton.tsx
index 91445d00..97444c6e 100644
--- a/subprojects/frontend/src/graph/ExportButton.tsx
+++ b/subprojects/frontend/src/graph/ExportButton.tsx
@@ -7,6 +7,11 @@
7import createCache from '@emotion/cache'; 7import createCache from '@emotion/cache';
8import { serializeStyles } from '@emotion/serialize'; 8import { serializeStyles } from '@emotion/serialize';
9import type { StyleSheet } from '@emotion/utils'; 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';
10import cancelSVG from '@material-icons/svg/svg/cancel/baseline.svg?raw'; 15import cancelSVG from '@material-icons/svg/svg/cancel/baseline.svg?raw';
11import labelSVG from '@material-icons/svg/svg/label/baseline.svg?raw'; 16import labelSVG from '@material-icons/svg/svg/label/baseline.svg?raw';
12import labelOutlinedSVG from '@material-icons/svg/svg/label/outline.svg?raw'; 17import labelOutlinedSVG from '@material-icons/svg/svg/label/outline.svg?raw';
@@ -15,9 +20,13 @@ import IconButton from '@mui/material/IconButton';
15import { styled, useTheme, type Theme } from '@mui/material/styles'; 20import { styled, useTheme, type Theme } from '@mui/material/styles';
16import { useCallback } from 'react'; 21import { useCallback } from 'react';
17 22
23import getLogger from '../utils/getLogger';
24
18import { createGraphTheme } from './GraphTheme'; 25import { createGraphTheme } from './GraphTheme';
19import { SVG_NS } from './postProcessSVG'; 26import { SVG_NS } from './postProcessSVG';
20 27
28const log = getLogger('graph.ExportButton');
29
21const PROLOG = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>'; 30const PROLOG = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>';
22 31
23const ExportButtonRoot = styled('div', { 32const ExportButtonRoot = styled('div', {
@@ -51,11 +60,91 @@ importSVG(labelSVG, 'icon-TRUE');
51importSVG(labelOutlinedSVG, 'icon-UNKNOWN'); 60importSVG(labelOutlinedSVG, 'icon-UNKNOWN');
52importSVG(cancelSVG, 'icon-ERROR'); 61importSVG(cancelSVG, 'icon-ERROR');
53 62
54function appendStyles( 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
142async function appendStyles(
55 svgDocument: XMLDocument, 143 svgDocument: XMLDocument,
56 svg: SVGSVGElement, 144 svg: SVGSVGElement,
57 theme: Theme, 145 theme: Theme,
58): void { 146 embedFonts?: 'woff2' | 'woff2-variations',
147): Promise<void> {
59 const cache = createCache({ 148 const cache = createCache({
60 key: 'refinery', 149 key: 'refinery',
61 container: svg, 150 container: svg,
@@ -69,6 +158,11 @@ function appendStyles(
69 noEmbedIcons: true, 158 noEmbedIcons: true,
70 }); 159 });
71 const rules: string[] = []; 160 const rules: string[] = [];
161 if (embedFonts === 'woff2') {
162 rules.push(await fetchFontCSS());
163 } else if (embedFonts === 'woff2-variations') {
164 rules.push(await fetchVariableFontCSS());
165 }
72 const sheet = { 166 const sheet = {
73 insert(rule) { 167 insert(rule) {
74 rules.push(rule); 168 rules.push(rule);
@@ -115,7 +209,7 @@ function fixForeignObjects(svgDocument: XMLDocument, svg: SVGSVGElement): void {
115 }); 209 });
116} 210}
117 211
118function downloadSVG(svgDocument: XMLDocument) { 212function downloadSVG(svgDocument: XMLDocument): void {
119 const serializer = new XMLSerializer(); 213 const serializer = new XMLSerializer();
120 const svgText = `${PROLOG}\n${serializer.serializeToString(svgDocument)}`; 214 const svgText = `${PROLOG}\n${serializer.serializeToString(svgDocument)}`;
121 const blob = new Blob([svgText], { 215 const blob = new Blob([svgText], {
@@ -130,6 +224,31 @@ function downloadSVG(svgDocument: XMLDocument) {
130 document.body.removeChild(link); 224 document.body.removeChild(link);
131} 225}
132 226
227async function exportSVG(
228 svgContainer: HTMLElement | undefined,
229 theme: Theme,
230): Promise<void> {
231 const svg = svgContainer?.querySelector('svg');
232 if (!svg) {
233 return;
234 }
235 const svgDocument = document.implementation.createDocument(
236 SVG_NS,
237 'svg',
238 null,
239 );
240 const copyOfSVG = svgDocument.importNode(svg, true);
241 const originalRoot = svgDocument.childNodes[0];
242 if (originalRoot === undefined) {
243 svgDocument.appendChild(copyOfSVG);
244 } else {
245 svgDocument.replaceChild(copyOfSVG, originalRoot);
246 }
247 fixForeignObjects(svgDocument, copyOfSVG);
248 await appendStyles(svgDocument, copyOfSVG, theme);
249 downloadSVG(svgDocument);
250}
251
133export default function ExportButton({ 252export default function ExportButton({
134 svgContainer, 253 svgContainer,
135}: { 254}: {
@@ -137,26 +256,10 @@ export default function ExportButton({
137}): JSX.Element { 256}): JSX.Element {
138 const theme = useTheme(); 257 const theme = useTheme();
139 const saveCallback = useCallback(() => { 258 const saveCallback = useCallback(() => {
140 const svg = svgContainer?.querySelector('svg'); 259 exportSVG(svgContainer, theme).catch((error) => {
141 if (!svg) { 260 log.error('Failed to export SVG', error);
142 return; 261 });
143 } 262 }, [svgContainer, theme]);
144 const svgDocument = document.implementation.createDocument(
145 SVG_NS,
146 'svg',
147 null,
148 );
149 const copyOfSVG = svgDocument.importNode(svg, true);
150 const originalRoot = svgDocument.childNodes[0];
151 if (originalRoot === undefined) {
152 svgDocument.appendChild(copyOfSVG);
153 } else {
154 svgDocument.replaceChild(copyOfSVG, originalRoot);
155 }
156 fixForeignObjects(svgDocument, copyOfSVG);
157 appendStyles(svgDocument, copyOfSVG, theme);
158 downloadSVG(svgDocument);
159 }, [theme, svgContainer]);
160 263
161 return ( 264 return (
162 <ExportButtonRoot> 265 <ExportButtonRoot>
diff --git a/yarn.lock b/yarn.lock
index 98311d89..7634b353 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1760,6 +1760,13 @@ __metadata:
1760 languageName: node 1760 languageName: node
1761 linkType: hard 1761 linkType: hard
1762 1762
1763"@fontsource/open-sans@npm:^5.0.24":
1764 version: 5.0.24
1765 resolution: "@fontsource/open-sans@npm:5.0.24"
1766 checksum: 10c0/e3306f3ff0b9d85703f5b3114f33e31747dcbdf35a2eff425d0a9afb61bfd69c7eb29a691a5acd4582aa808ec123e51f223b49dad60d2caae9890d72e03feb7d
1767 languageName: node
1768 linkType: hard
1769
1763"@hpcc-js/wasm@npm:^2.16.0": 1770"@hpcc-js/wasm@npm:^2.16.0":
1764 version: 2.16.0 1771 version: 2.16.0
1765 resolution: "@hpcc-js/wasm@npm:2.16.0" 1772 resolution: "@hpcc-js/wasm@npm:2.16.0"
@@ -2173,6 +2180,7 @@ __metadata:
2173 "@emotion/utils": "npm:^1.2.1" 2180 "@emotion/utils": "npm:^1.2.1"
2174 "@fontsource-variable/jetbrains-mono": "npm:^5.0.19" 2181 "@fontsource-variable/jetbrains-mono": "npm:^5.0.19"
2175 "@fontsource-variable/open-sans": "npm:^5.0.25" 2182 "@fontsource-variable/open-sans": "npm:^5.0.25"
2183 "@fontsource/open-sans": "npm:^5.0.24"
2176 "@hpcc-js/wasm": "npm:^2.16.0" 2184 "@hpcc-js/wasm": "npm:^2.16.0"
2177 "@lezer/common": "npm:^1.2.1" 2185 "@lezer/common": "npm:^1.2.1"
2178 "@lezer/generator": "npm:^1.6.0" 2186 "@lezer/generator": "npm:^1.6.0"