Вы здесь

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;
}

 

Пример короткого текста на картинке

Текст среднего размера

Очень длинный текст, в котором появляются артефакты на тенях.

Поделиться:

Оставить комментарий