javascripthtmlsvgpsd

How to convert PSD vector path to SVG path data


I'm trying to convert PSD path records to SVG Path data.

Since I don't want to cross post I'll link to the original question. Anyone who wants to copy in the relevant data can copy it back here.

Basically, I get the PSD, parse it, and get the shape information from vector mask object.

It contains an array called paths that what look to be points shown below:

 _ = require 'lodash'

# A path record describes a single point in a vector path. This is used
# in a couple of different places, but most notably in vector shapes.
module.exports = class PathRecord
  constructor: (@file) ->
    @recordType = null

  parse: ->
    @recordType = @file.readShort()

    switch @recordType
      when 0, 3 then @_readPathRecord()
      when 1, 2, 4, 5 then @_readBezierPoint()
      when 7 then @_readClipboardRecord()
      when 8 then @_readInitialFill()
      else @file.seek(24, true)

  export: ->
    _.merge { recordType: @recordType }, switch @recordType
      when 0, 3 then { numPoints: @numPoints }
      when 1, 2, 4, 5
        linked: @linked
        closed: (@recordType in [1, 2])
        preceding:
          vert: @precedingVert
          horiz: @precedingHoriz
        anchor:
          vert: @anchorVert
          horiz: @anchorHoriz
        leaving:
          vert: @leavingVert
          horiz: @leavingHoriz
      when 7
        clipboard:
          top: @clipboardTop
          left: @clipboardLeft
          bottom: @clipboardBottom
          right: @clipboardRight
          resolution: @clipboardResolution
      when 8 then { initialFill: @initialFill }
      else {}

  isBezierPoint: -> @recordType in [1, 2, 4, 5]

  _readPathRecord: ->
    @numPoints = @file.readShort()
    @file.seek 22, true

  _readBezierPoint: ->
    @linked = @recordType in [1, 4]

    @precedingVert = @file.readPathNumber()
    @precedingHoriz = @file.readPathNumber()

    @anchorVert = @file.readPathNumber()
    @anchorHoriz = @file.readPathNumber()

    @leavingVert = @file.readPathNumber()
    @leavingHoriz = @file.readPathNumber()

  _readClipboardRecord: ->
    @clipboardTop = @file.readPathNumber()
    @clipboardLeft = @file.readPathNumber()
    @clipboardBottom = @file.readPathNumber()
    @clipboardRight = @file.readPathNumber()
    @clipboardResolution = @file.readPathNumber()
    @file.seek 4, true

  _readInitialFill: ->
    @initialFill = @file.readShort()
    @file.seek 22, true

I'm trying to convert that info into SVG path data but I am stuck at two points. What record relates to what path command and the data seems to be values less than 1.

Here is example path data for the Tiger shape you can create in Photoshop:

I've trimmed the data

[
  {
    "recordType": 6
  },
  {
    "recordType": 8,
    "initialFill": 0
  },
  {
    "recordType": 0,
    "numPoints": 257
  },
  {
    "recordType": 2,
    "linked": false,
    "closed": true,
    "preceding": {
      "vert": 0.14081686735153198,
      "horiz": 0.07748442888259888
    },
    "anchor": {
      "vert": 0.14081686735153198,
      "horiz": 0.0777387022972107
    },
    "leaving": {
      "vert": 0.13936221599578857,
      "horiz": 0.0777667760848999
    }
  },
  {
    "recordType": 2,
    "linked": false,
    "closed": true,
    "preceding": {
      "vert": 0.13929903507232666,
      "horiz": 0.07793217897415161
    },
    "anchor": {
      "vert": 0.1385088562965393,
      "horiz": 0.07837295532226562
    },
    "leaving": {
      "vert": 0.13777965307235718,
      "horiz": 0.07837295532226562
    }
  },
  {
    "recordType": 2,
    "linked": false,
    "closed": true,
    "preceding": {
      "vert": 0.13706856966018677,
      "horiz": 0.07837295532226562
    },
    "anchor": {
      "vert": 0.13632577657699585,
      "horiz": 0.07837295532226562
    },
    "leaving": {
      "vert": 0.1364198923110962,
      "horiz": 0.07855236530303955
    }
  },
  {
    "recordType": 2,
    "linked": false,
    "closed": true,
    "preceding": {
      "vert": 0.13649815320968628,
      "horiz": 0.07873183488845825
    },
    "anchor": {
      "vert": 0.13657790422439575,
      "horiz": 0.07890427112579346
    },
    "leaving": {
      "vert": 0.1359773874282837,
      "horiz": 0.07879406213760376
    }
  },
  {
    "recordType": 2,
    "linked": false,
    "closed": true,
    "preceding": {
      "vert": 0.13536030054092407,
      "horiz": 0.07869088649749756
    },
    "anchor": {
      "vert": 0.1347590684890747,
      "horiz": 0.07858771085739136
    },
    "leaving": {
      "vert": 0.13486969470977783,
      "horiz": 0.07879406213760376
    }
  },
  {
    "recordType": 2,
    "linked": false,
    "closed": true,
    "preceding": {
      "vert": 0.13499760627746582,
      "horiz": 0.07900881767272949
    },
    "anchor": {
      "vert": 0.13512402772903442,
      "horiz": 0.07922220230102539
    },
    "leaving": {
      "vert": 0.1344437599182129,
      "horiz": 0.07920092344284058
    }
  },
  {
    "recordType": 2,
    "linked": false,
    "closed": true,
    "preceding": {
      "vert": 0.1268816590309143,
      "horiz": 0.08006417751312256
    },
    "anchor": {
      "vert": 0.12613815069198608,
      "horiz": 0.08038073778152466
    },
    "leaving": {
      "vert": 0.12613815069198608,
      "horiz": 0.08055287599563599
    }
  },
  {
    "recordType": 2,
    "linked": false,
    "closed": true,
    "preceding": {
      "vert": 0.12613815069198608,
      "horiz": 0.08073228597640991
    },
    "anchor": {
      "vert": 0.12613815069198608,
      "horiz": 0.08091175556182861
    },
    "leaving": {
      "vert": 0.1256791353225708,
      "horiz": 0.0807945728302002
    }
  },
  {
    "recordType": 2,
    "linked": false,
    "closed": true,
    "preceding": {
      "vert": 0.12177199125289917,
      "horiz": 0.08080857992172241
    },
    "anchor": {
      "vert": 0.12177199125289917,
      "horiz": 0.08080857992172241
    },
    "leaving": {
      "vert": 0.12169301509857178,
      "horiz": 0.08107715845108032
    }
  }
]

The post on github has the function that is parsing the data.

https://github.com/meltingice/psd.js/issues/284


Solution

  • Different path object models

    Photoshop path model: based on drawing points

    The photoshop path model defines a path based on drawing points – each of these points can contain 2 "tangent handles".

    This model actually reflects the UI concept you would experience in a graphic application: you can pull tangent handles from any drawing point to control the curvature of a path segment.

    point.anchor – visually set drawing point
    point.preceding – left tangent handle
    point.leaving – right tangent handle

    SVG path model: segment based

    In comparison to the psd/psd.js model a SVG C command would start with the previous point's leaving coordinates like so:

    previousPoint.leaving – previous point's tangent handle
    point.preceding – right tangent handle
    point.anchor – final point/position

    The right point.leaving would already belong to the next command in svg.

    So you need to reorder the retrieved point/coordinate data.

    recordType to svg cammand types

    You can rather ignore them – these properties are only relevant for a photoshop UI behavior.

    Command types

    Fortunately, you can easily translate all psd points to C cubic curvetos. The only exception is the mandatory M (moveto) command that is decribed by:

    firstPoint.anchor

    L (linetos) are also described by 3 object properties: if preceding, anchor and leaving are equal – we can convert these points to L commands (a C command with equal values for the 2 control points and the final point would also work).

    Closing the clip path

    As mentioned before we need to reorder/shift the photoshop point data:
    The last closing command will need the first point's preceding coordinates:

    point.leaving
    pointStart.preceding – first point left tangent handle
    pointStart.anchor – first point/position

    Coordinate scaling

    Actually a bit odd – coordinates are stored relative to the psd document's width and height.

    scaleX = psdDocumentWidth
    scaleY = psdDocumentHeight

    Since we don't have to bother about shorthand commands like V or H or A (arcs) we can multiply x (even - as starting from 0) and y (odd) values within a loop.

    Compound paths

    A clip path might include sub paths e.g the hole in an "O" shaped path.

    Sub paths are introduced by empty object items (not containing any preceding, anchor or leaving property values) like

    { recordType: 0, numPoints: 4 }  
    

    So we can use this for splitting the data into array chunks.

    Example: translate psd clip path to svg

    let clip = clipPath;
    
    // remove first 3 entries from array
    clip.splice(0, 3);
    
    /**
    * clip path might be a compound path containing sub paths
    */
    let subPaths = splitSubpaths(clip);
    
    // collect path data
    let pathData = [];
    
    subPaths.forEach((subPath) => {
      pathData.push({
        type: "M",
        values: [subPath[0].anchor.horiz, subPath[0].anchor.vert]
      });
    
      for (let i = 1; i < subPath.length; i++) {
        let com = subPath[i];
        let prev = subPath[i - 1] ? subPath[i - 1] : subPath[i];
        let p0 = { x: prev.anchor.horiz, y: prev.anchor.vert };
        let cp1 = { x: prev.leaving.horiz, y: prev.leaving.vert };
        let cp2 = { x: com.preceding.horiz, y: com.preceding.vert };
        let p = { x: com.anchor.horiz, y: com.anchor.vert };
    
        /**
        * is lineto - 
        * if preceding, anchor and leaving points 
        * are equal
        */
        if (
          com.preceding.horiz === com.leaving.horiz &&
          com.preceding.vert === com.leaving.vert &&
          com.anchor.horiz === com.preceding.horiz &&
          com.anchor.vert === com.preceding.vert &&
          com.anchor.horiz === com.leaving.horiz &&
          com.anchor.vert === com.leaving.vert
        ) {
          pathData.push({
            type: "L",
            values: [p.x, p.y]
          });
        }
        // is cubic
        else {
          pathData.push({
            type: "C",
            values: [cp1.x, cp1.y, cp2.x, cp2.y, p.x, p.y]
          });
        }
      }
    
      // close path
      let pointL = subPath.length - 1;
      let comLast = {};
      let isClosingLineTo = false;
    
      if (
        subPath[pointL].leaving.horiz === subPath[pointL].preceding.horiz &&
        subPath[pointL].leaving.vert === subPath[pointL].preceding.vert &&
        subPath[pointL].anchor.horiz === subPath[pointL].preceding.horiz &&
        subPath[pointL].anchor.vert === subPath[pointL].preceding.vert
      ) {
        isClosingLineTo = true;
      }
      
      if(!isClosingLineTo){
        pathData.push({
          type: "C",
          values: [
            subPath[pointL].leaving.horiz,
            subPath[pointL].leaving.vert,
            subPath[0].preceding.horiz,
            subPath[0].preceding.vert,
            subPath[0].anchor.horiz,
            subPath[0].anchor.vert
          ]
        });
      }
    
      // all clip paths are closed – append z command
      pathData.push({
        type: "z",
        values: []
      });
    });
    
    //scale according to psd doc width
    pathData = scalePathData(pathData, docWidth, docHeight, 3);
    
    // convert path data to d attribute
    let d = pathDataToD(pathData);
    path.setAttribute("d", d);
    svg.setAttribute("viewBox", [0, 0, docWidth, docHeight].join(" "));
    
    // show output
    svgOut.value = new XMLSerializer().serializeToString(svg);
    
    
    function scalePathData(pathData, scaleX = 1, scaleY = 1, decimals = 3) {
      let pathDataScaled = [];
      pathData.forEach((com, i) => {
        if (pathData[i].values.length) {
          for (let v = 0; v < com.values.length; v++) {
            let scale = v % 2 === 0 ? scaleX : scaleY;
            pathData[i].values[v] = +(com.values[v] * scale).toFixed(decimals);
          }
        }
      });
      return pathData;
    }
    
    
    /**
     * pathData to svg d attribute
     */
    function pathDataToD(pathData) {
      let d = pathData
        .map((com) => {
          return `${com.type}${com.values.join(" ")}`;
        })
        .join("");
      // optimize whitespace and delimiters
      d = d.replaceAll(",", " ").replaceAll(" -", "-");
      return d;
    }
    
    /**
     * split compound paths into sub path array chunks
     */
    function splitSubpaths(pathPointArray) {
      let subPathArr = [];
      let subPathMindex = [0];
      pathPointArray.forEach((com, i) => {
        // starting new subpath
        if (!com.anchor) {
          subPathMindex.push(i);
          pathPointArray.splice(i, 1);
        }
      });
      // create subpath array
      subPathMindex.forEach((index, i) => {
        let n = subPathMindex[i + 1];
        let thisSeg = pathPointArray.slice(index, n);
        subPathArr.push(thisSeg);
      });
      return subPathArr;
    }
    svg{
      height: 20em;
      border: 1px solid #ccc;
    }
    
    textarea{
      display:block;
      width:100%;
      min-height:20em;
    }
    <svg id="svg" viewBox="0 0 100 100">
      <path id="path" d="" />
    </svg>
    <h3>Output</h3>
    <textarea id="svgOut" ></textarea>
    
    <script>
      let docWidth = 100;
      let docHeight = 50;
    
      let clipPath = [
        // first  3 entries can be removed
        { recordType: 6 },
        { recordType: 8, initialFill: 0 },
        { recordType: 0, numPoints: 4 },
        // first  point anchor = M command
        {
          recordType: 1,
          linked: true,
          closed: true,
          preceding: { vert: 0.20999997854232788, horiz: 0.14666664600372314 },
          anchor: { vert: 0.20999997854232788, horiz: 0.2149999737739563 },
          leaving: { vert: 0.20999997854232788, horiz: 0.32833331823349 }
        },
        {
          recordType: 1,
          linked: true,
          closed: true,
          preceding: { vert: 0.3233333230018616, horiz: 0.3799999952316284 },
          anchor: { vert: 0.4699999690055847, horiz: 0.3799999952316284 },
          leaving: { vert: 0.6166666150093079, horiz: 0.3799999952316284 }
        },
        {
          recordType: 1,
          linked: true,
          closed: true,
          preceding: { vert: 0.6399999856948853, horiz: 0.38499999046325684 },
          anchor: { vert: 0.6399999856948853, horiz: 0.29499995708465576 },
          leaving: { vert: 0.6399999856948853, horiz: 0.20499998331069946 }
        },
        {
          recordType: 1,
          linked: true,
          closed: true,
          preceding: { vert: 0.7666666507720947, horiz: 0.11499994993209839 },
          anchor: { vert: 0.6399999856948853, horiz: 0.11499994993209839 },
          leaving: { vert: 0.5133333206176758, horiz: 0.11499994993209839 }
        },
        // init new sub path - will be omitted
        { recordType: 0, numPoints: 4 },
        
        // start sub path data
        {
          recordType: 1,
          linked: true,
          closed: true,
          preceding: { vert: 0.3799999952316284, horiz: 0.3009999990463257 },
          anchor: { vert: 0.3699999451637268, horiz: 0.2799999713897705 },
          leaving: { vert: 0.35192763805389404, horiz: 0.24204808473587036 }
        },
        {
          recordType: 1,
          linked: true,
          closed: true,
          preceding: { vert: 0.3779999613761902, horiz: 0.22299998998641968 },
          anchor: { vert: 0.4299999475479126, horiz: 0.22499996423721313 },
          leaving: { vert: 0.4819999933242798, horiz: 0.22699999809265137 }
        },
        {
          recordType: 1,
          linked: true,
          closed: true,
          preceding: { vert: 0.5239999890327454, horiz: 0.23499995470046997 },
          anchor: { vert: 0.5299999713897705, horiz: 0.26999998092651367 },
          leaving: { vert: 0.5359999537467957, horiz: 0.3049999475479126 }
        },
        {
          recordType: 1,
          linked: true,
          closed: true,
          preceding: { vert: 0.5339999794960022, horiz: 0.32799994945526123 },
          anchor: { vert: 0.4899999499320984, horiz: 0.32499998807907104 },
          leaving: { vert: 0.44599997997283936, horiz: 0.3219999670982361 }
        },
        // new sub path
        { recordType: 0, numPoints: 4 },
        
        // preceding == anchor == leaving => L lineto command
        {
          recordType: 1,
          linked: true,
          closed: true,
          preceding: { vert: 0.5299999713897705, horiz: 0.18499994277954102 },
          anchor: { vert: 0.5299999713897705, horiz: 0.18499994277954102 },
          leaving: { vert: 0.5299999713897705, horiz: 0.18499994277954102 }
        },
        {
          recordType: 1,
          linked: true,
          closed: true,
          preceding: { vert: 0.5299999713897705, horiz: 0.1499999761581421 },
          anchor: { vert: 0.5299999713897705, horiz: 0.1499999761581421 },
          leaving: { vert: 0.5299999713897705, horiz: 0.1499999761581421 }
        },
        {
          recordType: 1,
          linked: true,
          closed: true,
          preceding: { vert: 0.5799999833106995, horiz: 0.1499999761581421 },
          anchor: { vert: 0.5799999833106995, horiz: 0.1499999761581421 },
          leaving: { vert: 0.5799999833106995, horiz: 0.1499999761581421 }
        },
        {
          recordType: 1,
          linked: true,
          closed: true,
          preceding: { vert: 0.5799999833106995, horiz: 0.1799999475479126 },
          anchor: { vert: 0.5799999833106995, horiz: 0.1799999475479126 },
          leaving: { vert: 0.5799999833106995, horiz: 0.1799999475479126 }
        }
      ];
      
    </script>