Rendering

Chainsaw separates what an image is (data: Image, the Srcset sets WidthSet / DensitySet, Picture) from how it becomes HTML (renderers). The defaults emit standard, semantic markup; you can swap any of three renderer contracts to produce lazy-loaded <img>, web components, JSON for headless APIs, or anything else.

The three contracts

Each maps 1:1 to one HTML element:

namespace Timber\Chainsaw\Render;

interface ImgRendererInterface
{
    public function render(Image|Srcset $image, array $attrs = []): MarkupInterface;
}

interface SourceRendererInterface
{
    public function render(Source $source, array $attrs = []): MarkupInterface;
}

interface PictureRendererInterface
{
    public function render(Picture $picture, array $attrs = []): MarkupInterface;
}

MarkupInterface extends \Stringable is a marker interface — output is already-safe HTML, distinct from URL/srcset text. The default value object is Render\Html.

Defaults

The library ships Render\HtmlImgRenderer, Render\HtmlSourceRenderer, and Render\HtmlPictureRenderer. They emit a clean, modern shape:

<img src="..." srcset="..." width="800" height="600" alt="" />

<source srcset="..." media="(min-width: 1024px)" type="image/webp" width="1600" height="900" />

<picture>
  <source ... />
  <source ... />
  <img ... />
</picture>

Default width/height come from the dimension chain (MetadataResolverInterface if configured, plus DimensionAware manipulators); alt="" is set to keep markup HTML-valid even when the user forgets to pass one.

Calling render()

echo $image->render(['alt' => 'Field at dusk']);
echo $set->render(['alt' => 'Hero', 'sizes' => '100vw']);   // WidthSet / DensitySet
echo $picture->render(['alt' => 'Hero']);

Image::__toString() and the sets’ __toString() still return the URL / srcset string respectively (so <img src="{{ image }}"> works in templates). Picture is not Stringable — call ->render() explicitly.

In Twig, the |render filter is the terminal step:

{{ image|render({ alt: 'Field at dusk' }) }}
{{ image|widths(400, 800)|render({ sizes: '100vw' }) }}
{{ image|picture({}, ['webp'])|render({ alt: 'Hero' }) }}

Bring your own renderer

Inject a custom renderer at ImageFactory construction time. Three optional ctor params:

$factory = new ImageFactory(
    provider: $cdn,
    imgRenderer: new LazysizesImgRenderer(),     // optional
    sourceRenderer: new LazysizesSourceRenderer(), // optional
    pictureRenderer: new FigureWrappedPictureRenderer(), // optional
);

Anything you don’t supply uses the default. The defaults compose: HtmlPictureRenderer is constructed with the resolved ImgRendererInterface and SourceRendererInterface, so a custom ImgRendererInterface automatically applies to the inner <img> of every <picture> — no decorator gymnastics required.

Worked example — lazy load via lazysizes

Goal: emit <img> with data-src (and data-srcset for srcset sets) so the lazysizes JS library can swap on intersect. Default <source> and <picture> markup, the inner <img> of every <picture> should also be lazy.

namespace App\Render;

use Timber\Chainsaw\Contract\Srcset;
use Timber\Chainsaw\Render\ImgRendererInterface;
use Timber\Chainsaw\Render\MarkupInterface;
use Timber\Chainsaw\Image;
use Timber\Chainsaw\Render\Html;

final readonly class LazysizesImgRenderer implements ImgRendererInterface
{
    public function __construct(
        public string $placeholder = 'data:image/svg+xml,%3Csvg/%3E',
    ) {}

    public function render(Image|Srcset $image, array $attrs = []): MarkupInterface
    {
        $base = $image instanceof Srcset ? $image->fallback() : $image;
        $dim  = $base->dimensions();

        $parts = [
            sprintf('src="%s"', htmlspecialchars($this->placeholder, ENT_QUOTES)),
            sprintf('data-src="%s"', htmlspecialchars((string) $base, ENT_QUOTES)),
        ];
        if ($image instanceof Srcset) {
            $parts[] = sprintf('data-srcset="%s"', htmlspecialchars((string) $image, ENT_QUOTES));
        }
        if ($dim->width !== null) {
            $parts[] = sprintf('width="%d"', $dim->width);
        }
        if ($dim->height !== null) {
            $parts[] = sprintf('height="%d"', $dim->height);
        }
        $parts[] = 'class="lazyload"';
        $parts[] = sprintf('alt="%s"', htmlspecialchars((string) ($attrs['alt'] ?? ''), ENT_QUOTES));

        return new Html('<img ' . implode(' ', $parts) . ' />');
    }
}

Wire it once:

$factory = new ImageFactory(
    provider: $cdn,
    imgRenderer: new LazysizesImgRenderer(),
);

Now every Image::render(), WidthSet::render() / DensitySet::render(), and the inner <img> of every Picture::render() emits the lazy markup. <picture> and <source> use the defaults.

Customizing only <source>

Implement SourceRendererInterface and pass to the factory. The default HtmlPictureRenderer calls your renderer for each <source>; the default <img> and <picture> skeleton are reused.

Customizing only <picture>

Implement PictureRendererInterface (e.g. to wrap the output in <figure><figcaption>). You can compose the existing img/source defaults — your ctor accepts an injected ImgRendererInterface and SourceRendererInterface:

final readonly class FigurePictureRenderer implements PictureRendererInterface
{
    public function __construct(
        private ImgRendererInterface $img = new HtmlImgRenderer(),
        private SourceRendererInterface $source = new HtmlSourceRenderer(),
    ) {}

    public function render(Picture $picture, array $attrs = []): MarkupInterface
    {
        $sources = array_map(
            fn (Source $s): string => (string) $this->source->render($s, $attrs),
            $picture->sources,
        );
        $img = (string) $this->img->render($picture->fallback, $attrs);
        $caption = isset($attrs['caption'])
            ? sprintf('<figcaption>%s</figcaption>', htmlspecialchars((string) $attrs['caption'], ENT_QUOTES))
            : '';

        return new Html(
            '<figure><picture>' . implode('', $sources) . $img . '</picture>' . $caption . '</figure>',
        );
    }
}

Other shapes

The contract is open — emit anything that’s MarkupInterface-compatible:

  • Web components: <lazy-img src="...">, <amp-img>, etc.
  • JSON: return a JSON-encoded payload wrapped in Html for a headless API
  • Stimulus / Alpine / Vue: emit data-controller="image" etc.
  • <noscript> fallback: wrap the lazy markup with a no-JS fallback inside

Per-call renderer overrides

The factory wires one renderer family. For one-off customization, instantiate a renderer and call it directly:

echo (new LazysizesImgRenderer())->render($image, ['alt' => 'Hero']);

The renderer doesn’t need access to anything from the factory — it operates on the value object you pass in.

Twig integration

The Twig |render filter dispatches through the renderers carried on the Image (set by the factory). Custom renderers wired into the factory automatically apply to every |render call in templates — no template change needed.

The filter is marked is_safe: ['html']. The method-call form {{ image.render() }} requires |raw because Twig auto-escapes method results regardless of return type; prefer the filter form.