io/svg-serializer/index.js

/*
JSCAD Object to SVG Format Serialization

## License

Copyright (c) 2018 JSCAD Organization https://github.com/jscad

All code released under MIT license

Notes:
1) geom2 conversion to:
     SVG GROUP containing a continous SVG PATH that contains the outlines of the geometry
2) geom3 conversion to:
     none
3) path2 conversion to:
     SVG GROUP containing a SVG PATH for each path
*/

/**
 * Serializer of JSCAD geometries to SVG source (XML).
 *
 * The serialization of the following geometries are possible.
 * - serialization of 2D geometry (geom2) to SVG path (a continous path containing the outlines of the geometry)
 * - serialization of 2D geometry (path2) to SVG path
 *
 * Colors are added to SVG shapes when found on the geometry.
 * Special attributes (id and class) are added to SVG shapes when found on the geometry.
 *
 * @module io/svg-serializer
 * @example
 * const { serializer, mimeType } = require('@jscad/svg-serializer')
 */

const { geometries, maths, measurements, utils } = require('@jscad/modeling')

const stringify = require('onml/lib/stringify')

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

const mimeType = 'image/svg+xml'

/**
 * Serialize the give objects to SVG code (XML).
 * @see https://www.w3.org/TR/SVG/Overview.html
 * @param {Object} options - options for serialization, REQUIRED
 * @param {String} [options.unit='mm'] - unit of design; em, ex, px, in, cm, mm, pt, pc
 * @param {Function} [options.statusCallback] - call back function for progress ({ progress: 0-100 })
 * @param {Object|Array} objects - objects to serialize as SVG
 * @returns {Array} serialized contents, SVG code (XML string)
 * @alias module:io/svg-serializer.serialize
 * @example
 * const geometry = primitives.square()
 * const svgData = serializer({unit: 'mm'}, geometry)
 */
const serialize = (options, ...objects) => {
  const defaults = {
    unit: 'mm', // em | ex | px | in | cm | mm | pt | pc
    decimals: 10000,
    version,
    statusCallback: null
  }
  options = Object.assign({}, defaults, options)

  objects = utils.flatten(objects)

  // convert only 2D geometries
  const objects2d = objects.filter((object) => geometries.geom2.isA(object) || geometries.path2.isA(object))

  if (objects2d.length === 0) throw new Error('only 2D geometries can be serialized to SVG')
  if (objects.length !== objects2d.length) console.warn('some objects could not be serialized to SVG')

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

  // get the lower and upper bounds of ALL convertable objects
  const bounds = getBounds(objects2d)

  let width = 0
  let height = 0
  if (bounds) {
    width = Math.round((bounds[1][0] - bounds[0][0]) * options.decimals) / options.decimals
    height = Math.round((bounds[1][1] - bounds[0][1]) * options.decimals) / options.decimals
  }

  let body = ['svg',
    {
      width: width + options.unit,
      height: height + options.unit,
      viewBox: ('0 0 ' + width + ' ' + height),
      fill: 'none',
      'fill-rule': 'evenodd',
      'stroke-width': '0.1px',
      version: '1.1',
      baseProfile: 'tiny',
      xmlns: 'http://www.w3.org/2000/svg',
      'xmlns:xlink': 'http://www.w3.org/1999/xlink'
    }
  ]
  if (bounds) {
    body = body.concat(convertObjects(objects2d, bounds, options))
  }

  const svg = `<?xml version="1.0" encoding="UTF-8"?>
<!-- Created by JSCAD SVG Serializer -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1 Tiny//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11-tiny.dtd">
${stringify(body, 2)}`

  options.statusCallback && options.statusCallback({ progress: 100 })
  return [svg]
}

/*
 * Measure the bounds of the given objects, which is required to offset all points to positive X/Y values.
 */
const getBounds = (objects) => {
  const allbounds = measurements.measureBoundingBox(objects)

  if (objects.length === 1) return allbounds

  // create a sum of the bounds
  const sumofbounds = allbounds.reduce((sum, bounds) => {
    maths.vec3.min(sum[0], sum[0], bounds[0])
    maths.vec3.max(sum[1], sum[1], bounds[1])
    return sum
  }, [[0, 0, 0], [0, 0, 0]])
  return sumofbounds
}

const convertObjects = (objects, bounds, options) => {
  const xoffset = 0 - bounds[0][0] // offset to X=0
  const yoffset = 0 - bounds[1][1] // offset to Y=0

  const contents = []
  objects.forEach((object, i) => {
    options.statusCallback && options.statusCallback({ progress: 100 * i / objects.length })

    if (geometries.geom2.isA(object)) {
      contents.push(convertGeom2(object, [xoffset, yoffset], options))
    }
    if (geometries.path2.isA(object)) {
      contents.push(convertPaths([object], [xoffset, yoffset], options))
    }
  })
  return contents
}

const reflect = (x, y, px, py) => {
  const ox = x - px
  const oy = y - py
  if (x === px && y === px) return [x, y]
  if (x === px) return [x, py - (oy)]
  if (y === py) return [px - (-ox), y]
  return [px - (-ox), py - (oy)]
}

const convertGeom2 = (object, offsets, options) => {
  const outlines = geometries.geom2.toOutlines(object)
  const paths = outlines.map((outline) => geometries.path2.fromPoints({ closed: true }, outline))

  options.color = 'black' // SVG initial color
  if (object.color) options.color = convertColor(object.color)
  options.id = null
  if (object.id) options.id = object.id
  options.class = null
  if (object.class) options.class = object.class

  return convertToContinousPath(paths, offsets, options)
}

const convertToContinousPath = (paths, offsets, options) => {
  let instructions = ''
  paths.forEach((path) => (instructions += convertPath(path, offsets, options)))
  const d = { fill: options.color, d: instructions }
  if (options.id) d.id = options.id
  if (options.class) d.class = options.class
  return ['g', ['path', d]]
}

const convertPaths = (paths, offsets, options) => paths.reduce((res, path, i) => {
  const d = { d: convertPath(path, offsets, options) }
  if (path.color) d.stroke = convertColor(path.color)
  if (path.id) d.id = path.id
  if (path.class) d.class = path.class
  return res.concat([['path', d]])
}, ['g'])

const convertPath = (path, offsets, options) => {
  let str = ''
  const numpointsClosed = path.points.length + (path.isClosed ? 1 : 0)
  for (let pointindex = 0; pointindex < numpointsClosed; pointindex++) {
    let pointindexwrapped = pointindex
    if (pointindexwrapped >= path.points.length) pointindexwrapped -= path.points.length
    const point = path.points[pointindexwrapped]
    const offpoint = [point[0] + offsets[0], point[1] + offsets[1]]
    const svgpoint = reflect(offpoint[0], offpoint[1], 0, 0)
    const x = Math.round(svgpoint[0] * options.decimals) / options.decimals
    const y = Math.round(svgpoint[1] * options.decimals) / options.decimals
    if (pointindex > 0) {
      str += `L${x} ${y}`
    } else {
      str += `M${x} ${y}`
    }
  }
  return str
}

const convertColor = (color) => `rgba(${color[0] * 255},${color[1] * 255},${color[2] * 255},${color[3]})`

module.exports = {
  serialize,
  mimeType
}