Как стать автором
Обновить

Генерируем красивые SVG-плейсхолдеры на Node.js

Время на прочтение11 мин
Количество просмотров10K


Использование SVG-картинок в качестве плейсхолдеров — это очень неплохая идея, особенно в нашем мире, когда чуть ли не все сайты состоят из кучи картинок, которые мы пытаемся асинхронно подгружать. Чем больше картинок и чем более объемные они, тем выше вероятность получения различных проблем, начиная от того, что пользователь не совсем понимает, а что же там собственно грузится, и заканчивая известным скачком всего интерфейса после прогрузки картинок. Особенно на плохом интернете с телефона — там может и на несколько экранов все улететь. Именно в такие моменты заглушки приходят на помощь. Еще один вариант их использования – это цензура. Бывают такие моменты, когда нужно скрыть от пользователя какую-то картинку, но хотелось бы сохранить общий стиль страницы, цвета и место, которое картинка занимает.


Но в большинстве статей все рассуждают о теории, о том, что было бы неплохо инлайново вставлять все эти картинки-заглушки в страницы, а мы сегодня посмотрим на практике, как можно генерировать их на свой вкус и цвет с помощью Node.js. Мы создадим handlebars-шаблоны из SVG-картинок и будем из заполнять разными способами, начиная от простой заливки цветом или градиентом и заканчивая триангуляцией, мозаикой Вороного и использованием фильтров. Все действия будут разбираться по шагам. Полагаю эта статья будет интересна начинающим, которым интересно, как это делается, и нужен подробный разбор действий, но и опытным разработчикам возможно приглянутся некоторые идеи.


Подготовка


Для начала мы отправимся в бездонное хранилище всевозможной всячины под названием NPM. Поскольку задача генерации наших картинок-заглушек предполагает однократную генерацию их на стороне сервера (или даже на машине разработчика, если речь о более-менее статическом сайте), то преждевременной оптимизацией мы заниматься не будем. Будем подключать все, что приглянется. Так что начинаем с заклинания npm init и приступаем к подбору зависимостей.


Для начала это ColorThief. Вы, вероятно, уже слышали о нем. Замечательная библиотека, которая может вычленять цветовую палитру наиболее используемых цветов на картинке. Нам как раз что-то такое и нужно для начала.


npm i --save color-thief

При установке этого пакета под линуксом возникла проблема — некий отсутствующий пакет cairo, которого нет в каталоге NPM. Эта странная ошибка решилась доустановкой девелоперских версий некоторых библиотек:


sudo apt install libcairo2-dev libjpeg-dev libgif-dev

Как этот инструмент работает будем смотреть в процессе. Но будет не лишним сразу подключить пакет rgb-hex для конвертирования цветового формата из RGB в Hex, что очевидно из его названия. Не будем заниматься велосипедостроением с такими простыми функциями.


npm i --save rgb-hex

С точки зрения обучения полезно писать такие вещи самостоятельно, но когда стоит задача по-быстрому собрать минимально работающий прототип, то подключение всего, что есть, из каталога NPM — это хорошая идея. Экономит кучу времени.

У заглушек один из самых важных параметров — это пропорции. Они должны совпадать с пропорциями оригинальной картинки. Соответственно нам нужно узнать ее размеры. Воспользуемся пакетом image-size для решения этого вопроса.


npm i --save image-size

Поскольку мы будем пробовать делать разные варианты картинок и все они будут в SVG формате, то так или иначе возникнет вопрос шаблонов для них. Можно конечно изворачиваться с шаблонными строками в JS, но зачем все это? Лучше уж взять "нормальный" шаблонизатор. К примеру handlebars. Просто и со вкусом, для нашей задачи будет в самый раз.


npm i --save handlebars

Мы не будем сразу устраивать какую-то сложную архитектуру для этого эксперимента. Создаем файл main.js и импортируем туда все наши зависимости, а также модуль для работы с файловой системой.


const ColorThief = require('color-thief');
const Handlebars = require('handlebars');
const rgbHex     = require('rgb-hex');
const sizeOf     = require('image-size');
const fs         = require('fs');

ColorThief требует дополнительной инициализации


const thief = new ColorThief();

Используя зависимости, которые мы подключили, решение задач "загрузить картинку в скрипт" и "получить ее размер" не составляет особого труда. Допустим у нас есть картинка 1.jpg:


const image  = fs.readFileSync('1.jpg');

const size   = sizeOf('1.jpg');
const height = size.height;
const width  = size.width;

Для людей, не знакомых с Node.js стоит сказать, что почти все, что связано с файловой системой, может происходить синхронно или асинхронно. У синхронных методов в названии в конце добавляется "Sync". Мы будем пользоваться ими, чтобы не сталкиваться с излишним усложнением и не ломать себе голову на ровном месте.


Перейдем к первому примеру.


Заливка цветом



Для начала решим задачу простой заливки прямоугольника. У нашей картинки будет три параметра — ширина, высота и цвет заливки. Делаем SVG-картинку с прямоугольником, но вместо этих значений подставляем пары скобок и названия полей, которые будут содержать данные, переданные из скрипта. Вы вероятно уже видели такой синтаксис с традиционным HTML (например во Vue используется что-то похожее), но никто не мешает его использовать и с SVG-картинкой — шаблонизатору все равно, что это будет в конечном счете. Текст – он и в африке текст.


<svg
    version='1.1'
    xmlns='http://www.w3.org/2000/svg'
    viewBox='0 0 100 100'
    preserveAspectRatio='none'
    height='{{ height }}'
    width='{{ width }}'>
    <rect x='0' y='0' height='100' width='100' fill='{{ color }}' />
</svg>

Далее ColorThief дает нам один наиболее распространенный цвет, в примере это серый. Для того, чтобы воспользоваться шаблоном, мы читаем файл с ним, говорим handlebars, чтобы эта библиотека его скомпилировала и затем генерируем строку с готовой SVG-заглушкой. Шаблонизатор сам подставляет наши данные (цвет и размеры) в нужные места.


function generateOneColor() {
    const rgb   = thief.getColor(image);
    const color = '#' + rgbHex(...rgb);

    const template = Handlebars.compile(fs.readFileSync('template-one-color.svg', 'utf-8'));
    const svg = template({
        height,
        width,
        color
    });

    fs.writeFileSync('1-one-color.svg', svg, 'utf-8');
}

Остается только записать результат в файл. Как можно видеть, работать с SVG довольно приятно — все файлы текстовые, можно легко их считывать и записывать. В результате получается картинка-прямоугольник. Ничего интересного, но по крайней мере мы убедились в работоспособности подхода (ссылка на полные исходники будет в конце статьи).


Заливка градиентом


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



Наш SVG-шаблон теперь расширился этим самым градиентом. Для примера будем использовать обычный линейный градиент. Нас интересуют только два параметра — цвет в начале и цвет в конце:


<defs>
    <linearGradient id='my-gradient'
        x1='0%'
        y1='0%'
        x2='100%'
        y2='0%'
        gradientTransform='rotate(45)'>
        <stop offset='0%'   style='stop-color:{{ startColor }};stop-opacity:1' />
        <stop offset='100%' style='stop-color:{{ endColor }};stop-opacity:1' />
    </linearGradient>
</defs>
<rect x='0' y='0' height='100' width='100' fill='url(#my-gradient)' />

Сами цвета получаем с помощью все того же ColorThief. Он имеет два режима работы – либо дает нам один основной цвет, либо палитру с тем количеством цветов, которое мы укажем. Достаточно удобно. Для градиента нам нужно два цвета.


В остальном этот пример похож на предыдущий:


function generateGradient() {
    const palette = thief.getPalette(image, 2);

    const startColor = '#' + rgbHex(...palette[0]);
    const endColor   = '#' + rgbHex(...palette[1]);

    const template = Handlebars.compile(fs.readFileSync('template-gradient.svg', 'utf-8'));
    const svg = template({
        height,
        width,
        startColor,
        endColor
    });

    // . . .

Таким образом можно делать всевозможные градиенты – не обязательно линейные. Но все же это достаточно скучный результат. Было бы здорово сделать какую-нибудь мозаику, которая хоть отдаленно будет походить на исходную картинку.


Мозаика из прямоугольников


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



Handlebars умеет много разных вещей, в частности в нем есть циклы. Будем передавать ему массив координат и цветов, а дальше он сам разберется. Мы лишь оборачиваем наш прямоугольник в шаблоне в each:


{{# each rects }}
    <rect x='{{ x }}' y='{{ y }}' height='11' width='11' fill='{{ color }}' />
{{/each }}

Соответственно в самом скрипте мы теперь имеем полноценную палитру цветов, проходим циклами по координатам X/Y и делаем по прямоугольнику со случайным цветом из палитры. Все достаточно просто:


function generateMosaic() {
    const palette = thief.getPalette(image, 16);

    palette.forEach(function(color, index) {
        palette[index] = '#' + rgbHex(...color);
    });

    const rects = [];

    for (let x = 0; x < 100; x += 10) {
        for (let y = 0; y < 100; y += 10) {
            const color = palette[Math.floor(Math.random() * 15)];

            rects.push({
                x,
                y,
                color
            });
        }
    }

    const template = Handlebars.compile(fs.readFileSync('template-mosaic.svg', 'utf-8'));
    const svg = template({
        height,
        width,
        rects
    });

    // . . .

Очевидно, что мозаика хоть и похожа по цветам на картинку, но вот с расположением цветов все совсем не так, как хотелось бы. Возможности ColorThief по этой части ограничены. Хотелось бы получить такую мозаику, в которой бы угадывалась изначальная картинка, а не просто был набор кирпичиков более-менее тех же цветов.


Улучшаем мозаику


Здесь нам придется немного углубиться и получить цвета из пикселей на картинке...



Поскольку у нас в консоли очевидно нет канваса, из которого мы обычно эти данные получаем, воспользуемся подспорьем в виде пакета get-pixels. Он может вытащить нужную информацию из буфера с картинкой, который у нас уже есть.


npm i --save get-pixels

Выглядеть это будет примерно так:


getPixels(image, 'image/jpg', (err, pixels) => {
    // . . .
});

Мы получаем объект, в котором содержится поле data — массив пикселей, такой же, как мы получаем из канваса. Напомню, что для того, чтобы получить цвет пикселя по координатам (X,Y) нужно произвести нехитрые вычисления:


const pixelPosition = 4 * (y * width + x);

const rgb = [
    pixels.data[pixelPosition],
    pixels.data[pixelPosition + 1],
    pixels.data[pixelPosition + 2]
];

Таким образом мы можем для каждого прямоугольника взять цвет не из палитры, а прямо из картинки, и использовать его. Получится что-то такое (главное тут не забыть, что координаты на картинке отличаются от наших “нормализованных” от 0 до 100):


function generateImprovedMosaic() {
    getPixels(image, 'image/jpg', (err, pixels) => {
        if (err) {
            console.log(err);
            return;
        }

        const rects = [];

        for (let x = 0; x < 100; x += 5) {
            const realX = Math.floor(x * width / 100);

            for (let y = 0; y < 100; y += 5) {
                const realY = Math.floor(y * height / 100);
                const pixelPosition = 4 * (realY * width + realX);

                const rgb = [
                    pixels.data[pixelPosition],
                    pixels.data[pixelPosition + 1],
                    pixels.data[pixelPosition + 2]
                ];

                const color = '#' + rgbHex(...rgb);

                rects.push({
                    x,
                    y,
                    color 
                });
            }
        }

        // . . .

Для большей красоты мы можем немного увеличить количество "кирпичиков", уменьшив их размер. Поскольку мы не передаем этот размер в шаблон (стоило бы конечно сделать его таким же параметром, как и ширина или высота картинки), изменим значения размеров в самом шаблоне:


{{# each rects }}
    <rect x='{{ x }}' y='{{ y }}' height='6' width='6' fill='{{ color }}' />
{{/each }}

Теперь у нас есть мозаика, действительно похожая на исходную картинку, но занимающая при этом на порядок меньше места.


Не стоит забывать, что GZIP хорошо сжимает такие повторяющиеся последовательности в текстовых файлах, так что при передаче в браузер размер такой превьюшки станет еще меньше.

Но пойдем дальше.


Триангуляция



Прямоугольники – это хорошо, но треугольники обычно дают куда более интересные результаты. Так что попробуем сделать мозаику из кучи треугольников. Есть разные подходы к этому вопросу, мы воспользуемся триангуляцией Делоне:


npm i --save delaunay-triangulate

Главное преимущество алгоритма, которым мы воспользуемся, в том, что он по возможности избегает треугольников с очень острыми и тупыми углами. Нам для красивого изображения узкие и длинные треугольники совершенно не нужны.


Это один из тех моментов, когда полезно знать, какие математические алгоритмы в нашей области существуют и в чем в них разница. Не обязательно помнить все их реализации, но по крайней мере полезно знать, что гуглить.

Разделим нашу задачу на меньшие. Для начала нужно нагенерировать точек для вершин треугольников. И было бы неплохо добавить немного случайностей в их координаты:


function generateTriangulation() {
    // . . .

    const basePoints = [];

    for (let x = 0; x <= 100; x += 5) {
        for (let y = 0; y <= 100; y += 5) {
            const point = [x, y];

            if ((x >= 5) && (x <= 95)) {
                point[0] += Math.floor(10 * Math.random() - 5);
            }

            if ((y >= 5) && (y <= 95)) {
                point[1] += Math.floor(10 * Math.random() - 5);
            }

            basePoints.push(point);
        }
    }

    const triangles = triangulate(basePoints);

    // . . .

Ознакомившись со структурой массива с треугольниками (console.log нам в помощь) находим себе точки, в которых будем брать цвет пикселя. Можно просто посчитать среднее арифметическое для координат вершин треугольников. Затем сдвигаем лишние точки с крайней границы, чтобы они никуда не вылезали и, получив настоящие, не нормализованные, координаты достаем цвет пикселя, который станет цветом треугольника.


const polygons = [];

triangles.forEach((triangle) => {
    let x = Math.floor((basePoints[triangle[0]][0]
        + basePoints[triangle[1]][0]
        + basePoints[triangle[2]][0]) / 3);

    let y = Math.floor((basePoints[triangle[0]][1]
        + basePoints[triangle[1]][1]
        + basePoints[triangle[2]][1]) / 3);

    if (x === 100) {
        x = 99;
    }

    if (y === 100) {
        y = 99;
    }

    const realX = Math.floor(x * width / 100);
    const realY = Math.floor(y * height / 100);

    const pixelPosition = 4 * (realY * width + realX);

    const rgb = [
        pixels.data[pixelPosition],
        pixels.data[pixelPosition + 1],
        pixels.data[pixelPosition + 2]
    ];

    const color = '#' + rgbHex(...rgb);

    const points = ' '
        + basePoints[triangle[0]][0] + ','
        + basePoints[triangle[0]][1] + ' '
        + basePoints[triangle[1]][0] + ','
        + basePoints[triangle[1]][1] + ' '
        + basePoints[triangle[2]][0] + ','
        + basePoints[triangle[2]][1];

    polygons.push({
        points,
        color
    });
});

Остается только собрать координаты нужных точек в строку и отправить ее вместе с цветом в Handlebars для обработки, как мы и делали раньше.


В самом шаблоне теперь у нас будут не прямоугольники, а полигоны:


{{# each polygons }}
    <polygon points='{{ points }}' style='stroke-width:0.1;stroke:{{ color }};fill:{{ color }}' />
{{/each }}

Триангуляция – это очень занятная вещь. Увеличивая количество треугольников можно получать просто красивые картинки, ведь никто не говорит, что мы обязательно должны их использовать только в качестве заглушек.


Мозаика Вороного


Существует задача, зеркальная предыдущей – разбиение или мозаика Вороного. Мы уже использовали ее при работе с шейдерами, но и здесь она тоже может пригодиться.



Как и с остальными известными алгоритмами, мы имеем готовую реализацию:


npm i --save voronoi

Дальнейшие действия будут очень похожи на то, что мы делали в предыдущем примере. Разница лишь в том, что теперь мы имеем другую структуру – вместо массива треугольников у нас сложный объект. И параметры немного другие. В остальном все почти то же самое. Массив базовых точек генерируется так же, пропустим его, чтобы не делать листинг слишком длинным:


function generateVoronoi() {
    // . . .

    const box = {
        xl: 0,
        xr: 100,
        yt: 0,
        yb: 100
    };

    const diagram = voronoi.compute(basePoints, box);
    const polygons = [];

    diagram.cells.forEach((cell) => {
        let x = cell.site.x;
        let y = cell.site.y;

        if (x === 100) {
            x = 99;
        }

        if (y === 100) {
            y = 99;
        }

        const realX = Math.floor(x * width / 100);
        const realY = Math.floor(y * height / 100);

        const pixelPosition = 4 * (realY * width + realX);

        const rgb = [
            pixels.data[pixelPosition],
            pixels.data[pixelPosition + 1],
            pixels.data[pixelPosition + 2]
        ];

        const color = '#' + rgbHex(...rgb);

        let points = '';

        cell.halfedges.forEach((halfedge) => {
            const endPoint = halfedge.getEndpoint();

            points += endPoint.x.toFixed(2) + ','
                    + endPoint.y.toFixed(2) + ' ';
        });

        polygons.push({
            points,
            color
        });
    });

    // . . .

В результате мы получаем мозаику из выпуклых многоугольников. Тоже весьма интересный результат.


Полезно округлять все числа или до целых или по крайней мере до пары знаков после запятой. Избыточная точность в SVG здесь совершенно не нужна, она будет только увеличивать размер картинок.

Размытая мозаика


Последний пример, который мы посмотрим – это размытая мозаика. В наших руках вся мощь SVG, так почему бы не воспользоваться фильтрами?



Берем первую мозаику из прямоугольников и добавляем к ней стандартный фильтр “размыливания”:


<defs>
    <filter id='my-filter' x='0' y='0'>
        <feGaussianBlur in='SourceGraphic' stdDeviation='2' />
    </filter>
</defs>

<g filter='url(#my-filter)'>
    {{# each rects }}
        <rect x='{{ x }}' y='{{ y }}' height='6' width='6' fill='{{ color }}' />
    {{/each }}
</g>

В результате получается размытая, “зацензуренная” превьюшка нашей картинки, занимает она почти в 10 раз меньше места (без сжатия), векторная и тянется на любой размер экрана. Таким же образом можно размылить и остальные варианты наших мозаик.


При применении такого фильтра к обычной мозаике из прямоугольников может получиться "эффект джипега", поэтому если вы будете использовать что-то подобное в продакшене, особенно к большим по размеру картинкам, то возможно будет красивее применять размыливание не к ней, а к разбиению Вороного.

Вместо заключения


В этой статье мы посмотрели, как можно генерировать всевозможные SVG-картинки-заглушки на Node.js и убедились, что это не такое уж и сложное занятие, если не писать все руками, а по возможности собирать готовые модули. Полные исходники доступны на гитхабе.

Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
Всего голосов 23: ↑23 и ↓0+23
Комментарии22

Публикации

Истории

Работа

Ближайшие события

15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань