HTML-инфографика поверх картинки

Создано: 2026-02-11 03:24 Время чтения: 4 мин

Инструкция по сборке инфографики: 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/. Иначе при повторном рендере текст задвоится.


Шаги

  1. Сгенерировать AI-картинку через generate_image.py
  2. Сохранить чистую картинку в /tmp/clean_bg_1920.png
    • если нужно масштабировать: Image.open(path).resize((1920, 1080), Image.LANCZOS)
  3. Создать /tmp/text_overlay.html — только текст, прозрачный фон
    • background: transparent на html, body
    • без <img>, без фоновых картинок
    • без text-shadow — тени создают полосы-артефакты
  4. Создать /tmp/build_infographic.py — скрипт сборки
  5. Запустить: python /tmp/build_infographic.py
  6. Проверить результат: открыть 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)).


Композиция: сначала анализ, потом текст

Не размещать текст по шаблону. Сначала найти тёмные однородные зоны на картинке, потом подстроить текст под них.

Алгоритм:

  1. Разбить картинку на сетку 4x4 и посчитать среднюю яркость каждой ячейки
  2. Найти 1-3 самые тёмные зоны с однородным цветом (avg < 30)
  3. Оценить размер каждой зоны — поместится ли текстовый блок
  4. Распределить текст: заголовок → самая большая тёмная зона, цитата → вторая по размеру
  5. Подстроить CSS-позиционирование и PIL-затемнение под найденные зоны
  6. Собрать и проверить — текст не должен перекрывать ключевые элементы картинки

Код анализа яркости:

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) — координаты из анализа сетки