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