В наше время многочисленные сайты создают страницы, которыми пользователи хотели бы делиться в разных социальных сетях или мессенджерах. Благодаря тегам Open Graph ссылки могут иметь красочное превью изображение, которое привлекает еще больше внимания, например, с помощью тега og:image.
Обычно многие веб-сайты не прикладывают особых усилий к предварительному просмотру изображений и просто добавляют одно изображение на большинство страниц. Если изображения нет, парсеры пытаются автоматически найти первое доступное подходящее изображение и использовать его.
Но представьте как бы было здорово иметь персонализированную картинку на странице, например, когда у вас есть профили ваших пользователей, или каких-то событий в которых они участвуют. Такая задача возникла и в нашем проекте, где мы хотели бы генерировать страницы с красочными изображениями которыми пользователи хотели бы делиться.
Например, посмотрите на картинку ниже которую мы генерируем для шаринга в социальных сетях. Тут есть кастомный шрифт, градиент, и даже локализация картинки на разные языки.

В нашем проекте первоначальные версии изображений создавались с помощью PHP, и какое-то время этого было достаточно, поскольку изображения были не очень сложными и содержали только картинку пользователя. Однако как только мы добавили имя пользователя, то уже сразу возникли проблемы с позиционированием текста. Возьмем для примера простой скрипт создания изображения на PHP.
Пример создания изображения на PHP
<?php $width = 400; $height = 200; $image = imagecreatetruecolor($width, $height); $backgroundColor = imagecolorallocate($image, 255, 255, 255); imagefill($image, 0, 0, $backgroundColor); $textColor = imagecolorallocate($image, 0, 0, 0); $fontFile = 'path/to/your/font.ttf'; $language = isset($_GET['lang']) ? $_GET['lang'] : 'ru'; $text = getLocalizedText($language); $fontSize = 24; $textbox = imagettfbbox($fontSize, 0, $fontFile, $text); $textX = ($width - ($textbox[2] - $textbox[0])) / 2; $textY = ($height - ($textbox[5] - $textbox[3])) / 2 + ($textbox[5] - $textbox[3]); imagettftext($image, $fontSize, 0, $textX, $textY, $textColor, $fontFile, $text); header("Content-Type: image/png"); imagepng($image); imagedestroy($image); function getLocalizedText($language) { switch ($language) { case 'en': return 'Hello, PHP!'; case 'fr': return 'Bonjour, PHP!'; default: return 'Привет, PHP!'; } }
Как видно из примера, если текст будет слишком длинный, то может уйти и за рамки изображения. А если сюда добавить градиенты, полупрозрачность, тени или что-то еще интересное, то код усложнится и поддерживать его будет не очень просто.
Все это приводит к тому, что затраты на изменение изображения требуют достаточно большого количества времени разработки.
Решение, о котором я хотел бы рассказать позволяет в разы сократить время разработки и упростить поддержку таких изображений. Более того - оно гибкое и очень легко масштабируется и для других вещей.
Для веб-разработчика HTML - это самый простой и удобный язык разметки. Вместе со стилями CSS вы можете создать гибкий интерфейс, учитывающий расположение любых элементов на странице — изображений, текста, таблиц, списков и т.д.
Давайте посмотрим и на примере ниже. Это простая HTML-страница со множеством стилей CSS, таких как градиент в тексте, тени, ограничение текстовых строк для длинного текста и другое.
Пример HTML кода для страницы профиля
html> <head> <style> html, body { margin: 0; padding: 0; } body { background-color: #0093E9; background-image: linear-gradient(160deg, #0093E9 0%, #80D0C7 100%); font-family: Helvetica, sans-serif; padding: 5%; text-align: center; } header { position: relative; } header .emoji { position: absolute; top: -10px; left: 0; transform: rotate(20deg); font-size: 3rem; } * { box-sizing: border-box; } h1 { text-transform: uppercase; font-size: 3rem; background: -webkit-linear-gradient(45deg, #85FFBD 0%, #FFFB7D 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; } .wrapper { display: flex; padding-top: 2rem; } .avatar { display: flex; justify-content: center; align-items: center; } .avatar img { width: 140px; height: 140px; border-radius: 100px; border: 5px solid rgba(255,255,255, 0.5); box-shadow: 0 0 10px rgba(0,0,0,0.2); object-fit: cover; } .content { padding: 1rem 2rem; } .content .text { font-size: 1.5rem; display: -webkit-box; -webkit-line-clamp: 3; /* number of lines to show */ line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; color: rgba(255,255,255, 0.8); } </style> </head> <body> <div> <header> <h1>Hello, Javascript</h1> <div class="emoji">?</div> <header> <div class="wrapper"> <div class="avatar"> <img src="https://sun9-57.userapi.com/impg/O3egMIWPZjhcKSThZ2hn7ByaQmET8ySOq5e4ww/O_ngP3qqEd8.jpg?size=1178x1789&quality=95&sign=71fcbf49ffff80fad9f0ef39f598cf69&type=album" alt=""/> </div> <div class="content"> <div class="text"><strong>Lorem Ipsum</strong> is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.</div> </div> </div> </div> </body> </html>
Теперь все, что нам нужно, так это за��устить веб-сервер, который может обслуживать этот HTML-код, и запустить приложение node.js с библиотекой Puppeteer, которая запускает headless версию Google Chrome, а затем вы можете сделать снимок экрана страницы и получить изображение.
Ниже приведен пример кода, который позволяет получить содержимое страницы с заданной шириной, высотой. Крайне важно обрабатывать исключения и закрывать страницы браузера, если что-то пойдет не так; в противном случае это может привести к утечке памяти.
import puppeteer, { Page } from 'puppeteer-core'; const width = 800; const height = 600; const deviceScaleFactor = 1; const imageType = 'webp'; const imageQuality = 90; const url = '<Путь к странице на веб сервере>'; // Путь к Chrome или Chromium // На macOS путь до браузера будет таким "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" // на Linux Для Google Chrome будет "/usr/bin/google-chrome-stable" const browserPath = '/usr/bin/chromium'; const browser = await puppeteer.launch({ headless: true, ignoreHTTPSErrors: true, executablePath: browserPath, args: ['--no-sandbox', '--disable-setuid-sandbox'], }); 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 Error(`Incorrect status page ${result?.status()}`); } } catch (error) { page.close(); throw new Error(error as string); } const data = await page.screenshot({ type: imageType, quality: imageQuality, encoding: 'binary', }); page.close();
В результате получается вот такое изображение, которое можно сохранить на диск или загрузить в свое S3 хранилище и предоставить его пользователям.

В нашем случае мы создали небольшое приложение node.js с Puppeteer, которое упаковано в контейнер Docker, и запускаем внутри него HTTP-сервер для управления запросами. HTML код мы генерируем с помощью серверного рендера React, в зависимости от переданных данных в запросе. В итоге приложение позволяет нам создавать изображения различного формата (например, png, jpeg или webp) для любой страницы веб-сайта. Также мы отключаем поддержку JS потому что отрисовкой занимается серверный рендер и экономим время загрузки страницы (нам нужны только картинки). С недавних пор мы полностью перешли на генерацию webp изображений и это позволило нам существенно сократить размер изображений и сделать градиенты (по сравнению с jpeg) более приятными.
В итоге, полная логика сервиса по генерации OG-изображений получилось такой:
Когда пользователь шарит где-то ссылку, то в
og:imageотдается специальная ссылка (с идентификатором его профиля, необходимым размером и расширением изображения) на прокси-сервер nginx.Прокси-сервер проверяет, находится ли изображение уже в хранилище S3 или нет.
Если изображение существует, оно отдается напрямую; в противном случае делается запрос к сервису node.js, который генерирует изображение, возвращает его и асинхронно загружает на S3.
Несмотря на незначительные технические трудности, новый сервис значительно сократил время на создание и поддержку изображений по сравнению с предыдущим подходом. Такое решение позволяет нам быстро обновлять изображения, просто меняя HTML-код, легко тестировать его в браузере и использовать все возможности CSS.
Само собой такой подход можно использовать и для создания любых других изображений как баннеры или даже, например, PDF файлов с описанием каких-либо товаров. Более того, для Puppeteer есть библиотека puppeteer-screen-recorder благодаря которой можно создавать видео ролики в которой анимация будет создана с помощью CSS. Это тоже может быть полезным потому что, например, iMessage умеет играть небольшие видео которые есть в метатеге og:video. Возможно в будущем мы увидим такую же возможность, например, и в Telegram.
import puppeteer from 'puppeteer'; import { PuppeteerScreenRecorder } from 'puppeteer-screen-recorder'; function delay(time: number) { return new Promise(function (resolve) { setTimeout(resolve, time); }); } (async () => { const browser = await puppeteer.launch({ headless: true }); const page = await browser.newPage(); await page.setViewport({ width: 640, height: 480, }); await page.goto('http://localhost:10001'); const recorder = new PuppeteerScreenRecorder(page, { fps: 30, videoCrf: 20, videoFrame: { width: 640, height: 480, }, }); await recorder.start('./simple.mp4'); await delay(500); await recorder.stop(); await page.close(); await browser.close(); })();
В итоге получается вот такой ролик

Для удобства вот пример докер-контейнера, который мы используем со всеми необходимыми библиотеками для работы с изображениями.
Пример Docker контейнера
FROM node:18 RUN 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 \ libvips \ chromium # Добавьте нужные вам шрифты COPY ./fonts /root/.fonts RUN fc-cache -fv # Сборка вашего проекта ... # Запуск приложения CMD ["node", "app.js"]
