diff options
author | Kristóf Marussy <kristof@marussy.com> | 2024-02-23 00:57:33 +0100 |
---|---|---|
committer | Kristóf Marussy <kristof@marussy.com> | 2024-02-23 19:13:12 +0100 |
commit | ee8ed85fe7b2212c3133692a990d80aca52ceef9 (patch) | |
tree | ed94a3515f9bd80a7b97509cf18f0036f292022c /subprojects/frontend/src/graph | |
parent | refactor(frontend): cleaner SVG export (diff) | |
download | refinery-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).
Diffstat (limited to 'subprojects/frontend/src/graph')
-rw-r--r-- | subprojects/frontend/src/graph/ExportButton.tsx | 149 |
1 files changed, 126 insertions, 23 deletions
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 @@ | |||
7 | import createCache from '@emotion/cache'; | 7 | import createCache from '@emotion/cache'; |
8 | import { serializeStyles } from '@emotion/serialize'; | 8 | import { serializeStyles } from '@emotion/serialize'; |
9 | import type { StyleSheet } from '@emotion/utils'; | 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'; | ||
10 | import cancelSVG from '@material-icons/svg/svg/cancel/baseline.svg?raw'; | 15 | import cancelSVG from '@material-icons/svg/svg/cancel/baseline.svg?raw'; |
11 | import labelSVG from '@material-icons/svg/svg/label/baseline.svg?raw'; | 16 | import labelSVG from '@material-icons/svg/svg/label/baseline.svg?raw'; |
12 | import labelOutlinedSVG from '@material-icons/svg/svg/label/outline.svg?raw'; | 17 | import labelOutlinedSVG from '@material-icons/svg/svg/label/outline.svg?raw'; |
@@ -15,9 +20,13 @@ import IconButton from '@mui/material/IconButton'; | |||
15 | import { styled, useTheme, type Theme } from '@mui/material/styles'; | 20 | import { styled, useTheme, type Theme } from '@mui/material/styles'; |
16 | import { useCallback } from 'react'; | 21 | import { useCallback } from 'react'; |
17 | 22 | ||
23 | import getLogger from '../utils/getLogger'; | ||
24 | |||
18 | import { createGraphTheme } from './GraphTheme'; | 25 | import { createGraphTheme } from './GraphTheme'; |
19 | import { SVG_NS } from './postProcessSVG'; | 26 | import { SVG_NS } from './postProcessSVG'; |
20 | 27 | ||
28 | const log = getLogger('graph.ExportButton'); | ||
29 | |||
21 | const PROLOG = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>'; | 30 | const PROLOG = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>'; |
22 | 31 | ||
23 | const ExportButtonRoot = styled('div', { | 32 | const ExportButtonRoot = styled('div', { |
@@ -51,11 +60,91 @@ importSVG(labelSVG, 'icon-TRUE'); | |||
51 | importSVG(labelOutlinedSVG, 'icon-UNKNOWN'); | 60 | importSVG(labelOutlinedSVG, 'icon-UNKNOWN'); |
52 | importSVG(cancelSVG, 'icon-ERROR'); | 61 | importSVG(cancelSVG, 'icon-ERROR'); |
53 | 62 | ||
54 | function appendStyles( | 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 | async 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 | ||
118 | function downloadSVG(svgDocument: XMLDocument) { | 212 | function 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 | ||
227 | async 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 | |||
133 | export default function ExportButton({ | 252 | export 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> |