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.
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
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 typesYou can rather ignore them – these properties are only relevant for a photoshop UI behavior.
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).
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
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.
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.
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>