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