Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

React-native-uri no longer well supported - Can react-native-svg support SvgXmlData instead? #1074

Closed
etaiklein opened this issue Aug 19, 2019 · 16 comments

Comments

@etaiklein
Copy link

Feature Request

React-native-uri no longer seems supported (last commit was in 2018 and many open PRs) - read more

Can react-native-community pull in this use case or otherwise take over and support this library?

Why it is needed

React-native-svg-uri is unique because it allows rendering SVGs from a data string. This is crucial for anyone composing their svgs on the server. (svgXmlData)

react-native-svg-uri currently also supports rendering a static image. IMO, react-native-uri doesn't actually need to support fetching from a static file since react-native-svg-transformer fills that niche.

Solutions

One idea is to clone react-native-svg-uri into react-native-community/react-native-svg-uri, merge in the current existing pull requests and do minor bug fixes support the last few RN versions.

Another idea is to create a new SVG data rendering component from scratch inside react-native-svg
import {SvgXmlData} from 'react-native-svg'; ... <SvgXmlData source={'svg'} />

@etaiklein etaiklein changed the title React-native-uri no longer well supported - Can this library support the SvgXmlData feature instead? React-native-uri no longer well supported - Can react-native-svg support SvgXmlData instead? Aug 19, 2019
@msand
Copy link
Collaborator

msand commented Aug 23, 2019

Perhaps something like this SvgFromXml.js:

import * as React from "react";
import { parse } from "svg-parser";
import {
  Circle,
  ClipPath,
  Defs,
  Ellipse,
  G,
  Image,
  Line,
  LinearGradient,
  Mask,
  Path,
  Pattern,
  Polygon,
  Polyline,
  RadialGradient,
  Rect,
  Stop,
  Svg,
  Symbol,
  Text,
  TextPath,
  TSpan,
  Use
} from "react-native-svg";

const tagNameToTag = {
  svg: Svg,
  circle: Circle,
  ellipse: Ellipse,
  g: G,
  text: Text,
  tspan: TSpan,
  textPath: TextPath,
  path: Path,
  polygon: Polygon,
  polyline: Polyline,
  line: Line,
  rect: Rect,
  use: Use,
  image: Image,
  symbol: Symbol,
  defs: Defs,
  linearGradient: LinearGradient,
  radialGradient: RadialGradient,
  stop: Stop,
  clipPath: ClipPath,
  pattern: Pattern,
  mask: Mask
};

const upperCase = (match, letter) => letter.toUpperCase();

const camelCase = phrase => phrase.replace(/-([a-z])/g, upperCase);

function transformStyle(string) {
  const style = {};
  const declarations = string.split(";");
  for (let i = 0, l = declarations.length; i < l; i++) {
    const declaration = declarations[i].split(":");
    const property = declaration[0];
    const value = declaration[1];
    style[camelCase(property.trim())] = value.trim();
  }
  return style;
}

function camelCaseProps(properties) {
  const { style } = properties;
  const props = {};
  for (let property in properties) {
    if (properties.hasOwnProperty(property)) {
      props[camelCase(property)] = properties[property];
    }
  }
  if (style) {
    props.style = transformStyle(style);
  }
  return props;
}

function childToSvg(child, i) {
  const { tagName, properties, children } = child;
  const Tag = tagNameToTag[tagName];
  return (
    <Tag key={i} {...camelCaseProps(properties)}>
      {children.map(childToSvg)}
    </Tag>
  );
}

function SVGroot({ root, override }) {
  const { properties, children } = root;
  return (
    <Svg {...camelCaseProps(properties)} {...override}>
      {children.map(childToSvg)}
    </Svg>
  );
}

export function SvgFromXml({ xml, ...props }) {
  try {
    const hast = parse(xml);
    return <SVGroot root={hast.children[0]} override={props} />;
  } catch (e) {
    console.log(e);
    return null;
  }
}

Example App.js:

import * as React from "react";
import { SvgFromXml } from "./SvgFromXml";

const xml = `
  <svg width="100%" height="100%" viewBox="0 0 32 32">
    <path
      fill-rule="evenodd"
      clip-rule="evenodd"
      fill="url(#paint0_linear)"
      d="M4 0C1.79086 0 0 1.79086 0 4V28C0 30.2091 1.79086 32 4 32H28C30.2091 32 32 30.2091 32 28V4C32 1.79086 30.2091 0 28 0H4ZM17 6C17 5.44772 17.4477 5 18 5H20C20.5523 5 21 5.44772 21 6V25C21 25.5523 20.5523 26 20 26H18C17.4477 26 17 25.5523 17 25V6ZM12 11C11.4477 11 11 11.4477 11 12V25C11 25.5523 11.4477 26 12 26H14C14.5523 26 15 25.5523 15 25V12C15 11.4477 14.5523 11 14 11H12ZM6 18C5.44772 18 5 18.4477 5 19V25C5 25.5523 5.44772 26 6 26H8C8.55228 26 9 25.5523 9 25V19C9 18.4477 8.55228 18 8 18H6ZM24 14C23.4477 14 23 14.4477 23 15V25C23 25.5523 23.4477 26 24 26H26C26.5523 26 27 25.5523 27 25V15C27 14.4477 26.5523 14 26 14H24Z"
    />
    <defs>
      <linearGradient
        id="paint0_linear"
        x1="0"
        y1="0"
        x2="8.46631"
        y2="37.3364"
        gradient-units="userSpaceOnUse">
        <stop offset="0" stop-color="#FEA267" />
        <stop offset="1" stop-color="#E75A4C" />
      </linearGradient>
    </defs>
  </svg>
`;

export default () => <SvgFromXml xml={xml} />;

@msand
Copy link
Collaborator

msand commented Aug 24, 2019

With uri support:

import React, { Component, useState, useEffect, useMemo } from "react";
import {
  Circle,
  ClipPath,
  Defs,
  Ellipse,
  G,
  Image,
  Line,
  LinearGradient,
  Mask,
  Path,
  Pattern,
  Polygon,
  Polyline,
  RadialGradient,
  Rect,
  Stop,
  Svg,
  Symbol,
  Text,
  TextPath,
  TSpan,
  Use
} from "react-native-svg";

export const tags = {
  svg: Svg,
  circle: Circle,
  ellipse: Ellipse,
  g: G,
  text: Text,
  tspan: TSpan,
  textPath: TextPath,
  path: Path,
  polygon: Polygon,
  polyline: Polyline,
  line: Line,
  rect: Rect,
  use: Use,
  image: Image,
  symbol: Symbol,
  defs: Defs,
  linearGradient: LinearGradient,
  radialGradient: RadialGradient,
  stop: Stop,
  clipPath: ClipPath,
  pattern: Pattern,
  mask: Mask
};

export function astToReact(child, i) {
  if (typeof child === "object") {
    const { Tag, props, children } = child;
    return (
      <Tag key={i} {...props}>
        {children.map(astToReact)}
      </Tag>
    );
  }
  return child;
}

export function SvgAst({ ast, override }) {
  const { props, children } = ast;
  return (
    <Svg {...props} {...override}>
      {children}
    </Svg>
  );
}

export function SvgXml({ xml, ...props }) {
  const ast = useMemo(() => xml && parse(xml), [xml]);
  return (ast && <SvgAst ast={ast} override={props} />) || null;
}

async function fetchText(uri) {
  const response = await fetch(uri);
  return await response.text();
}

const err = console.error.bind(console);

export function SvgUri({ uri, ...props }) {
  const [xml, setXml] = useState();
  useEffect(() => {
    fetchText(uri)
      .then(setXml)
      .catch(err);
  }, [uri]);
  return (xml && <SvgXml xml={xml} {...props} />) || null;
}

export class SvgFromXml extends Component {
  state = {};
  componentDidMount() {
    const { xml } = this.props;
    this.parse(xml);
  }
  componentDidUpdate(prevProps) {
    const { xml } = this.props;
    if (xml !== prevProps.xml) {
      this.parse(xml);
    }
  }
  parse(xml) {
    try {
      const ast = parse(xml);
      this.setState({ ast });
    } catch (e) {
      console.error(e);
    }
  }
  render() {
    const { ast } = this.state;
    return ast ? <SvgAst ast={ast} override={this.props} /> : null;
  }
}

export class SvgFromUri extends Component {
  state = {};
  componentDidMount() {
    const { uri } = this.props;
    this.fetch(uri);
  }
  componentDidUpdate(prevProps) {
    const { uri } = this.props;
    if (uri !== prevProps.uri) {
      this.fetch(uri);
    }
  }
  async fetch(uri) {
    try {
      const xml = await fetchText(uri);
      this.setState({ xml });
    } catch (e) {
      console.error(e);
    }
  }
  render() {
    const { xml } = this.state;
    return xml ? <SvgFromXml xml={xml} {...this.props} /> : null;
  }
}

const upperCase = (match, letter) => letter.toUpperCase();

const camelCase = phrase => phrase.replace(/-([a-z])/g, upperCase);

export function getStyle(string) {
  const style = {};
  const declarations = string.split(";");
  for (let i = 0, l = declarations.length; i < l; i++) {
    const declaration = declarations[i].split(":");
    const property = declaration[0];
    const value = declaration[1];
    style[camelCase(property.trim())] = value.trim();
  }
  return style;
}

// slimmed down parser based on https://github.com/Rich-Harris/svg-parser

function locate(source, search) {
  const lines = source.split("\n");
  for (let line = 0, l = lines.length; line < l; line++) {
    const { length } = lines[line];
    if (search < length) {
      return { line, column: search };
    } else {
      search -= length;
    }
  }
}

const validNameCharacters = /[a-zA-Z0-9:_-]/;
const whitespace = /[\s\t\r\n]/;
const quotemark = /['"]/;

function repeat(str, i) {
  let result = "";
  while (i--) result += str;
  return result;
}

export function parse(source) {
  const length = source.length;
  let currentElement = null;
  let state = metadata;
  let children = null;
  let root = null;
  let stack = [];

  function error(message) {
    const { line, column } = locate(source, i);
    const before = source
      .slice(0, i)
      .replace(/^\t+/, match => repeat("  ", match.length));
    const beforeLine = /(^|\n).*$/.exec(before)[0];
    const after = source.slice(i);
    const afterLine = /.*(\n|$)/.exec(after)[0];

    const snippet = `${beforeLine}${afterLine}\n${repeat(
      " ",
      beforeLine.length
    )}^`;

    throw new Error(
      `${message} (${line}:${column}). If this is valid SVG, it's probably a bug. Please raise an issue\n\n${snippet}`
    );
  }

  function metadata() {
    while (
      (i < length && source[i] !== "<") ||
      !validNameCharacters.test(source[i + 1])
    ) {
      i++;
    }

    return neutral();
  }

  function neutral() {
    let text = "";
    while (i < length && source[i] !== "<") text += source[i++];

    if (/\S/.test(text)) {
      children.push(text);
    }

    if (source[i] === "<") {
      return openingTag;
    }

    return neutral;
  }

  function openingTag() {
    const char = source[i];

    if (char === "?") return neutral; // <?xml...

    if (char === "!") {
      if (source.slice(i + 1, i + 3) === "--") return comment;
      if (source.slice(i + 1, i + 8) === "[CDATA[") return cdata;
      if (/doctype/i.test(source.slice(i + 1, i + 8))) return neutral;
    }

    if (char === "/") return closingTag;

    const tag = getName();
    const props = {};
    const element = {
      tag,
      props,
      children: [],
      Tag: tags[tag]
    };

    if (currentElement) {
      children.push(element);
    } else {
      root = element;
    }

    getAttributes(props);

    const { style } = props;
    if (style) {
      props.style = getStyle(style);
    }

    let selfClosing = false;

    if (source[i] === "/") {
      i += 1;
      selfClosing = true;
    }

    if (source[i] !== ">") {
      error("Expected >");
    }

    if (!selfClosing) {
      currentElement = element;
      ({ children } = element);
      stack.push(element);
    }

    return neutral;
  }

  function comment() {
    const index = source.indexOf("-->", i);
    if (!~index) error("expected -->");

    i = index + 2;
    return neutral;
  }

  function cdata() {
    const index = source.indexOf("]]>", i);
    if (!~index) error("expected ]]>");

    i = index + 2;
    return neutral;
  }

  function closingTag() {
    const tag = getName();

    if (!tag) error("Expected tag name");

    if (tag !== currentElement.tag) {
      error(
        `Expected closing tag </${tag}> to match opening tag <${
          currentElement.tag
        }>`
      );
    }

    if (source[i] !== ">") {
      error("Expected >");
    }

    stack.pop();
    currentElement = stack[stack.length - 1];
    if (currentElement) {
      ({ children } = currentElement);
    }

    return neutral;
  }

  function getName() {
    let name = "";
    while (i < length && validNameCharacters.test(source[i]))
      name += source[i++];

    return name;
  }

  function getAttributes(props) {
    while (i < length) {
      if (!whitespace.test(source[i])) return;
      allowSpaces();

      const name = getName();
      if (!name) return;

      let value = true;

      allowSpaces();
      if (source[i] === "=") {
        i += 1;
        allowSpaces();

        value = getAttributeValue();
        if (!isNaN(value) && value.trim() !== "") value = +value; // TODO whitelist numeric attributes?
      }

      props[camelCase(name)] = value;
    }
  }

  function getAttributeValue() {
    return quotemark.test(source[i])
      ? getQuotedAttributeValue()
      : getUnquotedAttributeValue();
  }

  function getUnquotedAttributeValue() {
    let value = "";
    do {
      const char = source[i];
      if (char === " " || char === ">" || char === "/") {
        return value;
      }

      value += char;
      i += 1;
    } while (i < length);

    return value;
  }

  function getQuotedAttributeValue() {
    const quotemark = source[i++];

    let value = "";
    let escaped = false;

    while (i < length) {
      const char = source[i++];
      if (char === quotemark && !escaped) {
        return value;
      }

      if (char === "\\" && !escaped) {
        escaped = true;
      }

      value += escaped ? `\\${char}` : char;
      escaped = false;
    }
  }

  function allowSpaces() {
    while (i < length && whitespace.test(source[i])) i += 1;
  }

  let i = 0;
  while (i < length) {
    if (!state) error("Unexpected character");
    state = state();
    i += 1;
  }

  if (state !== neutral) {
    error("Unexpected end of input");
  }

  root.children = root.children.map(astToReact);

  return root;
}

@msand
Copy link
Collaborator

msand commented Aug 25, 2019

@etaiklein I've optimized the implementation a bit, and added hooks based ones as well.

@etaiklein
Copy link
Author

Amazing work and quick response - thank you so much!

I was able to run the example in the readme no problem. However, I found a few cases blocking me from using my existing web svgs:

Failure 1: Adding inline style to text

<svg width="32" height="32" viewBox="0 0 32 32">
  <text x="1" y="20" style="fill:red;">TEST</text>
</svg>

TypeError: Cannot read property 'trim' of undefined

Failure 2: Including a <style> tag

<svg width="32" height="32" viewBox="0 0 32 32">
  <style>
    .small { font: italic 13px sans-serif; }
  </style>
  <text x="1" y="20" class="small">TEST</text>
</svg>
ExceptionsManager.js:82 Unhandled JS Exception: Invariant Violation: Invariant Violation: Invariant Violation: Invariant Violation: Element type is invalid: expected a string (for built-in components) or a class/function (for composite components) but got: undefined. You likely forgot to export your component from the file it's defined in, or you might have mixed up default and named imports.

Check the render method of `SvgXml`.

This error is located at:
    in RNSVGGroup (at G.js:23)
    in G (at Svg.js:127)
    in RNSVGSvgView (at Svg.js:116)
    in Svg (at xml.js:53)
    in SvgAst (at xml.js:61)

Failure 3: Accessibility elements title + desc

<svg version="1" id="cat" viewBox="0 0 720 800" aria-labelledby="catTitle catDesc" role="img">
  <title id="catTitle">Pixels, My Super-friendly Cat</title>
  <desc id="catDesc">An illustrated gray cat with bright green blinking eyes.</desc>
  <text x="20" y="35">TODO: cat</text>
</svg>
Unhandled JS Exception: Invariant Violation: Invariant Violation: Invariant Violation: Invariant Violation: Element type is invalid: expected a string (for built-in components) or a class/function (for composite components) but got: undefined. You likely forgot to export your component from the file it's defined in, or you might have mixed up default and named imports.

Check the render method of `SvgXml`.

This error is located at:
    in RNSVGGroup (at G.js:23)
    in G (at Svg.js:127)
    in RNSVGSvgView (at Svg.js:116)
    in Svg (at xml.js:53)
    in SvgAst (at xml.js:61)

It also broke on <use xlink:href ... /> but that's not supported in svg2, and href works fine.

Thanks again!

@msand
Copy link
Collaborator

msand commented Aug 27, 2019

  1. Should be fixed in the master and develop branch, haven't cut a new release after fixing it though.

  2. I'm not sure if it makes sense to support css selectors and style elements. I'd rather keep the parser and processing as minimal as possible.

  3. Not sure what the title and desc elements should map to, maybe:
    accessibilityRole="image"
    accessibilityLabel="Pixels, My Super-friendly Cat"
    accessibilityHint="An illustrated gray cat with bright green blinking eyes."
    Set as props on the Svg root element.

@msand
Copy link
Collaborator

msand commented Aug 27, 2019

If you need support for the style element, I'd suggest using the fork from here: https://github.com/iqqmuT/react-native-svg-uri

vault-development/react-native-svg-uri@master...iqqmuT:master

@msand
Copy link
Collaborator

msand commented Aug 27, 2019

Alternatively, something like svgo:

Add to package.json:

  "postinstall": "rn-nodeify --install \"fs,path,buffer,os,stream,util,events\"",

Run in root of project:

npm i asyncstorage-down buffer events path-browserify react-native-level-fs react-native-os readable-stream rn-nodeify stream-browserify util svgo@cancerberoSgx/svgo#8d30aa4

Add to top of index.js:

import './shim'

App.js

import React from "react";
import { SvgCssXml } from "./SvgCss";

const xml = `<svg width="32" height="32" viewBox="0 0 32 32">
  <style>
    .small { font: italic 13px sans-serif; }
  </style>
  <text x="1" y="20" class="small">TEST</text>
</svg>`;

export default () => <SvgCssXml xml={xml} width="100%" height="100%" />;

SvgCss.js

import React, { useEffect, useMemo, useState } from "react";
import { parse, SvgAst } from "react-native-svg";
import SVGO from "svgo";

const svgo = new SVGO({
  full: true,
  plugins: [
    {
      cleanupAttrs: true
    },
    {
      inlineStyles: true
    },
    {
      removeDoctype: true
    },
    {
      removeXMLProcInst: true
    },
    {
      removeComments: true
    },
    {
      removeMetadata: true
    },
    {
      removeTitle: true
    },
    {
      removeDesc: true
    },
    {
      removeUselessDefs: true
    },
    {
      removeEditorsNSData: true
    },
    {
      removeEmptyAttrs: true
    },
    {
      removeHiddenElems: true
    },
    {
      removeEmptyText: true
    },
    {
      removeEmptyContainers: true
    },
    {
      convertStyleToAttrs: true
    },
    {
      removeUnknownsAndDefaults: true
    },
    {
      removeNonInheritableGroupAttrs: true
    },
    {
      removeUselessStrokeAndFill: true
    },
    {
      removeUnusedNS: true
    },
    {
      cleanupIDs: true
    },
    {
      removeRasterImages: false
    }
  ]
});

export function SvgCssXml(props) {
  const { xml, override } = props;
  const ast = useMemo(() => {
    let parsed = null;
    if (xml) {
      const start = +new Date();
      svgo._optimizeOnce(xml, {}, optimized => {
        const end = +new Date();
        console.log(`svgo spent ${end - start}ms of the user experience`);
        parsed = parse(optimized.data);
      });
    }
    return parsed;
  }, [xml]);
  return (ast && <SvgAst ast={ast} override={override || props} />) || null;
}

async function fetchText(uri) {
  const response = await fetch(uri);
  return await response.text();
}

const err = console.error.bind(console);

export function SvgCssUri(props) {
  const { uri } = props;
  const [xml, setXml] = useState();
  useEffect(() => {
    fetchText(uri)
      .then(setXml)
      .catch(err);
  }, [uri]);
  return (xml && <SvgCssXml xml={xml} override={props} />) || null;
}

@msand
Copy link
Collaborator

msand commented Aug 27, 2019

I think the specific optimizations which make sense can be quite project specific, so I think it's better that each company/project create their own SvgCss.js file as in my previous comment.
But, I'd certainly recommend measuring how much time is spent optimizing / converting style elements, and make a mindful decision if it makes sense to do that every time a user views a new svg file, or if it's possible to only do it once at build time.

Preferably, use svg without style elements, css selectors and all the complexities involved with the cascade. And instead use G elements, inline attributes, and styles; using plain inheritance to get reuse of css style declarations, rather than adding another layer of pre-processing. Then you can use the plain SvgXml and SvgUri elements.

@etaiklein
Copy link
Author

  1. Should be fixed in the master and develop branch, haven't cut a new release after fixing it though.

Awesome! Looking forward to trying it.

  1. I'm not sure if it makes sense to support css selectors and style elements. I'd rather keep the parser and processing as minimal as possible.

Most of our SVGs are directly exported in design programs like Adobe Illustrator. Illustrator's exported SVGs heavily use <style> elements.
We also use @font-face in <style> for adding custom fonts in text-heavy svgs.

SVGO might be our answer! I'll try it out later.

  1. Not sure what the title and desc elements should map to, maybe:
    accessibilityRole="image"
    accessibilityLabel="Pixels, My Super-friendly Cat"
    accessibilityHint="An illustrated gray cat with bright green blinking eyes."
    Set as props on the Svg root element.

This sounds good to me!

@msand
Copy link
Collaborator

msand commented Aug 27, 2019

@etaiklein Custom fonts won't work, unless they're bundled with the os, or you fetch the fonts from somewhere dynamically and register them, or you make them available at build time.

@etaiklein
Copy link
Author

@etaiklein Custom fonts won't work, unless they're bundled with the os, or you fetch the fonts from somewhere dynamically and register them, or you make them available at build time.

We do the latter by base-64 encoding the .woff and including it in the @font-face in <style>

@msand
Copy link
Collaborator

msand commented Aug 27, 2019

Well, then you can probably extract them from the xml and use https://github.com/eVisit/react-native-dynamic-fonts
As it supports base64 encoded TTF or OTF

@etaiklein
Copy link
Author

etaiklein commented Aug 27, 2019

After your changes, I was able to get my server-side generated code working (without SVGO) with the following changes to my svg code:

  1. remove <title> and <desc>
  2. replace xlink:href with href
  3. remove <style> and replace font-family attribute font with registered fonts

@msand
Copy link
Collaborator

msand commented Aug 27, 2019

You could also run svgo on the server, preferably as a build step such that it only happens if the original content has changed.

@msand
Copy link
Collaborator

msand commented Aug 27, 2019

@etaiklein Added support for xlink:href in f3bd3a2

@msand
Copy link
Collaborator

msand commented Oct 21, 2019

@etaiklein I've added style element support in the css branch: #1155
Could you test it?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants