HTML-инфографика поверх картинки
Инструкция по сборке инфографики: AI-картинка на фон + HTML-текст сверху.
Почему композитный подход
Chrome headless не дотягивает <img> до нижнего края viewport — остаётся чёрная полоса ~40px. Решение: фон масштабируется через PIL (точно в пиксель), текст рендерится Chrome с прозрачным фоном, результат склеивается через Image.alpha_composite().
Файловая структура
| Файл | Назначение |
|---|---|
/tmp/clean_bg_1920.png |
Чистая AI-картинка без текста (1920x1080) |
/tmp/text_overlay.html |
HTML с текстом, прозрачный фон |
/tmp/build_infographic.py |
Скрипт сборки: PIL-фон + Chrome-текст |
render_html.py (корень проекта) |
Универсальный рендерер HTML → PNG |
founder/assets/*.webp |
Финальная инфографика |
Важно: в founder/assets/ сохраняется только финальный результат. Чистую картинку хранить в /tmp/. Иначе при повторном рендере текст задвоится.
Шаги
- Сгенерировать AI-картинку через
generate_image.py - Сохранить чистую картинку в
/tmp/clean_bg_1920.png- если нужно масштабировать:
Image.open(path).resize((1920, 1080), Image.LANCZOS)
- если нужно масштабировать:
- Создать
/tmp/text_overlay.html— только текст, прозрачный фонbackground: transparentнаhtml, body- без
<img>, без фоновых картинок - без
text-shadow— тени создают полосы-артефакты
- Создать
/tmp/build_infographic.py— скрипт сборки - Запустить:
python /tmp/build_infographic.py - Проверить результат: открыть PNG, убедиться что нет полос, задвоений, артефактов
Скрипт сборки (шаблон)
#!/usr/bin/env python3
"""Сборка инфографики: PIL-фон + Chrome-текст"""
from PIL import Image, ImageFilter, ImageDraw
import subprocess, os
CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
W, H = 1920, 1080
CLEAN_BG = '/tmp/clean_bg_1920.png'
HTML_PATH = '/tmp/text_overlay.html'
OUTPUT_PNG = '/tmp/infographic.png'
OUTPUT_WEBP = 'founder/assets/имя_файла.webp'
# 1. Фон
bg = Image.open(CLEAN_BG).convert('RGBA')
# 2. Мягкое затемнение зон с текстом
dark = Image.new('RGBA', (W, H), (0, 0, 0, 0))
d = ImageDraw.Draw(dark)
# Правый верхний — под заголовок
d.rectangle([1100, 0, 1920, 250], fill=(10, 10, 10, 140))
# Левый нижний — под цитату
d.rectangle([0, 780, 850, 1080], fill=(10, 10, 10, 150))
# Размытие для мягких краёв
dark = dark.filter(ImageFilter.GaussianBlur(radius=80))
bg = Image.alpha_composite(bg, dark)
# 3. Текст через Chrome с прозрачным фоном
tmp_text = '/tmp/text_overlay.png'
subprocess.run([
CHROME, "--headless", "--disable-gpu",
f"--screenshot={tmp_text}",
f"--window-size={W},{H + 40}",
"--default-background-color=00000000",
f"file://{os.path.abspath(HTML_PATH)}",
], capture_output=True, text=True)
text = Image.open(tmp_text).convert('RGBA').crop((0, 0, W, H))
os.remove(tmp_text)
# 4. Склейка
result = Image.alpha_composite(bg, text)
result.save(OUTPUT_PNG, 'PNG')
result.save(OUTPUT_WEBP, 'WEBP', quality=92)
print(f'Done: {result.size}')
HTML текстового оверлея (шаблон)
Прозрачный фон. Только текст и SVG-лого. Без text-shadow.
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 1920px;
height: 1080px;
overflow: hidden;
font-family: -apple-system, 'Helvetica Neue', Arial, sans-serif;
color: #e8e4e1;
background: transparent;
}
.container { width: 1920px; height: 1080px; position: relative; }
.content { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
/* Лого канала — правый верхний, над заголовком */
.channel-logo {
position: absolute;
top: 30px; right: 70px;
display: flex; align-items: center; gap: 10px;
}
.channel-logo .logo-text {
font-size: 13px; font-weight: 600;
color: rgba(255,215,0,0.7);
letter-spacing: 3px; text-transform: uppercase;
}
.logo-icon { width: 32px; height: 32px; opacity: 0.7; }
/* Заголовок — правый верхний */
.top-right {
position: absolute; top: 80px; right: 70px; text-align: right;
}
.top-right h1 {
font-size: 56px; font-weight: 900;
letter-spacing: 8px; text-transform: uppercase;
color: #FFD700; line-height: 1.1;
}
.top-right .sub {
font-size: 19px; font-weight: 300;
color: rgba(255,255,255,0.6);
letter-spacing: 5px; text-transform: uppercase; margin-top: 10px;
}
.top-right .accent-line {
width: 120px; height: 2px;
background: linear-gradient(90deg, transparent, #FFD700);
margin-top: 14px; margin-left: auto;
}
/* Цитата — левый нижний */
.bottom-left {
position: absolute; bottom: 100px; left: 70px; max-width: 700px;
}
.bottom-left .accent-line {
width: 120px; height: 2px;
background: linear-gradient(90deg, #FFD700, transparent);
margin-bottom: 16px;
}
.bottom-left .quote {
font-size: 32px; font-weight: 300;
color: rgba(255,255,255,0.95); line-height: 1.4;
}
.bottom-left .quote em {
font-style: normal; font-weight: 700; color: #FFD700;
}
.bottom-left .punch {
margin-top: 12px; font-size: 18px; font-weight: 800;
color: #FFD700; letter-spacing: 4px;
text-transform: uppercase; opacity: 0.85;
}
.bottom-left .footer {
margin-top: 14px; font-size: 12px;
color: rgba(255,255,255,0.3); letter-spacing: 3px;
}
</style>
</head>
<body>
<div class="container">
<div class="content">
<div class="channel-logo">
<svg class="logo-icon" viewBox="0 0 32 32" fill="none">
<path d="M12 4h8v2h-1v8l6 10v2H7v-2l6-10V6h-1V4z"
stroke="#FFD700" stroke-width="1.2" fill="none" opacity="0.7"/>
<circle cx="14" cy="20" r="1.5" fill="#FFD700" opacity="0.4"/>
<circle cx="18" cy="22" r="1" fill="#FFD700" opacity="0.3"/>
<circle cx="16" cy="18" r="0.8" fill="#FFD700" opacity="0.5"/>
</svg>
<span class="logo-text">Системная лаборатория AI</span>
</div>
<div class="top-right">
<h1>ЗАГОЛОВОК</h1>
<div class="sub">подзаголовок</div>
<div class="accent-line"></div>
</div>
<div class="bottom-left">
<div class="accent-line"></div>
<div class="quote">Текст <em>акцент</em> продолжение</div>
<div class="punch">Панчлайн</div>
<div class="footer">trip2g.com</div>
</div>
</div>
</div>
</body>
</html>
Цветовая палитра
| Элемент | Цвет |
|---|---|
| Заголовок | #FFD700 (золотой) |
| Подзаголовок | rgba(255,255,255,0.6) |
| Цитата | rgba(255,255,255,0.95) |
| Акцент в цитате | #FFD700 bold |
| Панчлайн | #FFD700 uppercase |
| Лого канала | rgba(255,215,0,0.7) |
| Футер | rgba(255,255,255,0.3) |
| Затемнение фона | (10, 10, 10, 140-150) + GaussianBlur(80) |
render_html.py (корень проекта)
Универсальный рендерер. Два режима:
# Простой: HTML → PNG (Chrome рендерит всё)
python render_html.py input.html output.png
# Композитный: PIL-фон + Chrome-текст (рекомендуется)
python render_html.py input.html output.png --bg /tmp/clean_bg.png
# С указанием размера
python render_html.py input.html output.png --bg /tmp/clean_bg.png 1920 1080
Полный код скрипта:
#!/usr/bin/env python3
"""
Рендер HTML-инфографики поверх фоновой картинки.
Два режима:
1. Простой: HTML → PNG
python render_html.py input.html output.png [width] [height]
2. Композитинг: фон (PIL) + текст (Chrome) → PNG
python render_html.py input.html output.png --bg /tmp/clean_bg.png [width] [height]
Режим 2 решает проблему Chrome, который не дотягивает <img> до нижнего края viewport.
Фон масштабируется через PIL, текст рендерится Chrome с прозрачным фоном, результат склеивается.
"""
import subprocess
import sys
import os
CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
def render_simple(html_path: str, output_path: str, width: int, height: int):
"""Рендер HTML как есть через Chrome."""
tmp_path = output_path + ".tmp.png"
subprocess.run([
CHROME, "--headless", "--disable-gpu",
f"--screenshot={tmp_path}",
f"--window-size={width},{height + 40}",
f"file://{os.path.abspath(html_path)}",
], capture_output=True, text=True)
if os.path.exists(tmp_path):
from PIL import Image
img = Image.open(tmp_path).crop((0, 0, width, height))
img.save(output_path, "PNG")
os.remove(tmp_path)
print(f"Saved: {output_path} ({os.path.getsize(output_path)} bytes)")
else:
print("Error: Chrome failed to render", file=sys.stderr)
sys.exit(1)
def render_composite(html_path: str, output_path: str, bg_path: str, width: int, height: int):
"""Фон через PIL + текст через Chrome с прозрачным фоном."""
from PIL import Image
# 1. Фон — масштабируем точно в нужный размер
bg = Image.open(bg_path).convert("RGBA").resize((width, height), Image.LANCZOS)
# 2. Текст — рендерим Chrome с прозрачным фоном
tmp_path = output_path + ".text.png"
subprocess.run([
CHROME, "--headless", "--disable-gpu",
f"--screenshot={tmp_path}",
f"--window-size={width},{height + 40}",
"--default-background-color=00000000",
f"file://{os.path.abspath(html_path)}",
], capture_output=True, text=True)
if not os.path.exists(tmp_path):
print("Error: Chrome failed to render text", file=sys.stderr)
sys.exit(1)
text = Image.open(tmp_path).convert("RGBA").crop((0, 0, width, height))
os.remove(tmp_path)
# 3. Склеиваем
result = Image.alpha_composite(bg, text)
result.save(output_path, "PNG")
print(f"Saved: {output_path} ({os.path.getsize(output_path)} bytes, composite)")
if __name__ == "__main__":
if len(sys.argv) < 3:
print("Usage: python render_html.py <input.html> <output.png> [--bg bg.png] [width] [height]")
sys.exit(1)
html = sys.argv[1]
out = sys.argv[2]
bg_path = None
args = sys.argv[3:]
if "--bg" in args:
idx = args.index("--bg")
bg_path = args[idx + 1]
args = args[:idx] + args[idx + 2:]
w = int(args[0]) if len(args) > 0 else 1920
h = int(args[1]) if len(args) > 1 else 1080
if bg_path:
render_composite(html, out, bg_path, w, h)
else:
render_simple(html, out, w, h)
Частые ошибки
-
Чёрная полоса внизу. Chrome
--window-sizeне совпадает с viewport.<img object-fit: fill>не дотягивает до края. Решение: не вставлять картинку в HTML, использовать PIL для фона. -
Задвоение текста. Сохранили инфографику (с текстом) как
filename.webpв assets, потом HTML ссылается на тот же файл как фон. Решение: чистую картинку хранить в/tmp/clean_bg*.png, в assets только финал. -
Полоса от тени. CSS
text-shadowна прозрачном фоне создаёт видимые артефакты — тень рендерится как отдельный полупрозрачный слой. Решение: убратьtext-shadowиз CSS, затемнять фон через PIL (ImageDraw.rectangle+GaussianBlur). -
Белая полоса внизу.
bodyимеет белый фон по умолчанию. Решение: для текстового оверлея —background: transparent. Для полного HTML —background: #1a1a1a. -
Chrome
--default-background-colorформат. Нужен hex RGBA без#:00000000= полностью прозрачный. Не0, не#00000000. -
Виньетка с резкими краями. CSS
radial-gradientв Chrome рендерится с видимыми границами. Решение: вместо CSS-виньетки использовать PIL-прямоугольники сGaussianBlur(radius=80)— мягкие края без артефактов. -
Crop обязателен. Chrome добавляет ~40px сверху из-за разницы window-size и viewport. Всегда кропать:
.crop((0, 0, W, H)).
Композиция: сначала анализ, потом текст
Не размещать текст по шаблону. Сначала найти тёмные однородные зоны на картинке, потом подстроить текст под них.
Алгоритм:
- Разбить картинку на сетку 4x4 и посчитать среднюю яркость каждой ячейки
- Найти 1-3 самые тёмные зоны с однородным цветом (avg < 30)
- Оценить размер каждой зоны — поместится ли текстовый блок
- Распределить текст: заголовок → самая большая тёмная зона, цитата → вторая по размеру
- Подстроить CSS-позиционирование и PIL-затемнение под найденные зоны
- Собрать и проверить — текст не должен перекрывать ключевые элементы картинки
Код анализа яркости:
from PIL import Image
import numpy as np
bg = Image.open('/tmp/clean_bg_1920.png').convert('RGB')
arr = np.array(bg, dtype=float)
for row in range(4):
for col in range(4):
y1, y2 = row * 270, (row + 1) * 270
x1, x2 = col * 480, (col + 1) * 480
avg = arr[y1:y2, x1:x2, :].mean()
print(f"[{row},{col}] x={x1}-{x2} y={y1}-{y2}: avg={avg:.0f}")
Правила:
- 1-3 текстовых блока максимум (лого, заголовок, цитата)
- Текст только на тёмных зонах (avg < 30-35)
- Если тёмных зон мало — не впихивать текст, лучше уменьшить количество блоков
- Затемнение (PIL): мягкие прямоугольники под зонами текста, размытые
GaussianBlur(80)— координаты из анализа сетки