Хабрахабр

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

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

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

Подготовка

Поскольку задача генерации наших картинок-заглушек предполагает однократную генерацию их на стороне сервера (или даже на машине разработчика, если речь о более-менее статическом сайте), то преждевременной оптимизацией мы заниматься не будем. Для начала мы отправимся в бездонное хранилище всевозможной всячины под названием 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

Можно конечно изворачиваться с шаблонными строками в JS, но зачем все это? Поскольку мы будем пробовать делать разные варианты картинок и все они будут в SVG формате, то так или иначе возникнет вопрос шаблонов для них. К примеру 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;

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

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

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

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

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

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

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) => { // . . .
});

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

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 и убедились, что это не такое уж и сложное занятие, если не писать все руками, а по возможности собирать готовые модули.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть