NodeJS: Как поставить текст на картинку?
Для нашей группы ВКонтакте регулярно требуются картинки с текстом, которые выкладывает Люба. Создавать же эти картинки в фотошопе достаточно долго. Для этого необходимо проделать очень много однотипных действий.
Есть несколько важных параметров итогового изображения:
- текст должен влезать на картинку, чтобы выглядел симметрично и красиво;
- у текста должна быть читаемость независимо от типа картинки на фоне(тёмная или светлая);
В итоге, написал небольшой скрипт на NodeJS, который подгоняет текст под размер картинки и выдаёт результат, который виден в конце этой страницы. Скачать код можно тут: https://yadi.sk/d/OLLUXtCDsTha4Q. Чтобы посмотреть примеры - скроль до конца страницы.
Чтобы использовать скрипт нужно установить NodeJS. Его можно скачать для своей платформы с официального сайта. После этого скачать код с Яндекс.Диска, распаковать его в папку и в консоли написать команду npm install. После установки всех зависимостей можно запускать скрипт: из консоли node generate. Возможно сделать исполняемый файл для Windows(нужно будет установить себе пакет pkg). Для этого в консоли npm run generate. После создания исполняемого файла, всё что нужно будет находится в папке dist. При первом запуске скрипт попросит всё, что ему нужно для работы, плюс создаст файл с настройками. Настройки можно менять и если всё совсем перестало работать, тогда можно удалить файл и он пересоздастся с настройками по умолчанию. Важный момент: для файла с текстами(text.txt) необходимо указать кодировку utf-8, а в Windows по умолчанию создаётся windows-1251 из-за чего текст будет отображаться некорректно.
Сам код написан грязно, но может быть кому-то сгодится для черпания новых идей.
const { createCanvas } = require('canvas') const fs = require('fs'); const yaml = require('yaml') const Jimp = require("jimp"); const dir = './images'; const resultDir = './result'; const file = './text.txt'; const settings = getSettings(fs, yaml, './settings.yaml'); if (!fs.existsSync(dir)) { error('В папке с скриптом создай папку '+dir+' и положи в неё картинки');return; } if (!fs.existsSync(file)) { error('В папке с скриптом создай файл '+file+' и на каждой его строке напиши тексты'); return; } if(!fs.existsSync(resultDir)){ error('Создана папка для хранения результатов: ' + resultDir); fs.mkdirSync(resultDir); } var images = fs.readdirSync(dir); var texts = fs.readFileSync(file, 'utf8'); var textsArr = texts.split(/\r?\n/g); if(!images.length){ error('Папка ' +dir+ ' пустая, положи в неё картинки'); return; } if(!textsArr.length){ error('В файле '+file+' нет аффирмаций, добавь их по одной аффирмации на строку'); return } //Начальные настройки var imageIndex = 0; var canvases = []; var ctxs = []; var buffers = []; var resultCount = 0; //Запускаем обработку текстов в цикле textsArr.forEach((text, index) => { var imageName = images[imageIndex]; var newImagePath = resultDir + '/' + index + imageName; imageIndex++; if(images.length == imageIndex){ imageIndex = 0; } canvases[index] = createCanvas(settings.width, settings.height); ctxs[index] = canvases[index].getContext('2d'); ctxs[index].font = `${settings.font.style} ${settings.font.size}px ${settings.font.name}`; //Подбираем размер шрифта, чтобы влезал по ширине var newFontSize = chooseFontSize(ctxs[index], text, settings.maxWidth, settings); var lineHeight = newFontSize * settings.font.lineHeightMultiplicator; ctxs[index].font = `${settings.font.style} ${newFontSize}px ${settings.font.name}`; var lines = wrapLines(ctxs[index], textsArr[index], settings.maxWidth); var marginTop = (settings.height - lines.length * lineHeight) / 2 + lineHeight / 2; //Печатаем на канвасе текст lines.forEach((line) => { ctxs[index].fillStyle = settings.font.fillStyle; ctxs[index].textBaseline = settings.font.textBaseline; ctxs[index].textAlign = settings.font.textAlign; ctxs[index].shadowOffsetX = settings.font.shadow.offsetX; ctxs[index].shadowOffsetY = settings.font.shadow.offsetY; ctxs[index].shadowColor = settings.font.shadow.color; ctxs[index].shadowBlur = settings.font.shadow.blur * settings.multiplicator; ctxs[index].strokeStyle = settings.font.stroke.style; ctxs[index].lineWidth = settings.font.stroke.lineWidth * settings.multiplicator; ctxs[index].strokeText(line, settings.width/2, marginTop); ctxs[index].fillText(line, settings.width/2, marginTop); ctxs[index].shadowOffsetX = settings.font.glow.offsetX; ctxs[index].shadowOffsetY = settings.font.glow.offsetY; ctxs[index].shadowColor = settings.font.glow.color; ctxs[index].shadowBlur = lineHeight; ctxs[index].fillText(line, settings.width/2, marginTop); marginTop += lineHeight; }); buffers[index] = canvases[index].toBuffer('image/png') //Сохраняем картинку с написанным текстом fs.writeFile(resultDir +'/'+ index + 'text.png', buffers[index], () => { console.log(`Текст "${text}" добавлен в файл ${resultDir +'/'+ index + 'text.png'}`); //Окончательно сводим все картинки, выводим результат, чистим файлы Jimp.read(dir +'/'+ imageName, (err, image) => { Jimp.read(resultDir +'/'+ index + 'text.png', (err, textImage) => { image .cover(settings.width, settings.height) .composite(textImage, 0, 0) .resize(settings.width / settings.multiplicator, settings.height / settings.multiplicator) .write(newImagePath, () => { console.log(`Текст "${text}" наложен на картинку`); fs.unlink(resultDir +'/'+ index + 'text.png', () => { console.log(`Файл ${resultDir +'/'+ index + 'text.png'} с текстом удалён`) resultCount++; if(resultCount == textsArr.length){ error('Все файлы обработаны, спасибо за использование нашей супер-программы :)'); } }); }); }); }) }) }); /** * Подбираем шрифт для печати на картинке. * Написано быстро и плохо, один и тот же код повторяется много раз * @param ctx * @param text * @param maxWidth * @returns {number} */ function chooseFontSize(ctx, text, maxWidth, settings){ var fontSize = settings.font.size; var lines = wrapLines(ctx, text, maxWidth); var marginTopMultiplikator = settings.marginTopMultiplikator; if(!lines.length){ //Шрифт слишком большой, какое-то из слов не влезает по ширине while (!lines.length){ fontSize = fontSize - 1; // Уменьшаем шрифт, чтобы всё влезло ctx.font = `${settings.font.style} ${fontSize}px ${settings.font.name}`; lines = wrapLines(ctx, text, maxWidth); if(fontSize == 0) { break; } } } else{ // Проверяем, сколько пустого места остаётся сверху var canvasHeight = ctx.canvas.height; var lineHeight = fontSize * settings.font.lineHeightMultiplicator; var marginTop = (canvasHeight - lines.length * lineHeight) / 2; if(marginTop < ((ctx.canvas.width - maxWidth) / 2) * marginTopMultiplikator){ while (marginTop < ((ctx.canvas.width - maxWidth) / 2) * marginTopMultiplikator){ fontSize--; // Увеличиваем шрифт, чтобы отступ сверху был больше lines = wrapLines(ctx, text, maxWidth); lineHeight = fontSize * settings.font.lineHeightMultiplicator; marginTop = (canvasHeight - lines.length * lineHeight) / 2; ctx.font = `${settings.font.style} ${fontSize}px ${settings.font.name}`; } } else{ while (marginTop > ((ctx.canvas.width - maxWidth) / 2) * marginTopMultiplikator && lines.length){ fontSize++; // Увеличиваем шрифт, чтобы отступ сверху был больше lines = wrapLines(ctx, text, maxWidth); lineHeight = fontSize * settings.font.lineHeightMultiplicator; marginTop = (canvasHeight - lines.length * lineHeight) / 2; ctx.font = `${settings.font.style} ${fontSize}px ${settings.font.name}`; } fontSize--; } lines = wrapLines(ctx, text, maxWidth); if(!lines.length){ while (!lines.length){ fontSize = fontSize - 1; // Уменьшаем шрифт, чтобы всё влезло ctx.font = `${settings.font.style} ${fontSize}px ${settings.font.name}`; lines = wrapLines(ctx, text, maxWidth); if(fontSize == 0) { break; } } } } return fontSize; } /** * Отдаёт кол-во линий текста, которые влезают в максимальную ширину канваса. * Если отдаёт пустой массив, то одно из слов не влезает по ширине и значит шрифт нужно уменьшать * @param ctx * @param text * @param maxWidth * @returns {Array} */ function wrapLines(ctx, text, maxWidth) { var lines = [], words = text.replace(/\n\n/g,' ` ').replace(/(\n\s|\s\n)/g,'\r') .replace(/\s\s/g,' ').replace('`',' ').replace(/(\r|\n)/g,' '+' ').split(' '), space = ctx.measureText(' ').width, width = 0, line = '', word = '', len = words.length, w = 0, i; for (i = 0; i < len; i++) { word = words[i]; w = word ? ctx.measureText(word).width : 0; if (w) { width = width + space + w; } if (w > maxWidth) { return []; } else if (w && width < maxWidth) { line += (i ? ' ' : '') + word; } else { !i || lines.push(line !== '' ? line.trim() : ''); line = word; width = w; } } if (len !== i || line !== '') { lines.push(line); } return lines; } /** * Отдаёт кол-во линий в файле * @param file * @returns {number} */ function countFileLines(file){ if(file){ var match = file.match(/\r?\n/g); if(match){ return match.length + 1; } else{ return 1; } } return 1; }; /** * Выводим ошибку в консоль * @param text */ function error(text){ console.log(text); setTimeout(function(){}, 5000); } /** * Получаем настройки или, если их нет, то создаём файл с дефолтными * @param fs * @param yaml * @returns {{multiplicator: number, width: number, fontSize: number, height: number, maxWidth: number}} */ function getSettings(fs, yaml, configPath){ var config = {}; //Создаём файл и пишем дефолтные настройки if (!fs.existsSync(configPath)) { config = { multiplicator: 1, width: 1024, height: 1024, maxWidth: 900, marginTopMultiplikator: 2.5, font: { name: 'Segoe Print', style: 'bold', lineHeightMultiplicator: 1.5, size: 100, fillStyle: 'rgba(255, 255, 255,0.9)', textBaseline: 'middle', textAlign: 'center', shadow: { offsetX: 1, offsetY: 1, color: 'rgba(0, 0, 0, 0.5)', blur: 8, }, stroke: { style: 'rgba(15, 15, 15, 0.9)', lineWidth: 5, }, glow: { offsetX: 1, offsetY: 1, color: 'rgba(255, 255, 255, 1)' } } }; const doc = new yaml.Document(); doc.version = true; doc.commentBefore = [ 'Конфигурация для создания картинок. Если что-то пошло не так, то удали этот файл и он пересоздастся с настройками по умолчанию.', 'multiplicator - множитель для увеличения картинки в самом начале, а потом уменьшения. Нужен, чтобы делать сглаживание. При увеличении - увеличивается время генерации', 'width - ширина картинки', 'height - высота картинки', 'maxWidth - максимальная ширина текста', 'marginTopMultiplikator - множитель для отступа сверху. Считает отступ сбоку((width - maxWidth) / 2) и умножает его на эту цифру', 'font - настройки текста', ' name - название шрифта', ' style - стиль шрифта(bold, italic или пусто. шрифт должен уметь поддерживать этот стиль)', ' lineHeightMultiplicator - множитель для высоты строки текста. На эту цифру умножается размер шрифта', ' size - начальный размер в пикселях, отталкиваясь от этого значения идёт изменение размера', ' shadow - тень под текстом', ' strokeStyle, lineWidth - обводка текста', 'glow - свечение под текстом', ].join('\n'); doc.contents = config; fs.writeFileSync(configPath, String(doc)); } else{ const file = fs.readFileSync(configPath, 'utf8'); config = yaml.parse(file) } var multiplicateFields = ['width', 'height', 'maxWidth']; multiplicateFields.forEach((field) => { config[field] *= config.multiplicator; }); config.font.size *= config.multiplicator; return config; }
Оставить комментарий