Converting SVG to PNG in multiple resolutions for React Native

Using Images in React Native

React Native provides a unified way of managing images in iOS and Android applications. Static resource images can be included in a React Native projects as explained in Static Image Resources by using a require statement. As an example, a component called MyCreditCardComponent that needs to display credit card images can load a static image from within the project with a JSX tag like this:

class MyCreditCardComponent extends React.Component
{
  render()
  {
    return <Image source={ require( '../images/Icon_AmericanExpress.png' ) } />
  }
}

The appropriate directory structure for this scenario would include the original image in native resolution, as well as 2x and 3x renderings of the same image.

app
  ├── components
  │   └── MyCreditCardComponent.js
  └── images
      ├── Icon_AmericanExpress.png
      ├── Icon_AmericanExpress@2x.png
      └── Icon_AmericanExpress@3x.png

The 2x and 3x renderings of the image are used for higher resolution displays, for instance the Apple Retina display.

Converting SVG vector files to PNG raster images for use in React Native

It is often the case in a project that the artwork is created in a vector format. Raster PNG files are then manually created in all required resolutions. Doing it can be a tedious process and change control is difficult. Also, the original vector images are often not part of the same project and are not in the same source control repository. They are not kept up to date with the respective commits, merges, etc.

Using SVG files to save the original vector artwork, storing them within the project and using a script to convert them to raster PNG images in the appropriate resolutions resolves that problem.

The tool build-app-images within Rebar's tools package automates this process by having the source vector file, stored as SVG, to be automatically rendered into the three PNG images required. The directory structure for this scenario is:

app
  ├── components
  │   └── MyCreditCardComponent.js
  └── images
      ├── Icon_AmericanExpress.svg
      ├── Icon_AmericanExpress.png
      ├── Icon_AmericanExpress@2x.png
      └── Icon_AmericanExpress@3x.png

Notice that the SVG file will not be included in the React Native bundle, since only require-ed PNG files are included.

Scanning all directories for SVG images

In the Rebar/Universal Relay Boilerplate directory structure all app related custom components are parts of units which are structures in an .\units directory. The app directory, as shown above, would be part of such an unit, in this case names rb-payments:

./
  └── units
      ├── unit1
      │   .....
      ├── rb-payments
      │   ├── components
      │   │   └── MyCreditCardComponent.js
      │   └── images
      │       ├── Icon_AmericanExpress.svg
      │       ├── Icon_AmericanExpress.png
      │       ├── Icon_AmericanExpress@2x.png
      │       └── Icon_AmericanExpress@3x.png
      │   .....
      └── unitX

By convention all SVG files that need to be converted are stored in the app/images sub-directory of the unit directory. The function below simply goes through all possible locations and finds all SVG files. Then it converts them one by one by calling convertSingleSVG2PNG.

function searchAndConvertSVG2PNG( directoryName: String ): void
{
  fs.readdirSync( directoryName )
    .filter( unitName =>
    {
      if( fs.statSync( directoryName + unitName )
        .isDirectory() )
      {
        const imagesDir = path.resolve( directoryName, unitName, 'app/images' )
        fs.readdirSync( imagesDir )
          .filter( pngName =>
          {
            if( pngName.endsWith( '.svg' ) )
            {
              convertSingleSVG2PNG( imagesDir, pngName )
            }
          } )
      }
    } )
}

The script is started with the following function call:

searchAndConvertSVG2PNG( 'units/' )

Converting a single SVG file

Each SVG image file is converted/rendered into three sizes required by the x1 x2 and x3 resolutions. The conversion function is run three times:

function convertSingleSVG2PNG( directoryName: String, svgName: String ): void
{
  const imageName = svgName.substring( 0, svgName.length - 4 )

  const svgData = {}

  convertSingleSVG2PNGWithAmplification( svgData, directoryName, imageName, 1 )
  convertSingleSVG2PNGWithAmplification( svgData, directoryName, imageName, 2 )
  convertSingleSVG2PNGWithAmplification( svgData, directoryName, imageName, 3 )
}

Function for creating a specific SVG file

The actual conversion function for a certain size has the following structure:

function convertSingleSVG2PNGWithAmplification( svgData: any, directoryName: String, imageName: String, amplification: Number ): void
{
  try
  {
    // Load the SVG file, determine the width, height and last modified date into a structure:
    // {
    //   buffer: Node.js buffer
    //   width: Number, must be in pixels
    //   height: Number, must be in pixels
    //   lastModified: Date
    //
    // }

    // Determine PNG file name

    // Determine last modified and compare to SVG. Use 1/1/1970 if not found so that SVG appears older

    // Generate PNG only if SVG is newer
  }
  catch( err )
  {
    throw new Error( "Could not convert " + directoryName + "." + imageName + ".svg because: " + err.message )
  }
}

Notice that the SVG data will be loaded only once, since the same svgData object is passed in every call to the function for the same SVG.

Load the SVG file, determine the width, height and last modified date

The data about the SVG file is retrieved in a straightforward piece of code:

if( !( svgData.buffer ) )
{
  // Determine the SVG file name
  const svgFileName = path.resolve( directoryName, imageName + '.svg' )

  // Read file
  const svgBuffer = fs.readFileSync( svgFileName )

  // Read as XML and get width and height
  const svgXml = xml.parseBuffer( svgBuffer )
  const width = parseDimension( svgXml.attrib.width, "Width", directoryName, imageName )
  const height = parseDimension( svgXml.attrib.height, "Height", directoryName, imageName )

  // Get last modified date/time
  const lastModified = getLastModifiedDateTimeForFile( svgFileName )

  Object.assign( svgData,
  {
    buffer: svgBuffer,
    width,
    height,
    lastModified
  } )

  console.log( "Processing SVG: " + svgFileName + ", last modified: " + renderPrettyDate( lastModified ) )
}

The Node.js fs module is used for the file reading and also getting the last modified date and time:

function getLastModifiedDateTimeForFile( fileName: String ): Date
{
  const stats = fs.statSync( fileName )
  return new Date( util.inspect( stats.mtime ) )
}

For the XML parsing, the excellent node-xml-lite * module is used. The SVG must have declared width and height in pixels. The parsing of the dimensions is done in the following fashion:

function parseDimension( dimensionValue: String, dimensionName: String, directoryName: String, imageName: String ): Number
{
  const dimensionAsString = dimensionValue
  if( !dimensionAsString.endsWith( "px" ) )
    throw new Error( dimensionName + " must end with px, but it rather is: " + dimensionAsString )
  const dimensionAsInt = parseInt( dimensionAsString )
  if( dimensionAsInt == NaN )
    throw new Error( dimensionName + " must be integer, but it rather is: " + dimensionAsString )

  return dimensionAsInt
}

Here is an example of a valid SVG image of a credit card taken from Material UI Credit Card Icons:

<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1"
  xmlns="http://www.w3.org/2000/svg"
  xmlns:xlink="http://www.w3.org/1999/xlink"
  x="0px" y="0px"
  width="60px"
  viewBox="0 0 60 40" enable-background="new 0 0 60 40" xml:space="preserve">
    <g>
        <!-- content of the svg file  -->
    </g>
</svg>

Notice the presence of width="60px" and height="40px" attributes.

Determining the PNG file name and last modified date

Straightforward code retrieves the last modified date, or if the file is missing, uses 1/1/1970:

// Determine PNG file name
const amplificationString = amplification > 1 ? '@' + amplification + 'x' : ''
const pngFileName = path.resolve( directoryName, imageName + amplificationString + '.png' )

// Determine last modified and compare to SVG. Use 1/1/1970 if not found so that SVG appears older
let pngLastModified = null
try
{
  pngLastModified = getLastModifiedDateTimeForFile( pngFileName )
}
catch( err )
{
  if( err.code === 'ENOENT' )
    pngLastModified = new Date( 0 )
  else throw err
}

Rendering PNG only if SVG is newer

Rendering the SVG file into PNG is performed using the excellent excellent svg2png * module. The rendering takes significantly more time than scanning the directories for SVG files and loading the file information. In order to speed up the execution, a PNG file is rendered only if:

Here is the code:

if( pngLastModified <= svgData.lastModified )
{
  const width = amplification * svgData.width
  const height = amplification * svgData.height

  console.log( "Rendering PNG: " + pngFileName + ", width: " + width + ", height: " + height )

  const pngBuffer = svg2png.sync( svgData.buffer,
  {
    width,
    height
  } )
  fs.writeFileSync( pngFileName, pngBuffer )
}
else
  console.log( "Skipping PNG: " + pngFileName + ", last modified: " + renderPrettyDate( pngLastModified ) )

Usage

The tool can be used within Rebar by running:

yarn build-app-images

For examples of using the script please read the description in the rb-tools unit's documentation.