1 | import React, {
|
2 | Component,
|
3 | ComponentType,
|
4 | useEffect,
|
5 | useMemo,
|
6 | useState,
|
7 | } from 'react';
|
8 | import Rect from './elements/Rect';
|
9 | import Circle from './elements/Circle';
|
10 | import Ellipse from './elements/Ellipse';
|
11 | import Polygon from './elements/Polygon';
|
12 | import Polyline from './elements/Polyline';
|
13 | import Line from './elements/Line';
|
14 | import Svg from './elements/Svg';
|
15 | import Path from './elements/Path';
|
16 | import G from './elements/G';
|
17 | import Text from './elements/Text';
|
18 | import TSpan from './elements/TSpan';
|
19 | import TextPath from './elements/TextPath';
|
20 | import Use from './elements/Use';
|
21 | import Image from './elements/Image';
|
22 | import Symbol from './elements/Symbol';
|
23 | import Defs from './elements/Defs';
|
24 | import LinearGradient from './elements/LinearGradient';
|
25 | import RadialGradient from './elements/RadialGradient';
|
26 | import Stop from './elements/Stop';
|
27 | import ClipPath from './elements/ClipPath';
|
28 | import Pattern from './elements/Pattern';
|
29 | import Mask from './elements/Mask';
|
30 | import Marker from './elements/Marker';
|
31 |
|
32 | export const tags: { [tag: string]: ComponentType } = {
|
33 | svg: Svg,
|
34 | circle: Circle,
|
35 | ellipse: Ellipse,
|
36 | g: G,
|
37 | text: Text,
|
38 | tspan: TSpan,
|
39 | textPath: TextPath,
|
40 | path: Path,
|
41 | polygon: Polygon,
|
42 | polyline: Polyline,
|
43 | line: Line,
|
44 | rect: Rect,
|
45 | use: Use,
|
46 | image: Image,
|
47 | symbol: Symbol,
|
48 | defs: Defs,
|
49 | linearGradient: LinearGradient,
|
50 | radialGradient: RadialGradient,
|
51 | stop: Stop,
|
52 | clipPath: ClipPath,
|
53 | pattern: Pattern,
|
54 | mask: Mask,
|
55 | marker: Marker,
|
56 | };
|
57 |
|
58 | function missingTag() {
|
59 | return null;
|
60 | }
|
61 |
|
62 | export interface AST {
|
63 | tag: string;
|
64 | style?: Styles;
|
65 | styles?: string;
|
66 | priority?: Map<string, boolean | undefined>;
|
67 | parent: AST | null;
|
68 | children: (AST | string)[] | (JSX.Element | string)[];
|
69 | props: {
|
70 | [prop: string]: Styles | string | undefined;
|
71 | };
|
72 | Tag: ComponentType;
|
73 | }
|
74 |
|
75 | export interface XmlAST extends AST {
|
76 | children: (XmlAST | string)[];
|
77 | parent: XmlAST | null;
|
78 | }
|
79 |
|
80 | export interface JsxAST extends AST {
|
81 | children: (JSX.Element | string)[];
|
82 | }
|
83 |
|
84 | export type AdditionalProps = {
|
85 | onError?: (error: Error) => void;
|
86 | override?: Object;
|
87 | };
|
88 |
|
89 | export type UriProps = { uri: string | null } & AdditionalProps;
|
90 | export type UriState = { xml: string | null };
|
91 |
|
92 | export type XmlProps = { xml: string | null } & AdditionalProps;
|
93 | export type XmlState = { ast: JsxAST | null };
|
94 |
|
95 | export type AstProps = { ast: JsxAST | null } & AdditionalProps;
|
96 |
|
97 | export function SvgAst({ ast, override }: AstProps) {
|
98 | if (!ast) {
|
99 | return null;
|
100 | }
|
101 | const { props, children } = ast;
|
102 | return (
|
103 | <Svg {...props} {...override}>
|
104 | {children}
|
105 | </Svg>
|
106 | );
|
107 | }
|
108 |
|
109 | export const err = console.error.bind(console);
|
110 |
|
111 | export function SvgXml(props: XmlProps) {
|
112 | const { onError = err, xml, override } = props;
|
113 | const ast = useMemo<JsxAST | null>(() => (xml !== null ? parse(xml) : null), [
|
114 | xml,
|
115 | ]);
|
116 |
|
117 | try {
|
118 | return <SvgAst ast={ast} override={override || props} />;
|
119 | } catch (error) {
|
120 | onError(error);
|
121 | return null;
|
122 | }
|
123 | }
|
124 |
|
125 | export async function fetchText(uri: string) {
|
126 | const response = await fetch(uri);
|
127 | return await response.text();
|
128 | }
|
129 |
|
130 | export function SvgUri(props: UriProps) {
|
131 | const { onError = err, uri } = props;
|
132 | const [xml, setXml] = useState<string | null>(null);
|
133 | useEffect(() => {
|
134 | uri
|
135 | ? fetchText(uri)
|
136 | .then(setXml)
|
137 | .catch(onError)
|
138 | : setXml(null);
|
139 | }, [onError, uri]);
|
140 | return <SvgXml xml={xml} override={props} />;
|
141 | }
|
142 |
|
143 | // Extending Component is required for Animated support.
|
144 |
|
145 | export class SvgFromXml extends Component<XmlProps, XmlState> {
|
146 | state = { ast: null };
|
147 | componentDidMount() {
|
148 | this.parse(this.props.xml);
|
149 | }
|
150 | componentDidUpdate(prevProps: { xml: string | null }) {
|
151 | const { xml } = this.props;
|
152 | if (xml !== prevProps.xml) {
|
153 | this.parse(xml);
|
154 | }
|
155 | }
|
156 | parse(xml: string | null) {
|
157 | try {
|
158 | this.setState({ ast: xml ? parse(xml) : null });
|
159 | } catch (e) {
|
160 | console.error(e);
|
161 | }
|
162 | }
|
163 | render() {
|
164 | const {
|
165 | props,
|
166 | state: { ast },
|
167 | } = this;
|
168 | return <SvgAst ast={ast} override={props.override || props} />;
|
169 | }
|
170 | }
|
171 |
|
172 | export class SvgFromUri extends Component<UriProps, UriState> {
|
173 | state = { xml: null };
|
174 | componentDidMount() {
|
175 | this.fetch(this.props.uri);
|
176 | }
|
177 | componentDidUpdate(prevProps: { uri: string | null }) {
|
178 | const { uri } = this.props;
|
179 | if (uri !== prevProps.uri) {
|
180 | this.fetch(uri);
|
181 | }
|
182 | }
|
183 | async fetch(uri: string | null) {
|
184 | try {
|
185 | this.setState({ xml: uri ? await fetchText(uri) : null });
|
186 | } catch (e) {
|
187 | console.error(e);
|
188 | }
|
189 | }
|
190 | render() {
|
191 | const {
|
192 | props,
|
193 | state: { xml },
|
194 | } = this;
|
195 | return <SvgFromXml xml={xml} override={props} />;
|
196 | }
|
197 | }
|
198 |
|
199 | const upperCase = (_match: string, letter: string) => letter.toUpperCase();
|
200 |
|
201 | export const camelCase = (phrase: string) =>
|
202 | phrase.replace(/[:-]([a-z])/g, upperCase);
|
203 |
|
204 | export type Styles = { [property: string]: string };
|
205 |
|
206 | export function getStyle(string: string): Styles {
|
207 | const style: Styles = {};
|
208 | const declarations = string.split(';');
|
209 | const { length } = declarations;
|
210 | for (let i = 0; i < length; i++) {
|
211 | const declaration = declarations[i];
|
212 | if (declaration.length !== 0) {
|
213 | const split = declaration.split(':');
|
214 | const property = split[0];
|
215 | const value = split[1];
|
216 | style[camelCase(property.trim())] = value.trim();
|
217 | }
|
218 | }
|
219 | return style;
|
220 | }
|
221 |
|
222 | export function astToReact(
|
223 | value: AST | string,
|
224 | index: number,
|
225 | ): JSX.Element | string {
|
226 | if (typeof value === 'object') {
|
227 | const { Tag, props, children } = value;
|
228 | return (
|
229 | <Tag key={index} {...props}>
|
230 | {(children as (AST | string)[]).map(astToReact)}
|
231 | </Tag>
|
232 | );
|
233 | }
|
234 | return value;
|
235 | }
|
236 |
|
237 |
|
238 |
|
239 | function repeat(str: string, i: number) {
|
240 | let result = '';
|
241 | while (i--) {
|
242 | result += str;
|
243 | }
|
244 | return result;
|
245 | }
|
246 |
|
247 | const toSpaces = (tabs: string) => repeat(' ', tabs.length);
|
248 |
|
249 | function locate(source: string, i: number) {
|
250 | const lines = source.split('\n');
|
251 | const nLines = lines.length;
|
252 | let column = i;
|
253 | let line = 0;
|
254 | for (; line < nLines; line++) {
|
255 | const { length } = lines[line];
|
256 | if (column >= length) {
|
257 | column -= length;
|
258 | } else {
|
259 | break;
|
260 | }
|
261 | }
|
262 | const before = source.slice(0, i).replace(/^\t+/, toSpaces);
|
263 | const beforeExec = /(^|\n).*$/.exec(before);
|
264 | const beforeLine = (beforeExec && beforeExec[0]) || '';
|
265 | const after = source.slice(i);
|
266 | const afterExec = /.*(\n|$)/.exec(after);
|
267 | const afterLine = afterExec && afterExec[0];
|
268 | const pad = repeat(' ', beforeLine.length);
|
269 | const snippet = `${beforeLine}${afterLine}\n${pad}^`;
|
270 | return { line, column, snippet };
|
271 | }
|
272 |
|
273 | const validNameCharacters = /[a-zA-Z0-9:_-]/;
|
274 | const whitespace = /[\s\t\r\n]/;
|
275 | const quotemarks = /['"]/;
|
276 |
|
277 | export type Middleware = (ast: XmlAST) => XmlAST;
|
278 |
|
279 | export function parse(source: string, middleware?: Middleware): JsxAST | null {
|
280 | const length = source.length;
|
281 | let currentElement: XmlAST | null = null;
|
282 | let state = metadata;
|
283 | let children = null;
|
284 | let root: XmlAST | undefined;
|
285 | let stack: XmlAST[] = [];
|
286 |
|
287 | function error(message: string) {
|
288 | const { line, column, snippet } = locate(source, i);
|
289 | throw new Error(
|
290 | `${message} (${line}:${column}). If this is valid SVG, it's probably a bug. Please raise an issue\n\n${snippet}`,
|
291 | );
|
292 | }
|
293 |
|
294 | function metadata() {
|
295 | while (
|
296 | i + 1 < length &&
|
297 | (source[i] !== '<' || !validNameCharacters.test(source[i + 1]))
|
298 | ) {
|
299 | i++;
|
300 | }
|
301 |
|
302 | return neutral();
|
303 | }
|
304 |
|
305 | function neutral() {
|
306 | let text = '';
|
307 | let char;
|
308 | while (i < length && (char = source[i]) !== '<') {
|
309 | text += char;
|
310 | i += 1;
|
311 | }
|
312 |
|
313 | if (/\S/.test(text)) {
|
314 | children.push(text);
|
315 | }
|
316 |
|
317 | if (source[i] === '<') {
|
318 | return openingTag;
|
319 | }
|
320 |
|
321 | return neutral;
|
322 | }
|
323 |
|
324 | function openingTag() {
|
325 | const char = source[i];
|
326 |
|
327 | if (char === '?') {
|
328 | return neutral;
|
329 | }
|
330 |
|
331 | if (char === '!') {
|
332 | const start = i + 1;
|
333 | if (source.slice(start, i + 3) === '--') {
|
334 | return comment;
|
335 | }
|
336 | const end = i + 8;
|
337 | if (source.slice(start, end) === '[CDATA[') {
|
338 | return cdata;
|
339 | }
|
340 | if (/doctype/i.test(source.slice(start, end))) {
|
341 | return neutral;
|
342 | }
|
343 | }
|
344 |
|
345 | if (char === '/') {
|
346 | return closingTag;
|
347 | }
|
348 |
|
349 | const tag = getName();
|
350 | const props: { [prop: string]: Styles | string | undefined } = {};
|
351 | const element: XmlAST = {
|
352 | tag,
|
353 | props,
|
354 | children: [],
|
355 | parent: currentElement,
|
356 | Tag: tags[tag] || missingTag,
|
357 | };
|
358 |
|
359 | if (currentElement) {
|
360 | children.push(element);
|
361 | } else {
|
362 | root = element;
|
363 | }
|
364 |
|
365 | getAttributes(props);
|
366 |
|
367 | const { style } = props;
|
368 | if (typeof style === 'string') {
|
369 | element.styles = style;
|
370 | props.style = getStyle(style);
|
371 | }
|
372 |
|
373 | let selfClosing = false;
|
374 |
|
375 | if (source[i] === '/') {
|
376 | i += 1;
|
377 | selfClosing = true;
|
378 | }
|
379 |
|
380 | if (source[i] !== '>') {
|
381 | error('Expected >');
|
382 | }
|
383 |
|
384 | if (!selfClosing) {
|
385 | currentElement = element;
|
386 | ({ children } = element);
|
387 | stack.push(element);
|
388 | }
|
389 |
|
390 | return neutral;
|
391 | }
|
392 |
|
393 | function comment() {
|
394 | const index = source.indexOf('-->', i);
|
395 | if (!~index) {
|
396 | error('expected -->');
|
397 | }
|
398 |
|
399 | i = index + 2;
|
400 | return neutral;
|
401 | }
|
402 |
|
403 | function cdata() {
|
404 | const index = source.indexOf(']]>', i);
|
405 | if (!~index) {
|
406 | error('expected ]]>');
|
407 | }
|
408 |
|
409 | children.push(source.slice(i + 7, index));
|
410 |
|
411 | i = index + 2;
|
412 | return neutral;
|
413 | }
|
414 |
|
415 | function closingTag() {
|
416 | const tag = getName();
|
417 |
|
418 | if (!tag) {
|
419 | error('Expected tag name');
|
420 | }
|
421 |
|
422 | if (currentElement && tag !== currentElement.tag) {
|
423 | error(
|
424 | `Expected closing tag </${tag}> to match opening tag <${currentElement.tag}>`,
|
425 | );
|
426 | }
|
427 |
|
428 | if (source[i] !== '>') {
|
429 | error('Expected >');
|
430 | }
|
431 |
|
432 | stack.pop();
|
433 | currentElement = stack[stack.length - 1];
|
434 | if (currentElement) {
|
435 | ({ children } = currentElement);
|
436 | }
|
437 |
|
438 | return neutral;
|
439 | }
|
440 |
|
441 | function getName() {
|
442 | let name = '';
|
443 | let char;
|
444 | while (i < length && validNameCharacters.test((char = source[i]))) {
|
445 | name += char;
|
446 | i += 1;
|
447 | }
|
448 |
|
449 | return name;
|
450 | }
|
451 |
|
452 | function getAttributes(props: {
|
453 | [x: string]: Styles | string | number | boolean | undefined;
|
454 | style?: string | Styles | undefined;
|
455 | }) {
|
456 | while (i < length) {
|
457 | if (!whitespace.test(source[i])) {
|
458 | return;
|
459 | }
|
460 | allowSpaces();
|
461 |
|
462 | const name = getName();
|
463 | if (!name) {
|
464 | return;
|
465 | }
|
466 |
|
467 | let value: boolean | number | string = true;
|
468 |
|
469 | allowSpaces();
|
470 | if (source[i] === '=') {
|
471 | i += 1;
|
472 | allowSpaces();
|
473 |
|
474 | value = getAttributeValue();
|
475 | if (!isNaN(+value) && value.trim() !== '') {
|
476 | value = +value;
|
477 | }
|
478 | }
|
479 |
|
480 | props[camelCase(name)] = value;
|
481 | }
|
482 | }
|
483 |
|
484 | function getAttributeValue(): string {
|
485 | return quotemarks.test(source[i])
|
486 | ? getQuotedAttributeValue()
|
487 | : getUnquotedAttributeValue();
|
488 | }
|
489 |
|
490 | function getUnquotedAttributeValue() {
|
491 | let value = '';
|
492 | do {
|
493 | const char = source[i];
|
494 | if (char === ' ' || char === '>' || char === '/') {
|
495 | return value;
|
496 | }
|
497 |
|
498 | value += char;
|
499 | i += 1;
|
500 | } while (i < length);
|
501 |
|
502 | return value;
|
503 | }
|
504 |
|
505 | function getQuotedAttributeValue() {
|
506 | const quotemark = source[i++];
|
507 |
|
508 | let value = '';
|
509 | let escaped = false;
|
510 |
|
511 | while (i < length) {
|
512 | const char = source[i++];
|
513 | if (char === quotemark && !escaped) {
|
514 | return value;
|
515 | }
|
516 |
|
517 | if (char === '\\' && !escaped) {
|
518 | escaped = true;
|
519 | }
|
520 |
|
521 | value += escaped ? `\\${char}` : char;
|
522 | escaped = false;
|
523 | }
|
524 |
|
525 | return value;
|
526 | }
|
527 |
|
528 | function allowSpaces() {
|
529 | while (i < length && whitespace.test(source[i])) {
|
530 | i += 1;
|
531 | }
|
532 | }
|
533 |
|
534 | let i = 0;
|
535 | while (i < length) {
|
536 | if (!state) {
|
537 | error('Unexpected character');
|
538 | }
|
539 | state = state();
|
540 | i += 1;
|
541 | }
|
542 |
|
543 | if (state !== neutral) {
|
544 | error('Unexpected end of input');
|
545 | }
|
546 |
|
547 | if (root) {
|
548 | const xml: XmlAST = (middleware ? middleware(root) : root) || root;
|
549 | const ast: (JSX.Element | string)[] = xml.children.map(astToReact);
|
550 | const jsx: JsxAST = xml as JsxAST;
|
551 | jsx.children = ast;
|
552 | return jsx;
|
553 | }
|
554 |
|
555 | return null;
|
556 | }
|