Как стать автором
Поиск
Написать публикацию
Обновить

Иконки прямо в коде: как мы избавились от assets, портируя приложение на Linux и macOS

Уровень сложностиСредний
Время на прочтение5 мин
Количество просмотров858

Привет, Хабр! Мы в ChameleonLab разрабатываем тулкит для стеганографии, который уже работает на Windows и macOS. Сейчас мы портируем его на Linux, и, как это часто бывает, именно на этом этапе классические проблемы с ресурсами (иконками, картинками) проявили себя во всей красе.

После релиза пользователи увидели наше решение и стали спрашивать, как оно устроено и почему приложение не тащит за собой папку с картинками. Раз уж сообществу это интересно, мы решили дать развёрнутый ответ. Расскажем, как встроили все иконки прямо в код с помощью SVG, и как внутренние итерации и поиски идеального решения привели нас к финальному варианту.

Программа "ChameleonLab"
Программа "ChameleonLab"

Проблема: «Таскать за собой папку с картинками»

Классический подход выглядит так:

  1. Создаётся папка assets/.

  2. В неё складываются десятки файлов: icon.png, logo.ico, close.svg.

  3. В коде пишется специальная функция resource_path, которая пытается найти эту папку, неважно, запущено приложение из исходников или из собранного .exe с помощью PyInstaller.

  4. Начинаются проблемы: неправильные пути (особенно между Windows, macOS и Linux), забытые при сборке файлы, невозможность легко поменять цвет иконок под тему приложения.

А главная беда — масштабирование. Растровые иконки (.png, .ico) ужасно смотрятся на HiDPI (Retina) дисплеях. SVG решает эту проблему, но таскать за собой кучу мелких .svg файлов всё равно неудобно.

Решение: SVG спешит на помощь

SVG — это, по сути, XML-код, то есть текст. А раз это текст, его можно хранить прямо в Python-коде, например, в словаре.

Идея проста:

  1. Создаём модуль icons.py.

  2. В нём — словарь, где ключ — имя иконки, а значение — строка с SVG-разметкой.

  3. Пишем функцию, которая на лету рендерит эту строку в QIcon.

Наша первая версия выглядела примерно так:

# icons.py (Первая, наивная версия)
from PyQt6 import QtGui, QtCore
from PyQt6.QtSvg import QSvgRenderer

# Словарь с иконками
_SVG_ICONS = {
    "embed": """<svg>...</svg>""",
    "reveal": """<svg>...</svg>""",
    # ... и так далее
}

def svg_icon(name: str, size: int = 24) -> QtGui.QIcon:
    """Создаёт QIcon из SVG-строки."""
    svg_str = _SVG_ICONS.get(name, "")
    data = QtCore.QByteArray(svg_str.encode("utf-8"))
    
    # Создаём QPixmap фиксированного размера
    pixmap = QtGui.QPixmap(size, size)
    pixmap.fill(QtCore.Qt.GlobalColor.transparent)
    
    # Рендерим SVG на pixmap
    renderer = QSvgRenderer(data)
    painter = QtGui.QPainter(pixmap)
    renderer.render(painter)
    painter.end()
    
    return QtGui.QIcon(pixmap)

Казалось бы, победа! Но первое же тестирование на разных устройствах выявило проблемы.

Итерация №1: В погоне за резкостью

На HiDPI-мониторах наши иконки выглядели размытыми. Проблема была очевидна.

В чём дело? Наш код создавал QPixmap размером, например, 24x24 пикселя. На экране с коэффициентом масштабирования 200% (DPI ratio = 2.0) система пыталась растянуть эту 24-пиксельную картинку на область 48x48 физических пикселей. Отсюда и размытие.

Решение — devicePixelRatio. Нужно рендерить иконку сразу в высоком разрешении.

Модифицируем нашу функцию:

# icons.py (Вторая версия, с поддержкой HiDPI)

def _svg_to_icon(svg_str: str, size: int = 24) -> QtGui.QIcon:
    # ...
    renderer = QSvgRenderer(QtCore.QByteArray(svg_str.encode("utf-8")))
    
    # 1. Получаем коэффициент масштабирования экрана
    app = QtCore.QCoreApplication.instance()
    dpr = app.primaryScreen().devicePixelRatio() if app else 1.0
    
    # 2. Создаём QPixmap в нужном физическом размере (e.g., 24 * 2.0 = 48px)
    pixmap_size = int(size * dpr)
    pixmap = QtGui.QPixmap(pixmap_size, pixmap_size)
    
    # 3. Указываем, что этот pixmap предназначен для HiDPI
    pixmap.setDevicePixelRatio(dpr)
    pixmap.fill(QtCore.Qt.GlobalColor.transparent)
    
    # Рендерим
    painter = QtGui.QPainter(pixmap)
    renderer.render(painter)
    painter.end()
    
    return QtGui.QIcon(pixmap)

Иконки стали идеально четкими. Но радость была недолгой.

Итерация №2: В поисках эстетики (и правильного флага)

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

Наши яркие, залитые цветом иконки, хоть и красивые сами по себе, выглядели чужеродно на фоне строгого, минималистичного UI, к которому мы стремились. Они визуально «шумели» и отвлекали от основного функционала. Стало очевидно, что нужен был полный редизайн в сторону более сдержанного, контурного стиля.

Эта итерация запомнилась нам и эпопеей с британским флагом. Мы перепробовали несколько вариантов SVG, и в какой-то момент, из-за ошибки в разметке, он действительно превратился в «просто синий квадрат» на кнопке. Этот забавный баг заставил нас вдвойне внимательнее отнестись к каждому графическому элементу в коде.

В итоге мы полностью перерисовали сет иконок.

# Старый стиль
"embed": """<svg xmlns="http://www.w.org/2000/svg" viewBox="0 0 24 24"><path fill="#10b981" d="..."/></svg>""",
# Новый, строгий стиль
"embed": """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#4B5563"><path d="..."/></svg>""",

После этой итерации визуальный стиль приложения наконец стал цельным.

Итог: Финальный код и выводы

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

# icons.py
import base64
from PyQt6 import QtGui, QtCore

try:
    from PyQt6.QtSvg import QSvgRenderer
    _HAS_QTSVG = True
except ImportError:
    _HAS_QTSVG = False

# Словарь со всеми SVG иконками
_SVG_ICONS = {
    # --- Иконки для бокового меню (строгий стиль) ---
    "embed": """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#4B5563">...</svg>""",
    "reveal": """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#4B5563">...</svg>""",
    # ... и другие иконки

    # --- Флаги ---
    "flag_ru": """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 9 6">...</svg>""",
    "flag_en": """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 600"><path fill="#012169" d="M0 0h1200v600H0z"/><path fill="#fff" d="M0 0l600 300M600 0l-600 300M0 600l600-300M600 600l-600-300M350 0v600m500-600v600m-600-250h500m-500 500h500"/><path fill="#c8102e" d="M0 0l600 300M600 0l-600 300M0 600l600-300M600 600l-600-300M175 0v600m850-600v600m-825-150h825m-825 300h825"/></svg>""",

    # --- Иконки для логов ---
    "success": """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="#22c55e">...</svg>""",
    # ...
}

def _svg_to_icon(svg_str: str, size: int = 24) -> QtGui.QIcon:
    """Вспомогательная функция для создания QIcon из SVG строки с поддержкой HiDPI."""
    if not _HAS_QTSVG:
        # Fallback...
        return QtGui.QIcon()

    data = QtCore.QByteArray(svg_str.encode("utf-8"))
    renderer = QSvgRenderer(data)
    
    app = QtCore.QCoreApplication.instance()
    dpr = app.primaryScreen().devicePixelRatio() if app and app.primaryScreen() else 1.0

    viewBox = renderer.viewBoxF()
    aspect_ratio = viewBox.height() / viewBox.width() if viewBox.width() > 0 else 1.0
    
    pixmap_size_w = int(size * dpr)
    pixmap_size_h = int(pixmap_size_w * aspect_ratio)
    
    pm = QtGui.QPixmap(pixmap_size_w, pixmap_size_h)
    pm.setDevicePixelRatio(dpr)
    pm.fill(QtCore.Qt.GlobalColor.transparent)
    
    painter = QtGui.QPainter(pm)
    painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing, True)
    renderer.render(painter)
    painter.end()
    
    return QtGui.QIcon(pm)

def svg_icon(name: str, size: int = 24) -> QtGui.QIcon:
    """Получает QIcon по имени из словаря."""
    svg = _SVG_ICONS.get(name, "")
    return _svg_to_icon(svg, size)

Заключение

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

Главные выводы, которые мы сделали:

  1. SVG в коде — это удобно. Вы получаете один-единственный исполняемый файл без зависимостей, идеальное масштабирование и возможность менять иконки на лету.

  2. Техническое совершенство — это только половина дела. Не менее важно, как элементы интерфейса ощущаются и выглядят вместе. Иногда приходится переделывать отлично работающий код просто потому, что он «не смотрится».

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

Надеюсь, эта статья была для вас интересной. Спасибо за внимание! Буду рад ответить на вопросы в комментариях.

Теги:
Хабы:
+1
Комментарии1

Публикации

Ближайшие события