Привет, Хабр! Мы в 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 размером, например, 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 и отлаживая внутреннюю эстетику, мы и написали эту статью. Надеемся, наш опыт будет полезен и вам.
Главные выводы, которые мы сделали:
-
SVG в коде — это удобно. Вы получаете один-единственный исполняемый файл без зависимостей, идеальное масштабирование и возможность менять иконки на лету.
-
Техническое совершенство — это только половина дела. Не менее важно, как элементы интерфейса ощущаются и выглядят вместе. Иногда приходится переделывать отлично работающий код просто потому, что он «не смотрится».
-
Внутреннее ревью и тестирование спасают от многих проблем. Иногда нужно остановиться и посмотреть на результат свежим взглядом, чтобы заметить то, что упустил в процессе разработки.
-
Скачать последнюю версию на macOS: ChameleonLab 1.3.0.0
-
Скачать последнюю версию на Windows: ChameleonLab 1.3.0.0
-
Telegram-канал: t.me/ChameleonLab
Надеюсь, эта статья была для вас интересной. Спасибо за внимание! Буду рад ответить на вопросы в комментариях.
ссылка на оригинал статьи https://habr.com/ru/articles/940180/
Добавить комментарий