Добрый вечер, Хабровчане! Новогодние праздники отгремели и все потихоньку входят в рабочий ритм после выходной недели, а это значит, что самое время описать свою новогоднюю забаву.
Если вам интересно узнать, как генерировать изображения из простых картинок с помощью PhantomJS и небольшой магии, то добро пожаловать под кат!
Немного предыстории
Этот новый год мы с друзьями решили провести необычно, добавить некоторый интерактив, который бы не зависел ни от кого. Так как большинство в моём кругу общения так или иначе связаны с компьютерными играми, то мной и моим другом (далее Никита) было решено придумать список новогодних достижений (или ачивок). Список был составлен за несколько дней и было решено как-нибудь их оформить и выдавать так, чтобы ачивки не забывались через пяти минут после получения. На итог приняли решение распечатать, наклеить на акварельную бумагу, сделать две дырки в верхнем правом и левом углах и вешать карточки с ачивками на шею. Полностью разобравшись с техпроцессом, Никита нарисовал незамысловатый дизайн, который прекрасно бы распечатался на чёрно-белом принтере и мы приступили к заполнению ачив.
Я сразу же решил, что руками добавлять текст в PSD файл более пятидесяти раз мне не хочется, и как любой адекватный программист потратил на автоматизацию часовой задачи немногим более двух часов.
Пример дизайна ачивки:
Реализация
Пул технологий был выбран моментально. Node.js для генерации текста и html страниц, PhantomJS для отрисовки и сохранения.
Парсим файл
Формат для задания ачивки был задан таким:
название -- цитата -- описание
(Цитата опциональна)
Нужно перевести файл, целиком состоящий из таких строк, в JS объект.
module.exports = (contents) => {
return new Promise((resolve, reject) => {
return resolve(contents.toString().split('\n').filter(e => e).map(e => {
const contents = e.split(' -- '); // Разбиваем контент
const achieve = {};
const achieveName = capitalizeFirstLetter(contents[0].trim());
let quote = capitalizeFirstLetter(contents[1].trim());
let achieveDescr = capitalizeFirstLetter((contents[2] || '').trim());
if (!achieveDescr) { // если нет описания, то не ввели цитату.
achieveDescr = quote;
quote = null;
}
achieve.name = achieveName;
achieve.description = achieveDescr;
if (quote) {
achieve.quote = quote;
}
return achieve;
}));
});
}
На вход функции подаётся Buffer
, который возвращает fs.readFile
, а на выходе мы имеем массив:
[{
"name": "Пейсатель",
"quote": "Клац-клац и отправил",
"description": "Написать статью на Хабрахабр."
}]
Отлично, работаем дальше.
Создаём html страницы
Для того, чтобы заставить PhantomJS открывать страницы, для начала нужны сами страницы.
Я создал простой template.html
<html>
<head>
<link rel="stylesheet" href="/index.css">
<meta charset="utf-8">
</head>
<body>
<div class="achieve">
<div class="achieve__wrapper">
<div class="achieve__text">
<div class="achieve__heading-text{{extraHtmlClass}}">
{{name}}
</div>
<div class="achieve__main-text">
<div class="achieve__artistic">
{{quote}}
</div>
<div class="achieve__description">
{{description}}
</div>
</div>
</div>
<div class="achieve__image"></div>
<div class="clearfix"></div>
</div>
</div>
</body>
</html>
и небольшой файл стилей к нему, который в точности повторяет дизайн.
body {
margin: 0;
padding: 0;
}
.achieve {
width: 917px;
background: #b3b4b3;
position: relative;
}
.achieve__wrapper {
padding-top: 35px;
}
.achieve__text, .achieve__image {
float: left;
}
.achieve__text {
padding-top: 15px;
padding-left: 50px;
width: 550px;
color: #353534;
min-height: 293px;
}
.achieve__heading-text {
font-size: 70pt;
margin-bottom: 40px;
font-weight: bold;
font-family: 'Impact', sans-serif;
font-style: italic;
}
.achieve__heading-text--small {
font-size: 50pt;
line-height: 50pt;
margin-bottom: 50px;
}
.achieve__heading-text--super-small {
font-size: 45pt;
line-height: 50pt;
margin-bottom: 50px;
}
.achieve__main-text {
padding-bottom: 30px;
}
.achieve__artistic, .achieve__description {
font-family: 'Verdana', sans-serif;
}
.achieve__artistic {
font-style: italic;
font-size: 20pt;
}
.achieve__image {
background: url('/image.png') no-repeat;
width: 300px;
height: 309px;
background-size: contain;
position: absolute;
bottom: 0;
right: 20px;
}
.achieve__description {
font-size: 25pt;
}
.clearfix {
clear: both;
}
В тексте template.html присутствует некий текст, обрамлённый {{ и }}. Это зачатки нашего будущего шаблонизатора, который будет изменять создавать html файл для ачивки исходя из данных от парсера.
Сам шаблонизатор уместился в 8 строчек:
function template (template, data) {
for (const key in data) {
const templateKey = '{{' + key + '}}';
template = template.replace(templateKey, data[key]);
}
return template;
}
Так же я добавил несколько правил, по которым должны добавляться классы к названию ачивки. (Ачивка с названием Экспериментатор не хотела влезать и всё время залезала на кубок)
if (element.name.split(' ').length > 1 || element.name.length >= 9) {
data.extraHtmlClass = ' achieve__heading-text--small';
}
if (element.name.split(' ').length === 1 && element.name.length >= 14) {
data.extraHtmlClass = ' achieve__heading-text--super-small';
}
Это вырезка из файла, который генерирует html, полная версия файла лежит тут.
На итог нам генерируется нужно количество html файлов, в которые подставлены название, цитата и описание ачивки.
Спешу добавить что у меня не получилось заставить PhantomJS читать директорию, хотя это должно быть просто. Посему вместе с html файлами я генерирую файл names.json
, который содержит все имена созданных страниц.
Создаём картинки
Самая простая часть, нам нужно скопировать example код из доки по PhantomJS, немного его модифицировать и запустить.
const fs = require('fs');
const data = require('../data/names.json');
const config = require('../config/config.json');
var pageCount = 0;
data.forEach(function (e) { // Перебираем все страницы
const page = require('webpage').create(); // Создаём phantomjs страницу
page.open('http://127.0.0.1:' + config.port + '/pages/' + e + '.html', function(status) { // загружаем html
setTimeout(function() {
if(status === "success") {
page.render('achievements/' + e + '.png'); // превращаем html в картинку
pageCount++;
if (pageCount === data.length) {
phantom.exit();
}
}
}, 2000);
});
page.onResourceError = function(resourceError) {
console.log('Unable to load resource (#' + resourceError.id + 'URL:' + resourceError.url + ')');
console.log('Error code: ' + resourceError.errorCode + '. Description: ' + resourceError.errorString);
};
});
Запускаем это phatnom lib/phantom.js
и генерируем изображения из html файлов.
Что-то тут не так...
Воистину что-то не так! PhantomJS загружает данные по HTTP, а HTTP сервера со статичными файлами у нас нет, и картинок мы заполучить не сможем. Это значит, что нужно сделать небольшой static сервер, который мы убъем под конец выполнения программы.
Слава богу, что за нас уже написали static сервер, и мы просто обязаны его использовать.
const static = require('node-static');
const file = new static.Server('./public');
module.exports.spawnServer = (port) => {
return new Promise(resolve => { // Как только создастся сервер, промис зарезолвится
const server = require('http').createServer(function (request, response) {
request.addListener('end', function () {
file.serve(request, response);
}).resume();
}).listen(port, () => {
resolve(server);
});
});
};
module.exports.killServer = (server) => {
server.close(); // Убить инстанс сервера. Быстро и безболезненно.
}
Теперь у нас есть парсер, который из текста делает JS массив, есть генератор страниц, статический сервер и phantomjs скрипт, который создаёт страницы. Осталось всё скомпоновать и новогоднее развлечение готово!
Так как весь код написан на Promises, а над всеми используемыми функциями есть Promise обёртки, то компоновка методов не займёт у нас много времени:
staticServer.spawnServer(config.port).then((serverInstance) => {
staticServerInstance = serverInstance;
return folderManager.create();
})
.then(() => promiseFuncs.readFile(listFile))
.then(buffer => parser(buffer))
.then(data => Promise.all(data.map(e => pageGenerator(e))))
.then(names => promiseFuncs.writeFile('./data/names.json', JSON.stringify(names)))
.then(() => promiseFuncs.execAndOnClose('./node_modules/.bin/phantomjs', ['lib/phantom.js']))
.then(() => {
staticServer.killServer(staticServerInstance);
console.log('Achievements generated!');
if (config.removeFolders) {
return folderManager.remove();
}
return;
}).catch(e => {
console.log(e);
});
Вот и всё. Осталось проявить капельку креатива и придумать оригинальные названия для достижений (желательно с использованием локальных мемов), и веселье гарантировано. Достаточно лишь праздновать получение каждого достижения праздничным "УРА!" и торжественно его выдавать.
К сожалению, даже половину ачивок не получилось раздать, хоть мы и очень старались, когда воплощали картинки в реальную жизнь. (Больше 50 достижений было наклеено на бумагу, продырявлено дыроколом и аккуратно обвязано ниточкой).
Надеюсь, что данная статья помогла вам разобраться, как можно из простого текста генерировать изображения с минимальными затратами.