For the complete documentation index, see llms.txt. This page is also available as Markdown.

Обработка изображений

Как уменьшать, обрезать и кэшировать превью загруженных картинок

Аватар, фото профиля, скриншот файла, снимок в альбоме — всё это загружает посетитель, и хранить такое в исходном виде нельзя: с телефона придёт снимок на 12 мегапикселей, который положит вёрстку списка и съест место на диске. Уменьшением, обрезкой, перекодированием и водяными знаками занимается сервис обработки изображений.

Главное правило

Обращайтесь к Johncms\Image\ImageProcessorInterface, а не к библиотеке напрямую.

За интерфейсом стоит Intervention Image, но её имя не должно встречаться нигде, кроме единственной реализации InterventionImageProcessor. Так переход на новую мажорную версию библиотеки — правка одного файла, а не всех мест, где сохраняется картинка.

Это тот же приём, что и у санитайзера HTML: вызывающий код говорит, что ему нужно получить, а чем это сделано — деталь реализации.

Как пользоваться

Внедрите интерфейс через конструктор:

<?php

declare(strict_types=1);

namespace Johncms\Modules\MyModule\Application\UseCases;

use Johncms\Http\UploadedFileDTO;
use Johncms\Image\ImageProcessingException;
use Johncms\Image\ImageProcessorInterface;
use Johncms\Modules\MyModule\Application\Exceptions\CoverUploadException;
use Johncms\Storage\StorageException;
use Johncms\Storage\StorageInterface;

final readonly class UploadCoverUseCase
{
    private const int COVER_WIDTH = 800;
    private const int COVER_HEIGHT = 600;

    public function __construct(
        private ImageProcessorInterface $imageProcessor,
        private StorageInterface $storage,
    ) {
    }

    public function execute(int $id, UploadedFileDTO $file): void
    {
        try {
            $this->storage->storeGenerated(
                'covers/' . $id . '.jpg',
                fn(string $target) => $this->imageProcessor->saveScaledDown(
                    $file->tmpPath,
                    $target,
                    self::COVER_WIDTH,
                    self::COVER_HEIGHT
                )
            );
        } catch (ImageProcessingException | StorageException $exception) {
            throw new CoverUploadException($exception->getMessage());
        }
    }
}

Источник и цель задаются путями к файлам: загрузка и так лежит на диске (UploadedFileDTO::$tmpPath), результат тоже нужен на диске. Методы ничего не возвращают — они пишут файл.

Готовую картинку кладём в хранилище, а не по пути, собранному из UPLOAD_PATH: storeGenerated() даёт обработчику временный путь, забирает написанное на диск и убирает за собой. Так папки создаются сами, права выставляются по конфигурации, а картинки можно перенести в облако, не трогая код.

Общие правила

Это верно для любого метода сервиса.

Формат берётся из расширения целевого файла. Сохранение в avatar.png даст PNG, в photo.jpg — JPEG, независимо от того, что загрузил посетитель.

Качество — последний аргумент, число от 0 до 100, по умолчанию 100. Оно влияет только на форматы со сжатием с потерями (JPEG, WebP); для PNG его наличие ничего не меняет.

Целевую папку сервис не создаёт. Если её нет, вызов закончится ImageProcessingException. При записи через storeGenerated() об этом думать не нужно — диск создаёт недостающие папки сам.

Доступные операции

Уместить в границы

Метод
Что делает

saveScaledDown($source, $target, ?$width, ?$height, $quality)

Вписывает картинку в заданные границы с сохранением пропорций. Картинку меньше границ не увеличивает — оставляет как есть

saveConverted($source, $target, $quality)

Та же картинка в исходном размере, перекодированная в другой формат

Стороны в saveScaledDown() необязательные: null означает «не ограничивать». Чтобы уменьшить только по ширине, передайте только её:

Получить точный размер

Когда картинка должна занять кадр фиксированного размера — плитку в сетке, обложку, карточку каталога, — исходник почти никогда не тех же пропорций. Методы отличаются тем, чем ради этого жертвуют.

Метод
Что делает
Чем жертвует

saveCropped($source, $target, $width, $height, $position, $quality)

Заполняет кадр целиком, лишнее обрезает по краям

Краями картинки

savePadded($source, $target, $width, $height, $background, $quality)

Помещает картинку целиком, недостающее заливает фоном

Полями по двум сторонам

saveBlurredThumbnail($source, $target, $width, $height, $quality)

Заполняет кадр размытой копией картинки, а сверху по центру кладёт её же уменьшенной

Ничем, но подложка размытая

saveStretched($source, $target, $width, $height, $quality)

Растягивает картинку до кадра

Пропорциями — картинка деформируется

Последний вариант — saveBlurredThumbnail() — нужен там, где миниатюры стоят сеткой одинаковых плиток, а обрезать картинки жалко: размытая подложка заполняет кадр вместо пустых полей. Так сделаны миниатюры альбома и превью скриншотов в загрузках.

Какую часть картинки оставит обрезка. saveCropped() по умолчанию берёт центр. Если важна другая часть, передайте позицию перечислением Johncms\Image\ImagePosition:

Значения: TopLeft, Top, TopRight, Left, Center, Right, BottomLeft, Bottom, BottomRight.

Чем заливать поля. Фон в savePadded() — любой цвет, понятный драйверу: 'fff', '#ffcc00', 'rgb(255, 0, 0)'. Чтобы поля остались прозрачными, передайте константу ImageProcessorInterface::TRANSPARENT и сохраняйте в формат с альфа-каналом — PNG или WebP:

Водяной знак

По умолчанию знак кладётся в правый нижний угол, непрозрачным и вплотную к краю. Позиция задаётся тем же перечислением ImagePosition, прозрачность — числом от 0 до 100, отступ от края — в пикселях:

Знак накладывается своего размера, под картинку он не подгоняется. Маленький логотип, растянутый на большое фото, вышел бы размытым, поэтому подготовьте файл нужного размера — или держите несколько вариантов знака под разные размеры картинок.

Обработка ошибок

Всё, с чем сервис не справился — битый файл, неподдерживаемый формат, недоступная для записи папка, — приходит одним исключением Johncms\Image\ImageProcessingException.

Превью

Превью — картинка, которую показывают вместо оригинала в списке. Генерировать её на каждый показ дорого: страница с двадцатью вложениями означала бы двадцать раз декодировать, уменьшить и размыть JPEG. Поэтому превью считается один раз и складывается на диск.

Этим занимается Johncms\Image\ThumbnailGenerator — внедрите его через конструктор так же, как сам сервис изображений. Он возвращает путь к готовому файлу, а пересчитывает его, только если файла нет или оригинал новее:

Кэш лежит в data/cache/thumbnailsвне папки сайта, и это сделано намеренно: превью отдаёт контроллер, а значит право посетителя видеть эту картинку есть где проверить. Каталог, который веб-сервер раздаёт напрямую, такой возможности не оставляет.

Чистится вместе с остальным кэшем, отдельно ничего настраивать не нужно:

Отдача превью

Готовый файл отдаётся классом Johncms\Http\CachedImageResponse:

CachedImageResponse сам проставляет Content-Type, Content-Length, Last-Modified и Cache-Control: private со сроком в неделю. Собирать BinaryFileResponse вручную не нужно: ядро намеренно не вызывает Response::prepare(), поэтому обычный файловый ответ ушёл бы с типом text/html.

Кэш помечен private, а не public: раздающий прокси перед сайтом иначе отдал бы сохранённую копию всем подряд, включая тех, кому картинка не предназначена. Браузеру самого посетителя это не мешает — именно он и делает повторные запросы на странице, полной миниатюр.

Никогда не берите путь из запроса

Адресуйте картинку по идентификатору записи, которой она принадлежит, а путь стройте в контроллере:

Ссылка вида preview.php?img=/upload/... — приглашение к обходу каталогов: имя с ../ внутри уводит чтение куда угодно по диску.

Если имя файла в адресе всё же неизбежно — например, нужен конкретный скриншот внутри папки, — одной меры мало. Ограничьте маршрут регулярным выражением:

…и дополнительно отрежьте путь из имени:

Проверок здесь две, и третью делает диск: за пределы своего корня он не выпускает — путь с ../ внутри закончится исключением, а не чтением чужого файла. Регулярное выражение маршрута отсекает очевидное, basename() убирает путь из имени.

Если файл читается не через диск, а напрямую с файловой системы, проверку границы придётся писать самому: сравнить realpath() от полного пути с realpath() от папки и убедиться, что первый начинается со второго.

Драйверы и настройки

Обработка идёт через расширение Imagick, если оно установлено, иначе через GD. Выбор делается автоматически, настраивать ничего не нужно.

Правила декодирования заданы в одном месте — методе manager() класса InterventionImageProcessor:

Настройка
Значение
Зачем

autoOrientation

включена

Снимок с телефона хранит поворот в EXIF, а не в пикселях. Без этого фото сохранялось бы лежащим на боку

decodeAnimation

выключена

CMS хранит один кадр. Раскладывать анимированный GIF покадрово — значит обработать каждый кадр и записать первый

strip

включена

Метаданные снимка содержат GPS-координаты места съёмки, а сохранённый файл публичный

Если нужной операции нет

Сначала посмотрите, не собирается ли нужное из того, что уже есть: несколько размеров одной картинки — это просто несколько вызовов от одного исходника.

Если этого не хватает — например, нужен поворот на заданный угол или наложение нескольких слоёв, — дальше всё зависит от того, кому эта операция пригодится.

Операция пригодится всем

ImageProcessorInterface — часть ядра, и дописывать в него метод у себя не нужно: правку сотрёт следующим обновлением CMS. Предложите изменение в репозитории JohnCMS — тогда метод появится у всех и будет реализован один раз.

Операция нужна только вашему модулю

Точки расширения, как у политик санитайзера, у сервиса изображений нет. Если операция слишком специфична, чтобы просить её в ядро, модуль обрабатывает картинку сам — библиотека Intervention Image есть в зависимостях CMS и доступна вашему коду.

Последнее обновление

Это было полезно?