Rendering

Chainsaw separates what an image is (data: Image, the Srcset sets WidthSet
/ DensitySet) from presentation policy (resolved into an
ImgElement / PictureElement by img()) from how the element
becomes HTML
(renderers). Policy lives in the element; renderers are pure emitters.
The defaults emit standard, semantic markup; 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 and receives a fully-resolved value object:

namespace Timber\Chainsaw\Render;

interface ImgRendererInterface
{
    public function render(ImgElement $img, array $attrs = []): MarkupInterface;
}

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

interface PictureRendererInterface
{
    public function render(PictureElement $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\Markup.
The element types (ImgElement, PictureElement) are themselves MarkupInterface:
casting one to a string renders it through the carried renderer.

Defaults

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

<img src="..." srcset="..." sizes="100vw" width="800" height="600"
     alt="Field at dusk" loading="lazy" decoding="async" />

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

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

width / height come from the dimension chain (MetadataResolverInterface if
configured, plus DimensionAware manipulators). The presentation attributes
(alt, loading, decoding, …) are resolved by img() and carried
on the element — renderers only emit what the element holds. A null attribute value
drops the attribute entirely. No alt is invented: the element carries whatever you
passed, and the zero-config shortcut carries none.

Getting an element to render

echo $image->img(alt: 'Field at dusk');                    // <img>
echo $set->img(alt: 'Hero', sizes: '100vw');               // srcset <img>
echo $picture;                                             // <picture>
echo $el->render(['class' => 'hero']);                     // last-minute attrs

Image::render() / Srcset::render() survive as a zero-config shortcut that builds
a default element internally (no alt). Image::__toString() and the sets’
__toString() still return the URL / srcset string (so <img src="{{ image }}">
works); the element types stringify to HTML.

In Twig, |img is the terminal that prints the tag:

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

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 FigurePictureRenderer(),        // 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.
The renderer receives a resolved ImgElement, so it reads accessors instead of
recomputing policy:

namespace App\Render;

use Timber\Chainsaw\Output\ImgElement;
use Timber\Chainsaw\Output\ImgRendererInterface;
use Timber\Chainsaw\Output\MarkupInterface;
use Timber\Chainsaw\Render\Markup;

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

    public function render(ImgElement $img, array $attrs = []): MarkupInterface
    {
        $parts = [
            sprintf('src="%s"', htmlspecialchars($this->placeholder, ENT_QUOTES)),
            sprintf('data-src="%s"', htmlspecialchars($img->src(), ENT_QUOTES)),
        ];
        if (($srcset = $img->srcset()) !== null) {
            $parts[] = sprintf('data-srcset="%s"', htmlspecialchars($srcset, ENT_QUOTES));
        }
        if (($width = $img->width()) !== null) {
            $parts[] = sprintf('width="%d"', $width);
        }
        if (($height = $img->height()) !== null) {
            $parts[] = sprintf('height="%d"', $height);
        }
        $parts[] = 'class="lazyload"';
        $parts[] = sprintf('alt="%s"', htmlspecialchars($img->alt ?? '', ENT_QUOTES));

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

Wire it once:

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

Now every img() / render() and the inner <img> of every <picture> emits the
lazy markup. <picture> and <source> use the defaults.

For a lazy-load convention that stays on the default renderer, use the
element’s null-drop: attrs: ['srcset' => null, 'data-srcset' => $set] moves the candidate list to a data attribute without a
custom renderer.

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. The
fallback is an ImgElement, ready to hand straight to the img renderer:

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

    public function render(PictureElement $picture, array $attrs = []): MarkupInterface
    {
        $sources = array_map(
            fn (SourceElement $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 Markup(
            '<figure><picture>' . implode('', $sources) . $img . '</picture>' . $caption . '</figure>',
        );
    }
}

$picture->sources is the declared source list. Media × format expansion (the
type-less group source, the fallback format pin) happens inside the default
HtmlPictureRenderer; a from-scratch PictureRendererInterface that bypasses it
is responsible for its own expansion.

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 Markup for a headless API
    (or reach for ImgElement::jsonSerialize()).
  • 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 with an element:

echo (new LazysizesImgRenderer())->render($image->img(alt: 'Hero'));

The renderer doesn’t need anything from the factory — it operates on the element you
pass in.

Twig integration

The |img and |picture filters build and print elements through the
renderers carried on the Image (set by the factory). A custom renderer wired into
the factory automatically applies to every filter call in templates — no template
change needed. Both filters are marked is_safe: ['html'], so {{ image|img(alt: '…') }} prints raw without |raw; passing an element as a plain variable
({{ el }}) needs the one-line safe-class registration described in
Twig.