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

от автора

Привет, Хабр! Мы в 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 размером, например, 24×24 пикселя. На экране с коэффициентом масштабирования 200% (DPI ratio = 2.0) система пыталась растянуть эту 24-пиксельную картинку на область 48×48 физических пикселей. Отсюда и размытие.

Решение — 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. Внутреннее ревью и тестирование спасают от многих проблем. Иногда нужно остановиться и посмотреть на результат свежим взглядом, чтобы заметить то, что упустил в процессе разработки.

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


ссылка на оригинал статьи https://habr.com/ru/articles/940180/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *