UNPKG

12.3 kBTypeScriptView Raw
1import React, {
2 Component,
3 ComponentType,
4 useEffect,
5 useMemo,
6 useState,
7} from 'react';
8import Rect from './elements/Rect';
9import Circle from './elements/Circle';
10import Ellipse from './elements/Ellipse';
11import Polygon from './elements/Polygon';
12import Polyline from './elements/Polyline';
13import Line from './elements/Line';
14import Svg from './elements/Svg';
15import Path from './elements/Path';
16import G from './elements/G';
17import Text from './elements/Text';
18import TSpan from './elements/TSpan';
19import TextPath from './elements/TextPath';
20import Use from './elements/Use';
21import Image from './elements/Image';
22import Symbol from './elements/Symbol';
23import Defs from './elements/Defs';
24import LinearGradient from './elements/LinearGradient';
25import RadialGradient from './elements/RadialGradient';
26import Stop from './elements/Stop';
27import ClipPath from './elements/ClipPath';
28import Pattern from './elements/Pattern';
29import Mask from './elements/Mask';
30import Marker from './elements/Marker';
31
32export 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
58function missingTag() {
59 return null;
60}
61
62export 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
75export interface XmlAST extends AST {
76 children: (XmlAST | string)[];
77 parent: XmlAST | null;
78}
79
80export interface JsxAST extends AST {
81 children: (JSX.Element | string)[];
82}
83
84export type AdditionalProps = {
85 onError?: (error: Error) => void;
86 override?: Object;
87};
88
89export type UriProps = { uri: string | null } & AdditionalProps;
90export type UriState = { xml: string | null };
91
92export type XmlProps = { xml: string | null } & AdditionalProps;
93export type XmlState = { ast: JsxAST | null };
94
95export type AstProps = { ast: JsxAST | null } & AdditionalProps;
96
97export 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
109export const err = console.error.bind(console);
110
111export 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
125export async function fetchText(uri: string) {
126 const response = await fetch(uri);
127 return await response.text();
128}
129
130export 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
145export 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
172export 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
199const upperCase = (_match: string, letter: string) => letter.toUpperCase();
200
201export const camelCase = (phrase: string) =>
202 phrase.replace(/[:-]([a-z])/g, upperCase);
203
204export type Styles = { [property: string]: string };
205
206export 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
222export 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// slimmed down parser based on https://github.com/Rich-Harris/svg-parser
238
239function repeat(str: string, i: number) {
240 let result = '';
241 while (i--) {
242 result += str;
243 }
244 return result;
245}
246
247const toSpaces = (tabs: string) => repeat(' ', tabs.length);
248
249function 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
273const validNameCharacters = /[a-zA-Z0-9:_-]/;
274const whitespace = /[\s\t\r\n]/;
275const quotemarks = /['"]/;
276
277export type Middleware = (ast: XmlAST) => XmlAST;
278
279export 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 } // <?xml...
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}