SVGPath/SVGPath.js

import { command } from './command/helpers.js'
import SVGCoordinates from './SVGCoordinates.js'
import SVGPathCommand from './command/SVGPathCommand.js'
import SVGPathMCommand from './command/SVGPathMCommand.js'
import parser from '../parser/parser.js'
import SVGPathZCommand from './command/SVGPathZCommand.js'
import SVGPathVCommand from './command/SVGPathVCommand.js'
import SVGPathHCommand from './command/SVGPathHCommand.js'
import { ErrorToken, WSPToken } from '../parser/Token.js'

// HELPERS --------------------------------------------------------------------
const SPLIT_REDUCER = (first, next, treshold) => (arr, nbr) => {
  const last = arr.pop()

  if (!last) { return [[first, nbr]] }
  if (last.length === treshold + 1) { return [...arr, last, [next, nbr]] }
  return [...arr, [...last, nbr]]
}

const SPLIT_SUM = (total, nbr) => { total += nbr; return total }

const SPLIT_TOKEN = {
  a: token => token.data.flat().reduce(SPLIT_REDUCER('a', 'a', 7), []),
  A: token => token.data.flat().reduce(SPLIT_REDUCER('A', 'A', 7), []),
  c: token => token.data.flat().reduce(SPLIT_REDUCER('c', 'c', 6), []),
  C: token => token.data.flat().reduce(SPLIT_REDUCER('C', 'C', 6), []),
  l: token => token.data.flat().reduce(SPLIT_REDUCER('l', 'l', 2), []),
  L: token => token.data.flat().reduce(SPLIT_REDUCER('L', 'L', 2), []),
  m: token => token.data.flat().reduce(SPLIT_REDUCER('m', 'l', 2), []),
  M: token => token.data.flat().reduce(SPLIT_REDUCER('M', 'L', 2), []),
  q: token => token.data.flat().reduce(SPLIT_REDUCER('q', 'q', 4), []),
  Q: token => token.data.flat().reduce(SPLIT_REDUCER('Q', 'Q', 4), []),
  s: token => token.data.flat().reduce(SPLIT_REDUCER('s', 's', 4), []),
  S: token => token.data.flat().reduce(SPLIT_REDUCER('S', 'S', 4), []),
  t: token => token.data.flat().reduce(SPLIT_REDUCER('t', 't', 2), []),
  T: token => token.data.flat().reduce(SPLIT_REDUCER('T', 'T', 2), []),
  h: token => [['h', token.data.flat().reduce(SPLIT_SUM)]],
  H: token => [['H', token.data.flat().reduce(SPLIT_SUM)]],
  v: token => [['v', token.data.flat().reduce(SPLIT_SUM)]],
  V: token => [['V', token.data.flat().reduce(SPLIT_SUM)]],
  z: () => [['z']],
  Z: () => [['Z']]
}

const MAKE_COMMAND_ABSOLUTE = cursor => cmd => {
  cmd.toAbsolute(cursor)

  if (cmd instanceof SVGPathMCommand) {
    cursor.m = cmd.coordinates
  }

  if (cmd instanceof SVGPathZCommand) {
    cursor.x = cursor.m.x
    cursor.y = cursor.m.y
    return cmd
  }

  cursor.x = cmd instanceof SVGPathVCommand ? cursor.x : cmd.x
  cursor.y = cmd instanceof SVGPathHCommand ? cursor.y : cmd.y

  return cmd
}

const MAKE_COMMAND_RELATIVE = cursor => cmd => {
  cmd.toRelative(cursor)

  if (cmd instanceof SVGPathMCommand) {
    cursor.m = new SVGCoordinates(...cmd.coordinates).absoluteFrom(cursor)
  }

  if (cmd instanceof SVGPathZCommand) {
    cursor.x = cursor.m.x
    cursor.y = cursor.m.y
    return cmd
  }

  cursor.x = cmd instanceof SVGPathVCommand ? cursor.x : cursor.x + cmd.x
  cursor.y = cmd instanceof SVGPathHCommand ? cursor.y : cursor.y + cmd.y

  return cmd
}

// MAIN API -------------------------------------------------------------------

/** Class representing an SVG Path */
class SVGPath extends Array {
  /**
   * Turn any string representing an SVG path into an array of SVG path
   * commands.
   *
   * Because `SVGPath` extend the native JavaScript [`Array`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array)
   * object, all its methods can be used to easily manipulate the path. That
   * said, be carful when using those methods as some can mutate the path
   * (i.e. `splice`) when other doesn't (i.e. `slice`).
   *
   * @param {string} path The string representation of the path
   */
  constructor (path) {
    super()

    const tokens = parser(typeof path === 'string' ? path : '')

    for (const token of tokens) {
      if (token instanceof WSPToken) { continue }
      if (token instanceof ErrorToken) {
        console.error(`Error at index: ${token.start}`)
        break
      }

      this.push(...SPLIT_TOKEN[token.name](token))
    }
  }

  get isFragment () {
    return !(this[0] instanceof SVGPathMCommand)
  }

  /** Create a new path matching the current one but with absolute commands only
   *
   * @param {SVGPath} path The path to convert
   * @returns {SVGPath}
   */
  static toAbsolute (path) {
    const cursor = new SVGCoordinates(0, 0)
    cursor.m = cursor
    const makeAbsolute = cmd => MAKE_COMMAND_ABSOLUTE(cursor)(command(...cmd))

    return path.map(makeAbsolute)
  }

  /** Mutate all the commands of the current path to make them absolute
   *
   * @returns {SVGPath}
   */
  toAbsolute () {
    const cursor = new SVGCoordinates(0, 0)
    cursor.m = cursor
    this.forEach(MAKE_COMMAND_ABSOLUTE(cursor))

    return this
  }

  /** Create a new path matching the current one but with relative commands only
   *
   * @param {SVGPath} path The path to convert
   * @param {SVGCoordinates} origin The point of origin to resolve coordinates
   * @returns {SVGPath}
   */
  static toRelative (path) {
    const cursor = new SVGCoordinates(0, 0)
    cursor.m = cursor
    const makeRelative = cmd => MAKE_COMMAND_RELATIVE(cursor)(command(...cmd))

    return path.map(makeRelative)
  }

  /** Mutate all the commands of the current path to make them relative
   *
   * @param {SVGCoordinates} origin The point of origin to resolve coordinates
   * @returns {SVGPath}
   */
  toRelative () {
    const cursor = new SVGCoordinates(0, 0)
    cursor.m = cursor
    this.forEach(MAKE_COMMAND_RELATIVE(cursor))

    return this
  }

  // ARRAY API ----------------------------------------------------------------

  /**
   * Overload the inherited `Array.prototype.push` to make sure we only
   * add SVGPathCommand within an SVGPath
   */
  push (...args) {
    for (const value of args) {
      if (value instanceof SVGPathCommand) {
        super.push(value)
        continue
      }

      if (Array.isArray(value)) {
        super.push(command(...value))
        continue
      }
    }

    return this.length
  }

  /**
   * Overload the inherited `Array.prototype.unshift` to make sure we only
   * add SVGPathCommand within an SVGPath
   */
  unshift (...args) {
    for (const value of args) {
      if (value instanceof SVGPathCommand) {
        super.unshift(value)
        continue
      }

      if (Array.isArray(value)) {
        super.unshift(command(...value))
        continue
      }
    }

    return this.length
  }

  /**
   * Return a string representation of the path
   *
   * Note that if the `compact` flag is set to `true`, the string representation
   * will be somewhat compacted. The main effects are that value separators are
   * reduced to the maximum and implicite command syntax are used whenever
   * possible.
   *
   * On the other hand, a non compact representation—if the `compact` flag is
   * set to `false`—is a little more human friendly with each command placed on
   * a new line.
   *
   * > **IMPORTANT:** _If the current `SVGPath` is invalide (the first command
   * > is not an {@link SVGPathMCommand} object), the string representation
   * > will be made valid by adding an implicite `M0,0` command at the start of
   * > the string._
   *
   * @param {Boolean} compact
   *   Indicate if the string representation must be optimized
   *   (`true`) or more human friendly (`false`)
   * @returns {String}
   */
  toString (compact) {
    const prefix = []

    if (!(this[0] instanceof SVGPathMCommand)) {
      prefix.push(new SVGPathMCommand(0, 0))
    }

    if (!compact) {
      return [...prefix, ...this].join('\n')
    }

    let prev
    return [...prefix, ...this].reduce((arr, cmd) => {
      const raw = Array.from(cmd)
      const current = raw.shift()

      if (
        (prev === 'm' && current === 'l') ||
        (prev === 'M' && current === 'L')
      ) {
        prev = current
      }

      if (prev === current && current !== 'm' && current !== 'M') {
        return [...arr, ',', raw.join(',')]
      }

      prev = current
      return [...arr, current, raw.join(',')]
    }, []).join('')
  }
}

// MODULE PUBLIC API ----------------------------------------------------------
export default SVGPath