Создание изображений с помощью node.js и puppeteer

В наше время множество сайтов создают страницы которыми пользователи хотели бы делиться друг с другом. Если раньше это была просто ссылка, то сейчас, благодаря Open Graph тегам ссылки могут иметь красочное превью изображение которые еще больше привлекает к себе внимание, например, в социальных сетях.

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

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

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

В нашем проекте, первые версии картинок генерировались на PHP и на первое время этого хватало - потому что картинки были достаточно простые и содержали только изображение пользователя. Но как только мы добавили имя пользователя - то сразу же начались проблемы с позиционированием текста. Например, вот небольшой пример создания простого изображения на php.

<?php
$width = 300;
$height = 150;
$image = imagecreatetruecolor($width, $height);

$backgroundColor = imagecolorallocate($image, 255, 255, 255);
imagefill($image, 0, 0, $backgroundColor);

$textColor = imagecolorallocate($image, 0, 0, 0);
$text = "Hello, PHP!";
$fontSize = 20;

$textX = ($width - imagefontwidth($fontSize) * strlen($text)) / 2;
$textY = ($height - imagefontheight($fontSize)) / 2;

imagestring($image, $fontSize, $textX, $textY, $text, $textColor);

header("Content-Type: image/png");
imagepng($image);
imagedestroy($image);
?>

Как видно из примера, если текст будет слишком длинный, то может уйти и за рамки изображения. А если сюда добавить градиенты, полупрозрачность, другие шрифты, эмоджи, то код очень усложнится и работать с ним будет не очень просто. Итого мы получаем вот такой список проблем:

  • Длинные названия (текст на английском может умещаться в размер изображения, а на другом языке выходить за рамки. Это нужно учитывать и правильно позиционировать).
  • Использование дополнительных шрифтов или emoji с iPhone. (текст с другим шрифтом тоже может выходить за рамки)
  • Для создания 2х или 3х размера изображения скорее всего нужно менять размеры шрифта, положение элементов на странице.

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

Решение, о котором я хотел бы рассказать позволяет в разы сократить время разработки и упростить поддержку таких изображений. Более того оно гибкое и очень легко масштабируется для других вещей.

Самым простым и удобным языком разметки является HTML. Вместе с CSS стилями можно сделать интерфейс который будет гибким и учитывать позиционирование любых элементов на странице - картинки, текст, таблицы, списки. А так же стилизовать это все любым образом.

В нашем проекте уже были страницы которые мы отображали с помощью серверного рендера на React и они содержали всю необходимую информацию. Все, что нужно было сделать - это только убрать лишнюю информацию со страницы и оставить только необходимые данные. После этого используя библиотеку puppeteer, которая запускает handless версию Google Chrome можно сделать скриншот страницы и получить нужное изображение.

Пример изображения с эмоджи

Ниже пример кода который позволяет получить содержимое страницы с указанной шириной и высотой. Тут очень важно обрабатывать исключения и закрывать страницы браузера, если что-то пошло не так. Иначе можно получить утечку по памяти.

const browser = await puppeteer.launch({
    headless: true,
    ignoreHTTPSErrors: true,
    executablePath: browserPath,
    args: ['--no-sandbox', '--disable-setuid-sandbox'],
    dumpio: true,
});

const page: Page = await browser.newPage();
await page.setJavaScriptEnabled(false);
await page.setViewport({
    width,
    height,
    deviceScaleFactor,
});

try {
    const result = await page.goto(url, {
        waitUntil: 'load',
    });

    if (result.status() !== 200) {
       page.close();
       throw new PageNotFoundError(`Incorrect status page ${result.status()}`);
    }
} catch (error) {
    page.close();
    throw new PageFetchError(error as string);
}

const data = await page.screenshot({
    type: imageType,
    quality: imageQuality,
    encoding: 'binary',
});

page.close();

В нашем случае мы не отключаем поддержку JS потому что отрисовкой занимается серверный рендер и экономим время загрузки страницы (нам нужны только картинки).

После того как изображение получено, его можно загрузить в какое-то S3 хранилище с определенным именнем и TTL временем жизни изображения, а потом уже обращаться к нему в OG тегах страницы.

Пример картинки с градиентом, кастомным шрифтом и красивым обрезанием текста

В итоге получилось легкое node.js приложение, которое мы запаковали в докер контейнер и оно позволяет генерировать изображения для разных страниц сайта с разными форматами. Логика нашего сервиса получилась такая:

  1. При открытии страницы мы отдаем OG ссылку на изображение которая ведет на наш прокси сервер
  2. Когда делается превью страницы и идет HTTP запрос за картинкой, то мы сначала на прокси сервере проверяем есть ли такая картинка в S3 хранилище
  3. Если картинка уже была сгенерирована, то отдаем ее
  4. Если картинки нет, то делаем запрос в сервис, он генерирует изображение и отдает его и в фоне загружает в S3 хранилище.

Так же были небольшие техническое сложности с которыми мы столкнулись:

  1. Chromium не смог запуститься на Docker образе ubuntu. Это проблему мы решили прямым скачиванием Google Chrome.
  2. Не все шрифты одинаковы полезны, один из шрифтов который мы загрузили ломал нам шрифт Emoji. Решили проблему методом исключения, но пришлось повозиться.

Но все это мелочи по сравнению с тем сколько времени уходило раньше на поддержку PHP кода для создания картинок. В итоге меньше чем за неделю новый сервис был готов к production решению. Такое решение позволило не только в разы сократить время поддержки изображений, но и генерировать красочные изображения для пользователей.

Для удобства предоставляю команды для сборки докер контейнера

FROM ubuntu:20.04

RUN ln -s /usr/share/zoneinfo/Etc/UTC /etc/localtime \
	&& apt -y update \
	&& apt -y install \
		git \
        openssh-server \
		gconf-service \
		libasound2 \
		libatk1.0-0 \
		libc6 \
		libcairo2 \
		libcups2 \
		libdbus-1-3 \
		libexpat1 \
		libfontconfig1 \
		libgcc1 \
		libgconf-2-4 \
		libgdk-pixbuf2.0-0 \
		libglib2.0-0 \
		libgtk-3-0 \
		libnspr4 \
		libpango-1.0-0 \
		libpangocairo-1.0-0 \
		libstdc++6 \
		libx11-6 \
		libx11-xcb1 \
		libxcb1 \
		libxcomposite1 \
		libxcursor1 \
		libxdamage1 \
		libxext6 \
		libxfixes3 \
		libxi6 \
		libxrandr2 \
		libxrender1 \
		libxss1 \
		libxtst6 \
		ca-certificates \
		fonts-liberation \
		libappindicator1 \
		libnss3 \
		lsb-release \
		xdg-utils \
		wget \
		curl \
		libnss3-dev \
		libgbm-dev \
		libu2f-udev \
		udev \
	&& (curl -fsSL https://deb.nodesource.com/setup_14.x | bash -) \
	&& apt-get install -y nodejs \
	&& wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb \
	&& apt install ./google-chrome-stable_current_amd64.deb \
	&& rm -rf ./google-chrome-stable_current_amd64.deb \
	&& apt-get clean \
	&& rm -rf /var/cache/apt/lists

# Add new fonts
COPY ./fonts /root/.fonts
RUN fc-cache -fv

# Build and run your node.js app
...

# Run node
CMD ["node", "app.js"]