Формируем изображения из текста в PhantomJS

в 15:03, , рубрики: javascript, node.js, nodejs, phantomjs, автоматизация рутины, генерация картинок, Новый Год

Добрый вечер, Хабровчане! Новогодние праздники отгремели и все потихоньку входят в рабочий ритм после выходной недели, а это значит, что самое время описать свою новогоднюю забаву.

Если вам интересно узнать, как генерировать изображения из простых картинок с помощью PhantomJS и небольшой магии, то добро пожаловать под кат!

Немного предыстории

Этот новый год мы с друзьями решили провести необычно, добавить некоторый интерактив, который бы не зависел ни от кого. Так как большинство в моём кругу общения так или иначе связаны с компьютерными играми, то мной и моим другом (далее Никита) было решено придумать список новогодних достижений (или ачивок). Список был составлен за несколько дней и было решено как-нибудь их оформить и выдавать так, чтобы ачивки не забывались через пяти минут после получения. На итог приняли решение распечатать, наклеить на акварельную бумагу, сделать две дырки в верхнем правом и левом углах и вешать карточки с ачивками на шею. Полностью разобравшись с техпроцессом, Никита нарисовал незамысловатый дизайн, который прекрасно бы распечатался на чёрно-белом принтере и мы приступили к заполнению ачив.

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

Пример дизайна ачивки:

image

Реализация

Пул технологий был выбран моментально. 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

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>

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

index.css

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

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

Автор: egorsm

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js