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

Проблема: «Таскать за собой папку с картинками»
Классический подход выглядит так:
Создаётся папка
assets/
.В неё складываются десятки файлов:
icon.png
,logo.ico
,close.svg
.В коде пишется специальная функция
resource_path
, которая пытается найти эту папку, неважно, запущено приложение из исходников или из собранного.exe
с помощью PyInstaller.Начинаются проблемы: неправильные пути (особенно между Windows, macOS и Linux), забытые при сборке файлы, невозможность легко поменять цвет иконок под тему приложения.
А главная беда — масштабирование. Растровые иконки (.png
, .ico
) ужасно смотрятся на HiDPI (Retina) дисплеях. SVG решает эту проблему, но таскать за собой кучу мелких .svg
файлов всё равно неудобно.
Решение: SVG спешит на помощь
SVG — это, по сути, XML-код, то есть текст. А раз это текст, его можно хранить прямо в Python-коде, например, в словаре.
Идея проста:
Создаём модуль
icons.py
.В нём — словарь, где ключ — имя иконки, а значение — строка с SVG-разметкой.
Пишем функцию, которая на лету рендерит эту строку в
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 и отлаживая внутреннюю эстетику, мы и написали эту статью. Надеемся, наш опыт будет полезен и вам.
Главные выводы, которые мы сделали:
SVG в коде — это удобно. Вы получаете один-единственный исполняемый файл без зависимостей, идеальное масштабирование и возможность менять иконки на лету.
Техническое совершенство — это только половина дела. Не менее важно, как элементы интерфейса ощущаются и выглядят вместе. Иногда приходится переделывать отлично работающий код просто потому, что он «не смотрится».
Внутреннее ревью и тестирование спасают от многих проблем. Иногда нужно остановиться и посмотреть на результат свежим взглядом, чтобы заметить то, что упустил в процессе разработки.
Скачать последнюю версию на macOS: ChameleonLab 1.3.0.0
Скачать последнюю версию на Windows: ChameleonLab 1.3.0.0
Telegram-канал: t.me/ChameleonLab
Надеюсь, эта статья была для вас интересной. Спасибо за внимание! Буду рад ответить на вопросы в комментариях.