An even better Imager macro for Craft CMS

3 months ago
#craftcms#twig

I've been using the same set of macros to transform images with Imager for a while now, it was time to for an update and learn some new Twig tricks along the way.

(In a hurry? Get the entire macro set here. Find out below how this came together.)

Ryan Oy R B Rsh4 GU unsplash

What does it need to do?

There were a couple things I wanted the macro collection to do. Of course a regular <img> tag. But we also want to get a direct link to the image. Lastly srcset must also be generated when passing multiple transforms.

Lay out some ground rules

The previous set of macros I used for the past years, started to show some flaws. So I created some ground rules that the new set should follow

  • Only have 1 place that actually does the transform. In other words, we want to be able to create several macros (one for a regular img tag, one with srcset, direct url, ... and possible other uses) but have only 1 place where the transform templating code is.
  • Make sure that there are a couple of defaults in the transform such as sharpening, interlaced, etc.
  • These defaults must be overridable per transform, not as a default for each transform. For example in a responsive image we might want to use cropmode for our desktop image, but use letterbox for the mobile image.

Doing the transform

The main macro will be the one that will do the transform with Imager. It'll return either one or multiple url's, either wrapped in src, width & height attributes or not. Let's start with doing some basic checks first:

{% macro src(image, transform, withSizeAttrs = false) %}{% spaceless %}
  {% if image %}
    {% if image.size|default < craft.app.config.general.maxUploadFileSize %}
      ...
    {% endif %}
  {% endif %}
{% endspaceless %}{% endmacro %}

We want to be able to either pass a single transform object, or an array of transforms. These transform(s) must be merged in with our defaults. If there is a single transform, we can do a simple merge:

{% if transform is iterable %}
  {# Some good defaults: #}
  {% set mergeThisIn = {
    position: ((image.focalPoint.x|default(0.5) * 100)) ~ '% ' ~ (image.focalPoint.y|default(0.5) * 100) ~ '%',
    effects: { sharpen: true },
    interlace:  true,
    mode: 'crop'
  } %}

  {% if transform|first is iterable %}
    {# This one will be a bit trickier #}
  {% else %}
    {# Single transform => simple merge #}
    {% set transform = mergeThisIn|merge(transform) %}
  {% endif %}
{% endif %}

It's not that easy when we're dealing with an array of transforms. Merging in our default parameters in an array with numerical keys isn't possible with Twig, as it will replace the entire object instead of merging it in. We have to loop over the array, update each transform object and then replace the whole array:

...
{% if transform|first is iterable %}
    {# This one will be a bit trickier #}

    {# Array of transforms (srcset) =>
       create a new transform array with the defaults added for each item #}
    {% set tempArr = [] %}
    {% for i in 0..transform|length-1 %}
      {% set tempArr = tempArr|merge({ (i): mergeThisIn|merge(transform[i])}) %}
    {% endfor %}
    {# We can't update the transform array because
    merge doesn't work with numeric keys.. so replace it #}
    {% set transform = tempArr %}
{% else %}
...

Now that we have updated the transform(s) with some good defaults, we can create the image(s) and return.

{# Do the transform #}
{% set transformed = craft.imager.transformImage(image, transform) %}
{% if transform|first is not iterable %}
  {# Return the transform #}
  {{ withSizeAttrs ? 'src="' }}{{ transformed.url }}{{ withSizeAttrs ? '"' }} {{ withSizeAttrs ? "width='#{transformed.width}' height='#{transformed.height}'"|raw }}
{% else %}
  {# We can only return strings with macros, this will be transformed into array later on #}
  {{ transformed|map(x => "#{x.url} #{x.width}w")|join(',')|raw }}
{% endif %}

You might have noticed something strange about the returned value when dealing with multiple transforms. Macros can only return strings basicly, as explained in this great answer on craftcms.stackexchange.com.

But I want to be able to loop over the generated images. To get this working, we can return a comma separated string and transform it into an array later on.

Using our transform macro

Now that our transform macro is done, let's use it in another macro to return an imgtag.

{% macro regular(image, transform) %}
  {% if transform|first is not iterable %}
    <img {{ _self.src(image, transform, true) }} alt="{{ (image is iterable) ? image.title : '' }}" />
  {% else %}
    {# Multiple transforms #}
    ...
  {% endif %}
 {% endmacro %}

When we passed in multiple transforms we'll have to do a little more work. We need a single base url for the src attribute, and we'll need our srcset string. Remember, we returned a comma separated string in our _self.src() macro. Converting this back to an array will help us in returning correct markup.

...
{% else %}
  {# Multiple transforms #}
  {# Do some magic to transform our string into an array #}
  {% set helper = create('craft\\helpers\\ArrayHelper') %}
  {% set string = _self.src(image, transform) %}
  {% set array = helper.toArray(string) %}

  {# Get the middle url as default src attribute, and strip out the srcset width descriptor #}
  <img src="{{ array[array|length // 2]|split(' ')[0] }}"
    srcset="{{ string }}"
    alt="{{ (image is iterable) ? image.title : '' }}" {{ attributes|raw }} />
{% endif %}
...

We've set up a solid system to handle our transforms. We can now use our macro to return a single url very easily:

{# Returns a plain url #}
{% macro url(image, transform) %}{% spaceless %}
  {{ _self.src(image, transform) }}
{% endspaceless %}{% endmacro %}

What does it look like in use

With our collection of macros in place, lt's time to wrap this up and look at how we can actually use 'em.

{% import '_macros/img-macros' as transform %}

{{ transform.regular(entry.image.one(), { width: 635, height: 476 }) }}
➡️ <img src="..." width="635" height="476" alt="..." />

{# Get a direct url #}
➡️ <div style="background-image: url({{ transform.url(entry.image.one(), { width: 635, height: 476 }) }});"></div>

{{ transform.regular(entry.image.one(), [{ width: 1440 }, { width: 850, height: 476, interlace: false }, { width: 640, height: 640, mode: 'letterbox', letterbox: { color: '#000', opacity: 0 } }]) }}
➡️ <img src="..." srcset="... 1440w, ... 850w, ... 640w" alt="..." >