io/svg-deserializer/src/index.js

/*
## License

Copyright (c) 2016 Z3 Development https://github.com/z3dev
              2017 Mark 'kaosat-dev' Moissette

The upgrades (direct geometry instantiation from this deserializer) and refactoring
have been very kindly sponsored by [Copenhagen Fabrication / Stykka](https://www.stykka.com/)

All code released under MIT license
*/

const saxes = require('saxes')

const { colors, transforms } = require('@jscad/modeling')
const { toArray } = require('@jscad/array-utils')

const version = require('../package.json').version

const { cagLengthX, cagLengthY, svgColorForTarget } = require('./helpers')
const { svgSvg, svgRect, svgCircle, svgGroup, svgLine, svgPath, svgEllipse, svgPolygon, svgPolyline, svgUse } = require('./svgElementHelpers')
const shapesMapGeometry = require('./shapesMapGeometry')
const shapesMapJscad = require('./shapesMapJscad')

/**
 * Deserializer of SVG source data to JSCAD geometries.
 * @see {@link https://github.com/jscad/OpenJSCAD.org/blob/master/packages/io/svg-deserializer/README.md|README} for supported conversion of SVG elements.
 * @module io/svg-deserializer
 * @example
 * const { deserializer, extension } = require('@jscad/svg-deserializer')
 */

/**
 * Deserialize the given SVG source into either a script or an array of geometries
 * @see {@link https://www.w3.org/TR/SVG/intro.html|SVG Specification}
 * @param {Object} options - options used during deserializing, REQUIRED
 * @param {boolean} [options.addMetadata=true] - toggle injection of metadata at the start of the script
 * @param {string} [options.filename='svg'] - filename of original SVG source
 * @param {string} [options.output='script'] - either 'script' or 'geometry' to set desired output
 * @param {float} [options.pxPmm] - custom pixels per mm unit
 * @param {integer} [options.segments] - number of segments for rounded shapes
 * @param {string} [options.target] - target 2D geometry; 'geom2' or 'path2'
 * @param {string} [options.version='0.0.0'] - version number to add to the metadata
 * @param {string} [options.pathSelfClosed='error'] - [error||trim||split] if path self-closes with one of commands without stop command right after
 * @param {string} input - SVG source data
 * @returns {(Array|String)} either an array of objects (geometry) or a string (script)
 * @alias module:io/svg-deserializer.deserialize
 */
const deserialize = (options, input) => {
  const defaults = {
    addMetaData: true,
    filename: 'svg',
    output: 'script',
    pxPmm: require('./constants').pxPmm,
    segments: 32,
    target: 'path', // target - 'geom2' or 'path'
    pathSelfClosed: 'error',
    version
  }
  options = Object.assign({}, defaults, options)
  return options.output === 'script' ? translate(input, options) : instantiate(input, options)
}

/*
 * Parse the given SVG source and return a set of geometries.
 * @param  {string} src - svg data as text
 * @param  {object} options - options (optional) anonymous object with:
 *  pxPmm {number} pixels per milimeter for calcuations
 *  version: {string} version number to add to the metadata
 *  addMetadata: {boolean} flag to enable/disable injection of metadata (producer, date, source)
 *
 * @return {[geometry]} a set of geometries
 */
const instantiate = (src, options) => {
  const { pxPmm } = options

  options && options.statusCallback && options.statusCallback({ progress: 0 })

  // parse the SVG source
  createSvgParser(src, pxPmm)
  if (!svgObj) {
    throw new Error('SVG parsing failed, no valid SVG data retrieved')
  }

  options && options.statusCallback && options.statusCallback({ progress: 50 })

  const result = objectify(options, svgObj)

  options && options.statusCallback && options.statusCallback({ progress: 100 })
  return result
}

/*
 * Parse the given SVG source and return a JSCAD script
 * @param  {string} src svg data as text
 * @param  {object} options options (optional) anonymous object with:
 *  pxPmm {number: pixels per milimeter for calcuations
 *  version: {string} version number to add to the metadata
 *  addMetadata: {boolean} flag to enable/disable injection of metadata (producer, date, source)
 *    at the start of the file
 * @return {string} a string (JSCAD script)
 */
const translate = (src, options) => {
  const { filename, version, pxPmm, addMetaData } = options

  options && options.statusCallback && options.statusCallback({ progress: 0 })

  // parse the SVG source
  createSvgParser(src, pxPmm)
  if (!svgObj) {
    throw new Error('SVG parsing failed, no valid SVG data retrieved')
  }

  // convert the internal objects to JSCAD code
  let code = addMetaData
    ? `//
  // producer: JSCAD SVG Deserializer ${version}
  // date: ${new Date()}
  // source: ${filename}
  //
`
    : ''
  code += 'const { colors, geometries, primitives, transforms } = require(\'@jscad/modeling\')\n\n'

  options && options.statusCallback && options.statusCallback({ progress: 50 })

  const scadCode = codify(options, svgObj)
  code += scadCode
  code += '\nmodule.exports = { main }'

  options && options.statusCallback && options.statusCallback({ progress: 100 })
  return code
}

// FIXME: should these be kept here ? any risk of side effects ?
let svgUnitsX
let svgUnitsY
let svgUnitsV
// processing controls
const svgObjects = [] // named objects
const svgGroups = [] // groups of objects
const svgDefs = [] // defined objects
let svgInDefs = false // svg DEFS element in process
let svgObj // svg in object form
let svgUnitsPmm = [1, 1]

/*
 * Convert the given group (of objects) into geometries
 */
const objectify = (options, group) => {
  const { target, segments, pathSelfClosed } = options
  const level = svgGroups.length
  // add this group to the heiarchy
  svgGroups.push(group)
  // create an indent for the generated code
  let i = level
  while (i > 0) {
    i--
  }

  let geometries = []

  const params = {
    svgUnitsPmm,
    svgUnitsX,
    svgUnitsY,
    svgUnitsV,
    level,
    target,
    svgGroups,
    segments,
    pathSelfClosed
  }
  // apply base level attributes to all shapes
  for (i = 0; i < group.objects.length; i++) {
    const obj = group.objects[i]
    let shapes = toArray(shapesMapGeometry(obj, objectify, params))
    shapes = shapes.map((shape) => {
      if ('transforms' in obj) {
        // NOTE: SVG specifications require that transforms are applied in the order given.
        // But these are applied in the order as required by JSCAD
        let rotateAttribute = null
        let scaleAttribute = null
        let translateAttribute = null

        for (let j = 0; j < obj.transforms.length; j++) {
          const t = obj.transforms[j]
          if ('rotate' in t) { rotateAttribute = t }
          if ('scale' in t) { scaleAttribute = t }
          if ('translate' in t) { translateAttribute = t }
        }
        if (scaleAttribute !== null) {
          let x = Math.abs(scaleAttribute.scale[0])
          let y = Math.abs(scaleAttribute.scale[1])
          shape = transforms.scale([x, y, 1], shape)
          // and mirror if necessary
          x = scaleAttribute.scale[0]
          y = scaleAttribute.scale[1]
          if (x < 0) {
            shape = transforms.mirrorX(shape)
          }
          if (y < 0) {
            shape = transforms.mirrorY(shape)
          }
        }
        if (rotateAttribute !== null) {
          const z = 0 - rotateAttribute.rotate * 0.017453292519943295 // radians
          shape = transforms.rotateZ(z, shape)
        }
        if (translateAttribute !== null) {
          const x = cagLengthX(translateAttribute.translate[0], svgUnitsPmm, svgUnitsX)
          const y = (0 - cagLengthY(translateAttribute.translate[1], svgUnitsPmm, svgUnitsY))
          shape = transforms.translate([x, y, 0], shape)
        }
      }
      const color = svgColorForTarget(target, obj)
      if (color) shape = colors.colorize(color, shape)
      return shape
    })
    geometries = geometries.concat(shapes)
  }

  // remove this group from the hiearchy
  svgGroups.pop()

  return geometries
}

/*
 * Convert the given group into JSCAD script
 */
const codify = (options, group) => {
  const { target, segments } = options
  const level = svgGroups.length
  // add this group to the heiarchy
  svgGroups.push(group)
  // create an indent for the generated code
  let indent = '  '
  let i = level
  while (i > 0) {
    indent += '  '
    i--
  }
  // pre-code
  let code = ''
  if (level === 0) {
    code += 'function main(params) {\n  let levels = {}\n  let paths = {}\n  let parts\n'
  }
  const ln = 'levels.l' + level
  code += `${indent}${ln} = []\n`

  // generate code for all objects
  for (i = 0; i < group.objects.length; i++) {
    const obj = group.objects[i]
    const on = 'paths.p' + i

    const params = {
      level,
      indent,
      ln,
      on,
      svgUnitsPmm,
      svgUnitsX,
      svgUnitsY,
      svgUnitsV,
      svgGroups,
      target,
      segments
    }

    const tmpCode = shapesMapJscad(obj, codify, params)
    code += tmpCode

    if ('transforms' in obj) {
      // NOTE: SVG specifications require that transforms are applied in the order given.
      // But these are applied in the order as required by JSCAD
      let rotateAttribute = null
      let scaleAttribute = null
      let translateAttribute = null

      for (let j = 0; j < obj.transforms.length; j++) {
        const t = obj.transforms[j]
        if ('rotate' in t) { rotateAttribute = t }
        if ('scale' in t) { scaleAttribute = t }
        if ('translate' in t) { translateAttribute = t }
      }
      if (scaleAttribute !== null) {
        let x = Math.abs(scaleAttribute.scale[0])
        let y = Math.abs(scaleAttribute.scale[1])
        code += `${indent}${on} = transforms.scale([${x}, ${y}, 1], ${on})\n`
        // and mirror if necessary
        x = scaleAttribute.scale[0]
        y = scaleAttribute.scale[1]
        if (x < 0) {
          code += `${indent}${on} = transforms.mirrorX(${on})\n`
        }
        if (y < 0) {
          code += `${indent}${on} = transforms.mirrorY(${on})\n`
        }
      }
      if (rotateAttribute !== null) {
        const z = 0 - rotateAttribute.rotate * 0.017453292519943295 // radians
        code += `${indent}${on} = transforms.rotateZ(${z}, ${on})\n`
      }
      if (translateAttribute !== null) {
        const x = cagLengthX(translateAttribute.translate[0], svgUnitsPmm, svgUnitsX)
        const y = (0 - cagLengthY(translateAttribute.translate[1], svgUnitsPmm, svgUnitsY))
        code += `${indent}${on} = transforms.translate([${x}, ${y}, 0], ${on})\n`
      }
    }
    const color = svgColorForTarget(target, obj)
    if (color) {
      code += `${indent}${on} = colors.colorize([${color}], ${on})\n`
    }
    code += `${indent}${ln} = ${ln}.concat(${on})\n\n`
  }
  // post-code
  if (level === 0) {
    code += indent + 'return ' + ln + '\n'
    code += '}\n'
  }
  // remove this group from the hiearchy
  svgGroups.pop()

  return code
}

const createSvgParser = (src, pxPmm) => {
  // create a parser for the XML
  const parser = new saxes.SaxesParser()
  if (pxPmm !== undefined && pxPmm > parser.pxPmm) {
    parser.pxPmm = pxPmm
  }
  // extend the parser with functions
  parser.on('error', (e) => {
    console.log(`ERROR: SVG file, line ${parser.line}, column ${parser.column}`)
    console.log(e)
  })

  parser.on('opentag', (node) => {
    const objMap = {
      SVG: svgSvg,
      G: svgGroup,
      RECT: svgRect,
      CIRCLE: svgCircle,
      ELLIPSE: svgEllipse,
      LINE: svgLine,
      POLYLINE: svgPolyline,
      POLYGON: svgPolygon,
      PATH: svgPath,
      USE: svgUse,
      DEFS: () => { svgInDefs = true; return undefined },
      DESC: () => undefined, // ignored by design
      TITLE: () => undefined, // ignored by design
      STYLE: () => undefined, // ignored by design
      undefined: () => console.log('WARNING: unsupported SVG element: ' + node.name)
    }
    node.attributes.position = [parser.line + 1, parser.column + 1]

    const elementName = node.name.toUpperCase()
    const obj = objMap[elementName] ? objMap[elementName](node.attributes, { svgObjects, customPxPmm: pxPmm }) : undefined

    // case 'SYMBOL':
    // this is just like an embedded SVG but does NOT render directly, only named
    // this requires another set of control objects
    // only add to named objects for later USE
    //  break;

    if (obj) {
      // add to named objects if necessary
      if ('id' in obj) {
        svgObjects[obj.id] = obj
      }
      if (obj.type === 'svg') {
        // initial SVG (group)
        svgGroups.push(obj)
        svgUnitsPmm = obj.unitsPmm
        svgUnitsX = obj.viewW
        svgUnitsY = obj.viewH
        svgUnitsV = obj.viewP
      } else {
        // add the object to the active group if necessary
        if (svgInDefs === true) {
          if (svgDefs.length > 0) {
            const group = svgDefs.pop()
            if ('objects' in group) {
              group.objects.push(obj)
            }
            svgDefs.push(group)
          }
          if (obj.type === 'group') {
            svgDefs.push(obj)
          }
        } else {
          if (svgGroups.length > 0) {
            const group = svgGroups.pop()
            if ('objects' in group) {
            // TBD apply presentation attributes from the group
              group.objects.push(obj)
            }
            svgGroups.push(group)
          }
          if (obj.type === 'group') {
            svgGroups.push(obj)
          }
        }
      }
    }
  })

  parser.on('closetag', (node) => {
    const popGroup = () => {
      if (svgInDefs === true) {
        return svgDefs.pop()
      } else {
        return svgGroups.pop()
      }
    }

    const objMap = {
      SVG: popGroup,
      DEFS: () => { svgInDefs = false },
      USE: popGroup,
      G: popGroup,
      undefined: () => {}
    }
    const elementName = node.name.toUpperCase()
    const obj = objMap[elementName] ? objMap[elementName]() : undefined

    // check for completeness
    if (svgGroups.length === 0) {
      svgObj = obj
    }
  })

  parser.on('end', () => {
    // console.log('SVG parsing completed')
  })

  // start the parser
  parser.write(src).close()
  return parser
}

const extension = 'svg'

module.exports = {
  deserialize,
  extension
}